From b0ec4aa8097042973a62b9420cccf2c6614f0491 Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 12:59:18 -0400 Subject: [PATCH 01/12] feat: add Ivanti ITSM plugin Monitors Ivanti Neurons for ITSM and Ivanti Service Manager through the REST business object API - incidents, service requests, changes, problems, knowledge articles and the CMDB. - Nine data streams, including a generic Business Object Records stream that reads any business object in the tenant, customer-defined ones included, without a plugin change - Imports Configuration Items and ticket-owning Teams into the graph - Four out-of-the-box dashboards: Service Desk Overview, Change And Problem Management, and a perspective for each object type - Works against cloud tenants and on-premises installs, which sit under a virtual directory such as /HEAT Business object names, field names and API limits were verified against a live on-premises tenant rather than assumed. Co-Authored-By: Claude Opus 5 --- .github/CODEOWNERS | 1 + plugins/Ivanti-ITSM/v1/configValidation.json | 21 + plugins/Ivanti-ITSM/v1/cspell.json | 13 + plugins/Ivanti-ITSM/v1/custom_types.json | 16 + .../v1/dataStreams/businessObjectRecords.json | 74 ++ .../Ivanti-ITSM/v1/dataStreams/changes.json | 317 +++++++++ .../v1/dataStreams/configurationItems.json | 201 ++++++ .../v1/dataStreams/connectionCheck.json | 25 + .../Ivanti-ITSM/v1/dataStreams/incidents.json | 333 +++++++++ .../v1/dataStreams/knowledgeArticles.json | 68 ++ .../Ivanti-ITSM/v1/dataStreams/problems.json | 302 +++++++++ .../scripts/errorHandling/odataError.js | 20 + .../v1/dataStreams/serviceRequests.json | 301 +++++++++ plugins/Ivanti-ITSM/v1/dataStreams/teams.json | 106 +++ .../changeAndProblemManagement.dash.json | 420 ++++++++++++ .../configurationItem.dash.json | 103 +++ .../v1/defaultContent/manifest.json | 8 + .../Ivanti-ITSM/v1/defaultContent/scopes.json | 26 + .../serviceDeskOverview.dash.json | 634 ++++++++++++++++++ .../v1/defaultContent/team.dash.json | 436 ++++++++++++ plugins/Ivanti-ITSM/v1/docs/README.md | 100 +++ plugins/Ivanti-ITSM/v1/icon.svg | 21 + .../v1/indexDefinitions/default.json | 83 +++ plugins/Ivanti-ITSM/v1/metadata.json | 64 ++ plugins/Ivanti-ITSM/v1/ui.json | 82 +++ 25 files changed, 3775 insertions(+) create mode 100644 plugins/Ivanti-ITSM/v1/configValidation.json create mode 100644 plugins/Ivanti-ITSM/v1/cspell.json create mode 100644 plugins/Ivanti-ITSM/v1/custom_types.json create mode 100644 plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json create mode 100644 plugins/Ivanti-ITSM/v1/dataStreams/changes.json create mode 100644 plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json create mode 100644 plugins/Ivanti-ITSM/v1/dataStreams/connectionCheck.json create mode 100644 plugins/Ivanti-ITSM/v1/dataStreams/incidents.json create mode 100644 plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json create mode 100644 plugins/Ivanti-ITSM/v1/dataStreams/problems.json create mode 100644 plugins/Ivanti-ITSM/v1/dataStreams/scripts/errorHandling/odataError.js create mode 100644 plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json create mode 100644 plugins/Ivanti-ITSM/v1/dataStreams/teams.json create mode 100644 plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json create mode 100644 plugins/Ivanti-ITSM/v1/defaultContent/configurationItem.dash.json create mode 100644 plugins/Ivanti-ITSM/v1/defaultContent/manifest.json create mode 100644 plugins/Ivanti-ITSM/v1/defaultContent/scopes.json create mode 100644 plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json create mode 100644 plugins/Ivanti-ITSM/v1/defaultContent/team.dash.json create mode 100644 plugins/Ivanti-ITSM/v1/docs/README.md create mode 100644 plugins/Ivanti-ITSM/v1/icon.svg create mode 100644 plugins/Ivanti-ITSM/v1/indexDefinitions/default.json create mode 100644 plugins/Ivanti-ITSM/v1/metadata.json create mode 100644 plugins/Ivanti-ITSM/v1/ui.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d689d1fc..bb11a9a1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,7 @@ plugins/Huntress/* @Deenk plugins/IncidentIO/ @noorulkhan-n plugins/FantasyPremierLeague/* @TimWheeler-SQUP plugins/GoogleSheets/* @kieranlangton +plugins/Ivanti-ITSM/* @shawn149 plugins/MetOffice/* @blackgrouse plugins/Phare/* @vinbab plugins/Postcoder/* @richbenwell diff --git a/plugins/Ivanti-ITSM/v1/configValidation.json b/plugins/Ivanti-ITSM/v1/configValidation.json new file mode 100644 index 00000000..b5da19a3 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/configValidation.json @@ -0,0 +1,21 @@ +{ + "steps": [ + { + "displayName": "Authenticate", + "dataStream": { "name": "connectionCheck" }, + "required": true, + "error": "Could not reach the Ivanti REST API. Check the tenant URL, and that the REST API key was created for this tenant and is activated.", + "success": "Connected successfully." + }, + { + "displayName": "Check incident access", + "dataStream": { + "name": "incidents", + "config": { "maxRecords": 1 } + }, + "required": false, + "error": "Connected, but incidents could not be read. Check the role of the account the REST API key was created On Behalf Of.", + "success": "Incidents accessible." + } + ] +} diff --git a/plugins/Ivanti-ITSM/v1/cspell.json b/plugins/Ivanti-ITSM/v1/cspell.json new file mode 100644 index 00000000..cda89a3a --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/cspell.json @@ -0,0 +1,13 @@ +{ + "words": [ + "Bragg", + "businessobject", + "CMDB", + "contactgroups", + "ITSM", + "Ivanti", + "odata", + "saasiteu", + "servicereqs" + ] +} diff --git a/plugins/Ivanti-ITSM/v1/custom_types.json b/plugins/Ivanti-ITSM/v1/custom_types.json new file mode 100644 index 00000000..00dd596a --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/custom_types.json @@ -0,0 +1,16 @@ +[ + { + "name": "Configuration Item", + "sourceType": "Configuration Item", + "icon": "server", + "singular": "Configuration Item", + "plural": "Configuration Items" + }, + { + "name": "Team", + "sourceType": "Team", + "icon": "users", + "singular": "Team", + "plural": "Teams" + } +] diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json b/plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json new file mode 100644 index 00000000..257128f0 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json @@ -0,0 +1,74 @@ +{ + "name": "businessObjectRecords", + "displayName": "Business Object Records", + "description": "Records from any Ivanti business object, including customer-defined ones, with all fields returned", + "tags": ["Advanced", "Service Management"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/{{businessObject}}", + "getArgs": [ + { + "key": "$top", + "value": "{{maxRecords || dataSource.maxRecords || 99}}" + }, + { "key": "$filter", "value": "{{ typeof filter !== 'undefined' ? filter : null }}" }, + { "key": "$select", "value": "{{ typeof select !== 'undefined' ? select : null }}" }, + { "key": "$orderby", "value": "{{ typeof orderBy !== 'undefined' ? orderBy : null }}" } + ], + "pathToData": "value", + "paging": { "mode": "none" }, + "errorHandling": { + "type": "script", + "script": "errorHandling/odataError.js" + } + }, + "matches": "none", + "manualConfigApply": true, + "ui": [ + { + "type": "text", + "name": "businessObject", + "label": "Business object name", + "placeholder": "incidents", + "help": "The business object name as used in the API, which is always the object name with an `s` on the end - `incidents`, `tasks`, `alerts`, `employees`. A customer-defined object follows the same rule.", + "validation": { + "required": true, + "pattern": { + "value": "^[A-Za-z0-9_]+$", + "message": "Use the API name of the business object, e.g. incidents" + } + } + }, + { + "type": "text", + "name": "filter", + "label": "Filter", + "placeholder": "Status eq 'Active'", + "help": "Uses the OData `$filter` syntax." + }, + { + "type": "text", + "name": "select", + "label": "Field(s)", + "placeholder": "RecId,Subject,Status", + "help": "Limits the response to these fields, which is worth doing for objects with many columns. A field name that does not exist fails the whole request." + }, + { + "type": "text", + "name": "orderBy", + "label": "Order by", + "placeholder": "CreatedDateTime desc", + "help": "Uses the OData `$orderby` syntax." + }, + { + "type": "number", + "name": "maxRecords", + "label": "Maximum records", + "help": "Ivanti returns at most 1000 records in one request.", + "validation": { "min": 1, "max": 1000, "valueAsNumber": true } + } + ], + "metadata": [{ "pattern": ".*" }], + "timeframes": false +} diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/changes.json b/plugins/Ivanti-ITSM/v1/dataStreams/changes.json new file mode 100644 index 00000000..f1b1a24c --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/dataStreams/changes.json @@ -0,0 +1,317 @@ +{ + "name": "changes", + "displayName": "Changes", + "description": "Change records, one row per record, with status, risk, schedule and ownership", + "tags": [ + "Tickets", + "Service Management" + ], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/changes", + "getArgs": [ + { + "key": "$top", + "value": "{{maxRecords || dataSource.maxRecords || 99}}" + }, + { + "key": "$filter", + "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" + }, + { + "key": "$select", + "value": "{{ typeof select !== 'undefined' ? select : null }}" + }, + { + "key": "$orderby", + "value": "{{ (typeof orderBy !== 'undefined' && orderBy) || 'CreatedDateTime desc' }}" + } + ], + "pathToData": "value", + "paging": { + "mode": "none" + }, + "errorHandling": { + "type": "script", + "script": "errorHandling/odataError.js" + } + }, + "matches": "none", + "ui": [ + { + "type": "switch", + "name": "dateField", + "label": "Timeframe applies to", + "defaultValue": "CreatedDateTime", + "help": "Which date the selected timeframe filters on. Set the timeframe to **None** to ignore dates and return the current backlog.", + "options": [ + { + "value": "CreatedDateTime", + "label": "Created" + }, + { + "value": "LastModDateTime", + "label": "Last modified" + }, + { + "value": "ClosedDateTime", + "label": "Closed" + } + ], + "tileEditorStep": [ + "Timeframe" + ] + }, + { + "type": "autocomplete", + "name": "status", + "label": "Status", + "isMulti": true, + "isClearable": true, + "allowCustomValues": true, + "help": "Filters in Ivanti rather than in the browser, so it also keeps large result sets under the record ceiling. Type a value to use a status this tenant has added.", + "data": { + "source": "fixed", + "values": [ + { + "value": "Logged", + "label": "Logged" + }, + { + "value": "Submitted", + "label": "Submitted" + }, + { + "value": "Pending Approval", + "label": "Pending Approval" + }, + { + "value": "Approved", + "label": "Approved" + }, + { + "value": "Scheduled", + "label": "Scheduled" + }, + { + "value": "Implemented", + "label": "Implemented" + }, + { + "value": "Rejected", + "label": "Rejected" + }, + { + "value": "Closed", + "label": "Closed" + } + ] + } + }, + { + "type": "objects", + "name": "team", + "label": "Team", + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "Team" + ] + } + } + }, + { + "type": "text", + "name": "filter", + "label": "Additional filter", + "placeholder": "Category eq 'Hardware'", + "help": "Appended to the filter this tile already builds, using the OData `$filter` syntax." + }, + { + "type": "text", + "name": "select", + "label": "Field(s)", + "placeholder": "RecId,Subject,Status,CreatedDateTime", + "help": "Limits the response to these fields. Worth setting for a business object with many fields, where the full payload is slow or too large. A field name that does not exist fails the whole request." + }, + { + "type": "text", + "name": "orderBy", + "label": "Order by", + "placeholder": "CreatedDateTime desc", + "help": "Ivanti applies this before the record ceiling, so it decides which records a truncated result keeps. Uses the OData `$orderby` syntax." + }, + { + "type": "number", + "name": "maxRecords", + "label": "Maximum records", + "help": "Ivanti rejects a request above the tenant page limit outright - the default limit is 100 and only an administrator can raise it. Narrow the status, team or timeframe filters instead.", + "validation": { + "min": 1, + "max": 1000, + "valueAsNumber": true + } + } + ], + "metadata": [ + { + "name": "Subject", + "displayName": "Subject", + "shape": "string", + "role": "label" + }, + { + "name": "ChangeNumber", + "displayName": "Change Number", + "shape": "string", + "role": "id" + }, + { + "name": "Status", + "displayName": "Status", + "shape": "string" + }, + { + "name": "Priority", + "displayName": "Priority", + "shape": "string" + }, + { + "name": "Urgency", + "displayName": "Urgency", + "shape": "string" + }, + { + "name": "Impact", + "displayName": "Impact", + "shape": "string" + }, + { + "name": "RiskLevel", + "displayName": "Risk", + "shape": "string" + }, + { + "name": "TypeOfChange", + "displayName": "Type", + "shape": "string" + }, + { + "name": "Category", + "displayName": "Category", + "shape": "string" + }, + { + "name": "Service", + "displayName": "Service", + "shape": "string" + }, + { + "name": "Owner", + "displayName": "Owner", + "shape": "string" + }, + { + "name": "OwnerTeam", + "displayName": "Owner Team", + "shape": "string" + }, + { + "name": "RequestorFullName", + "displayName": "Requestor", + "shape": "string" + }, + { + "name": "Source", + "displayName": "Source", + "shape": "string" + }, + { + "name": "ScheduledStartDate", + "displayName": "Scheduled Start", + "shape": "date" + }, + { + "name": "ScheduledEndDate", + "displayName": "Scheduled End", + "shape": "date" + }, + { + "name": "CreatedDateTime", + "displayName": "Created", + "shape": "date", + "role": "timestamp" + }, + { + "name": "LastModDateTime", + "displayName": "Last Modified", + "shape": "date" + }, + { + "name": "ClosedDateTime", + "displayName": "Closed", + "shape": "date" + }, + { + "name": "ageDays", + "displayName": "Age (days)", + "computed": true, + "valueExpression": "{{ $['CreatedDateTime'] ? Math.floor((Date.now() - new Date($['CreatedDateTime']).getTime()) / 86400000) : null }}", + "shape": "number" + }, + { + "name": "FinalState", + "displayName": "Closed Out", + "shape": "string", + "visible": false + }, + { + "name": "TotalTimeSpent", + "displayName": "Time Spent", + "shape": "minutes", + "visible": false + }, + { + "name": "Description", + "displayName": "Description", + "shape": "string", + "visible": false + }, + { + "name": "Resolution", + "displayName": "Resolution", + "shape": "string", + "visible": false + }, + { + "name": "CreatedBy", + "displayName": "Created By", + "shape": "string", + "visible": false + }, + { + "name": "RecId", + "displayName": "Record ID", + "shape": "guid", + "visible": false + }, + { + "pattern": ".*" + } + ], + "defaultShaping": { + "sort": { + "by": [ + [ + "CreatedDateTime", + "desc" + ] + ] + } + }, + "timeframes": true, + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" +} diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json b/plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json new file mode 100644 index 00000000..129b1b45 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json @@ -0,0 +1,201 @@ +{ + "name": "configurationItems", + "displayName": "Configuration Items", + "description": "Configuration items from the CMDB, one row per item, with type, status, ownership and location", + "tags": [ + "Inventory", + "Service Management" + ], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/cis", + "getArgs": [ + { + "key": "$filter", + "value": "{{ [dataSource.ciFilter || null, (typeof filter !== 'undefined' ? filter : null)].filter(Boolean).join(' and ') || null }}" + } + ], + "pathToData": "value", + "paging": { + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "$top", + "value": "100" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "value" + }, + "base": 0 + }, + "out": { + "realm": "queryArg", + "path": "$skip" + } + }, + "errorHandling": { + "type": "script", + "script": "errorHandling/odataError.js" + } + }, + "matches": "none", + "ui": [ + { + "type": "text", + "name": "filter", + "label": "Additional filter", + "placeholder": "CIType eq 'Server'", + "help": "Applied on top of the configuration item filter set on the data source, using the OData `$filter` syntax.", + "tileEditorStep": [ + "Parameters" + ] + } + ], + "metadata": [ + { + "name": "Name", + "displayName": "Name", + "shape": "string", + "role": "label" + }, + { + "name": "CIType", + "displayName": "Type", + "shape": "string" + }, + { + "name": "Status", + "displayName": "Status", + "shape": [ + "state", + { + "map": { + "success": [ + "Active", + "Operational", + "Deployed" + ], + "warning": [ + "Maintenance", + "Pending", + "In Repair" + ], + "error": [ + "Retired", + "Disposed", + "Inactive" + ], + "unknown": [ + "In Stock", + "Ordered" + ] + } + } + ] + }, + { + "name": "Owner", + "displayName": "Owner", + "shape": "string" + }, + { + "name": "PhysicalLocation", + "displayName": "Location", + "shape": "string" + }, + { + "name": "SerialNumber", + "displayName": "Serial Number", + "shape": "string" + }, + { + "name": "Manufacturer", + "displayName": "Manufacturer", + "shape": "string" + }, + { + "name": "Model", + "displayName": "Model", + "shape": "string" + }, + { + "name": "AssetTag", + "displayName": "Asset Tag", + "shape": "string", + "visible": false + }, + { + "name": "IPAddress", + "displayName": "IP Address", + "shape": "string", + "visible": false + }, + { + "name": "OperatingSystem", + "displayName": "Operating System", + "shape": "string", + "visible": false + }, + { + "name": "CreatedDateTime", + "displayName": "Created", + "shape": "date", + "visible": false + }, + { + "name": "LastModDateTime", + "displayName": "Last Modified", + "shape": "date" + }, + { + "name": "RecId", + "displayName": "Record ID", + "shape": "guid", + "role": "id", + "visible": false + }, + { + "name": "OwnerTeam", + "displayName": "Owner Team", + "shape": "string" + }, + { + "name": "Description", + "displayName": "Description", + "shape": "string", + "visible": false + }, + { + "name": "ivnt_AssignedUserName", + "displayName": "Assigned User", + "shape": "string", + "visible": false + }, + { + "name": "ComplianceState", + "displayName": "Compliance State", + "shape": "string", + "visible": false + }, + { + "name": "ivnt_WarrantyExpiry", + "displayName": "Warranty Expiry", + "shape": "date", + "visible": false + }, + { + "name": "LastScanDateTime", + "displayName": "Last Scanned", + "shape": "date", + "visible": false + }, + { + "pattern": ".*" + } + ], + "timeframes": false +} diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/connectionCheck.json b/plugins/Ivanti-ITSM/v1/dataStreams/connectionCheck.json new file mode 100644 index 00000000..cde540dc --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/dataStreams/connectionCheck.json @@ -0,0 +1,25 @@ +{ + "name": "connectionCheck", + "displayName": "Connection Check", + "description": "A single employee record, returned to confirm the tenant URL and REST API key are accepted", + "tags": ["Status", "Service Management"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/employees", + "getArgs": [{ "key": "$top", "value": "1" }], + "pathToData": "value", + "paging": { "mode": "none" }, + "errorHandling": { + "type": "script", + "script": "errorHandling/odataError.js" + } + }, + "matches": "none", + "visibility": { "type": "hidden" }, + "metadata": [ + { "name": "RecId", "displayName": "Record ID", "shape": "guid", "role": "id" }, + { "pattern": ".*" } + ], + "timeframes": false +} diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json b/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json new file mode 100644 index 00000000..d360817b --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json @@ -0,0 +1,333 @@ +{ + "name": "incidents", + "displayName": "Incidents", + "description": "Incidents, one row per record, with status, priority, category, ownership and SLA breach flags", + "tags": [ + "Incidents", + "Service Management" + ], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/incidents", + "getArgs": [ + { + "key": "$top", + "value": "{{maxRecords || dataSource.maxRecords || 99}}" + }, + { + "key": "$filter", + "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" + }, + { + "key": "$select", + "value": "{{ typeof select !== 'undefined' ? select : null }}" + }, + { + "key": "$orderby", + "value": "{{ (typeof orderBy !== 'undefined' && orderBy) || 'CreatedDateTime desc' }}" + } + ], + "pathToData": "value", + "paging": { + "mode": "none" + }, + "errorHandling": { + "type": "script", + "script": "errorHandling/odataError.js" + } + }, + "matches": "none", + "ui": [ + { + "type": "switch", + "name": "dateField", + "label": "Timeframe applies to", + "defaultValue": "CreatedDateTime", + "help": "Which date the selected timeframe filters on. Set the timeframe to **None** to ignore dates and return the current backlog.", + "options": [ + { + "value": "CreatedDateTime", + "label": "Created" + }, + { + "value": "LastModDateTime", + "label": "Last modified" + }, + { + "value": "ResolvedDateTime", + "label": "Resolved" + }, + { + "value": "ClosedDateTime", + "label": "Closed" + } + ], + "tileEditorStep": [ + "Timeframe" + ] + }, + { + "type": "autocomplete", + "name": "status", + "label": "Status", + "isMulti": true, + "isClearable": true, + "allowCustomValues": true, + "help": "Filters in Ivanti rather than in the browser, so it also keeps large result sets under the record ceiling. Type a value to use a status this tenant has added.", + "data": { + "source": "fixed", + "values": [ + { + "value": "Logged", + "label": "Logged" + }, + { + "value": "Active", + "label": "Active" + }, + { + "value": "Waiting for Customer", + "label": "Waiting for Customer" + }, + { + "value": "Resolved", + "label": "Resolved" + }, + { + "value": "Closed", + "label": "Closed" + } + ] + } + }, + { + "type": "objects", + "name": "team", + "label": "Team", + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "Team" + ] + } + } + }, + { + "type": "text", + "name": "filter", + "label": "Additional filter", + "placeholder": "Category eq 'Hardware'", + "help": "Appended to the filter this tile already builds, using the OData `$filter` syntax." + }, + { + "type": "text", + "name": "select", + "label": "Field(s)", + "placeholder": "RecId,Subject,Status,CreatedDateTime", + "help": "Limits the response to these fields. Worth setting for a business object with many fields, where the full payload is slow or too large. A field name that does not exist fails the whole request." + }, + { + "type": "text", + "name": "orderBy", + "label": "Order by", + "placeholder": "CreatedDateTime desc", + "help": "Ivanti applies this before the record ceiling, so it decides which records a truncated result keeps. Uses the OData `$orderby` syntax." + }, + { + "type": "number", + "name": "maxRecords", + "label": "Maximum records", + "help": "Ivanti rejects a request above the tenant page limit outright - the default limit is 100 and only an administrator can raise it. Narrow the status, team or timeframe filters instead.", + "validation": { + "min": 1, + "max": 1000, + "valueAsNumber": true + } + } + ], + "metadata": [ + { + "name": "Subject", + "displayName": "Subject", + "shape": "string", + "role": "label" + }, + { + "name": "IncidentNumber", + "displayName": "Incident Number", + "shape": "string", + "role": "id" + }, + { + "name": "Status", + "displayName": "Status", + "shape": "string" + }, + { + "name": "Priority", + "displayName": "Priority", + "shape": "string" + }, + { + "name": "Urgency", + "displayName": "Urgency", + "shape": "string" + }, + { + "name": "Impact", + "displayName": "Impact", + "shape": "string" + }, + { + "name": "Category", + "displayName": "Category", + "shape": "string" + }, + { + "name": "Subcategory", + "displayName": "Subcategory", + "shape": "string" + }, + { + "name": "Service", + "displayName": "Service", + "shape": "string" + }, + { + "name": "Owner", + "displayName": "Owner", + "shape": "string" + }, + { + "name": "OwnerTeam", + "displayName": "Owner Team", + "shape": "string" + }, + { + "name": "ProfileFullName", + "displayName": "Customer", + "shape": "string" + }, + { + "name": "LoginId", + "displayName": "Customer Login", + "shape": "string", + "visible": false + }, + { + "name": "Source", + "displayName": "Source", + "shape": "string" + }, + { + "name": "CreatedDateTime", + "displayName": "Created", + "shape": "date", + "role": "timestamp" + }, + { + "name": "LastModDateTime", + "displayName": "Last Modified", + "shape": "date" + }, + { + "name": "ResolvedDateTime", + "displayName": "Resolved", + "shape": "date" + }, + { + "name": "ClosedDateTime", + "displayName": "Closed", + "shape": "date" + }, + { + "name": "RespondedDateTime", + "displayName": "Responded", + "shape": "date", + "visible": false + }, + { + "name": "ageDays", + "displayName": "Age (days)", + "computed": true, + "valueExpression": "{{ $['CreatedDateTime'] ? Math.floor((Date.now() - new Date($['CreatedDateTime']).getTime()) / 86400000) : null }}", + "shape": "number" + }, + { + "name": "IsInFinalState", + "displayName": "Closed Out", + "shape": "boolean", + "visible": false + }, + { + "name": "IsResponseBreached", + "displayName": "Response SLA Breached", + "shape": "boolean" + }, + { + "name": "IsResolutionBreached", + "displayName": "Resolution SLA Breached", + "shape": "boolean" + }, + { + "name": "SLA", + "displayName": "SLA", + "shape": "string", + "visible": false + }, + { + "name": "IsVIP", + "displayName": "VIP", + "shape": "boolean", + "visible": false + }, + { + "name": "TotalTimeSpent", + "displayName": "Time Spent", + "shape": "minutes", + "visible": false + }, + { + "name": "Symptom", + "displayName": "Description", + "shape": "string", + "visible": false + }, + { + "name": "Resolution", + "displayName": "Resolution", + "shape": "string", + "visible": false + }, + { + "name": "CreatedBy", + "displayName": "Created By", + "shape": "string", + "visible": false + }, + { + "name": "RecId", + "displayName": "Record ID", + "shape": "guid", + "visible": false + }, + { + "pattern": ".*" + } + ], + "defaultShaping": { + "sort": { + "by": [ + [ + "CreatedDateTime", + "desc" + ] + ] + } + }, + "timeframes": true, + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" +} diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json b/plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json new file mode 100644 index 00000000..f501da25 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json @@ -0,0 +1,68 @@ +{ + "name": "knowledgeArticles", + "displayName": "Knowledge Articles", + "description": "Knowledge base articles, one row per article, with status and category", + "tags": ["Resources", "Service Management"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/FRS_Knowledges", + "getArgs": [ + { + "key": "$filter", + "value": "{{ typeof filter !== 'undefined' ? filter : null }}" + } + ], + "pathToData": "value", + "paging": { + "mode": "offset", + "pageSize": { "realm": "queryArg", "path": "$top", "value": "100" }, + "offset": { + "mode": "row", + "rowCountIn": { "realm": "payloadArraySize", "path": "value" }, + "base": 0 + }, + "out": { "realm": "queryArg", "path": "$skip" } + }, + "errorHandling": { + "type": "script", + "script": "errorHandling/odataError.js" + } + }, + "matches": "none", + "ui": [ + { + "type": "text", + "name": "filter", + "label": "Additional filter", + "placeholder": "Status eq 'Published'", + "help": "Restricts which articles are returned, using the OData `$filter` syntax." + } + ], + "metadata": [ + { "name": "Title", "displayName": "Title", "shape": "string", "role": "label" }, + { "name": "Subject", "displayName": "Subject", "shape": "string", "visible": false }, + { + "name": "Status", + "displayName": "Status", + "shape": [ + "state", + { + "map": { + "success": ["Published", "Approved"], + "warning": ["Draft", "Pending Approval", "In Review"], + "error": ["Retired", "Rejected", "Archived"] + } + } + ] + }, + { "name": "Category", "displayName": "Category", "shape": "string" }, + { "name": "LastModDateTime", "displayName": "Last Modified", "shape": "date", "role": "timestamp" }, + { "name": "RecId", "displayName": "Record ID", "shape": "guid", "role": "id", "visible": false }, + { "pattern": ".*" } + ], + "defaultShaping": { + "sort": { "by": [["LastModDateTime", "desc"]] } + }, + "timeframes": false +} diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/problems.json b/plugins/Ivanti-ITSM/v1/dataStreams/problems.json new file mode 100644 index 00000000..4ff52e09 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/dataStreams/problems.json @@ -0,0 +1,302 @@ +{ + "name": "problems", + "displayName": "Problems", + "description": "Problem records, one row per record, with status, category, root cause and ownership", + "tags": [ + "Tickets", + "Service Management" + ], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/problems", + "getArgs": [ + { + "key": "$top", + "value": "{{maxRecords || dataSource.maxRecords || 99}}" + }, + { + "key": "$filter", + "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" + }, + { + "key": "$select", + "value": "{{ typeof select !== 'undefined' ? select : null }}" + }, + { + "key": "$orderby", + "value": "{{ (typeof orderBy !== 'undefined' && orderBy) || 'CreatedDateTime desc' }}" + } + ], + "pathToData": "value", + "paging": { + "mode": "none" + }, + "errorHandling": { + "type": "script", + "script": "errorHandling/odataError.js" + } + }, + "matches": "none", + "ui": [ + { + "type": "switch", + "name": "dateField", + "label": "Timeframe applies to", + "defaultValue": "CreatedDateTime", + "help": "Which date the selected timeframe filters on. Set the timeframe to **None** to ignore dates and return the current backlog.", + "options": [ + { + "value": "CreatedDateTime", + "label": "Created" + }, + { + "value": "LastModDateTime", + "label": "Last modified" + }, + { + "value": "ClosedDateTime", + "label": "Closed" + } + ], + "tileEditorStep": [ + "Timeframe" + ] + }, + { + "type": "autocomplete", + "name": "status", + "label": "Status", + "isMulti": true, + "isClearable": true, + "allowCustomValues": true, + "help": "Filters in Ivanti rather than in the browser, so it also keeps large result sets under the record ceiling. Type a value to use a status this tenant has added.", + "data": { + "source": "fixed", + "values": [ + { + "value": "Logged", + "label": "Logged" + }, + { + "value": "Active", + "label": "Active" + }, + { + "value": "Known Error", + "label": "Known Error" + }, + { + "value": "Resolved", + "label": "Resolved" + }, + { + "value": "Closed", + "label": "Closed" + } + ] + } + }, + { + "type": "objects", + "name": "team", + "label": "Team", + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "Team" + ] + } + } + }, + { + "type": "text", + "name": "filter", + "label": "Additional filter", + "placeholder": "Category eq 'Hardware'", + "help": "Appended to the filter this tile already builds, using the OData `$filter` syntax." + }, + { + "type": "text", + "name": "select", + "label": "Field(s)", + "placeholder": "RecId,Subject,Status,CreatedDateTime", + "help": "Limits the response to these fields. Worth setting for a business object with many fields, where the full payload is slow or too large. A field name that does not exist fails the whole request." + }, + { + "type": "text", + "name": "orderBy", + "label": "Order by", + "placeholder": "CreatedDateTime desc", + "help": "Ivanti applies this before the record ceiling, so it decides which records a truncated result keeps. Uses the OData `$orderby` syntax." + }, + { + "type": "number", + "name": "maxRecords", + "label": "Maximum records", + "help": "Ivanti rejects a request above the tenant page limit outright - the default limit is 100 and only an administrator can raise it. Narrow the status, team or timeframe filters instead.", + "validation": { + "min": 1, + "max": 1000, + "valueAsNumber": true + } + } + ], + "metadata": [ + { + "name": "Subject", + "displayName": "Subject", + "shape": "string", + "role": "label" + }, + { + "name": "ProblemNumber", + "displayName": "Problem Number", + "shape": "string", + "role": "id" + }, + { + "name": "Status", + "displayName": "Status", + "shape": "string" + }, + { + "name": "Priority", + "displayName": "Priority", + "shape": "string" + }, + { + "name": "Urgency", + "displayName": "Urgency", + "shape": "string" + }, + { + "name": "Impact", + "displayName": "Impact", + "shape": "string" + }, + { + "name": "TypeOfProblem", + "displayName": "Type", + "shape": "string" + }, + { + "name": "Category", + "displayName": "Category", + "shape": "string" + }, + { + "name": "Service", + "displayName": "Service", + "shape": "string" + }, + { + "name": "Owner", + "displayName": "Owner", + "shape": "string" + }, + { + "name": "OwnerTeam", + "displayName": "Owner Team", + "shape": "string" + }, + { + "name": "Source", + "displayName": "Source", + "shape": "string" + }, + { + "name": "CreatedDateTime", + "displayName": "Created", + "shape": "date", + "role": "timestamp" + }, + { + "name": "LastModDateTime", + "displayName": "Last Modified", + "shape": "date" + }, + { + "name": "ClosedDateTime", + "displayName": "Closed", + "shape": "date" + }, + { + "name": "KnownErrorDate", + "displayName": "Known Error Since", + "shape": "date" + }, + { + "name": "ageDays", + "displayName": "Age (days)", + "computed": true, + "valueExpression": "{{ $['CreatedDateTime'] ? Math.floor((Date.now() - new Date($['CreatedDateTime']).getTime()) / 86400000) : null }}", + "shape": "number" + }, + { + "name": "IsInFinalState", + "displayName": "Closed Out", + "shape": "boolean", + "visible": false + }, + { + "name": "TotalTimeSpent", + "displayName": "Time Spent", + "shape": "minutes", + "visible": false + }, + { + "name": "Description", + "displayName": "Description", + "shape": "string", + "visible": false + }, + { + "name": "RootCause", + "displayName": "Root Cause", + "shape": "string", + "visible": false + }, + { + "name": "Workaround", + "displayName": "Workaround", + "shape": "string", + "visible": false + }, + { + "name": "Resolution", + "displayName": "Resolution", + "shape": "string", + "visible": false + }, + { + "name": "CreatedBy", + "displayName": "Created By", + "shape": "string", + "visible": false + }, + { + "name": "RecId", + "displayName": "Record ID", + "shape": "guid", + "visible": false + }, + { + "pattern": ".*" + } + ], + "defaultShaping": { + "sort": { + "by": [ + [ + "CreatedDateTime", + "desc" + ] + ] + } + }, + "timeframes": true, + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" +} diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/scripts/errorHandling/odataError.js b/plugins/Ivanti-ITSM/v1/dataStreams/scripts/errorHandling/odataError.js new file mode 100644 index 00000000..cda217c3 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/dataStreams/scripts/errorHandling/odataError.js @@ -0,0 +1,20 @@ +// Ivanti reports failures in several shapes depending on where the request is +// rejected: OData surfaces { error: { message } } (message is sometimes an +// object with a `value`), while the API gateway returns a bare { Message }. +const error = (data && data.error) || {}; +const message = error.message && error.message.value ? error.message.value : error.message; +const apiMessage = message || (data && (data.Message || data.message)); + +let hint = ""; +if (response.status === 401 || response.status === 403) { + // An ISM REST API key is bound to the tenant and IP it was created for, and + // inherits the role of its "On Behalf Of" account - so a key that works + // elsewhere can still be rejected here. + hint = + " Check the REST API key belongs to this tenant, is activated, and that its role can read this business object."; +} else if (response.status === 404) { + hint = + " Check the tenant URL, and that the business object name is spelled as the API expects it (the object name with an 's' on the end)."; +} + +result = (apiMessage || "Ivanti returned HTTP " + response.status) + hint; diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json b/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json new file mode 100644 index 00000000..57fabb20 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json @@ -0,0 +1,301 @@ +{ + "name": "serviceRequests", + "displayName": "Service Requests", + "description": "Service requests, one row per record, with status, urgency and ownership", + "tags": [ + "Tickets", + "Service Management" + ], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/servicereqs", + "getArgs": [ + { + "key": "$top", + "value": "{{maxRecords || dataSource.maxRecords || 99}}" + }, + { + "key": "$filter", + "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" + }, + { + "key": "$select", + "value": "{{ typeof select !== 'undefined' ? select : null }}" + }, + { + "key": "$orderby", + "value": "{{ (typeof orderBy !== 'undefined' && orderBy) || 'CreatedDateTime desc' }}" + } + ], + "pathToData": "value", + "paging": { + "mode": "none" + }, + "errorHandling": { + "type": "script", + "script": "errorHandling/odataError.js" + } + }, + "matches": "none", + "ui": [ + { + "type": "switch", + "name": "dateField", + "label": "Timeframe applies to", + "defaultValue": "CreatedDateTime", + "help": "Which date the selected timeframe filters on. Set the timeframe to **None** to ignore dates and return the current backlog.", + "options": [ + { + "value": "CreatedDateTime", + "label": "Created" + }, + { + "value": "LastModDateTime", + "label": "Last modified" + }, + { + "value": "ResolvedDateTime", + "label": "Resolved" + }, + { + "value": "ClosedDateTime", + "label": "Closed" + } + ], + "tileEditorStep": [ + "Timeframe" + ] + }, + { + "type": "autocomplete", + "name": "status", + "label": "Status", + "isMulti": true, + "isClearable": true, + "allowCustomValues": true, + "help": "Filters in Ivanti rather than in the browser, so it also keeps large result sets under the record ceiling. Type a value to use a status this tenant has added.", + "data": { + "source": "fixed", + "values": [ + { + "value": "Logged", + "label": "Logged" + }, + { + "value": "Active", + "label": "Active" + }, + { + "value": "Waiting for Approval", + "label": "Waiting for Approval" + }, + { + "value": "Fulfilled", + "label": "Fulfilled" + }, + { + "value": "Cancelled", + "label": "Cancelled" + }, + { + "value": "Closed", + "label": "Closed" + } + ] + } + }, + { + "type": "objects", + "name": "team", + "label": "Team", + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "Team" + ] + } + } + }, + { + "type": "text", + "name": "filter", + "label": "Additional filter", + "placeholder": "Category eq 'Hardware'", + "help": "Appended to the filter this tile already builds, using the OData `$filter` syntax." + }, + { + "type": "text", + "name": "select", + "label": "Field(s)", + "placeholder": "RecId,Subject,Status,CreatedDateTime", + "help": "Limits the response to these fields. Worth setting for a business object with many fields, where the full payload is slow or too large. A field name that does not exist fails the whole request." + }, + { + "type": "text", + "name": "orderBy", + "label": "Order by", + "placeholder": "CreatedDateTime desc", + "help": "Ivanti applies this before the record ceiling, so it decides which records a truncated result keeps. Uses the OData `$orderby` syntax." + }, + { + "type": "number", + "name": "maxRecords", + "label": "Maximum records", + "help": "Ivanti rejects a request above the tenant page limit outright - the default limit is 100 and only an administrator can raise it. Narrow the status, team or timeframe filters instead.", + "validation": { + "min": 1, + "max": 1000, + "valueAsNumber": true + } + } + ], + "metadata": [ + { + "name": "Subject", + "displayName": "Subject", + "shape": "string", + "role": "label" + }, + { + "name": "ServiceReqNumber", + "displayName": "Request Number", + "shape": "string", + "role": "id" + }, + { + "name": "Status", + "displayName": "Status", + "shape": "string" + }, + { + "name": "Urgency", + "displayName": "Urgency", + "shape": "string" + }, + { + "name": "Service", + "displayName": "Service", + "shape": "string" + }, + { + "name": "Owner", + "displayName": "Owner", + "shape": "string" + }, + { + "name": "OwnerTeam", + "displayName": "Owner Team", + "shape": "string" + }, + { + "name": "ProfileFullName", + "displayName": "Customer", + "shape": "string" + }, + { + "name": "LoginId", + "displayName": "Customer Login", + "shape": "string", + "visible": false + }, + { + "name": "Source", + "displayName": "Source", + "shape": "string" + }, + { + "name": "CreatedDateTime", + "displayName": "Created", + "shape": "date", + "role": "timestamp" + }, + { + "name": "LastModDateTime", + "displayName": "Last Modified", + "shape": "date" + }, + { + "name": "ResolvedDateTime", + "displayName": "Resolved", + "shape": "date" + }, + { + "name": "ClosedDateTime", + "displayName": "Closed", + "shape": "date" + }, + { + "name": "ageDays", + "displayName": "Age (days)", + "computed": true, + "valueExpression": "{{ $['CreatedDateTime'] ? Math.floor((Date.now() - new Date($['CreatedDateTime']).getTime()) / 86400000) : null }}", + "shape": "number" + }, + { + "name": "IsInFinalState", + "displayName": "Closed Out", + "shape": "boolean", + "visible": false + }, + { + "name": "SLA", + "displayName": "SLA", + "shape": "string", + "visible": false + }, + { + "name": "IsVIP", + "displayName": "VIP", + "shape": "boolean", + "visible": false + }, + { + "name": "TotalTimeSpent", + "displayName": "Time Spent", + "shape": "minutes", + "visible": false + }, + { + "name": "Symptom", + "displayName": "Description", + "shape": "string", + "visible": false + }, + { + "name": "Resolution", + "displayName": "Resolution", + "shape": "string", + "visible": false + }, + { + "name": "CreatedBy", + "displayName": "Created By", + "shape": "string", + "visible": false + }, + { + "name": "RecId", + "displayName": "Record ID", + "shape": "guid", + "visible": false + }, + { + "pattern": ".*" + } + ], + "defaultShaping": { + "sort": { + "by": [ + [ + "CreatedDateTime", + "desc" + ] + ] + } + }, + "timeframes": true, + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" +} diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/teams.json b/plugins/Ivanti-ITSM/v1/dataStreams/teams.json new file mode 100644 index 00000000..b19a7390 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/dataStreams/teams.json @@ -0,0 +1,106 @@ +{ + "name": "teams", + "displayName": "Teams", + "description": "Ticket-owning teams, one row per team", + "tags": [ + "Organization", + "Service Management" + ], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/{{dataSource.teamBusinessObject || 'standarduserteams'}}", + "getArgs": [ + { + "key": "$filter", + "value": "{{ typeof filter !== 'undefined' ? filter : null }}" + } + ], + "pathToData": "value", + "paging": { + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "$top", + "value": "100" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "value" + }, + "base": 0 + }, + "out": { + "realm": "queryArg", + "path": "$skip" + } + }, + "errorHandling": { + "type": "script", + "script": "errorHandling/odataError.js" + } + }, + "matches": "none", + "ui": [ + { + "type": "text", + "name": "filter", + "label": "Additional filter", + "placeholder": "Status eq 'Active'", + "help": "Restricts which teams are returned, using the OData `$filter` syntax." + } + ], + "metadata": [ + { + "name": "Team", + "displayName": "Team", + "shape": "string", + "role": "label" + }, + { + "name": "TeamEmail", + "displayName": "Team Email", + "shape": "string" + }, + { + "name": "TeamManager", + "displayName": "Manager", + "shape": "string" + }, + { + "name": "TeamManagerEmail", + "displayName": "Manager Email", + "shape": "string", + "visible": false + }, + { + "name": "IsServiceDesk", + "displayName": "Service Desk", + "shape": "boolean" + }, + { + "name": "TeamLevel", + "displayName": "Level", + "shape": "string" + }, + { + "name": "LastModDateTime", + "displayName": "Last Modified", + "shape": "date", + "visible": false + }, + { + "name": "RecId", + "displayName": "Record ID", + "shape": "guid", + "role": "id", + "visible": false + }, + { + "pattern": ".*" + } + ], + "timeframes": false +} diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json b/plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json new file mode 100644 index 00000000..abacddf3 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json @@ -0,0 +1,420 @@ +{ + "name": "Change And Problem Management", + "schemaVersion": "1.5", + "timeframe": "last30days", + "variables": [], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "73331821-35a2-4cbe-ba20-ccab633f4886", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Changes", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[changes]}}", + "name": "changes", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [ + { + "type": "count" + } + ] + }, + "dataSourceConfig": { + "filter": "Status ne 'Implemented' and Status ne 'Rejected' and Status ne 'Closed' and Status ne 'Cancelled'" + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Open changes" + } + } + } + } + }, + { + "i": "77e182ad-fada-4572-b71a-a22c207ad9e0", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Changes Pending Approval", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[changes]}}", + "name": "changes", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [ + { + "type": "count" + } + ] + }, + "dataSourceConfig": { + "filter": "Status eq 'Pending Approval'" + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Pending approval" + } + } + } + } + }, + { + "i": "d0609c38-de8d-4f62-b7a7-094ed2eee4a9", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Problems", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[problems]}}", + "name": "problems", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [ + { + "type": "count" + } + ] + }, + "dataSourceConfig": { + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'" + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Open problems" + } + } + } + } + }, + { + "i": "c3a9213d-8142-4952-9c42-6a7d2b2c2ea6", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Known Errors", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[problems]}}", + "name": "problems", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [ + { + "type": "count" + } + ] + }, + "dataSourceConfig": { + "filter": "Status eq 'Known Error'" + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Known errors" + } + } + } + } + }, + { + "i": "7d6fa5d6-4003-40ea-9179-7455a49e963c", + "x": 0, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Change Pipeline by Status", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[changes]}}", + "name": "changes", + "pluginConfigId": "{{configId}}", + "group": { + "by": [ + [ + "Status", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "sort": { + "by": [ + [ + "count", + "desc" + ] + ], + "top": 10 + } + }, + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "Status_uniqueValues", + "yAxisData": [ + "count" + ], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Changes", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "vertical", + "displayMode": "actual", + "showTotals": false, + "showValue": true, + "grouping": false, + "range": { + "type": "auto" + } + } + } + } + } + }, + { + "i": "89cd7354-05d4-4241-beb1-6c06198ce872", + "x": 2, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Problems by Status", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[problems]}}", + "name": "problems", + "pluginConfigId": "{{configId}}", + "group": { + "by": [ + [ + "Status", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "dataSourceConfig": { + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'" + } + }, + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "Status_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "4980a2ab-6296-4dde-bfe7-376e15f7cb1a", + "x": 0, + "y": 5, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Recent Changes", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[changes]}}", + "name": "changes", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [ + [ + "CreatedDateTime", + "desc" + ] + ], + "top": 10 + } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "Subject", + "ChangeNumber", + "Status", + "Priority", + "OwnerTeam", + "CreatedDateTime" + ] + } + } + } + } + }, + { + "i": "5369486c-ce2d-4ec1-a5ab-3c918b44fb4c", + "x": 0, + "y": 9, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Problems", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[problems]}}", + "name": "problems", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [ + [ + "CreatedDateTime", + "desc" + ] + ], + "top": 10 + }, + "dataSourceConfig": { + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'" + } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "Subject", + "ProblemNumber", + "Status", + "Priority", + "OwnerTeam", + "CreatedDateTime" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/configurationItem.dash.json b/plugins/Ivanti-ITSM/v1/defaultContent/configurationItem.dash.json new file mode 100644 index 00000000..d567e504 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/defaultContent/configurationItem.dash.json @@ -0,0 +1,103 @@ +{ + "name": "Configuration Item", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": [ + "{{variables.[Configuration Item]}}" + ], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "fd385beb-7ba1-44c7-aa61-3f4fa038d7f8", + "x": 0, + "y": 0, + "w": 1, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Details", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-properties", + "name": "properties" + }, + "scope": { + "scope": "{{scopes.[Configuration Items]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Configuration Item]}}" + }, + "variables": [ + "{{variables.[Configuration Item]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true + } + } + } + } + }, + { + "i": "029c3345-e766-4224-96c9-bc20f73c6656", + "x": 1, + "y": 0, + "w": 3, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Configuration Items", + "description": "All configuration items in the CMDB", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[configurationItems]}}", + "name": "configurationItems", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [ + [ + "Name", + "asc" + ] + ] + } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "Name", + "CIType", + "Status", + "Owner", + "PhysicalLocation", + "Manufacturer", + "Model" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/manifest.json b/plugins/Ivanti-ITSM/v1/defaultContent/manifest.json new file mode 100644 index 00000000..cde0b999 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/defaultContent/manifest.json @@ -0,0 +1,8 @@ +{ + "items": [ + { "name": "serviceDeskOverview", "type": "dashboard" }, + { "name": "changeAndProblemManagement", "type": "dashboard" }, + { "name": "configurationItem", "type": "dashboard" }, + { "name": "team", "type": "dashboard" } + ] +} diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/scopes.json b/plugins/Ivanti-ITSM/v1/defaultContent/scopes.json new file mode 100644 index 00000000..e1af1a66 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/defaultContent/scopes.json @@ -0,0 +1,26 @@ +[ + { + "name": "Configuration Items", + "matches": { + "sourceType": { "type": "oneOf", "values": ["Configuration Item"] } + }, + "variable": { + "name": "Configuration Item", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + }, + { + "name": "Teams", + "matches": { + "sourceType": { "type": "oneOf", "values": ["Team"] } + }, + "variable": { + "name": "Team", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + } +] diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json b/plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json new file mode 100644 index 00000000..5462b500 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json @@ -0,0 +1,634 @@ +{ + "name": "Service Desk Overview", + "schemaVersion": "1.5", + "timeframe": "last30days", + "variables": [], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "86e48ef2-fc74-49cb-90d4-0357ce31c9ad", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Incidents", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [ + { + "type": "count" + } + ] + }, + "dataSourceConfig": { + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Open incidents" + } + } + } + } + }, + { + "i": "97de2f40-4ac0-4de9-b217-bbd58bb14b96", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Service Requests", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[serviceRequests]}}", + "name": "serviceRequests", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [ + { + "type": "count" + } + ] + }, + "dataSourceConfig": { + "filter": "Status ne 'Fulfilled' and Status ne 'Cancelled' and Status ne 'Closed'", + "select": "Subject,ServiceReqNumber,Status,Urgency,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,IsInFinalState,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Open requests" + } + } + } + } + }, + { + "i": "c2602ff9-b29e-440a-a7b7-a347a0df2485", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Average Incident Age (Days)", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [ + { + "type": "mean", + "names": [ + "ageDays" + ] + } + ] + }, + "dataSourceConfig": { + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "ageDays_mean", + "comparisonColumn": "none", + "label": "Average age (days)" + } + } + } + } + }, + { + "i": "88c8f4c5-a0eb-409a-9b61-9142601a9a32", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Unassigned Open Incidents", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "Owner", + "operation": "empty" + } + ] + }, + "group": { + "by": [], + "aggregate": [ + { + "type": "count" + } + ] + }, + "dataSourceConfig": { + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Unassigned" + } + } + } + } + }, + { + "i": "e4b97ea1-d454-4207-a534-6c1454705afa", + "x": 0, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Incidents by Status", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "group": { + "by": [ + [ + "Status", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "dataSourceConfig": { + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + } + }, + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "Status_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "a1106a2c-794c-4a85-8a84-3417e3be6bc1", + "x": 2, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Incidents by Priority", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "group": { + "by": [ + [ + "Priority", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "dataSourceConfig": { + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + } + }, + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "Priority_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "c091f41d-fcde-4eb3-afb2-1d37f4882909", + "x": 0, + "y": 5, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Incidents by Category", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "group": { + "by": [ + [ + "Category", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "sort": { + "by": [ + [ + "count", + "desc" + ] + ], + "top": 10 + }, + "dataSourceConfig": { + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + } + }, + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "Category_uniqueValues", + "yAxisData": [ + "count" + ], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Incidents", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "vertical", + "displayMode": "actual", + "showTotals": false, + "showValue": false, + "grouping": false, + "range": { + "type": "auto" + } + } + } + } + } + }, + { + "i": "52f81450-3593-46b6-b5ca-b9d40df3b92c", + "x": 2, + "y": 5, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Incidents by Owner Team", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "group": { + "by": [ + [ + "OwnerTeam", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "sort": { + "by": [ + [ + "count", + "desc" + ] + ], + "top": 10 + }, + "dataSourceConfig": { + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + } + }, + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "OwnerTeam_uniqueValues", + "yAxisData": [ + "count" + ], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Incidents", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "horizontal", + "displayMode": "actual", + "showTotals": false, + "showValue": true, + "grouping": false, + "range": { + "type": "auto" + } + } + } + } + } + }, + { + "i": "bc3fb880-32da-4cfc-8c27-1c9a4a8f71fd", + "x": 0, + "y": 8, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Service Requests by Status", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[serviceRequests]}}", + "name": "serviceRequests", + "pluginConfigId": "{{configId}}", + "group": { + "by": [ + [ + "Status", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "dataSourceConfig": { + "filter": "Status ne 'Fulfilled' and Status ne 'Cancelled' and Status ne 'Closed'", + "select": "Subject,ServiceReqNumber,Status,Urgency,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,IsInFinalState,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + } + }, + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "Status_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "50e5b5a9-2f3d-4c60-bae3-9bacc23abac3", + "x": 2, + "y": 8, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Oldest Open Incidents", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [ + [ + "ageDays", + "desc" + ] + ], + "top": 10 + }, + "dataSourceConfig": { + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "orderBy": "CreatedDateTime asc", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "Subject", + "IncidentNumber", + "Status", + "Priority", + "OwnerTeam", + "ageDays" + ], + "columnDisplayNames": { + "ageDays": "Age (days)" + } + } + } + } + } + }, + { + "i": "a90fcafe-6024-4575-b1a9-518fae9c48a7", + "x": 0, + "y": 11, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Recent Incidents", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [ + [ + "CreatedDateTime", + "desc" + ] + ], + "top": 10 + }, + "dataSourceConfig": { + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "Subject", + "IncidentNumber", + "Status", + "Priority", + "OwnerTeam", + "CreatedDateTime" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/team.dash.json b/plugins/Ivanti-ITSM/v1/defaultContent/team.dash.json new file mode 100644 index 00000000..96fda288 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/defaultContent/team.dash.json @@ -0,0 +1,436 @@ +{ + "name": "Team", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": [ + "{{variables.[Team]}}" + ], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "a7b296df-bd39-4a0e-a9d4-99fba5b45383", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Incidents", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "team": { + "variable": "{{variables.[Team]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Teams]}}" + }, + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + }, + "group": { + "by": [], + "aggregate": [ + { + "type": "count" + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Open incidents" + } + } + } + } + }, + { + "i": "c07842b5-56b1-484e-b401-0dbdcf8d1822", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Service Requests", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[serviceRequests]}}", + "name": "serviceRequests", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "team": { + "variable": "{{variables.[Team]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Teams]}}" + }, + "filter": "Status ne 'Fulfilled' and Status ne 'Cancelled' and Status ne 'Closed'", + "select": "Subject,ServiceReqNumber,Status,Urgency,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,IsInFinalState,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + }, + "group": { + "by": [], + "aggregate": [ + { + "type": "count" + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Open requests" + } + } + } + } + }, + { + "i": "f0dad060-cdbb-497b-b1ba-fa69c6a987eb", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Changes", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[changes]}}", + "name": "changes", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "team": { + "variable": "{{variables.[Team]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Teams]}}" + }, + "filter": "Status ne 'Implemented' and Status ne 'Rejected' and Status ne 'Closed' and Status ne 'Cancelled'" + }, + "group": { + "by": [], + "aggregate": [ + { + "type": "count" + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Open changes" + } + } + } + } + }, + { + "i": "acb0f316-b7de-4f54-a2ed-4275b42c8d46", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Problems", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[problems]}}", + "name": "problems", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "team": { + "variable": "{{variables.[Team]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Teams]}}" + }, + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'" + }, + "group": { + "by": [], + "aggregate": [ + { + "type": "count" + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Open problems" + } + } + } + } + }, + { + "i": "c9a87d60-856c-4899-9e1c-3308a3d28d5b", + "x": 0, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Incidents by Status", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "team": { + "variable": "{{variables.[Team]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Teams]}}" + }, + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + }, + "group": { + "by": [ + [ + "Status", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "Status_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "35c89c94-8b1c-4e69-b44e-98dc348716ce", + "x": 2, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Incidents by Priority", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "team": { + "variable": "{{variables.[Team]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Teams]}}" + }, + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + }, + "group": { + "by": [ + [ + "Priority", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "sort": { + "by": [ + [ + "count", + "desc" + ] + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "Priority_uniqueValues", + "yAxisData": [ + "count" + ], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Incidents", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "vertical", + "displayMode": "actual", + "showTotals": false, + "showValue": true, + "grouping": false, + "range": { + "type": "auto" + } + } + } + } + } + }, + { + "i": "fb7ea14d-f724-4d2b-83ce-25c892d73169", + "x": 0, + "y": 5, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Team's Open Incidents", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[incidents]}}", + "name": "incidents", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "team": { + "variable": "{{variables.[Team]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Teams]}}" + }, + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + }, + "sort": { + "by": [ + [ + "CreatedDateTime", + "desc" + ] + ], + "top": 10 + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "Subject", + "IncidentNumber", + "Status", + "Priority", + "Owner", + "CreatedDateTime" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/Ivanti-ITSM/v1/docs/README.md b/plugins/Ivanti-ITSM/v1/docs/README.md new file mode 100644 index 00000000..0141f9c1 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/docs/README.md @@ -0,0 +1,100 @@ +Monitor your service desk in SquaredUp — incidents, service requests, changes, problems, knowledge articles and the CMDB — using the [Ivanti Neurons for ITSM](https://www.ivanti.com/products/itsm) [REST API](https://docs.ivanti.com/neurons-for-itsm/admin-user-help/enu/latest/rest-api). The plugin works against both the cloud service and an on-premises Ivanti Service Manager instance. + +> ⚠️ This plugin is read-only. It never creates, updates, closes or deletes anything in Ivanti. + +> ⚠️ Ivanti business objects are heavily customizable, so field and object names vary between tenants. The plugin ships with the out-of-the-box names; see [Adapting to a customized tenant](#adapting-to-a-customized-tenant) if yours differ. + +## Before you start + +You need the **Reference ID** of an Ivanti REST API key, and the tenant address you sign in to. + +1. Sign in to the Ivanti **Configuration console** as an administrator. +2. Go to **Configure > Security Controls > API Keys**. +3. If there is no group for REST API keys yet, click **Add Key Group**, give it a name and description, and click **Save Key Group**. +4. Select that group and click **Add API Key**. +5. Fill in the new key: + - **Reference ID** is generated for you — this is the value the plugin needs. + - Tick **Activated**. + - Set **On Behalf Of** to a service account, and **In Role** to the role that account should act as. The key inherits that role, so the role must be able to read incidents, service requests, changes, problems, knowledge articles and configuration items. A read-only role is enough. +6. Click **Save Key**, then copy the **Reference ID**. Ivanti documents these steps in full under [Using the REST API Key](https://docs.ivanti.com/neurons-for-itsm/admin-user-help/enu/latest/using-the-rest-api-key). +7. In SquaredUp, paste the tenant address into **Tenant URL** and the Reference ID into **REST API key**. + +A key only works for the tenant it was created in, and optionally for the IP addresses listed against it. A key from a test tenant will be rejected by production. + +## Configuration fields + +| Field | What it is | Where to find it | Required | +| ----- | ---------- | ---------------- | -------- | +| **Tenant URL** | The address of the Ivanti instance, with no path. The plugin appends `/api/odata/businessobject/…` itself. | The address bar when you are signed in to Ivanti, for example `https://yourtenant.saasiteu.com`. | Yes | +| **REST API key** | Sent as the `Authorization: rest_api_key=…` header on every request. | Configuration console → **Configure > Security Controls > API Keys** → the key's **Reference ID**. | Yes | +| **Maximum records per request** | Default ceiling for tiles that fetch a single unpaged result set. Must not exceed the tenant's page limit, which is 100 unless an Ivanti administrator has raised it. | — | No (defaults to 99) | +| **Configuration item filter** | An OData `$filter` limiting which configuration items are imported, for example `Status eq 'Active'`. | — | No | +| **API version** | Which REST API version to call — version 1 (`/api/odata/…`) or version 2 (`/api/odatav2/…`). Version 2 supports bracketed filters. | — | No (defaults to version 1) | +| **Team business object** | Which business object ticket-owning teams are imported from. The object must expose the team name in a field called `Team`. | — | No (defaults to `standarduserteams`) | +| **Ignore certificate errors** | For an on-premises instance presenting an untrusted certificate. | — | No | + +Everything below **REST API key** is optional and appears only when **Show advanced options** is ticked. + +On save, SquaredUp calls the API for a single employee record to confirm the tenant URL and key are accepted, then reads one incident to confirm the key's role can see ticket data. An authentication failure here almost always means the key belongs to a different tenant, is deactivated, or is restricted to other IP addresses. + +## What this plugin monitors + +- **Service desk workload** — open incidents and service requests by status, priority, category, service and owning team, with an age in days on every row. +- **Change and problem management** — the change pipeline by status, and the open problem backlog. +- **The CMDB** — configuration items with their type, status, owner, location and hardware detail. +- **Knowledge** — knowledge base articles and their publication status. +- **Anything else** — the **Business Object Records** stream reads any business object in the tenant, including customer-defined ones, without a plugin change. + +The out-of-the-box dashboards are a **Service Desk Overview**, a **Change And Problem Management** dashboard, and a perspective for each **Configuration Item** and **Team**. + +## Data streams + +- **Incidents** — incidents account-wide, filterable by status, owning team and an OData expression. +- **Service Requests** — service requests, with the same filters. +- **Changes** — change records, with the same filters. +- **Problems** — problem records, with the same filters. +- **Configuration Items** — configuration items from the CMDB, account-wide. +- **Teams** — ticket-owning teams, account-wide. +- **Knowledge Articles** — knowledge base articles, account-wide. +- **Business Object Records** — records from any named business object, with `$filter`, `$select` and `$orderby` passed through. + +Every ticket stream returns the fields listed above **plus every other field the business object exposes**, so tenant-specific fields — SLA targets, custom categories, approval detail — appear in the tile editor's column list without any plugin change. + +## What gets indexed + +| Object type | API source | Represents | +| ----------- | ---------- | ---------- | +| **Configuration Item** | `GET /api/odata/businessobject/cis` | An item in the CMDB — server, workstation, application or service. | +| **Team** | `GET /api/odata/businessobject/standarduserteams` | A team that tickets are assigned to, matched to a ticket's `OwnerTeam`. | + +**Relationships:** none. Tickets are not indexed as objects; they are queried live, and the **Team** perspective scopes ticket tiles by matching the team name against the ticket's `OwnerTeam` field. + +Both import steps are optional, so a tenant that has renamed or restricted one of these business objects still imports the other. A step that cannot read its business object is reported as a warning on the data source rather than failing the whole import. + +## Adapting to a customized tenant + +Ivanti tenants routinely rename fields, add statuses and define their own business objects. Three things absorb that without a code change: + +- **Status filters accept custom values.** The status list on each ticket tile is a suggestion — type any status this tenant uses and it is sent to the API as written. +- **What counts as "open" is a status list, not a flag.** The out-of-the-box tiles define open by excluding `Resolved`, `Closed` and `Cancelled` (plus `Fulfilled` for requests and `Implemented`/`Rejected` for changes). If this tenant retires tickets into some other status, add it to the tile's **Additional filter** as `Status ne ''`, or the tile will count abandoned tickets as open. Ivanti's own `IsInFinalState` field is not a substitute: it cannot be filtered on, and it reads `false` on cancelled records. +- **The additional filter field** on every tile takes a raw OData `$filter`, so you can filter on fields the plugin does not know about. It is combined with whatever the tile already filters on using `and`. +- **Business Object Records** reads any object by its API name. The API name is the business object name with an `s` on the end — `incidents`, `tasks`, `alerts`, `employees` — and the same rule applies to customer-defined objects. + +If **Team business object** needs changing, confirm the object first: compare the `OwnerTeam` values actually in use on tickets against the names in the candidate object. Contact groups are a common wrong answer - in a stock tenant they hold advisory and approval boards rather than ticket-owning teams. + +## Known limitations + +- **Ivanti caps a page at 100 records by default, and rejects anything larger.** A request above the tenant page limit fails with `You cannot query more than 100 records` rather than returning fewer rows, so **Maximum records per request** must be at or below that limit. Only an Ivanti administrator can raise it. Ticket tiles issue one request rather than paging, so a count on a backlog larger than the cap under-reports — narrow the tile with its status, team or filter parameters instead. +- **Wide business objects can be slow or too large.** An Ivanti record carries every field the tenant has defined, which for incidents can be hundreds of columns. If a tile times out or fails with a response-size error, set its **Field(s)** parameter to just the columns that tile uses — that is usually a tenfold reduction in payload. +- **Sort order decides what a truncated result keeps.** Ivanti applies `$orderby` before the record ceiling. Ticket tiles default to newest-first, so a tile about the oldest records needs its **Order by** field set accordingly — the **Oldest Open Incidents** tile does this. +- **Large result sets can exceed the response limit.** A tile that returns many thousands of wide records can fail with a response-size error. Use the **Configuration item filter** on the data source to keep a large CMDB import manageable. +- **Date filtering is applied by Ivanti, not by SquaredUp.** Selecting a timeframe adds an OData comparison on the date field chosen in the tile's **Timeframe applies to** setting, as an unquoted ISO-8601 literal such as `2016-07-24T23:42:48Z`. Tiles default to the **None** timeframe — the current backlog — so the out-of-the-box dashboards do not depend on it. +- **Only eight filter operators.** Ivanti supports [`eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and` and `or`](https://docs.ivanti.com/neurons-for-itsm/admin-user-help/enu/latest/get-business-object-by-filter) — there is no `contains`, `startswith` or `not`, so the **Additional filter** field cannot do partial text matching. +- **Selecting several statuses or teams at once needs API version 2.** Combining an OR-group with another condition requires brackets, which only version 2 of the API supports. On version 1, filter on one status and one team at a time, or switch **API version** to version 2. +- **Field names are tenant-specific.** Columns such as `Priority`, `OwnerTeam` and `ProfileFullName` are the out-of-the-box names. Where a tenant has renamed one, the named column is empty and the tenant's own field appears alongside it instead. +- **No deep links back into Ivanti.** Record URLs are not constructible from the API response, so table rows do not link to the record in Ivanti. +- **Read-only.** The plugin never creates, modifies or deletes anything in Ivanti, and does not use the create, update, quick action or attachment APIs. + +## Acknowledgements + +Built with **Eric Bragg**, who shaped the design and tested every version against a live on-premises Ivanti instance. The business object names, field names and API limits this plugin ships with are correct because he verified them rather than left them as assumptions. diff --git a/plugins/Ivanti-ITSM/v1/icon.svg b/plugins/Ivanti-ITSM/v1/icon.svg new file mode 100644 index 00000000..739aa698 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/icon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/plugins/Ivanti-ITSM/v1/indexDefinitions/default.json b/plugins/Ivanti-ITSM/v1/indexDefinitions/default.json new file mode 100644 index 00000000..94677b40 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/indexDefinitions/default.json @@ -0,0 +1,83 @@ +{ + "steps": [ + { + "name": "configurationItems", + "dataStream": { + "name": "configurationItems" + }, + "timeframe": "none", + "objectMapping": { + "id": "RecId", + "name": "Name", + "type": { + "value": "Configuration Item" + }, + "properties": [ + { + "ciType": "CIType" + }, + { + "status": "Status" + }, + { + "owner": "Owner" + }, + { + "location": "PhysicalLocation" + }, + { + "serialNumber": "SerialNumber" + }, + { + "manufacturer": "Manufacturer" + }, + { + "model": "Model" + }, + { + "assetTag": "AssetTag" + }, + { + "ipAddress": "IPAddress" + }, + { + "operatingSystem": "OperatingSystem" + }, + { + "ownerTeam": "OwnerTeam" + } + ] + }, + "optional": true + }, + { + "name": "teams", + "dataStream": { + "name": "teams" + }, + "timeframe": "none", + "objectMapping": { + "id": "RecId", + "name": "Team", + "type": { + "value": "Team" + }, + "properties": [ + { + "teamEmail": "TeamEmail" + }, + { + "teamManager": "TeamManager" + }, + { + "isServiceDesk": "IsServiceDesk" + }, + { + "teamLevel": "TeamLevel" + } + ] + }, + "optional": true + } + ] +} diff --git a/plugins/Ivanti-ITSM/v1/metadata.json b/plugins/Ivanti-ITSM/v1/metadata.json new file mode 100644 index 00000000..b04956bf --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/metadata.json @@ -0,0 +1,64 @@ +{ + "name": "ivanti-itsm", + "displayName": "Ivanti ITSM", + "version": "1.0.0", + "author": { + "name": "@shawn149", + "type": "community" + }, + "description": "Monitor Ivanti incidents, service requests, changes, problems and configuration items in SquaredUp.", + "category": "Service Management", + "type": "hybrid", + "schemaVersion": "2.1", + "importNotSupported": false, + "restrictedToPlatforms": [], + "keywords": [ + "ivanti", + "itsm", + "neurons", + "service manager", + "heat", + "service desk", + "help desk", + "incidents", + "service requests", + "changes", + "problems", + "cmdb" + ], + "objectTypes": [ + "Configuration Item", + "Team" + ], + "links": [ + { + "category": "documentation", + "url": "https://github.com/squaredup/plugins/blob/main/plugins/Ivanti-ITSM/v1/docs/README.md", + "label": "Help adding this plugin" + }, + { + "category": "source", + "url": "https://github.com/squaredup/plugins/tree/main/plugins/Ivanti-ITSM/v1", + "label": "Repository" + } + ], + "base": { + "plugin": "WebAPI", + "majorVersion": "1", + "config": { + "baseUrl": "{{tenantUrl.endsWith('/') ? tenantUrl.slice(0, -1) : tenantUrl}}/api", + "authMode": "none", + "headers": [ + { + "key": "Authorization", + "value": "rest_api_key={{restApiKey}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "queryArgs": [] + } + } +} diff --git a/plugins/Ivanti-ITSM/v1/ui.json b/plugins/Ivanti-ITSM/v1/ui.json new file mode 100644 index 00000000..76418a07 --- /dev/null +++ b/plugins/Ivanti-ITSM/v1/ui.json @@ -0,0 +1,82 @@ +[ + { + "type": "text", + "name": "tenantUrl", + "label": "Tenant URL", + "placeholder": "https://yourtenant.saasiteu.com", + "help": "The address you sign in to Ivanti with. The plugin appends `/api/odata/businessobject/...` itself.\n\nAn on-premises instance is usually installed under a virtual directory, so include it - for example `https://help.example.com/HEAT`.", + "validation": { + "required": true, + "pattern": { + "value": "^https://[^\\s/@?#]+(?:/[^\\s?#]*)?$", + "message": "Enter the https address of your Ivanti instance, e.g. https://yourtenant.saasiteu.com or https://help.example.com/HEAT" + } + } + }, + { + "type": "password", + "name": "restApiKey", + "label": "REST API key", + "help": "Create in **Configure > Security Controls > API Keys** in the Configuration console. The key is the **Reference ID** of an API key, and inherits the role of the account it was created **On Behalf Of** - that account needs read access to the business objects you want to dashboard.", + "validation": { + "required": true + } + }, + { + "type": "checkbox", + "name": "showAdvanced", + "label": "Show advanced options", + "value": "show" + }, + { + "type": "fieldGroup", + "name": "advanced", + "visible": { "showAdvanced": "show" }, + "fields": [ + { + "type": "number", + "name": "maxRecords", + "label": "Maximum records per request", + "defaultValue": 99, + "help": "Ivanti rejects any request above the tenant page limit, which is 100 unless an administrator has raised it - a higher value here fails rather than returning fewer rows. Sets the default for tiles that do not page, and can be overridden per tile.", + "validation": { + "min": 1, + "max": 1000, + "valueAsNumber": true + } + }, + { + "type": "switch", + "name": "apiPath", + "label": "API version", + "defaultValue": "odata", + "help": "Version 2 adds bracketed filters, so a tile filtering on several statuses or teams at once needs it. Version 1 is present on every release and is the safe default.", + "options": [ + { "value": "odata", "label": "Version 1" }, + { "value": "odatav2", "label": "Version 2" } + ] + }, + { + "type": "text", + "name": "teamBusinessObject", + "label": "Team business object", + "placeholder": "standarduserteams", + "defaultValue": "standarduserteams", + "help": "The business object ticket-owning teams are imported from. The default suits most tenants; the object must expose the team name as `Team`, `Name` or `TeamName`. Do not point this at contact groups - those hold approval and advisory boards, not the teams that own tickets." + }, + { + "type": "text", + "name": "ciFilter", + "label": "Configuration item filter", + "placeholder": "Status eq 'Active'", + "help": "Narrows which configuration items are imported, using the OData `$filter` syntax. Leave empty to import the whole CMDB." + }, + { + "type": "checkbox", + "name": "ignoreCertificateErrors", + "label": "Ignore certificate errors", + "help": "Enable only for an on-premises instance presenting a self-signed or otherwise untrusted certificate." + } + ] + } +] From 2bda3c05d0f646e41b2f64708b04ecb723328642 Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 14:00:44 -0400 Subject: [PATCH 02/12] fix: make Ivanti ITSM CODEOWNERS rule cover nested files A trailing /* only matches direct children, and every plugin file lives under v1/, so the rule assigned no files. A directory pattern matches everything beneath it. Co-Authored-By: Claude Opus 5.5 --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bb11a9a1..b0da873d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,7 +14,7 @@ plugins/Huntress/* @Deenk plugins/IncidentIO/ @noorulkhan-n plugins/FantasyPremierLeague/* @TimWheeler-SQUP plugins/GoogleSheets/* @kieranlangton -plugins/Ivanti-ITSM/* @shawn149 +/plugins/Ivanti-ITSM/ @shawn149 plugins/MetOffice/* @blackgrouse plugins/Phare/* @vinbab plugins/Postcoder/* @richbenwell From da0a514d2479c979d8608e18bc3546123ee39b66 Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 14:12:38 -0400 Subject: [PATCH 03/12] Enforce tenant page limits in Ivanti queries This change replaces the old per-tile maxRecords default with a datasource pageLimit and caps all Ivanti requests at the tenant's configured page size. It updates the UI and docs to describe the tenant limit, keeps unpaged tiles below that ceiling, and ensures paged imports use the same limit so requests no longer fail when a higher value is entered. --- .../v1/dataStreams/businessObjectRecords.json | 6 +++--- plugins/Ivanti-ITSM/v1/dataStreams/changes.json | 5 ++--- .../Ivanti-ITSM/v1/dataStreams/configurationItems.json | 2 +- plugins/Ivanti-ITSM/v1/dataStreams/incidents.json | 5 ++--- .../Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json | 2 +- plugins/Ivanti-ITSM/v1/dataStreams/problems.json | 5 ++--- plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json | 5 ++--- plugins/Ivanti-ITSM/v1/dataStreams/teams.json | 2 +- plugins/Ivanti-ITSM/v1/docs/README.md | 4 ++-- plugins/Ivanti-ITSM/v1/ui.json | 9 ++++----- 10 files changed, 20 insertions(+), 25 deletions(-) diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json b/plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json index 257128f0..b7ef6444 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json @@ -10,7 +10,7 @@ "getArgs": [ { "key": "$top", - "value": "{{maxRecords || dataSource.maxRecords || 99}}" + "value": "{{ Math.min((typeof maxRecords !== 'undefined' && Number(maxRecords)) || (Number(dataSource.pageLimit) || 100), (Number(dataSource.pageLimit) || 100)) }}" }, { "key": "$filter", "value": "{{ typeof filter !== 'undefined' ? filter : null }}" }, { "key": "$select", "value": "{{ typeof select !== 'undefined' ? select : null }}" }, @@ -65,8 +65,8 @@ "type": "number", "name": "maxRecords", "label": "Maximum records", - "help": "Ivanti returns at most 1000 records in one request.", - "validation": { "min": 1, "max": 1000, "valueAsNumber": true } + "help": "Defaults to the page limit set on the data source, and is capped at it - a higher value is reduced to the limit rather than failing. Narrow the filter to see different records.", + "validation": { "min": 1, "valueAsNumber": true } } ], "metadata": [{ "pattern": ".*" }], diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/changes.json b/plugins/Ivanti-ITSM/v1/dataStreams/changes.json index f1b1a24c..037e81d8 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/changes.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/changes.json @@ -13,7 +13,7 @@ "getArgs": [ { "key": "$top", - "value": "{{maxRecords || dataSource.maxRecords || 99}}" + "value": "{{ Math.min((typeof maxRecords !== 'undefined' && Number(maxRecords)) || (Number(dataSource.pageLimit) || 100), (Number(dataSource.pageLimit) || 100)) }}" }, { "key": "$filter", @@ -147,10 +147,9 @@ "type": "number", "name": "maxRecords", "label": "Maximum records", - "help": "Ivanti rejects a request above the tenant page limit outright - the default limit is 100 and only an administrator can raise it. Narrow the status, team or timeframe filters instead.", + "help": "Defaults to the page limit set on the data source, and is capped at it - a higher value is reduced to the limit rather than failing. Narrow the status, team or timeframe filters to see different records.", "validation": { "min": 1, - "max": 1000, "valueAsNumber": true } } diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json b/plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json index 129b1b45..8e36d6f1 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json @@ -22,7 +22,7 @@ "pageSize": { "realm": "queryArg", "path": "$top", - "value": "100" + "value": "{{ (Number(dataSource.pageLimit) || 100) }}" }, "offset": { "mode": "row", diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json b/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json index d360817b..ff6ff86d 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json @@ -13,7 +13,7 @@ "getArgs": [ { "key": "$top", - "value": "{{maxRecords || dataSource.maxRecords || 99}}" + "value": "{{ Math.min((typeof maxRecords !== 'undefined' && Number(maxRecords)) || (Number(dataSource.pageLimit) || 100), (Number(dataSource.pageLimit) || 100)) }}" }, { "key": "$filter", @@ -139,10 +139,9 @@ "type": "number", "name": "maxRecords", "label": "Maximum records", - "help": "Ivanti rejects a request above the tenant page limit outright - the default limit is 100 and only an administrator can raise it. Narrow the status, team or timeframe filters instead.", + "help": "Defaults to the page limit set on the data source, and is capped at it - a higher value is reduced to the limit rather than failing. Narrow the status, team or timeframe filters to see different records.", "validation": { "min": 1, - "max": 1000, "valueAsNumber": true } } diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json b/plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json index f501da25..62d900b3 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json @@ -16,7 +16,7 @@ "pathToData": "value", "paging": { "mode": "offset", - "pageSize": { "realm": "queryArg", "path": "$top", "value": "100" }, + "pageSize": { "realm": "queryArg", "path": "$top", "value": "{{ (Number(dataSource.pageLimit) || 100) }}" }, "offset": { "mode": "row", "rowCountIn": { "realm": "payloadArraySize", "path": "value" }, diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/problems.json b/plugins/Ivanti-ITSM/v1/dataStreams/problems.json index 4ff52e09..b5c0b3ce 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/problems.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/problems.json @@ -13,7 +13,7 @@ "getArgs": [ { "key": "$top", - "value": "{{maxRecords || dataSource.maxRecords || 99}}" + "value": "{{ Math.min((typeof maxRecords !== 'undefined' && Number(maxRecords)) || (Number(dataSource.pageLimit) || 100), (Number(dataSource.pageLimit) || 100)) }}" }, { "key": "$filter", @@ -135,10 +135,9 @@ "type": "number", "name": "maxRecords", "label": "Maximum records", - "help": "Ivanti rejects a request above the tenant page limit outright - the default limit is 100 and only an administrator can raise it. Narrow the status, team or timeframe filters instead.", + "help": "Defaults to the page limit set on the data source, and is capped at it - a higher value is reduced to the limit rather than failing. Narrow the status, team or timeframe filters to see different records.", "validation": { "min": 1, - "max": 1000, "valueAsNumber": true } } diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json b/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json index 57fabb20..a1293ec0 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json @@ -13,7 +13,7 @@ "getArgs": [ { "key": "$top", - "value": "{{maxRecords || dataSource.maxRecords || 99}}" + "value": "{{ Math.min((typeof maxRecords !== 'undefined' && Number(maxRecords)) || (Number(dataSource.pageLimit) || 100), (Number(dataSource.pageLimit) || 100)) }}" }, { "key": "$filter", @@ -143,10 +143,9 @@ "type": "number", "name": "maxRecords", "label": "Maximum records", - "help": "Ivanti rejects a request above the tenant page limit outright - the default limit is 100 and only an administrator can raise it. Narrow the status, team or timeframe filters instead.", + "help": "Defaults to the page limit set on the data source, and is capped at it - a higher value is reduced to the limit rather than failing. Narrow the status, team or timeframe filters to see different records.", "validation": { "min": 1, - "max": 1000, "valueAsNumber": true } } diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/teams.json b/plugins/Ivanti-ITSM/v1/dataStreams/teams.json index b19a7390..ced69434 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/teams.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/teams.json @@ -22,7 +22,7 @@ "pageSize": { "realm": "queryArg", "path": "$top", - "value": "100" + "value": "{{ (Number(dataSource.pageLimit) || 100) }}" }, "offset": { "mode": "row", diff --git a/plugins/Ivanti-ITSM/v1/docs/README.md b/plugins/Ivanti-ITSM/v1/docs/README.md index 0141f9c1..c26c0326 100644 --- a/plugins/Ivanti-ITSM/v1/docs/README.md +++ b/plugins/Ivanti-ITSM/v1/docs/README.md @@ -27,7 +27,7 @@ A key only works for the tenant it was created in, and optionally for the IP add | ----- | ---------- | ---------------- | -------- | | **Tenant URL** | The address of the Ivanti instance, with no path. The plugin appends `/api/odata/businessobject/…` itself. | The address bar when you are signed in to Ivanti, for example `https://yourtenant.saasiteu.com`. | Yes | | **REST API key** | Sent as the `Authorization: rest_api_key=…` header on every request. | Configuration console → **Configure > Security Controls > API Keys** → the key's **Reference ID**. | Yes | -| **Maximum records per request** | Default ceiling for tiles that fetch a single unpaged result set. Must not exceed the tenant's page limit, which is 100 unless an Ivanti administrator has raised it. | — | No (defaults to 99) | +| **Page limit** | The most records Ivanti returns in one request. Every tile and import is kept at or below it, and imports use it as their page size. | Set by an Ivanti administrator for the tenant — ask them for the value. It is 100 unless they have raised it. | No (defaults to 100) | | **Configuration item filter** | An OData `$filter` limiting which configuration items are imported, for example `Status eq 'Active'`. | — | No | | **API version** | Which REST API version to call — version 1 (`/api/odata/…`) or version 2 (`/api/odatav2/…`). Version 2 supports bracketed filters. | — | No (defaults to version 1) | | **Team business object** | Which business object ticket-owning teams are imported from. The object must expose the team name in a field called `Team`. | — | No (defaults to `standarduserteams`) | @@ -84,7 +84,7 @@ If **Team business object** needs changing, confirm the object first: compare th ## Known limitations -- **Ivanti caps a page at 100 records by default, and rejects anything larger.** A request above the tenant page limit fails with `You cannot query more than 100 records` rather than returning fewer rows, so **Maximum records per request** must be at or below that limit. Only an Ivanti administrator can raise it. Ticket tiles issue one request rather than paging, so a count on a backlog larger than the cap under-reports — narrow the tile with its status, team or filter parameters instead. +- **Ivanti caps each request at the tenant page limit, 100 by default.** Ivanti rejects a request above the limit with `You cannot query more than 100 records` rather than returning fewer rows. Set **Page limit** to the value your Ivanti administrator has configured; a tile's own **Maximum records** is capped at it, so a tile never asks for more than the tenant allows. Ticket tiles make a single request rather than paging, so a count on a backlog larger than the limit under-reports. Narrow the tile with its status, team or filter parameters, or ask the administrator to raise the limit. Configuration items, teams and knowledge articles are paged, so they are not affected. - **Wide business objects can be slow or too large.** An Ivanti record carries every field the tenant has defined, which for incidents can be hundreds of columns. If a tile times out or fails with a response-size error, set its **Field(s)** parameter to just the columns that tile uses — that is usually a tenfold reduction in payload. - **Sort order decides what a truncated result keeps.** Ivanti applies `$orderby` before the record ceiling. Ticket tiles default to newest-first, so a tile about the oldest records needs its **Order by** field set accordingly — the **Oldest Open Incidents** tile does this. - **Large result sets can exceed the response limit.** A tile that returns many thousands of wide records can fail with a response-size error. Use the **Configuration item filter** on the data source to keep a large CMDB import manageable. diff --git a/plugins/Ivanti-ITSM/v1/ui.json b/plugins/Ivanti-ITSM/v1/ui.json index 76418a07..a0296605 100644 --- a/plugins/Ivanti-ITSM/v1/ui.json +++ b/plugins/Ivanti-ITSM/v1/ui.json @@ -35,13 +35,12 @@ "fields": [ { "type": "number", - "name": "maxRecords", - "label": "Maximum records per request", - "defaultValue": 99, - "help": "Ivanti rejects any request above the tenant page limit, which is 100 unless an administrator has raised it - a higher value here fails rather than returning fewer rows. Sets the default for tiles that do not page, and can be overridden per tile.", + "name": "pageLimit", + "label": "Page limit", + "defaultValue": 100, + "help": "The most records Ivanti returns in one request. An Ivanti administrator sets this for the tenant and it is 100 unless they have raised it - enter the value they confirm. Ivanti rejects any request above it, so every tile and import is kept at or below this number.", "validation": { "min": 1, - "max": 1000, "valueAsNumber": true } }, From c7eb8046c3316cb9d36779c5f9535cb0369c0c7f Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 14:21:18 -0400 Subject: [PATCH 04/12] Ivanti-ITSM: enable paging and trim payloads Switch business object streams to offset paging ($top/$skip) and default ordering (CreatedDateTime asc); remove per-stream $top/maxRecords and orderBy UI fields. Update configValidation to use businessObjectRecords (select RecId) for incident access checks. Add explicit select lists to default dashboards/tiles to reduce payload size. Tweak help text and docs to clarify tenant page limit behavior and response-size guidance. --- plugins/Ivanti-ITSM/v1/configValidation.json | 4 +- .../Ivanti-ITSM/v1/dataStreams/changes.json | 44 +++++++++---------- .../Ivanti-ITSM/v1/dataStreams/incidents.json | 44 +++++++++---------- .../Ivanti-ITSM/v1/dataStreams/problems.json | 44 +++++++++---------- .../v1/dataStreams/serviceRequests.json | 44 +++++++++---------- .../changeAndProblemManagement.dash.json | 24 +++++++--- .../serviceDeskOverview.dash.json | 1 - .../v1/defaultContent/team.dash.json | 6 ++- plugins/Ivanti-ITSM/v1/docs/README.md | 7 ++- 9 files changed, 107 insertions(+), 111 deletions(-) diff --git a/plugins/Ivanti-ITSM/v1/configValidation.json b/plugins/Ivanti-ITSM/v1/configValidation.json index b5da19a3..a0a194bb 100644 --- a/plugins/Ivanti-ITSM/v1/configValidation.json +++ b/plugins/Ivanti-ITSM/v1/configValidation.json @@ -10,8 +10,8 @@ { "displayName": "Check incident access", "dataStream": { - "name": "incidents", - "config": { "maxRecords": 1 } + "name": "businessObjectRecords", + "config": { "businessObject": "incidents", "select": "RecId", "maxRecords": 1 } }, "required": false, "error": "Connected, but incidents could not be read. Check the role of the account the REST API key was created On Behalf Of.", diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/changes.json b/plugins/Ivanti-ITSM/v1/dataStreams/changes.json index 037e81d8..fe757cbb 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/changes.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/changes.json @@ -11,10 +11,6 @@ "httpMethod": "get", "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/changes", "getArgs": [ - { - "key": "$top", - "value": "{{ Math.min((typeof maxRecords !== 'undefined' && Number(maxRecords)) || (Number(dataSource.pageLimit) || 100), (Number(dataSource.pageLimit) || 100)) }}" - }, { "key": "$filter", "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" @@ -25,12 +21,29 @@ }, { "key": "$orderby", - "value": "{{ (typeof orderBy !== 'undefined' && orderBy) || 'CreatedDateTime desc' }}" + "value": "CreatedDateTime asc" } ], "pathToData": "value", "paging": { - "mode": "none" + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "$top", + "value": "{{ (Number(dataSource.pageLimit) || 100) }}" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "value" + }, + "base": 0 + }, + "out": { + "realm": "queryArg", + "path": "$skip" + } }, "errorHandling": { "type": "script", @@ -70,7 +83,7 @@ "isMulti": true, "isClearable": true, "allowCustomValues": true, - "help": "Filters in Ivanti rather than in the browser, so it also keeps large result sets under the record ceiling. Type a value to use a status this tenant has added.", + "help": "Filters in Ivanti rather than in the browser, so it also cuts how many records the tile has to page through. Type a value to use a status this tenant has added.", "data": { "source": "fixed", "values": [ @@ -135,23 +148,6 @@ "label": "Field(s)", "placeholder": "RecId,Subject,Status,CreatedDateTime", "help": "Limits the response to these fields. Worth setting for a business object with many fields, where the full payload is slow or too large. A field name that does not exist fails the whole request." - }, - { - "type": "text", - "name": "orderBy", - "label": "Order by", - "placeholder": "CreatedDateTime desc", - "help": "Ivanti applies this before the record ceiling, so it decides which records a truncated result keeps. Uses the OData `$orderby` syntax." - }, - { - "type": "number", - "name": "maxRecords", - "label": "Maximum records", - "help": "Defaults to the page limit set on the data source, and is capped at it - a higher value is reduced to the limit rather than failing. Narrow the status, team or timeframe filters to see different records.", - "validation": { - "min": 1, - "valueAsNumber": true - } } ], "metadata": [ diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json b/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json index ff6ff86d..1b8587ac 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json @@ -11,10 +11,6 @@ "httpMethod": "get", "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/incidents", "getArgs": [ - { - "key": "$top", - "value": "{{ Math.min((typeof maxRecords !== 'undefined' && Number(maxRecords)) || (Number(dataSource.pageLimit) || 100), (Number(dataSource.pageLimit) || 100)) }}" - }, { "key": "$filter", "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" @@ -25,12 +21,29 @@ }, { "key": "$orderby", - "value": "{{ (typeof orderBy !== 'undefined' && orderBy) || 'CreatedDateTime desc' }}" + "value": "CreatedDateTime asc" } ], "pathToData": "value", "paging": { - "mode": "none" + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "$top", + "value": "{{ (Number(dataSource.pageLimit) || 100) }}" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "value" + }, + "base": 0 + }, + "out": { + "realm": "queryArg", + "path": "$skip" + } }, "errorHandling": { "type": "script", @@ -74,7 +87,7 @@ "isMulti": true, "isClearable": true, "allowCustomValues": true, - "help": "Filters in Ivanti rather than in the browser, so it also keeps large result sets under the record ceiling. Type a value to use a status this tenant has added.", + "help": "Filters in Ivanti rather than in the browser, so it also cuts how many records the tile has to page through. Type a value to use a status this tenant has added.", "data": { "source": "fixed", "values": [ @@ -127,23 +140,6 @@ "label": "Field(s)", "placeholder": "RecId,Subject,Status,CreatedDateTime", "help": "Limits the response to these fields. Worth setting for a business object with many fields, where the full payload is slow or too large. A field name that does not exist fails the whole request." - }, - { - "type": "text", - "name": "orderBy", - "label": "Order by", - "placeholder": "CreatedDateTime desc", - "help": "Ivanti applies this before the record ceiling, so it decides which records a truncated result keeps. Uses the OData `$orderby` syntax." - }, - { - "type": "number", - "name": "maxRecords", - "label": "Maximum records", - "help": "Defaults to the page limit set on the data source, and is capped at it - a higher value is reduced to the limit rather than failing. Narrow the status, team or timeframe filters to see different records.", - "validation": { - "min": 1, - "valueAsNumber": true - } } ], "metadata": [ diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/problems.json b/plugins/Ivanti-ITSM/v1/dataStreams/problems.json index b5c0b3ce..4d8324fa 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/problems.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/problems.json @@ -11,10 +11,6 @@ "httpMethod": "get", "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/problems", "getArgs": [ - { - "key": "$top", - "value": "{{ Math.min((typeof maxRecords !== 'undefined' && Number(maxRecords)) || (Number(dataSource.pageLimit) || 100), (Number(dataSource.pageLimit) || 100)) }}" - }, { "key": "$filter", "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" @@ -25,12 +21,29 @@ }, { "key": "$orderby", - "value": "{{ (typeof orderBy !== 'undefined' && orderBy) || 'CreatedDateTime desc' }}" + "value": "CreatedDateTime asc" } ], "pathToData": "value", "paging": { - "mode": "none" + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "$top", + "value": "{{ (Number(dataSource.pageLimit) || 100) }}" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "value" + }, + "base": 0 + }, + "out": { + "realm": "queryArg", + "path": "$skip" + } }, "errorHandling": { "type": "script", @@ -70,7 +83,7 @@ "isMulti": true, "isClearable": true, "allowCustomValues": true, - "help": "Filters in Ivanti rather than in the browser, so it also keeps large result sets under the record ceiling. Type a value to use a status this tenant has added.", + "help": "Filters in Ivanti rather than in the browser, so it also cuts how many records the tile has to page through. Type a value to use a status this tenant has added.", "data": { "source": "fixed", "values": [ @@ -123,23 +136,6 @@ "label": "Field(s)", "placeholder": "RecId,Subject,Status,CreatedDateTime", "help": "Limits the response to these fields. Worth setting for a business object with many fields, where the full payload is slow or too large. A field name that does not exist fails the whole request." - }, - { - "type": "text", - "name": "orderBy", - "label": "Order by", - "placeholder": "CreatedDateTime desc", - "help": "Ivanti applies this before the record ceiling, so it decides which records a truncated result keeps. Uses the OData `$orderby` syntax." - }, - { - "type": "number", - "name": "maxRecords", - "label": "Maximum records", - "help": "Defaults to the page limit set on the data source, and is capped at it - a higher value is reduced to the limit rather than failing. Narrow the status, team or timeframe filters to see different records.", - "validation": { - "min": 1, - "valueAsNumber": true - } } ], "metadata": [ diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json b/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json index a1293ec0..7acf65fa 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json @@ -11,10 +11,6 @@ "httpMethod": "get", "endpointPath": "{{dataSource.apiPath || 'odata'}}/businessobject/servicereqs", "getArgs": [ - { - "key": "$top", - "value": "{{ Math.min((typeof maxRecords !== 'undefined' && Number(maxRecords)) || (Number(dataSource.pageLimit) || 100), (Number(dataSource.pageLimit) || 100)) }}" - }, { "key": "$filter", "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" @@ -25,12 +21,29 @@ }, { "key": "$orderby", - "value": "{{ (typeof orderBy !== 'undefined' && orderBy) || 'CreatedDateTime desc' }}" + "value": "CreatedDateTime asc" } ], "pathToData": "value", "paging": { - "mode": "none" + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "$top", + "value": "{{ (Number(dataSource.pageLimit) || 100) }}" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "value" + }, + "base": 0 + }, + "out": { + "realm": "queryArg", + "path": "$skip" + } }, "errorHandling": { "type": "script", @@ -74,7 +87,7 @@ "isMulti": true, "isClearable": true, "allowCustomValues": true, - "help": "Filters in Ivanti rather than in the browser, so it also keeps large result sets under the record ceiling. Type a value to use a status this tenant has added.", + "help": "Filters in Ivanti rather than in the browser, so it also cuts how many records the tile has to page through. Type a value to use a status this tenant has added.", "data": { "source": "fixed", "values": [ @@ -131,23 +144,6 @@ "label": "Field(s)", "placeholder": "RecId,Subject,Status,CreatedDateTime", "help": "Limits the response to these fields. Worth setting for a business object with many fields, where the full payload is slow or too large. A field name that does not exist fails the whole request." - }, - { - "type": "text", - "name": "orderBy", - "label": "Order by", - "placeholder": "CreatedDateTime desc", - "help": "Ivanti applies this before the record ceiling, so it decides which records a truncated result keeps. Uses the OData `$orderby` syntax." - }, - { - "type": "number", - "name": "maxRecords", - "label": "Maximum records", - "help": "Defaults to the page limit set on the data source, and is capped at it - a higher value is reduced to the limit rather than failing. Narrow the status, team or timeframe filters to see different records.", - "validation": { - "min": 1, - "valueAsNumber": true - } } ], "metadata": [ diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json b/plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json index abacddf3..8562b57f 100644 --- a/plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json +++ b/plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json @@ -38,7 +38,8 @@ ] }, "dataSourceConfig": { - "filter": "Status ne 'Implemented' and Status ne 'Rejected' and Status ne 'Closed' and Status ne 'Cancelled'" + "filter": "Status ne 'Implemented' and Status ne 'Rejected' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,ChangeNumber,Status,Priority,Urgency,Impact,RiskLevel,TypeOfChange,Category,Service,Owner,OwnerTeam,RequestorFullName,Source,ScheduledStartDate,ScheduledEndDate,CreatedDateTime,LastModDateTime,ClosedDateTime,FinalState,TotalTimeSpent,Description,Resolution,CreatedBy,RecId" } }, "visualisation": { @@ -83,7 +84,8 @@ ] }, "dataSourceConfig": { - "filter": "Status eq 'Pending Approval'" + "filter": "Status eq 'Pending Approval'", + "select": "Subject,ChangeNumber,Status,Priority,Urgency,Impact,RiskLevel,TypeOfChange,Category,Service,Owner,OwnerTeam,RequestorFullName,Source,ScheduledStartDate,ScheduledEndDate,CreatedDateTime,LastModDateTime,ClosedDateTime,FinalState,TotalTimeSpent,Description,Resolution,CreatedBy,RecId" } }, "visualisation": { @@ -128,7 +130,8 @@ ] }, "dataSourceConfig": { - "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'" + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,ProblemNumber,Status,Priority,Urgency,Impact,TypeOfProblem,Category,Service,Owner,OwnerTeam,Source,CreatedDateTime,LastModDateTime,ClosedDateTime,KnownErrorDate,IsInFinalState,TotalTimeSpent,Description,RootCause,Workaround,Resolution,CreatedBy,RecId" } }, "visualisation": { @@ -173,7 +176,8 @@ ] }, "dataSourceConfig": { - "filter": "Status eq 'Known Error'" + "filter": "Status eq 'Known Error'", + "select": "Subject,ProblemNumber,Status,Priority,Urgency,Impact,TypeOfProblem,Category,Service,Owner,OwnerTeam,Source,CreatedDateTime,LastModDateTime,ClosedDateTime,KnownErrorDate,IsInFinalState,TotalTimeSpent,Description,RootCause,Workaround,Resolution,CreatedBy,RecId" } }, "visualisation": { @@ -230,6 +234,9 @@ ] ], "top": 10 + }, + "dataSourceConfig": { + "select": "Subject,ChangeNumber,Status,Priority,Urgency,Impact,RiskLevel,TypeOfChange,Category,Service,Owner,OwnerTeam,RequestorFullName,Source,ScheduledStartDate,ScheduledEndDate,CreatedDateTime,LastModDateTime,ClosedDateTime,FinalState,TotalTimeSpent,Description,Resolution,CreatedBy,RecId" } }, "visualisation": { @@ -296,7 +303,8 @@ ] }, "dataSourceConfig": { - "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'" + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,ProblemNumber,Status,Priority,Urgency,Impact,TypeOfProblem,Category,Service,Owner,OwnerTeam,Source,CreatedDateTime,LastModDateTime,ClosedDateTime,KnownErrorDate,IsInFinalState,TotalTimeSpent,Description,RootCause,Workaround,Resolution,CreatedBy,RecId" } }, "visualisation": { @@ -343,6 +351,9 @@ ] ], "top": 10 + }, + "dataSourceConfig": { + "select": "Subject,ChangeNumber,Status,Priority,Urgency,Impact,RiskLevel,TypeOfChange,Category,Service,Owner,OwnerTeam,RequestorFullName,Source,ScheduledStartDate,ScheduledEndDate,CreatedDateTime,LastModDateTime,ClosedDateTime,FinalState,TotalTimeSpent,Description,Resolution,CreatedBy,RecId" } }, "visualisation": { @@ -394,7 +405,8 @@ "top": 10 }, "dataSourceConfig": { - "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'" + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,ProblemNumber,Status,Priority,Urgency,Impact,TypeOfProblem,Category,Service,Owner,OwnerTeam,Source,CreatedDateTime,LastModDateTime,ClosedDateTime,KnownErrorDate,IsInFinalState,TotalTimeSpent,Description,RootCause,Workaround,Resolution,CreatedBy,RecId" } }, "visualisation": { diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json b/plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json index 5462b500..598d0700 100644 --- a/plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json +++ b/plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json @@ -552,7 +552,6 @@ }, "dataSourceConfig": { "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", - "orderBy": "CreatedDateTime asc", "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" } }, diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/team.dash.json b/plugins/Ivanti-ITSM/v1/defaultContent/team.dash.json index 96fda288..1fbfca23 100644 --- a/plugins/Ivanti-ITSM/v1/defaultContent/team.dash.json +++ b/plugins/Ivanti-ITSM/v1/defaultContent/team.dash.json @@ -145,7 +145,8 @@ "workspace": "{{workspaceId}}", "scope": "{{scopes.[Teams]}}" }, - "filter": "Status ne 'Implemented' and Status ne 'Rejected' and Status ne 'Closed' and Status ne 'Cancelled'" + "filter": "Status ne 'Implemented' and Status ne 'Rejected' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,ChangeNumber,Status,Priority,Urgency,Impact,RiskLevel,TypeOfChange,Category,Service,Owner,OwnerTeam,RequestorFullName,Source,ScheduledStartDate,ScheduledEndDate,CreatedDateTime,LastModDateTime,ClosedDateTime,FinalState,TotalTimeSpent,Description,Resolution,CreatedBy,RecId" }, "group": { "by": [], @@ -198,7 +199,8 @@ "workspace": "{{workspaceId}}", "scope": "{{scopes.[Teams]}}" }, - "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'" + "filter": "Status ne 'Resolved' and Status ne 'Closed' and Status ne 'Cancelled'", + "select": "Subject,ProblemNumber,Status,Priority,Urgency,Impact,TypeOfProblem,Category,Service,Owner,OwnerTeam,Source,CreatedDateTime,LastModDateTime,ClosedDateTime,KnownErrorDate,IsInFinalState,TotalTimeSpent,Description,RootCause,Workaround,Resolution,CreatedBy,RecId" }, "group": { "by": [], diff --git a/plugins/Ivanti-ITSM/v1/docs/README.md b/plugins/Ivanti-ITSM/v1/docs/README.md index c26c0326..f4baf848 100644 --- a/plugins/Ivanti-ITSM/v1/docs/README.md +++ b/plugins/Ivanti-ITSM/v1/docs/README.md @@ -27,7 +27,7 @@ A key only works for the tenant it was created in, and optionally for the IP add | ----- | ---------- | ---------------- | -------- | | **Tenant URL** | The address of the Ivanti instance, with no path. The plugin appends `/api/odata/businessobject/…` itself. | The address bar when you are signed in to Ivanti, for example `https://yourtenant.saasiteu.com`. | Yes | | **REST API key** | Sent as the `Authorization: rest_api_key=…` header on every request. | Configuration console → **Configure > Security Controls > API Keys** → the key's **Reference ID**. | Yes | -| **Page limit** | The most records Ivanti returns in one request. Every tile and import is kept at or below it, and imports use it as their page size. | Set by an Ivanti administrator for the tenant — ask them for the value. It is 100 unless they have raised it. | No (defaults to 100) | +| **Page limit** | The most records Ivanti returns in one request. Every stream pages at this size, so no request ever exceeds it. | Set by an Ivanti administrator for the tenant — ask them for the value. It is 100 unless they have raised it. | No (defaults to 100) | | **Configuration item filter** | An OData `$filter` limiting which configuration items are imported, for example `Status eq 'Active'`. | — | No | | **API version** | Which REST API version to call — version 1 (`/api/odata/…`) or version 2 (`/api/odatav2/…`). Version 2 supports bracketed filters. | — | No (defaults to version 1) | | **Team business object** | Which business object ticket-owning teams are imported from. The object must expose the team name in a field called `Team`. | — | No (defaults to `standarduserteams`) | @@ -84,10 +84,9 @@ If **Team business object** needs changing, confirm the object first: compare th ## Known limitations -- **Ivanti caps each request at the tenant page limit, 100 by default.** Ivanti rejects a request above the limit with `You cannot query more than 100 records` rather than returning fewer rows. Set **Page limit** to the value your Ivanti administrator has configured; a tile's own **Maximum records** is capped at it, so a tile never asks for more than the tenant allows. Ticket tiles make a single request rather than paging, so a count on a backlog larger than the limit under-reports. Narrow the tile with its status, team or filter parameters, or ask the administrator to raise the limit. Configuration items, teams and knowledge articles are paged, so they are not affected. +- **Page limit must match the tenant.** Ivanti rejects a request above the tenant page limit with `You cannot query more than 100 records` rather than returning fewer rows. Set **Page limit** to the value your Ivanti administrator has configured — 100 unless they have raised it. Every stream pages at that size, so a higher limit means fewer requests, not more data. - **Wide business objects can be slow or too large.** An Ivanti record carries every field the tenant has defined, which for incidents can be hundreds of columns. If a tile times out or fails with a response-size error, set its **Field(s)** parameter to just the columns that tile uses — that is usually a tenfold reduction in payload. -- **Sort order decides what a truncated result keeps.** Ivanti applies `$orderby` before the record ceiling. Ticket tiles default to newest-first, so a tile about the oldest records needs its **Order by** field set accordingly — the **Oldest Open Incidents** tile does this. -- **Large result sets can exceed the response limit.** A tile that returns many thousands of wide records can fail with a response-size error. Use the **Configuration item filter** on the data source to keep a large CMDB import manageable. +- **Broad tiles can exceed the response limit.** Every stream pages until it has fetched all matching records, so counts and charts are complete — but a tile with no filter fetches everything. An incident record carries roughly 250 fields, so an unfiltered Incidents or Service Requests tile can reach SquaredUp's response-size limit within a few hundred records and fail with a response-size error. Narrow broad tiles with a status or timeframe filter, and set **Field(s)** to only the columns the tile needs; the out-of-the-box tiles do both. For a large CMDB, use the **Configuration item filter** on the data source. - **Date filtering is applied by Ivanti, not by SquaredUp.** Selecting a timeframe adds an OData comparison on the date field chosen in the tile's **Timeframe applies to** setting, as an unquoted ISO-8601 literal such as `2016-07-24T23:42:48Z`. Tiles default to the **None** timeframe — the current backlog — so the out-of-the-box dashboards do not depend on it. - **Only eight filter operators.** Ivanti supports [`eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and` and `or`](https://docs.ivanti.com/neurons-for-itsm/admin-user-help/enu/latest/get-business-object-by-filter) — there is no `contains`, `startswith` or `not`, so the **Additional filter** field cannot do partial text matching. - **Selecting several statuses or teams at once needs API version 2.** Combining an OR-group with another condition requires brackets, which only version 2 of the API supports. On version 1, filter on one status and one team at a time, or switch **API version** to version 2. From 31ff5ecd5246ff24c105375dd737273e464857be Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 14:23:00 -0400 Subject: [PATCH 05/12] Clarify Ivanti tenant URL docs Update the Ivanti ITSM plugin documentation to clarify that Tenant URL may include an on-prem virtual directory while SaaS URLs remain root-only. This avoids confusion about how the plugin appends the OData API path. --- plugins/Ivanti-ITSM/v1/docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Ivanti-ITSM/v1/docs/README.md b/plugins/Ivanti-ITSM/v1/docs/README.md index f4baf848..9c93514a 100644 --- a/plugins/Ivanti-ITSM/v1/docs/README.md +++ b/plugins/Ivanti-ITSM/v1/docs/README.md @@ -25,7 +25,7 @@ A key only works for the tenant it was created in, and optionally for the IP add | Field | What it is | Where to find it | Required | | ----- | ---------- | ---------------- | -------- | -| **Tenant URL** | The address of the Ivanti instance, with no path. The plugin appends `/api/odata/businessobject/…` itself. | The address bar when you are signed in to Ivanti, for example `https://yourtenant.saasiteu.com`. | Yes | +| **Tenant URL** | The address of the Ivanti instance. For an on-premises instance, include the virtual directory, for example `https://help.example.com/HEAT`. The plugin appends `/api/odata/businessobject/…` itself. | The address bar when you are signed in to Ivanti, for example `https://yourtenant.saasiteu.com`. | Yes | | **REST API key** | Sent as the `Authorization: rest_api_key=…` header on every request. | Configuration console → **Configure > Security Controls > API Keys** → the key's **Reference ID**. | Yes | | **Page limit** | The most records Ivanti returns in one request. Every stream pages at this size, so no request ever exceeds it. | Set by an Ivanti administrator for the tenant — ask them for the value. It is 100 unless they have raised it. | No (defaults to 100) | | **Configuration item filter** | An OData `$filter` limiting which configuration items are imported, for example `Status eq 'Active'`. | — | No | From 1479d4777251f8cc7e81e83c7a4957522d7b7992 Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 14:24:17 -0400 Subject: [PATCH 06/12] Clarify Ivanti tenant customization docs Update the Ivanti ITSM README wording to clarify that the tenant customization guidance applies as a cohesive set of behaviors that work without code changes. This improves the explanation of custom status handling and open-ticket logic for customized tenants. --- plugins/Ivanti-ITSM/v1/docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Ivanti-ITSM/v1/docs/README.md b/plugins/Ivanti-ITSM/v1/docs/README.md index 9c93514a..7bdc402d 100644 --- a/plugins/Ivanti-ITSM/v1/docs/README.md +++ b/plugins/Ivanti-ITSM/v1/docs/README.md @@ -73,7 +73,7 @@ Both import steps are optional, so a tenant that has renamed or restricted one o ## Adapting to a customized tenant -Ivanti tenants routinely rename fields, add statuses and define their own business objects. Three things absorb that without a code change: +Ivanti tenants routinely rename fields, add statuses and define their own business objects. The following absorb that without a code change: - **Status filters accept custom values.** The status list on each ticket tile is a suggestion — type any status this tenant uses and it is sent to the API as written. - **What counts as "open" is a status list, not a flag.** The out-of-the-box tiles define open by excluding `Resolved`, `Closed` and `Cancelled` (plus `Fulfilled` for requests and `Implemented`/`Rejected` for changes). If this tenant retires tickets into some other status, add it to the tile's **Additional filter** as `Status ne ''`, or the tile will count abandoned tickets as open. Ivanti's own `IsInFinalState` field is not a substitute: it cannot be filtered on, and it reads `false` on cancelled records. From fddb952685ff5d9d014bc9fe20a4fc69f24c50ec Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 14:28:19 -0400 Subject: [PATCH 07/12] Add certificate ignore option for Ivanti ITSM Adds support for ignoring TLS certificate validation when connecting to Ivanti ITSM. This exposes the ignoreCertificateErrors config flag in the plugin metadata so users can connect to endpoints with self-signed or otherwise untrusted certificates. --- plugins/Ivanti-ITSM/v1/metadata.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/Ivanti-ITSM/v1/metadata.json b/plugins/Ivanti-ITSM/v1/metadata.json index b04956bf..4cba21a1 100644 --- a/plugins/Ivanti-ITSM/v1/metadata.json +++ b/plugins/Ivanti-ITSM/v1/metadata.json @@ -58,7 +58,8 @@ "value": "application/json" } ], - "queryArgs": [] + "queryArgs": [], + "ignoreCertificateErrors": "{{ignoreCertificateErrors}}" } } } From 606050bb576b354ee55f49efee1dee40601eb1c6 Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 14:33:33 -0400 Subject: [PATCH 08/12] Fix Ivanti team filter fallback Team-scoped Ivanti incident, change, problem and service request tiles were falling back to broad matches when no team filter values were available. This change adds a no-match fallback (`RecId eq ''`) so tiles deliberately return no data instead of all tickets. It also clarifies in the docs and UI that team names must come from a `Team` field and that contact groups are not valid ticket-owning teams. --- plugins/Ivanti-ITSM/v1/dataStreams/changes.json | 2 +- plugins/Ivanti-ITSM/v1/dataStreams/incidents.json | 2 +- plugins/Ivanti-ITSM/v1/dataStreams/problems.json | 2 +- plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json | 2 +- plugins/Ivanti-ITSM/v1/docs/README.md | 2 +- plugins/Ivanti-ITSM/v1/ui.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/changes.json b/plugins/Ivanti-ITSM/v1/dataStreams/changes.json index fe757cbb..c3c15b9c 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/changes.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/changes.json @@ -13,7 +13,7 @@ "getArgs": [ { "key": "$filter", - "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" + "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || \"RecId eq ''\" : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" }, { "key": "$select", diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json b/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json index 1b8587ac..ca432057 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json @@ -13,7 +13,7 @@ "getArgs": [ { "key": "$filter", - "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" + "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || \"RecId eq ''\" : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" }, { "key": "$select", diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/problems.json b/plugins/Ivanti-ITSM/v1/dataStreams/problems.json index 4d8324fa..c8c96c0f 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/problems.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/problems.json @@ -13,7 +13,7 @@ "getArgs": [ { "key": "$filter", - "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" + "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || \"RecId eq ''\" : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" }, { "key": "$select", diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json b/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json index 7acf65fa..a16a21f2 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json @@ -13,7 +13,7 @@ "getArgs": [ { "key": "$filter", - "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" + "value": "{{ [(typeof timeframe !== 'undefined' && timeframe.enum !== 'none') ? `${dateField || 'CreatedDateTime'} ge ${new Date(timeframe.start).toISOString().split('.')[0]}Z and ${dateField || 'CreatedDateTime'} le ${new Date(timeframe.end).toISOString().split('.')[0]}Z` : null, (Array.isArray(status) && status.length) ? status.map((v => (v && typeof v === 'object') ? (v.value != null ? v.value : v.label) : v)).filter(v => v != null && v !== '').map(v => `Status eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || null : null, (Array.isArray(team) && team.length) ? team.map(t => (t && typeof t === 'object') ? (t.name != null ? t.name : t.displayName) : t).filter(v => v != null && v !== '').map(v => `OwnerTeam eq '${String(v).split(\"'\").join(\"''\")}'`).join(' or ') || \"RecId eq ''\" : null, typeof filter !== 'undefined' ? filter : null].filter(Boolean).map((c, i, a) => (a.length > 1 && c.indexOf(' or ') !== -1) ? '(' + c + ')' : c).join(' and ') || null }}" }, { "key": "$select", diff --git a/plugins/Ivanti-ITSM/v1/docs/README.md b/plugins/Ivanti-ITSM/v1/docs/README.md index 7bdc402d..4375edda 100644 --- a/plugins/Ivanti-ITSM/v1/docs/README.md +++ b/plugins/Ivanti-ITSM/v1/docs/README.md @@ -80,7 +80,7 @@ Ivanti tenants routinely rename fields, add statuses and define their own busine - **The additional filter field** on every tile takes a raw OData `$filter`, so you can filter on fields the plugin does not know about. It is combined with whatever the tile already filters on using `and`. - **Business Object Records** reads any object by its API name. The API name is the business object name with an `s` on the end — `incidents`, `tasks`, `alerts`, `employees` — and the same rule applies to customer-defined objects. -If **Team business object** needs changing, confirm the object first: compare the `OwnerTeam` values actually in use on tickets against the names in the candidate object. Contact groups are a common wrong answer - in a stock tenant they hold advisory and approval boards rather than ticket-owning teams. +If **Team business object** needs changing, confirm the object first: compare the `OwnerTeam` values actually in use on tickets against the names in the candidate object. Contact groups are a common wrong answer - in a stock tenant they hold advisory and approval boards rather than ticket-owning teams. If the object has no `Team` field, imported teams have no name, and Team-scoped tiles deliberately show no tickets rather than falling back to every team's. ## Known limitations diff --git a/plugins/Ivanti-ITSM/v1/ui.json b/plugins/Ivanti-ITSM/v1/ui.json index a0296605..30b3311a 100644 --- a/plugins/Ivanti-ITSM/v1/ui.json +++ b/plugins/Ivanti-ITSM/v1/ui.json @@ -61,7 +61,7 @@ "label": "Team business object", "placeholder": "standarduserteams", "defaultValue": "standarduserteams", - "help": "The business object ticket-owning teams are imported from. The default suits most tenants; the object must expose the team name as `Team`, `Name` or `TeamName`. Do not point this at contact groups - those hold approval and advisory boards, not the teams that own tickets." + "help": "The business object ticket-owning teams are imported from. The default suits most tenants. The object must hold the team name in a field called `Team`, which is what tickets' `OwnerTeam` values are matched against. Do not point this at contact groups - those hold approval and advisory boards, not the teams that own tickets." }, { "type": "text", From 8ff9f4e04b7cbc960f7c62a324bee20238e5e68a Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 14:39:16 -0400 Subject: [PATCH 09/12] Sanitize pageLimit and add numeric validation Ensure paging arguments are positive whole numbers and add input validation. Replace uses of (Number(dataSource.pageLimit) || 100) with Math.max(1, Math.floor(Number(dataSource.pageLimit) || 100)) across data streams to prevent zero/decimal values. Update businessObjectRecords $top expression to floor maxRecords and enforce min=1. Add regex pattern checks for whole numbers on maxRecords and the UI page limit. Affected files: dataStreams/* (businessObjectRecords, changes, configurationItems, incidents, knowledgeArticles, problems, serviceRequests, teams) and v1/ui.json. --- plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json | 4 ++-- plugins/Ivanti-ITSM/v1/dataStreams/changes.json | 2 +- plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json | 2 +- plugins/Ivanti-ITSM/v1/dataStreams/incidents.json | 2 +- plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json | 2 +- plugins/Ivanti-ITSM/v1/dataStreams/problems.json | 2 +- plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json | 2 +- plugins/Ivanti-ITSM/v1/dataStreams/teams.json | 2 +- plugins/Ivanti-ITSM/v1/ui.json | 1 + 9 files changed, 10 insertions(+), 9 deletions(-) diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json b/plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json index b7ef6444..8d36f3b7 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/businessObjectRecords.json @@ -10,7 +10,7 @@ "getArgs": [ { "key": "$top", - "value": "{{ Math.min((typeof maxRecords !== 'undefined' && Number(maxRecords)) || (Number(dataSource.pageLimit) || 100), (Number(dataSource.pageLimit) || 100)) }}" + "value": "{{ Math.min((typeof maxRecords !== 'undefined' && Math.floor(Number(maxRecords))) || Math.max(1, Math.floor(Number(dataSource.pageLimit) || 100)), Math.max(1, Math.floor(Number(dataSource.pageLimit) || 100))) }}" }, { "key": "$filter", "value": "{{ typeof filter !== 'undefined' ? filter : null }}" }, { "key": "$select", "value": "{{ typeof select !== 'undefined' ? select : null }}" }, @@ -66,7 +66,7 @@ "name": "maxRecords", "label": "Maximum records", "help": "Defaults to the page limit set on the data source, and is capped at it - a higher value is reduced to the limit rather than failing. Narrow the filter to see different records.", - "validation": { "min": 1, "valueAsNumber": true } + "validation": { "min": 1, "pattern": { "value": "^[0-9]+$", "message": "Enter a whole number, e.g. 50" }, "valueAsNumber": true } } ], "metadata": [{ "pattern": ".*" }], diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/changes.json b/plugins/Ivanti-ITSM/v1/dataStreams/changes.json index c3c15b9c..2090e2d4 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/changes.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/changes.json @@ -30,7 +30,7 @@ "pageSize": { "realm": "queryArg", "path": "$top", - "value": "{{ (Number(dataSource.pageLimit) || 100) }}" + "value": "{{ Math.max(1, Math.floor(Number(dataSource.pageLimit) || 100)) }}" }, "offset": { "mode": "row", diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json b/plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json index 8e36d6f1..93a0bb83 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/configurationItems.json @@ -22,7 +22,7 @@ "pageSize": { "realm": "queryArg", "path": "$top", - "value": "{{ (Number(dataSource.pageLimit) || 100) }}" + "value": "{{ Math.max(1, Math.floor(Number(dataSource.pageLimit) || 100)) }}" }, "offset": { "mode": "row", diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json b/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json index ca432057..a574c383 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/incidents.json @@ -30,7 +30,7 @@ "pageSize": { "realm": "queryArg", "path": "$top", - "value": "{{ (Number(dataSource.pageLimit) || 100) }}" + "value": "{{ Math.max(1, Math.floor(Number(dataSource.pageLimit) || 100)) }}" }, "offset": { "mode": "row", diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json b/plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json index 62d900b3..27ce1594 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/knowledgeArticles.json @@ -16,7 +16,7 @@ "pathToData": "value", "paging": { "mode": "offset", - "pageSize": { "realm": "queryArg", "path": "$top", "value": "{{ (Number(dataSource.pageLimit) || 100) }}" }, + "pageSize": { "realm": "queryArg", "path": "$top", "value": "{{ Math.max(1, Math.floor(Number(dataSource.pageLimit) || 100)) }}" }, "offset": { "mode": "row", "rowCountIn": { "realm": "payloadArraySize", "path": "value" }, diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/problems.json b/plugins/Ivanti-ITSM/v1/dataStreams/problems.json index c8c96c0f..2c96741d 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/problems.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/problems.json @@ -30,7 +30,7 @@ "pageSize": { "realm": "queryArg", "path": "$top", - "value": "{{ (Number(dataSource.pageLimit) || 100) }}" + "value": "{{ Math.max(1, Math.floor(Number(dataSource.pageLimit) || 100)) }}" }, "offset": { "mode": "row", diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json b/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json index a16a21f2..9da0aae5 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/serviceRequests.json @@ -30,7 +30,7 @@ "pageSize": { "realm": "queryArg", "path": "$top", - "value": "{{ (Number(dataSource.pageLimit) || 100) }}" + "value": "{{ Math.max(1, Math.floor(Number(dataSource.pageLimit) || 100)) }}" }, "offset": { "mode": "row", diff --git a/plugins/Ivanti-ITSM/v1/dataStreams/teams.json b/plugins/Ivanti-ITSM/v1/dataStreams/teams.json index ced69434..de97a4ae 100644 --- a/plugins/Ivanti-ITSM/v1/dataStreams/teams.json +++ b/plugins/Ivanti-ITSM/v1/dataStreams/teams.json @@ -22,7 +22,7 @@ "pageSize": { "realm": "queryArg", "path": "$top", - "value": "{{ (Number(dataSource.pageLimit) || 100) }}" + "value": "{{ Math.max(1, Math.floor(Number(dataSource.pageLimit) || 100)) }}" }, "offset": { "mode": "row", diff --git a/plugins/Ivanti-ITSM/v1/ui.json b/plugins/Ivanti-ITSM/v1/ui.json index 30b3311a..38f6107f 100644 --- a/plugins/Ivanti-ITSM/v1/ui.json +++ b/plugins/Ivanti-ITSM/v1/ui.json @@ -41,6 +41,7 @@ "help": "The most records Ivanti returns in one request. An Ivanti administrator sets this for the tenant and it is 100 unless they have raised it - enter the value they confirm. Ivanti rejects any request above it, so every tile and import is kept at or below this number.", "validation": { "min": 1, + "pattern": { "value": "^[0-9]+$", "message": "Enter a whole number, e.g. 100" }, "valueAsNumber": true } }, From 5687bf357c8fbab1b8103471b491ae7e5ef7a26d Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 14:42:33 -0400 Subject: [PATCH 10/12] Adjust configuration item dashboard layout Increase the first widget width from 1 to 4 and remove a redundant "Configuration Items" data-stream tile in plugins/Ivanti-ITSM/v1/defaultContent/configurationItem.dash.json. Cleans up the dashboard layout and removes duplicate content. --- .../configurationItem.dash.json | 51 +------------------ 1 file changed, 1 insertion(+), 50 deletions(-) diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/configurationItem.dash.json b/plugins/Ivanti-ITSM/v1/defaultContent/configurationItem.dash.json index d567e504..caa7bdd3 100644 --- a/plugins/Ivanti-ITSM/v1/defaultContent/configurationItem.dash.json +++ b/plugins/Ivanti-ITSM/v1/defaultContent/configurationItem.dash.json @@ -14,7 +14,7 @@ "i": "fd385beb-7ba1-44c7-aa61-3f4fa038d7f8", "x": 0, "y": 0, - "w": 1, + "w": 4, "h": 4, "moved": false, "static": false, @@ -48,55 +48,6 @@ } } } - }, - { - "i": "029c3345-e766-4224-96c9-bc20f73c6656", - "x": 1, - "y": 0, - "w": 3, - "h": 4, - "moved": false, - "static": false, - "z": 0, - "config": { - "_type": "tile/data-stream", - "title": "Configuration Items", - "description": "All configuration items in the CMDB", - "timeframe": "none", - "activePluginConfigIds": [ - "{{configId}}" - ], - "dataStream": { - "id": "{{dataStreams.[configurationItems]}}", - "name": "configurationItems", - "pluginConfigId": "{{configId}}", - "sort": { - "by": [ - [ - "Name", - "asc" - ] - ] - } - }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "Name", - "CIType", - "Status", - "Owner", - "PhysicalLocation", - "Manufacturer", - "Model" - ] - } - } - } - } } ] } From 19b65feee3227db3e37a9bc2634467a1a03e5a15 Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 15:00:25 -0400 Subject: [PATCH 11/12] Limit recent tiles to timeframe; add dateField Set Recent Changes tile timeframe to last30days and Recent Incidents to last7days, and add a "dateField": "CreatedDateTime" to both tiles' dataSourceConfig to enable server-side date filtering. Update plugin docs to explain these exceptions (Recent Incidents = 7 days, Recent Changes = 30 days) and why a window is used to avoid paging every ticket. Files changed: changeAndProblemManagement.dash.json, serviceDeskOverview.dash.json, plugins/Ivanti-ITSM/v1/docs/README.md. --- .../v1/defaultContent/changeAndProblemManagement.dash.json | 5 +++-- .../v1/defaultContent/serviceDeskOverview.dash.json | 5 +++-- plugins/Ivanti-ITSM/v1/docs/README.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json b/plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json index 8562b57f..cb88e544 100644 --- a/plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json +++ b/plugins/Ivanti-ITSM/v1/defaultContent/changeAndProblemManagement.dash.json @@ -335,7 +335,7 @@ "_type": "tile/data-stream", "title": "Recent Changes", "description": "", - "timeframe": "none", + "timeframe": "last30days", "activePluginConfigIds": [ "{{configId}}" ], @@ -353,7 +353,8 @@ "top": 10 }, "dataSourceConfig": { - "select": "Subject,ChangeNumber,Status,Priority,Urgency,Impact,RiskLevel,TypeOfChange,Category,Service,Owner,OwnerTeam,RequestorFullName,Source,ScheduledStartDate,ScheduledEndDate,CreatedDateTime,LastModDateTime,ClosedDateTime,FinalState,TotalTimeSpent,Description,Resolution,CreatedBy,RecId" + "select": "Subject,ChangeNumber,Status,Priority,Urgency,Impact,RiskLevel,TypeOfChange,Category,Service,Owner,OwnerTeam,RequestorFullName,Source,ScheduledStartDate,ScheduledEndDate,CreatedDateTime,LastModDateTime,ClosedDateTime,FinalState,TotalTimeSpent,Description,Resolution,CreatedBy,RecId", + "dateField": "CreatedDateTime" } }, "visualisation": { diff --git a/plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json b/plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json index 598d0700..1a6d2644 100644 --- a/plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json +++ b/plugins/Ivanti-ITSM/v1/defaultContent/serviceDeskOverview.dash.json @@ -589,7 +589,7 @@ "_type": "tile/data-stream", "title": "Recent Incidents", "description": "", - "timeframe": "none", + "timeframe": "last7days", "activePluginConfigIds": [ "{{configId}}" ], @@ -607,7 +607,8 @@ "top": 10 }, "dataSourceConfig": { - "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId" + "select": "Subject,IncidentNumber,Status,Priority,Urgency,Impact,Category,Subcategory,Service,Owner,OwnerTeam,ProfileFullName,LoginId,Source,CreatedDateTime,LastModDateTime,ResolvedDateTime,ClosedDateTime,RespondedDateTime,IsInFinalState,IsResponseBreached,IsResolutionBreached,SLA,IsVIP,TotalTimeSpent,Symptom,Resolution,CreatedBy,RecId", + "dateField": "CreatedDateTime" } }, "visualisation": { diff --git a/plugins/Ivanti-ITSM/v1/docs/README.md b/plugins/Ivanti-ITSM/v1/docs/README.md index 4375edda..ddc9d67f 100644 --- a/plugins/Ivanti-ITSM/v1/docs/README.md +++ b/plugins/Ivanti-ITSM/v1/docs/README.md @@ -87,7 +87,7 @@ If **Team business object** needs changing, confirm the object first: compare th - **Page limit must match the tenant.** Ivanti rejects a request above the tenant page limit with `You cannot query more than 100 records` rather than returning fewer rows. Set **Page limit** to the value your Ivanti administrator has configured — 100 unless they have raised it. Every stream pages at that size, so a higher limit means fewer requests, not more data. - **Wide business objects can be slow or too large.** An Ivanti record carries every field the tenant has defined, which for incidents can be hundreds of columns. If a tile times out or fails with a response-size error, set its **Field(s)** parameter to just the columns that tile uses — that is usually a tenfold reduction in payload. - **Broad tiles can exceed the response limit.** Every stream pages until it has fetched all matching records, so counts and charts are complete — but a tile with no filter fetches everything. An incident record carries roughly 250 fields, so an unfiltered Incidents or Service Requests tile can reach SquaredUp's response-size limit within a few hundred records and fail with a response-size error. Narrow broad tiles with a status or timeframe filter, and set **Field(s)** to only the columns the tile needs; the out-of-the-box tiles do both. For a large CMDB, use the **Configuration item filter** on the data source. -- **Date filtering is applied by Ivanti, not by SquaredUp.** Selecting a timeframe adds an OData comparison on the date field chosen in the tile's **Timeframe applies to** setting, as an unquoted ISO-8601 literal such as `2016-07-24T23:42:48Z`. Tiles default to the **None** timeframe — the current backlog — so the out-of-the-box dashboards do not depend on it. +- **Date filtering is applied by Ivanti, not by SquaredUp.** Selecting a timeframe adds an OData comparison on the date field chosen in the tile's **Timeframe applies to** setting, as an unquoted ISO-8601 literal such as `2016-07-24T23:42:48Z`. Tiles default to the **None** timeframe — the current backlog. The exceptions are the **Recent Incidents** and **Recent Changes** tables, which show tickets created in the last 7 and 30 days respectively. A window keeps them from paging through every ticket ever logged to show ten rows, so a quiet period shows fewer than ten. - **Only eight filter operators.** Ivanti supports [`eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and` and `or`](https://docs.ivanti.com/neurons-for-itsm/admin-user-help/enu/latest/get-business-object-by-filter) — there is no `contains`, `startswith` or `not`, so the **Additional filter** field cannot do partial text matching. - **Selecting several statuses or teams at once needs API version 2.** Combining an OR-group with another condition requires brackets, which only version 2 of the API supports. On version 1, filter on one status and one team at a time, or switch **API version** to version 2. - **Field names are tenant-specific.** Columns such as `Priority`, `OwnerTeam` and `ProfileFullName` are the out-of-the-box names. Where a tenant has renamed one, the named column is empty and the tenant's own field appears alongside it instead. From 5e9272c2434a63a2ec3ce3474626afc027423b61 Mon Sep 17 00:00:00 2001 From: Shawn Williams Date: Wed, 23 Sep 2026 15:19:50 -0400 Subject: [PATCH 12/12] Clarify Ivanti pagination docs Update the Ivanti plugin documentation to explain how the page limit applies to paged streams versus the Business Object Records stream. This also clarifies the response-size and partial-result caveats for broad tiles, so users understand when a higher page limit increases throughput and when it changes result completeness. --- plugins/Ivanti-ITSM/v1/docs/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/Ivanti-ITSM/v1/docs/README.md b/plugins/Ivanti-ITSM/v1/docs/README.md index ddc9d67f..b6b01b65 100644 --- a/plugins/Ivanti-ITSM/v1/docs/README.md +++ b/plugins/Ivanti-ITSM/v1/docs/README.md @@ -27,7 +27,7 @@ A key only works for the tenant it was created in, and optionally for the IP add | ----- | ---------- | ---------------- | -------- | | **Tenant URL** | The address of the Ivanti instance. For an on-premises instance, include the virtual directory, for example `https://help.example.com/HEAT`. The plugin appends `/api/odata/businessobject/…` itself. | The address bar when you are signed in to Ivanti, for example `https://yourtenant.saasiteu.com`. | Yes | | **REST API key** | Sent as the `Authorization: rest_api_key=…` header on every request. | Configuration console → **Configure > Security Controls > API Keys** → the key's **Reference ID**. | Yes | -| **Page limit** | The most records Ivanti returns in one request. Every stream pages at this size, so no request ever exceeds it. | Set by an Ivanti administrator for the tenant — ask them for the value. It is 100 unless they have raised it. | No (defaults to 100) | +| **Page limit** | The most records Ivanti returns in one request. Paged streams fetch in pages of this size, and **Business Object Records** returns at most this many, so no request exceeds it. | Set by an Ivanti administrator for the tenant — ask them for the value. It is 100 unless they have raised it. | No (defaults to 100) | | **Configuration item filter** | An OData `$filter` limiting which configuration items are imported, for example `Status eq 'Active'`. | — | No | | **API version** | Which REST API version to call — version 1 (`/api/odata/…`) or version 2 (`/api/odatav2/…`). Version 2 supports bracketed filters. | — | No (defaults to version 1) | | **Team business object** | Which business object ticket-owning teams are imported from. The object must expose the team name in a field called `Team`. | — | No (defaults to `standarduserteams`) | @@ -84,9 +84,9 @@ If **Team business object** needs changing, confirm the object first: compare th ## Known limitations -- **Page limit must match the tenant.** Ivanti rejects a request above the tenant page limit with `You cannot query more than 100 records` rather than returning fewer rows. Set **Page limit** to the value your Ivanti administrator has configured — 100 unless they have raised it. Every stream pages at that size, so a higher limit means fewer requests, not more data. +- **Page limit must match the tenant.** Ivanti rejects a request above the tenant page limit with `You cannot query more than 100 records` rather than returning fewer rows. Set **Page limit** to the value your Ivanti administrator has configured — 100 unless they have raised it. Paged streams fetch in pages of that size, so for them a higher limit means fewer requests, not more data. **Business Object Records** makes a single request, so for it a higher limit means more records. - **Wide business objects can be slow or too large.** An Ivanti record carries every field the tenant has defined, which for incidents can be hundreds of columns. If a tile times out or fails with a response-size error, set its **Field(s)** parameter to just the columns that tile uses — that is usually a tenfold reduction in payload. -- **Broad tiles can exceed the response limit.** Every stream pages until it has fetched all matching records, so counts and charts are complete — but a tile with no filter fetches everything. An incident record carries roughly 250 fields, so an unfiltered Incidents or Service Requests tile can reach SquaredUp's response-size limit within a few hundred records and fail with a response-size error. Narrow broad tiles with a status or timeframe filter, and set **Field(s)** to only the columns the tile needs; the out-of-the-box tiles do both. For a large CMDB, use the **Configuration item filter** on the data source. +- **Broad tiles can exceed the response limit.** The ticket, configuration item, team and knowledge article streams page until they have fetched every matching record, so their counts and charts are complete — but a tile with no filter fetches everything. **Business Object Records** is the exception: it makes a single request and returns at most one page, so its results can be partial. An incident record carries roughly 250 fields, so an unfiltered Incidents or Service Requests tile can reach SquaredUp's response-size limit within a few hundred records and fail with a response-size error. Narrow broad tiles with a status or timeframe filter, and set **Field(s)** to only the columns the tile needs; the out-of-the-box tiles do both. For a large CMDB, use the **Configuration item filter** on the data source. - **Date filtering is applied by Ivanti, not by SquaredUp.** Selecting a timeframe adds an OData comparison on the date field chosen in the tile's **Timeframe applies to** setting, as an unquoted ISO-8601 literal such as `2016-07-24T23:42:48Z`. Tiles default to the **None** timeframe — the current backlog. The exceptions are the **Recent Incidents** and **Recent Changes** tables, which show tickets created in the last 7 and 30 days respectively. A window keeps them from paging through every ticket ever logged to show ten rows, so a quiet period shows fewer than ten. - **Only eight filter operators.** Ivanti supports [`eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and` and `or`](https://docs.ivanti.com/neurons-for-itsm/admin-user-help/enu/latest/get-business-object-by-filter) — there is no `contains`, `startswith` or `not`, so the **Additional filter** field cannot do partial text matching. - **Selecting several statuses or teams at once needs API version 2.** Combining an OR-group with another condition requires brackets, which only version 2 of the API supports. On version 1, filter on one status and one team at a time, or switch **API version** to version 2.