diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fbfec164..f7193eff 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -33,6 +33,7 @@ /plugins/Spotify/ @jame2O /plugins/Steam/ @kmichalski0 /plugins/SumoLogic/ @richpeters +/plugins/TeamDynamix/ @shawn149 /plugins/TransportForLondon/ @clarkd /plugins/UniFi/ @adamkinniburgh /plugins/UptimeRobot/ @kieranlangton diff --git a/plugins/TeamDynamix/v1/dataStreams/accounts.json b/plugins/TeamDynamix/v1/dataStreams/accounts.json index e711c4c7..ab05a416 100644 --- a/plugins/TeamDynamix/v1/dataStreams/accounts.json +++ b/plugins/TeamDynamix/v1/dataStreams/accounts.json @@ -27,6 +27,8 @@ { "name": "country", "displayName": "Country", "shape": "string" }, { "name": "createdDate", "displayName": "Created", "shape": "date" } ], - "timeframes": false, + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none", "visibility": { "type": "hidden" } } diff --git a/plugins/TeamDynamix/v1/dataStreams/accountsIndex.json b/plugins/TeamDynamix/v1/dataStreams/accountsIndex.json new file mode 100644 index 00000000..cc8f200b --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/accountsIndex.json @@ -0,0 +1,38 @@ +{ + "name": "accountsIndex", + "displayName": "Accounts (Import)", + "description": "Active accounts and departments, one row per account (empty unless inventory import is turned on)", + "tags": ["Organization"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "post", + "endpointPath": "api/accounts/search", + "postBody": { + "IsActive": true, + "SearchText": "{{ dataSource.importInventory === true || dataSource.importInventory === 'true' ? null : 'squaredup-inventory-import-disabled' }}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "accounts.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": "none", + "metadata": [ + { "name": "name", "displayName": "Name", "shape": "string", "role": "label" }, + { "name": "accountId", "displayName": "Account ID", "shape": "string", "role": "id" }, + { "name": "code", "displayName": "Code", "shape": "string" }, + { "name": "managerName", "displayName": "Manager", "shape": "string" }, + { "name": "parentName", "displayName": "Parent", "shape": "string" }, + { "name": "industryName", "displayName": "Industry", "shape": "string" }, + { "name": "city", "displayName": "City", "shape": "string" }, + { "name": "stateName", "displayName": "State", "shape": "string" }, + { "name": "country", "displayName": "Country", "shape": "string" }, + { "name": "createdDate", "displayName": "Created", "shape": "date" } + ], + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none", + "visibility": { "type": "hidden" } +} diff --git a/plugins/TeamDynamix/v1/dataStreams/assetCount.json b/plugins/TeamDynamix/v1/dataStreams/assetCount.json new file mode 100644 index 00000000..0810bfb1 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/assetCount.json @@ -0,0 +1,47 @@ +{ + "name": "assetCount", + "displayName": "Asset Count", + "description": "Number of assets in an asset application, optionally only those in or out of service (exact, not limited by Maximum assets)", + "tags": ["Assets", "Inventory"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "post", + "endpointPath": "api/{{object.rawId}}/assets/search/paged", + "getArgs": [ + { "key": "pageSize", "value": "1" }, + { "key": "pageNumber", "value": "1" } + ], + "postBody": { + "OnlyParentAssets": false, + "IsInService": "{{typeof inService === 'undefined' || !inService || inService === 'all' ? null : inService === 'inService'}}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "assetCount.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Asset App"] } + }, + "ui": [ + { + "type": "switch", + "name": "inService", + "label": "Service state", + "defaultValue": "all", + "options": [ + { "value": "all", "label": "All" }, + { "value": "inService", "label": "In service" }, + { "value": "outOfService", "label": "Out of service" } + ] + } + ], + "metadata": [ + { "name": "assets", "displayName": "Assets", "shape": "number", "role": "value" } + ], + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" +} diff --git a/plugins/TeamDynamix/v1/dataStreams/assets.json b/plugins/TeamDynamix/v1/dataStreams/assets.json index c9610e3e..7e50bfc7 100644 --- a/plugins/TeamDynamix/v1/dataStreams/assets.json +++ b/plugins/TeamDynamix/v1/dataStreams/assets.json @@ -8,8 +8,8 @@ "httpMethod": "post", "endpointPath": "api/{{object.rawId}}/assets/search", "postBody": { - "MaxResults": "{{maxResults || dataSource.maxRecords || 2000}}", - "IsInService": "{{typeof inService === 'undefined' || inService === '' || inService === 'all' ? null : inService === 'inService'}}" + "MaxResults": "{{(typeof maxResults !== 'undefined' && maxResults) || dataSource.maxRecords || 2000}}", + "IsInService": "{{typeof inService === 'undefined' || !inService || inService === 'all' ? null : inService === 'inService'}}" }, "paging": { "mode": "none" }, "postRequestScript": "assets.js", @@ -71,5 +71,7 @@ "defaultShaping": { "sort": { "by": [["name", "asc"]] } }, - "timeframes": false + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" } diff --git a/plugins/TeamDynamix/v1/dataStreams/assetsIndex.json b/plugins/TeamDynamix/v1/dataStreams/assetsIndex.json new file mode 100644 index 00000000..9692325e --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/assetsIndex.json @@ -0,0 +1,55 @@ +{ + "name": "assetsIndex", + "displayName": "Assets (Import)", + "description": "Assets in an asset application, one row per asset, up to the maximum records per request (empty unless inventory import is turned on)", + "tags": ["Assets", "Inventory"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "post", + "endpointPath": "api/{{object.rawId}}/assets/search", + "postBody": { + "MaxResults": "{{dataSource.maxRecords || 2000}}", + "ExternalIDs": "{{ dataSource.importInventory === true || dataSource.importInventory === 'true' ? null : ['squaredup-inventory-import-disabled'] }}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "assets.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Asset App"] } + }, + "metadata": [ + { "name": "name", "displayName": "Asset", "shape": "string", "role": "label" }, + { "name": "sourceId", "displayName": "Source ID", "shape": "string", "role": "id", "visible": false }, + { "name": "assetId", "displayName": "Asset ID", "shape": "string", "visible": false }, + { "name": "link", "displayName": "Link", "shape": ["url", { "label": "Open in TeamDynamix" }], "role": "link" }, + { "name": "statusName", "displayName": "Status", "shape": "string" }, + { "name": "tag", "displayName": "Tag", "shape": "string" }, + { "name": "serialNumber", "displayName": "Serial Number", "shape": "string" }, + { "name": "manufacturerName", "displayName": "Manufacturer", "shape": "string" }, + { "name": "productModelName", "displayName": "Model", "shape": "string" }, + { "name": "supplierName", "displayName": "Supplier", "shape": "string", "visible": false }, + { "name": "locationName", "displayName": "Location", "shape": "string" }, + { "name": "locationRoomName", "displayName": "Room", "shape": "string", "visible": false }, + { "name": "owningCustomerName", "displayName": "Owner", "shape": "string" }, + { "name": "owningDepartmentName", "displayName": "Owning Department", "shape": "string" }, + { "name": "requestingCustomerName", "displayName": "Requestor", "shape": "string", "visible": false }, + { "name": "purchaseCost", "displayName": "Purchase Cost", "shape": ["number", { "decimalPlaces": 2, "thousandsSeparator": true }] }, + { "name": "acquisitionDate", "displayName": "Acquired", "shape": "date" }, + { "name": "expectedReplacementDate", "displayName": "Expected Replacement", "shape": "date" }, + { "name": "createdDate", "displayName": "Created", "shape": "date", "visible": false }, + { "name": "modifiedDate", "displayName": "Modified", "shape": "date" }, + { "name": "parentName", "displayName": "Parent Asset", "shape": "string", "visible": false }, + { "name": "externalId", "displayName": "External ID", "shape": "string", "visible": false }, + { "name": "configurationItemId", "displayName": "Configuration Item ID", "shape": "string", "visible": false }, + { "name": "appId", "displayName": "App ID", "shape": "string", "visible": false }, + { "name": "sourceType", "displayName": "Object Type", "shape": "string", "visible": false } + ], + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none", + "visibility": { "type": "hidden" } +} diff --git a/plugins/TeamDynamix/v1/dataStreams/configurationItems.json b/plugins/TeamDynamix/v1/dataStreams/configurationItems.json index a0c5908c..097f94fa 100644 --- a/plugins/TeamDynamix/v1/dataStreams/configurationItems.json +++ b/plugins/TeamDynamix/v1/dataStreams/configurationItems.json @@ -1,14 +1,15 @@ { "name": "configurationItems", "displayName": "Configuration Items", - "description": "Configuration items in the CMDB of an asset application, one row per item", + "description": "Standalone configuration items in the CMDB of an asset application, one row per item (items that only mirror an asset are left out)", "tags": ["Configuration Items", "Inventory"], "baseDataSourceName": "httpRequestScopedSingle", "config": { "httpMethod": "post", "endpointPath": "api/{{object.rawId}}/cmdb/search", "postBody": { - "IsActive": "{{typeof activeState === 'undefined' || activeState === '' || activeState === 'all' ? null : activeState === 'active'}}" + "MaxResults": 10000, + "IsActive": "{{typeof activeState === 'undefined' || !activeState ? true : activeState === 'all' ? null : activeState === 'active'}}" }, "paging": { "mode": "none" }, "postRequestScript": "configurationItems.js", @@ -57,5 +58,7 @@ "defaultShaping": { "sort": { "by": [["name", "asc"]] } }, - "timeframes": false + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" } diff --git a/plugins/TeamDynamix/v1/dataStreams/configurationItemsIndex.json b/plugins/TeamDynamix/v1/dataStreams/configurationItemsIndex.json new file mode 100644 index 00000000..3f5016b5 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/configurationItemsIndex.json @@ -0,0 +1,58 @@ +{ + "name": "configurationItemsIndex", + "displayName": "Configuration Items (Import)", + "description": "Every active standalone configuration item in an asset application, fetched page by page (empty unless inventory import is turned on)", + "tags": ["Configuration Items", "Inventory"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "post", + "endpointPath": "api/{{object.rawId}}/cmdb/search/paged", + "postBody": { + "IsActive": true, + "NameLike": "{{ dataSource.importInventory === true || dataSource.importInventory === 'true' ? null : 'squaredup-inventory-import-disabled' }}" + }, + "paging": { + "mode": "offset", + "pageSize": { "realm": "queryArg", "path": "pageSize", "value": "250" }, + "offset": { + "mode": "page", + "rowCountIn": { "realm": "payloadArraySize", "path": "Data" }, + "base": 1 + }, + "out": { "realm": "queryArg", "path": "pageNumber" } + }, + "postRequestScript": "configurationItems.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Asset App"] } + }, + "metadata": [ + { "name": "name", "displayName": "Configuration Item", "shape": "string", "role": "label" }, + { "name": "sourceId", "displayName": "Source ID", "shape": "string", "role": "id", "visible": false }, + { "name": "configurationItemId", "displayName": "CI ID", "shape": "string", "visible": false }, + { "name": "link", "displayName": "Link", "shape": ["url", { "label": "Open in TeamDynamix" }], "role": "link" }, + { "name": "typeName", "displayName": "Type", "shape": "string" }, + { "name": "isActive", "displayName": "Active", "shape": "boolean" }, + { "name": "ownerFullName", "displayName": "Owner", "shape": "string" }, + { "name": "owningDepartmentName", "displayName": "Owning Department", "shape": "string" }, + { "name": "owningGroupName", "displayName": "Owning Group", "shape": "string" }, + { "name": "locationName", "displayName": "Location", "shape": "string" }, + { "name": "maintenanceScheduleName", "displayName": "Maintenance Window", "shape": "string" }, + { "name": "backingItemType", "displayName": "Backing Item Type", "shape": "string", "visible": false }, + { "name": "isSystemMaintained", "displayName": "System Maintained", "shape": "boolean", "visible": false }, + { "name": "externalSourceName", "displayName": "External Source", "shape": "string", "visible": false }, + { "name": "externalId", "displayName": "External ID", "shape": "string", "visible": false }, + { "name": "createdDate", "displayName": "Created", "shape": "date", "visible": false }, + { "name": "modifiedDate", "displayName": "Modified", "shape": "date" }, + { "name": "appId", "displayName": "App ID", "shape": "string", "visible": false }, + { "name": "sourceType", "displayName": "Object Type", "shape": "string", "visible": false } + ], + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none", + "visibility": { "type": "hidden" } +} diff --git a/plugins/TeamDynamix/v1/dataStreams/currentUser.json b/plugins/TeamDynamix/v1/dataStreams/currentUser.json index 0f3ee2f1..f2675dcf 100644 --- a/plugins/TeamDynamix/v1/dataStreams/currentUser.json +++ b/plugins/TeamDynamix/v1/dataStreams/currentUser.json @@ -22,6 +22,8 @@ { "name": "BEID", "displayName": "BEID", "shape": "string" }, { "name": "UID", "displayName": "UID", "shape": "string", "visible": false } ], - "timeframes": false, + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none", "visibility": { "type": "hidden" } } diff --git a/plugins/TeamDynamix/v1/dataStreams/groups.json b/plugins/TeamDynamix/v1/dataStreams/groups.json index 46c28f26..94f6032b 100644 --- a/plugins/TeamDynamix/v1/dataStreams/groups.json +++ b/plugins/TeamDynamix/v1/dataStreams/groups.json @@ -25,6 +25,8 @@ { "name": "CreatedDate", "displayName": "Created", "shape": "date" }, { "name": "ModifiedDate", "displayName": "Modified", "shape": "date" } ], - "timeframes": false, + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none", "visibility": { "type": "hidden" } } diff --git a/plugins/TeamDynamix/v1/dataStreams/knowledgeBaseArticles.json b/plugins/TeamDynamix/v1/dataStreams/knowledgeBaseArticles.json index 64a79fa7..e0b7a46b 100644 --- a/plugins/TeamDynamix/v1/dataStreams/knowledgeBaseArticles.json +++ b/plugins/TeamDynamix/v1/dataStreams/knowledgeBaseArticles.json @@ -11,7 +11,7 @@ "IncludeArticleBodies": false, "IncludeShortcuts": false, "IsPublished": "{{typeof publishedState === 'undefined' || publishedState === '' || publishedState === 'all' ? null : publishedState === 'published'}}", - "ReturnCount": "{{maxResults || dataSource.maxRecords || 1000}}" + "ReturnCount": "{{(typeof maxResults !== 'undefined' && maxResults) || dataSource.maxRecords || 1000}}" }, "paging": { "mode": "none" }, "postRequestScript": "knowledgeBaseArticles.js", @@ -21,7 +21,8 @@ } }, "matches": { - "sourceType": { "type": "oneOf", "values": ["TeamDynamix Application"] } + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Application"] }, + "appClass": { "type": "oneOf", "values": ["TDClient"] } }, "ui": [ { @@ -62,5 +63,7 @@ "defaultShaping": { "sort": { "by": [["modifiedDate", "desc"]] } }, - "timeframes": false + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" } diff --git a/plugins/TeamDynamix/v1/dataStreams/locations.json b/plugins/TeamDynamix/v1/dataStreams/locations.json index 2df5b04f..6c0b3e17 100644 --- a/plugins/TeamDynamix/v1/dataStreams/locations.json +++ b/plugins/TeamDynamix/v1/dataStreams/locations.json @@ -5,8 +5,13 @@ "tags": ["Organization", "Inventory"], "baseDataSourceName": "httpRequestUnscoped", "config": { - "httpMethod": "get", - "endpointPath": "api/locations", + "httpMethod": "post", + "endpointPath": "api/locations/search", + "postBody": { + "IsActive": true, + "ReturnItemCounts": true, + "ReturnRooms": false + }, "paging": { "mode": "none" }, "postRequestScript": "locations.js", "errorHandling": { @@ -33,5 +38,7 @@ "defaultShaping": { "sort": { "by": [["ticketsCount", "desc"]] } }, - "timeframes": false + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" } diff --git a/plugins/TeamDynamix/v1/dataStreams/locationsIndex.json b/plugins/TeamDynamix/v1/dataStreams/locationsIndex.json new file mode 100644 index 00000000..8d98f801 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/locationsIndex.json @@ -0,0 +1,43 @@ +{ + "name": "locationsIndex", + "displayName": "Locations (Import)", + "description": "Active locations with their item counts, one row per location (empty unless inventory import is turned on)", + "tags": ["Organization", "Inventory"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "post", + "endpointPath": "api/locations/search", + "postBody": { + "IsActive": true, + "ReturnItemCounts": true, + "ReturnRooms": false, + "NameLike": "{{ dataSource.importInventory === true || dataSource.importInventory === 'true' ? null : 'squaredup-inventory-import-disabled' }}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "locations.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": "none", + "metadata": [ + { "name": "name", "displayName": "Location", "shape": "string", "role": "label" }, + { "name": "locationId", "displayName": "Location ID", "shape": "string", "role": "id" }, + { "name": "ticketsCount", "displayName": "Tickets", "shape": "number", "role": "value" }, + { "name": "assetsCount", "displayName": "Assets", "shape": "number" }, + { "name": "configurationItemsCount", "displayName": "Configuration Items", "shape": "number" }, + { "name": "usersCount", "displayName": "Users", "shape": "number" }, + { "name": "roomsCount", "displayName": "Rooms", "shape": "number" }, + { "name": "city", "displayName": "City", "shape": "string" }, + { "name": "state", "displayName": "State", "shape": "string" }, + { "name": "country", "displayName": "Country", "shape": "string" }, + { "name": "address", "displayName": "Address", "shape": "string" }, + { "name": "latitude", "displayName": "Latitude", "shape": "number", "visible": false }, + { "name": "longitude", "displayName": "Longitude", "shape": "number", "visible": false } + ], + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none", + "visibility": { "type": "hidden" } +} diff --git a/plugins/TeamDynamix/v1/dataStreams/projects.json b/plugins/TeamDynamix/v1/dataStreams/projects.json index 531e3158..afcbef88 100644 --- a/plugins/TeamDynamix/v1/dataStreams/projects.json +++ b/plugins/TeamDynamix/v1/dataStreams/projects.json @@ -19,7 +19,8 @@ } }, "matches": { - "sourceType": { "type": "oneOf", "values": ["TeamDynamix Application"] } + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Application"] }, + "appClass": { "type": "oneOf", "values": ["TDProjects"] } }, "ui": [ { @@ -87,5 +88,7 @@ "defaultShaping": { "sort": { "by": [["endDate", "asc"]] } }, - "timeframes": false + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" } diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js b/plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js index f73ed344..2a1b87fd 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js @@ -1,6 +1,12 @@ // Projected rather than passed through: an Account carries an Attributes array of custom // fields and four separate address lines that add bulk without adding anything a dashboard // or the graph uses. +// TeamDynamix never sends null for an unset date: it sends 0001-01-01T00:00:00 (.NET's minimum +// date), which SquaredUp displays as 12/31/1900 and which passes "not empty" filters. Anything +// before 1901-01-02 is treated as unset, which covers that value however it is normalised. +const UNSET_DATE_BEFORE = Date.parse("1901-01-02T00:00:00Z"); +const realDate = (value) => (value && Date.parse(value) >= UNSET_DATE_BEFORE ? value : null); + result = (Array.isArray(data) ? data : []).map((account) => ({ accountId: String(account.ID), name: account.Name, @@ -11,5 +17,5 @@ result = (Array.isArray(data) ? data : []).map((account) => ({ city: account.City || "", stateName: account.StateName || "", country: account.Country || "", - createdDate: account.CreatedDate || null + createdDate: realDate(account.CreatedDate) })); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/assetCount.js b/plugins/TeamDynamix/v1/dataStreams/scripts/assetCount.js new file mode 100644 index 00000000..93189ddd --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/assetCount.js @@ -0,0 +1,4 @@ +// The paged search is asked for a single asset; only its TotalCount is used. That counts every +// match, where the Assets stream stops at Maximum assets (2,000 by default) and one asset +// application can hold tens of thousands. A missing total is left empty rather than shown as 0. +result = [{ assets: typeof data?.TotalCount === "number" ? data.TotalCount : null }]; diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js b/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js index fd05baa1..e3d13ec2 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js @@ -7,10 +7,16 @@ // and Attributes are never included in search results — only loading an asset individually // returns them, which this stream doesn't do. +// TeamDynamix never sends null for an unset date: it sends 0001-01-01T00:00:00 (.NET's minimum +// date), which SquaredUp displays as 12/31/1900 and which passes "not empty" filters. Anything +// before 1901-01-02 is treated as unset, which covers that value however it is normalised. +const UNSET_DATE_BEFORE = Date.parse("1901-01-02T00:00:00Z"); +const realDate = (value) => (value && Date.parse(value) >= UNSET_DATE_BEFORE ? value : null); + // A sandbox Web API address (/SBTDWebApi) needs the matching sandbox web app (/SBTDNext) — -// otherwise every sandbox asset link points at the production app instead. SBTDNext is -// inferred from the same SB-prefix convention as SBTDWebApi; the API spec has no web UI -// paths to confirm it against. +// otherwise every sandbox asset link points at the production app instead. The API spec has +// no web UI paths; SBTDNext follows the same SB prefix as SBTDWebApi, confirmed on a real +// sandbox tenant. const rawBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") .trim() .replace(/\/+$/, ""); @@ -18,6 +24,11 @@ const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); const nextApp = isSandbox ? "SBTDNext" : "TDNext"; +// An unset manufacturer, model, supplier, location, owner or department arrives as the literal +// name "None" rather than empty, so a breakdown tile's biggest bar is "None". Blank it so empty +// filters and tile SQL treat it as unset. +const named = (value) => (value && value !== "None" ? value : ""); + result = (Array.isArray(data) ? data : []).map((asset) => ({ // Asset IDs are only unique within an application, so the graph id has to carry the app // too — two asset apps in the same tenant can both have an asset 1041. @@ -30,20 +41,20 @@ result = (Array.isArray(data) ? data : []).map((asset) => ({ statusName: asset.StatusName || "", tag: asset.Tag || "", serialNumber: asset.SerialNumber || "", - manufacturerName: asset.ManufacturerName || "", - productModelName: asset.ProductModelName || "", - supplierName: asset.SupplierName || "", - locationName: asset.LocationName || "", - locationRoomName: asset.LocationRoomName || "", - owningCustomerName: asset.OwningCustomerName || "", - owningDepartmentName: asset.OwningDepartmentName || "", - requestingCustomerName: asset.RequestingCustomerName || "", + manufacturerName: named(asset.ManufacturerName), + productModelName: named(asset.ProductModelName), + supplierName: named(asset.SupplierName), + locationName: named(asset.LocationName), + locationRoomName: named(asset.LocationRoomName), + owningCustomerName: named(asset.OwningCustomerName), + owningDepartmentName: named(asset.OwningDepartmentName), + requestingCustomerName: named(asset.RequestingCustomerName), purchaseCost: typeof asset.PurchaseCost === "number" ? asset.PurchaseCost : null, - acquisitionDate: asset.AcquisitionDate || null, - expectedReplacementDate: asset.ExpectedReplacementDate || null, - createdDate: asset.CreatedDate || null, - modifiedDate: asset.ModifiedDate || null, - parentName: asset.ParentName || "", + acquisitionDate: realDate(asset.AcquisitionDate), + expectedReplacementDate: realDate(asset.ExpectedReplacementDate), + createdDate: realDate(asset.CreatedDate), + modifiedDate: realDate(asset.ModifiedDate), + parentName: named(asset.ParentName), externalId: asset.ExternalID || "", configurationItemId: asset.ConfigurationItemID ? String(asset.ConfigurationItemID) : "", appId: String(asset.AppID ?? ""), diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js index 1a8ac48c..d60e7f43 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js @@ -1,28 +1,46 @@ -// Serves double duty: this stream backs both the configurationItems import step and CI tiles. +// Serves both CI tiles (configurationItems, the unpaged cmdb/search) and the import +// (configurationItemsIndex, cmdb/search/paged, which wraps each page in { Data: [...] }). // -// ConfigurationItemSearch has no MaxResults field and no paging (confirmed against the API: -// the only paged CMDB endpoint is cmdb/searches/{searchId}/results, which needs a saved search -// the customer would have to pre-create in TeamDynamix — not viable for a zero-touch setup). -// So a large, unfiltered CMDB can return more rows than the ~6MB stream response cap allows. -// Two defences: keep each row small (the projection below), and cap the row count actually -// returned rather than trust the upstream call to stay small. The IsActive filter set by the -// stream's own config already narrows the common case before this ever runs. -// BackingItemType is an integer enum on the wire. A CI can be backed by a real asset or -// service record elsewhere in TeamDynamix, which is worth showing — but not as "27". +// The import has to page. TeamDynamix creates a configuration item for every asset, so a big +// asset application's CMDB is almost all asset mirrors (49,000+ in one real tenant), and one +// unpaged search for it ran the stream out of memory before this script could drop them. +// Tiles stay on the unpaged search, capped below: walking hundreds of 250-row pages (the API's +// maximum page size) is fine every 12 hours but far too slow for a tile. The unpaged search's +// MaxResults counts the asset mirrors dropped below, so it only guards memory; the number of +// rows a tile shows is capped at the end of this script instead. + +// BackingItemType is an integer enum on the wire. A CI can be backed by a service record +// elsewhere in TeamDynamix, which is worth showing — but not as "47". 87 isn't in the +// published enum, but real tenants send it for service offering CIs. const BACKING_ITEM_TYPE_NAMES = { - 27: "Asset", 47: "Service", - 63: "Configuration Item" + 63: "Configuration Item", + 87: "Service Offering" }; +// TeamDynamix creates a system-maintained configuration item for every asset, so importing +// both would list each asset twice in search, pickers and counts. Only standalone items are +// kept, which is also what TeamDynamix itself means by a configuration item count. +const ASSET_BACKING_ITEM_TYPE = 27; + +// An unset owner, department, location and similar name can arrive as the literal "None" +// rather than empty, so a breakdown tile's biggest bar is "None". Blank it so it reads as unset. +const named = (value) => (value && value !== "None" ? value : ""); + +// TeamDynamix never sends null for an unset date: it sends 0001-01-01T00:00:00 (.NET's minimum +// date), which SquaredUp displays as 12/31/1900 and which passes "not empty" filters. Anything +// before 1901-01-02 is treated as unset, which covers that value however it is normalised. +const UNSET_DATE_BEFORE = Date.parse("1901-01-02T00:00:00Z"); +const realDate = (value) => (value && Date.parse(value) >= UNSET_DATE_BEFORE ? value : null); + // No custom-attribute columns: the API's own description for this endpoint says Attachments // and Attributes are never included in search results — only loading a configuration item // individually returns them, which this stream doesn't do. // A sandbox Web API address (/SBTDWebApi) needs the matching sandbox web app (/SBTDNext) — -// otherwise every sandbox CI link points at the production app instead. SBTDNext is inferred -// from the same SB-prefix convention as SBTDWebApi; the API spec has no web UI paths to -// confirm it against. +// otherwise every sandbox CI link points at the production app instead. The API spec has no +// web UI paths; SBTDNext follows the same SB prefix as SBTDWebApi, confirmed on a real +// sandbox tenant. const rawBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") .trim() .replace(/\/+$/, ""); @@ -30,37 +48,53 @@ const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); const nextApp = isSandbox ? "SBTDNext" : "TDNext"; -const rows = (Array.isArray(data) ? data : []).map((item) => ({ - // As with assets, CI IDs are only unique within an application. - sourceId: `${item.AppID}-${item.ID}`, - configurationItemId: String(item.ID), - name: item.Name || `Configuration Item ${item.ID}`, - link: webBaseUrl - ? `${webBaseUrl}/${nextApp}/Apps/${item.AppID}/Assets/CIDet?CID=${item.ID}` - : item.Uri || "", - typeName: item.TypeName || "", - isActive: Boolean(item.IsActive), - ownerFullName: item.OwnerFullName || "", - owningDepartmentName: item.OwningDepartmentName || "", - owningGroupName: item.OwningGroupName || "", - locationName: item.LocationName || "", - maintenanceScheduleName: item.MaintenanceScheduleName || "", - backingItemType: BACKING_ITEM_TYPE_NAMES[item.BackingItemType] || "", - isSystemMaintained: Boolean(item.IsSystemMaintained), - externalSourceName: item.ExternalSourceName || "", - externalId: item.ExternalID || "", - // Note the Utc suffix: configuration items use CreatedDateUtc / ModifiedDateUtc where - // tickets and assets use CreatedDate / ModifiedDate. - createdDate: item.CreatedDateUtc || null, - modifiedDate: item.ModifiedDateUtc || null, - appId: String(item.AppID ?? ""), - sourceType: "TeamDynamix Configuration Item" -})); +const items = Array.isArray(data) ? data : Array.isArray(data?.Data) ? data.Data : []; + +// The tile stream asks for at most 10,000 items (its postBody MaxResults). Unlimited, a CMDB +// of 49,000 asset mirrors ran the stream out of memory with no useful message. If the limit is +// reached, standalone items may be missing, so say so rather than show a silently short list. +const TILE_SEARCH_LIMIT = 10000; +if (Array.isArray(data) && data.length >= TILE_SEARCH_LIMIT) { + throw new Error( + "This asset application's CMDB has more than 10,000 configuration items, mostly copies " + + "of its assets, which is too many to load in a tile. Turn on Import assets, " + + "configuration items, accounts and locations in the data source's Advanced options " + + "to import its standalone configuration items instead." + ); +} + +const rows = items + .filter((item) => item.BackingItemType !== ASSET_BACKING_ITEM_TYPE) + .map((item) => ({ + // As with assets, CI IDs are only unique within an application. + sourceId: `${item.AppID}-${item.ID}`, + configurationItemId: String(item.ID), + name: item.Name || `Configuration Item ${item.ID}`, + link: webBaseUrl + ? `${webBaseUrl}/${nextApp}/Apps/${item.AppID}/Assets/CIDet?CID=${item.ID}` + : item.Uri || "", + typeName: item.TypeName || "", + isActive: Boolean(item.IsActive), + ownerFullName: named(item.OwnerFullName), + owningDepartmentName: named(item.OwningDepartmentName), + owningGroupName: named(item.OwningGroupName), + locationName: named(item.LocationName), + maintenanceScheduleName: named(item.MaintenanceScheduleName), + backingItemType: BACKING_ITEM_TYPE_NAMES[item.BackingItemType] || "", + isSystemMaintained: Boolean(item.IsSystemMaintained), + externalSourceName: named(item.ExternalSourceName), + externalId: item.ExternalID || "", + // Note the Utc suffix: configuration items use CreatedDateUtc / ModifiedDateUtc where + // tickets and assets use CreatedDate / ModifiedDate. + createdDate: realDate(item.CreatedDateUtc), + modifiedDate: realDate(item.ModifiedDateUtc), + appId: String(item.AppID ?? ""), + sourceType: "TeamDynamix Configuration Item" + })); -// The API gave us no way to ask for fewer rows, so the cap is enforced here instead, using -// the same "Maximum records per request" plugin setting the ticket/asset/time streams pass -// to the API directly. Sort so a forced cut drops the stalest records first rather than an -// arbitrary API-returned order. +// The cap uses the same "Maximum records per request" plugin setting the ticket/asset/time +// streams pass to the API directly. A 250-row import page never reaches it. Sort so a forced +// cut drops the stalest records first rather than an arbitrary API-returned order. const maxRecords = Number(context?.dataSources?.[0]?.maxRecords) || 2000; result = rows .sort((a, b) => (b.modifiedDate || "").localeCompare(a.modifiedDate || "")) diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/errorHandling/rateLimit.js b/plugins/TeamDynamix/v1/dataStreams/scripts/errorHandling/rateLimit.js index 070fcc9e..1d1302cb 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/errorHandling/rateLimit.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/errorHandling/rateLimit.js @@ -1,12 +1,20 @@ // Shared by every stream. TeamDynamix reports quota exhaustion as a bare 429 with the // reset time in a header and nothing useful in the body, so without this a rate-limited // tile just says "request failed" and the user has no idea it will fix itself. +// +// This also runs when TeamDynamix never answered (DNS, TLS or a timeout), and then there is no +// response to read, so every read is guarded rather than crashing and hiding the real error. +const status = response?.status; const header = (name) => { - const headers = response.headers || {}; + const headers = response?.headers || {}; return headers[name] || headers[name.toLowerCase()] || headers[name.toUpperCase()]; }; -if (response.status === 429) { +if (!status) { + result = + "TeamDynamix didn't respond. Check the Web API address and that the site is reachable " + + "from SquaredUp."; +} else if (status === 429) { const resetAt = header("X-RateLimit-Reset"); const limit = header("X-RateLimit-Limit"); result = @@ -17,13 +25,18 @@ if (response.status === 429) { ? `The limit resets at ${resetAt} — this tile will load again after that.` : "Wait a minute and refresh.") + " If this happens often, reduce the number of TeamDynamix tiles refreshing at once."; -} else if (response.status === 401 || response.status === 403) { +} else if (status === 401 || status === 403) { result = "TeamDynamix denied this request. Either the admin service account has no access to " + "this application, or the BEID and Web Services Key are no longer valid."; +} else if (status === 404) { + // Most often a tile scoped to the wrong kind of application: services and knowledge base + // articles only exist in a client portal application, projects in a project application. + result = + `${data?.Message || "TeamDynamix couldn't find this."} Check the tile is scoped to the ` + + "right kind of application: a client portal for services and knowledge base articles, " + + "or a project application for projects."; } else { // TeamDynamix error bodies are shaped { "Message": "..." }. - result = - (data && (data.Message || data.message)) || - `TeamDynamix request failed with HTTP ${response.status}.`; + result = data?.Message || data?.message || `TeamDynamix request failed with HTTP ${status}.`; } diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js b/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js index 2f9f569f..6e37dc78 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js @@ -3,10 +3,16 @@ // search response at all (only loading an article individually returns them); WhitelistGroups // is the one thing actually present that's dropped here as not dashboard-usable. // +// TeamDynamix never sends null for an unset date: it sends 0001-01-01T00:00:00 (.NET's minimum +// date), which SquaredUp displays as 12/31/1900 and which passes "not empty" filters. Anything +// before 1901-01-02 is treated as unset, which covers that value however it is normalised. +const UNSET_DATE_BEFORE = Date.parse("1901-01-02T00:00:00Z"); +const realDate = (value) => (value && Date.parse(value) >= UNSET_DATE_BEFORE ? value : null); + // A sandbox Web API address (/SBTDWebApi) needs the matching sandbox client portal // (/SBTDClient) — otherwise every sandbox article link points at the production portal -// instead. SBTDClient is inferred from the same SB-prefix convention as SBTDWebApi; the API -// spec has no web UI paths to confirm it against. +// instead. The API spec has no web UI paths; SBTDClient follows the same SB prefix as +// SBTDWebApi, confirmed on a real sandbox tenant. const rawBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") .trim() .replace(/\/+$/, ""); @@ -27,9 +33,9 @@ result = (Array.isArray(data) ? data : []).map((article) => ({ ownerFullName: article.OwnerFullName || "", owningGroupName: article.OwningGroupName || "", summary: article.Summary || "", - reviewDate: article.ReviewDateUtc || null, - createdDate: article.CreatedDate || null, - modifiedDate: article.ModifiedDate || null, + reviewDate: realDate(article.ReviewDateUtc), + createdDate: realDate(article.CreatedDate), + modifiedDate: realDate(article.ModifiedDate), revisionNumber: typeof article.RevisionNumber === "number" ? article.RevisionNumber : null, appId: String(article.AppID ?? "") })); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/locations.js b/plugins/TeamDynamix/v1/dataStreams/scripts/locations.js index f4dcd76d..e790209c 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/locations.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/locations.js @@ -2,16 +2,22 @@ // (TicketsCount, AssetsCount, ConfigurationItemsCount, UsersCount), which makes this the // cheapest way to get a per-site overview without querying each app in turn. // -// The Rooms array is dropped: a large campus can carry hundreds of rooms per location, which -// is most of the payload and none of the value at this level. +// Only locations/search with ReturnItemCounts fills the counts in. GET api/locations returns +// -1 for every one, meaning "not loaded", so a summed total came out as minus the number of +// locations. Any -1 that still arrives is nulled rather than counted as a real number. +// +// The Rooms array is dropped (and not requested): a large campus can carry hundreds of rooms +// per location, which is most of the payload and none of the value at this level. +const count = (value) => (typeof value === "number" && value >= 0 ? value : null); + result = (Array.isArray(data) ? data : []).map((location) => ({ locationId: String(location.ID), name: location.Name, - ticketsCount: location.TicketsCount ?? 0, - assetsCount: location.AssetsCount ?? 0, - configurationItemsCount: location.ConfigurationItemsCount ?? 0, - usersCount: location.UsersCount ?? 0, - roomsCount: location.RoomsCount ?? 0, + ticketsCount: count(location.TicketsCount), + assetsCount: count(location.AssetsCount), + configurationItemsCount: count(location.ConfigurationItemsCount), + usersCount: count(location.UsersCount), + roomsCount: count(location.RoomsCount), city: location.City || "", state: location.State || "", country: location.Country || "", diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js b/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js index fafb05db..3de16ada 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js @@ -8,6 +8,12 @@ // CustomColumns and NonWorkingDays are never included in search results — only loading a // project individually returns them, which this stream doesn't do. +// TeamDynamix never sends null for an unset date: it sends 0001-01-01T00:00:00 (.NET's minimum +// date), which SquaredUp displays as 12/31/1900 and which passes "not empty" filters. Anything +// before 1901-01-02 is treated as unset, which covers that value however it is normalised. +const UNSET_DATE_BEFORE = Date.parse("1901-01-02T00:00:00Z"); +const realDate = (value) => (value && Date.parse(value) >= UNSET_DATE_BEFORE ? value : null); + result = (Array.isArray(data) ? data : []).map((project) => ({ projectId: String(project.ID), name: project.Name || `Project ${project.ID}`, @@ -21,8 +27,8 @@ result = (Array.isArray(data) ? data : []).map((project) => ({ priorityName: project.PriorityName || "", classificationName: project.ClassificationName || "", serviceName: project.ServiceName || "", - startDate: project.StartDate || null, - endDate: project.EndDate || null, + startDate: realDate(project.StartDate), + endDate: realDate(project.EndDate), estimatedHours: typeof project.EstimatedHours === "number" ? project.EstimatedHours : null, actualHours: typeof project.ActualHours === "number" ? project.ActualHours : null, budget: typeof project.Budget === "number" ? project.Budget : null, @@ -30,7 +36,7 @@ result = (Array.isArray(data) ? data : []).map((project) => ({ expensesBudgetUsed: typeof project.ExpensesBudgetUsed === "number" ? project.ExpensesBudgetUsed : null, isActive: Boolean(project.IsActive), - statusLastUpdated: project.StatusModifiedDate || null, - createdDate: project.CreatedDate || null, + statusLastUpdated: realDate(project.StatusModifiedDate), + createdDate: realDate(project.CreatedDate), appId: String(project.AppID ?? "") })); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/services.js b/plugins/TeamDynamix/v1/dataStreams/scripts/services.js index d5e634fe..71ba31a9 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/services.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/services.js @@ -7,6 +7,12 @@ // returns them, which this stream doesn't do. Attempting to project them here would only ever // yield empty columns. +// TeamDynamix never sends null for an unset date: it sends 0001-01-01T00:00:00 (.NET's minimum +// date), which SquaredUp displays as 12/31/1900 and which passes "not empty" filters. Anything +// before 1901-01-02 is treated as unset, which covers that value however it is normalised. +const UNSET_DATE_BEFORE = Date.parse("1901-01-02T00:00:00Z"); +const realDate = (value) => (value && Date.parse(value) >= UNSET_DATE_BEFORE ? value : null); + result = (Array.isArray(data) ? data : []).map((service) => ({ serviceId: String(service.ID), name: service.Name || `Service ${service.ID}`, @@ -21,7 +27,7 @@ result = (Array.isArray(data) ? data : []).map((service) => ({ typeof service.ServiceOfferingsCount === "number" ? service.ServiceOfferingsCount : 0, maintenanceScheduleName: service.MaintenanceScheduleName || "", requestTypeName: service.RequestTypeName || "", - createdDate: service.CreatedDateUtc || null, - modifiedDate: service.ModifiedDateUtc || null, + createdDate: realDate(service.CreatedDateUtc), + modifiedDate: realDate(service.ModifiedDateUtc), appId: String(service.AppID ?? "") })); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/ticketCount.js b/plugins/TeamDynamix/v1/dataStreams/scripts/ticketCount.js new file mode 100644 index 00000000..a50ec861 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/ticketCount.js @@ -0,0 +1,5 @@ +// The paged search is asked for a single ticket; only its TotalCount is used. That counts every +// match, where the Tickets stream stops at Maximum tickets (2,000 by default) and a busy +// application can have several times that many open. A missing total is left empty rather +// than shown as 0. +result = [{ tickets: typeof data?.TotalCount === "number" ? data.TotalCount : null }]; diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js index c15e8478..826122dc 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js @@ -3,6 +3,10 @@ // through untouched, a few hundred tickets overrun the ~6MB stream response cap and the tile // fails with Function.ResponseSizeTooLarge rather than anything diagnosable. So this projects // to a working set. Everything dropped here is either unbounded or not dashboard-usable. +// +// Each shaped cell carries raw, value and formatted copies, so even an empty date costs about +// 90 bytes a row. Respond-by, goes-off-hold, estimate, form, reference code and requestor email +// were dropped to keep a 2,000-ticket tile well under the ~6MB cap (about 2.9KB a row before). // StatusClass is an integer enum on the wire; dashboards need to group and read it as text. const STATUS_CLASS_NAMES = { @@ -15,6 +19,12 @@ const STATUS_CLASS_NAMES = { 6: "Requested" }; +// TeamDynamix never sends null for an unset date: it sends 0001-01-01T00:00:00 (.NET's minimum +// date), which SquaredUp displays as 12/31/1900 and which passes "not empty" filters. Anything +// before 1901-01-02 is treated as unset, which covers that value however it is normalised. +const UNSET_DATE_BEFORE = Date.parse("1901-01-02T00:00:00Z"); +const realDate = (value) => (value && Date.parse(value) >= UNSET_DATE_BEFORE ? value : null); + // No custom-attribute columns: the API's own description for this endpoint says Attachments, // Attributes, Description, Notify and Tasks are never included in search results — only // loading a ticket individually returns them, which this stream doesn't do. @@ -26,8 +36,8 @@ const STATUS_CLASS_NAMES = { // // A sandbox Web API address (/SBTDWebApi) needs the matching sandbox web app (/SBTDNext) — // otherwise every sandbox ticket link points at the production app instead. The API spec -// doesn't document web UI paths at all (sandbox or otherwise), so SBTDNext is inferred from -// the same SB-prefix convention as SBTDWebApi, not confirmed against a real sandbox tenant. +// doesn't document web UI paths at all (sandbox or otherwise); SBTDNext follows the same SB +// prefix as SBTDWebApi, confirmed on a real sandbox tenant. const rawBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") .trim() .replace(/\/+$/, ""); @@ -40,6 +50,14 @@ const ticketLink = (ticket) => ? `${webBaseUrl}/${nextApp}/Apps/${ticket.AppID}/Tickets/TicketDet?TicketID=${ticket.ID}` : ticket.Uri || ""; +// An unset service, account, location and similar name can arrive as the literal "None" rather +// than empty, so a breakdown tile's biggest bar is "None". Blank it so it reads as unset. +const named = (value) => (value && value !== "None" ? value : ""); + +// An empty assignee or group would otherwise group into an unlabelled bar or row, hiding +// unassigned work, which is usually the first thing a service desk wants to see. +const UNASSIGNED = "(Unassigned)"; + // Rolled up into one health value so a status tile works without shaping. SLA breach is the // most urgent thing about a ticket, so it outranks the status class. const ticketHealth = (ticket) => { @@ -59,34 +77,27 @@ result = (Array.isArray(data) ? data : []).map((ticket) => ({ statusClassName: STATUS_CLASS_NAMES[ticket.StatusClass] || "Unknown", priorityName: ticket.PriorityName || "", priorityOrder: typeof ticket.PriorityOrder === "number" ? ticket.PriorityOrder : null, - typeName: ticket.TypeName || "", - classificationName: ticket.ClassificationName || "", - urgencyName: ticket.UrgencyName || "", - impactName: ticket.ImpactName || "", - accountName: ticket.AccountName || "", - serviceName: ticket.ServiceName || "", - locationName: ticket.LocationName || "", - sourceName: ticket.SourceName || "", - responsibleFullName: ticket.ResponsibleFullName || "", - responsibleGroupName: ticket.ResponsibleGroupName || "", + typeName: named(ticket.TypeName), + classificationName: named(ticket.ClassificationName), + urgencyName: named(ticket.UrgencyName), + impactName: named(ticket.ImpactName), + accountName: named(ticket.AccountName), + serviceName: named(ticket.ServiceName), + locationName: named(ticket.LocationName), + sourceName: named(ticket.SourceName), + responsibleFullName: named(ticket.ResponsibleFullName) || UNASSIGNED, + responsibleGroupName: named(ticket.ResponsibleGroupName) || UNASSIGNED, requestorName: ticket.RequestorName || "", - requestorEmail: ticket.RequestorEmail || "", - createdDate: ticket.CreatedDate || null, - modifiedDate: ticket.ModifiedDate || null, - completedDate: ticket.CompletedDate || null, - respondedDate: ticket.RespondedDate || null, + createdDate: realDate(ticket.CreatedDate), + modifiedDate: realDate(ticket.ModifiedDate), + completedDate: realDate(ticket.CompletedDate), + respondedDate: realDate(ticket.RespondedDate), daysOld: typeof ticket.DaysOld === "number" ? ticket.DaysOld : null, slaName: ticket.SlaName || "", isSlaViolated: Boolean(ticket.IsSlaViolated), - isSlaRespondByViolated: Boolean(ticket.IsSlaRespondByViolated), isSlaResolveByViolated: Boolean(ticket.IsSlaResolveByViolated), - respondByDate: ticket.RespondByDate || null, - resolveByDate: ticket.ResolveByDate || null, + resolveByDate: realDate(ticket.ResolveByDate), isOnHold: Boolean(ticket.IsOnHold), - goesOffHoldDate: ticket.GoesOffHoldDate || null, actualMinutes: typeof ticket.ActualMinutes === "number" ? ticket.ActualMinutes : null, - estimatedMinutes: typeof ticket.EstimatedMinutes === "number" ? ticket.EstimatedMinutes : null, - formName: ticket.FormName || "", - refCode: ticket.RefCode || "", appId: String(ticket.AppID ?? "") })); diff --git a/plugins/TeamDynamix/v1/dataStreams/services.json b/plugins/TeamDynamix/v1/dataStreams/services.json index 93a55031..2626d8a2 100644 --- a/plugins/TeamDynamix/v1/dataStreams/services.json +++ b/plugins/TeamDynamix/v1/dataStreams/services.json @@ -18,7 +18,8 @@ } }, "matches": { - "sourceType": { "type": "oneOf", "values": ["TeamDynamix Application"] } + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Application"] }, + "appClass": { "type": "oneOf", "values": ["TDClient"] } }, "ui": [ { @@ -53,5 +54,7 @@ "defaultShaping": { "sort": { "by": [["name", "asc"]] } }, - "timeframes": false + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" } diff --git a/plugins/TeamDynamix/v1/dataStreams/ticketCount.json b/plugins/TeamDynamix/v1/dataStreams/ticketCount.json new file mode 100644 index 00000000..e07da7f0 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/ticketCount.json @@ -0,0 +1,90 @@ +{ + "name": "ticketCount", + "displayName": "Ticket Count", + "description": "Number of tickets in a ticketing application that match a status, group, SLA and date filter (exact, not limited by Maximum tickets)", + "tags": ["Tickets", "Service Management"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "post", + "endpointPath": "api/{{object.rawId}}/tickets/search/paged", + "getArgs": [ + { "key": "pageSize", "value": "1" }, + { "key": "pageNumber", "value": "1" } + ], + "postBody": { + "StatusClassIDs": "{{typeof statusClass === 'undefined' || !statusClass || !statusClass.length ? null : [].concat(statusClass).map(Number)}}", + "CreatedDateFrom": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Created' ? null : timeframe.start}}", + "CreatedDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Created' ? null : timeframe.end}}", + "ModifiedDateFrom": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Modified' ? null : timeframe.start}}", + "ModifiedDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Modified' ? null : timeframe.end}}", + "ClosedDateFrom": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Closed' ? null : timeframe.start}}", + "ClosedDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Closed' ? null : timeframe.end}}", + "PrimaryResponsibilityGroupIDs": "{{typeof groups !== 'undefined' && Array.isArray(groups) && groups.length ? groups.flatMap((o) => o.rawId).map(Number) : null}}", + "SlaViolationStatus": "{{typeof sla !== 'undefined' && sla === 'violated' ? true : null}}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "ticketCount.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Ticketing App"] } + }, + "ui": [ + { + "type": "switch", + "name": "dateField", + "label": "Timeframe applies to", + "defaultValue": "Created", + "help": "Which ticket date the selected timeframe filters on. Set the timeframe to **None** to ignore dates entirely and return the current backlog.", + "options": [ + { "value": "Created", "label": "Created" }, + { "value": "Modified", "label": "Last modified" }, + { "value": "Closed", "label": "Closed" } + ], + "tileEditorStep": ["Timeframe"] + }, + { + "type": "choiceChips", + "name": "statusClass", + "label": "Status", + "isMulti": true, + "help": "Leave empty for all tickets. Filtering here is done by TeamDynamix rather than in the browser, so it also keeps large result sets manageable.", + "options": [ + { "value": "1", "label": "New" }, + { "value": "2", "label": "In Process" }, + { "value": "5", "label": "On Hold" }, + { "value": "6", "label": "Requested" }, + { "value": "3", "label": "Completed" }, + { "value": "4", "label": "Cancelled" } + ] + }, + { + "type": "objects", + "name": "groups", + "label": "Group(s)", + "help": "Matches the group a ticket is assigned to, not groups that only own one of its tasks.", + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Group"] } + } + }, + { + "type": "switch", + "name": "sla", + "label": "SLA", + "defaultValue": "all", + "options": [ + { "value": "all", "label": "All" }, + { "value": "violated", "label": "Violated only" } + ] + } + ], + "metadata": [ + { "name": "tickets", "displayName": "Tickets", "shape": "number", "role": "value" } + ], + "timeframes": true, + "supportsNoneTimeframe": true, + "defaultTimeframe": "dashboard" +} diff --git a/plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json b/plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json index 2cf1ecde..935f4f8c 100644 --- a/plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json +++ b/plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json @@ -28,5 +28,7 @@ "defaultShaping": { "sort": { "by": [["Order", "asc"]] } }, - "timeframes": false + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" } diff --git a/plugins/TeamDynamix/v1/dataStreams/tickets.json b/plugins/TeamDynamix/v1/dataStreams/tickets.json index e4acffa1..1108a1b2 100644 --- a/plugins/TeamDynamix/v1/dataStreams/tickets.json +++ b/plugins/TeamDynamix/v1/dataStreams/tickets.json @@ -8,14 +8,15 @@ "httpMethod": "post", "endpointPath": "api/{{object.rawId}}/tickets/search", "postBody": { - "MaxResults": "{{maxResults || dataSource.maxRecords || 500}}", - "StatusClassIDs": "{{Array.isArray(statusClass) && statusClass.length ? statusClass.map(Number) : (statusClass ? [Number(statusClass)] : null)}}", - "CreatedDateFrom": "{{(typeof timeframe === 'undefined' || timeframe.enum === 'none' || (dateField || 'Created') !== 'Created') ? null : timeframe.start}}", - "CreatedDateTo": "{{(typeof timeframe === 'undefined' || timeframe.enum === 'none' || (dateField || 'Created') !== 'Created') ? null : timeframe.end}}", - "ModifiedDateFrom": "{{(typeof timeframe === 'undefined' || timeframe.enum === 'none' || dateField !== 'Modified') ? null : timeframe.start}}", - "ModifiedDateTo": "{{(typeof timeframe === 'undefined' || timeframe.enum === 'none' || dateField !== 'Modified') ? null : timeframe.end}}", - "ClosedDateFrom": "{{(typeof timeframe === 'undefined' || timeframe.enum === 'none' || dateField !== 'Closed') ? null : timeframe.start}}", - "ClosedDateTo": "{{(typeof timeframe === 'undefined' || timeframe.enum === 'none' || dateField !== 'Closed') ? null : timeframe.end}}" + "MaxResults": "{{(typeof maxResults !== 'undefined' && maxResults) || dataSource.maxRecords || 2000}}", + "StatusClassIDs": "{{typeof statusClass === 'undefined' || !statusClass || !statusClass.length ? null : [].concat(statusClass).map(Number)}}", + "CreatedDateFrom": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Created' ? null : timeframe.start}}", + "CreatedDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Created' ? null : timeframe.end}}", + "ModifiedDateFrom": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Modified' ? null : timeframe.start}}", + "ModifiedDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Modified' ? null : timeframe.end}}", + "ClosedDateFrom": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Closed' ? null : timeframe.start}}", + "ClosedDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Closed' ? null : timeframe.end}}", + "PrimaryResponsibilityGroupIDs": "{{typeof groups !== 'undefined' && Array.isArray(groups) && groups.length ? groups.flatMap((o) => o.rawId).map(Number) : null}}" }, "paging": { "mode": "none" }, "postRequestScript": "tickets.js", @@ -57,6 +58,15 @@ { "value": "4", "label": "Cancelled" } ] }, + { + "type": "objects", + "name": "groups", + "label": "Group(s)", + "help": "Matches the group a ticket is assigned to, not groups that only own one of its tasks.", + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Group"] } + } + }, { "type": "number", "name": "maxResults", @@ -99,7 +109,6 @@ { "name": "responsibleFullName", "displayName": "Assigned To", "shape": "string" }, { "name": "responsibleGroupName", "displayName": "Assigned Group", "shape": "string" }, { "name": "requestorName", "displayName": "Requestor", "shape": "string" }, - { "name": "requestorEmail", "displayName": "Requestor Email", "shape": "string", "visible": false }, { "name": "createdDate", "displayName": "Created", "shape": "date", "role": "timestamp" }, { "name": "modifiedDate", "displayName": "Modified", "shape": "date" }, { "name": "completedDate", "displayName": "Completed", "shape": "date" }, @@ -107,16 +116,10 @@ { "name": "daysOld", "displayName": "Age (days)", "shape": "number" }, { "name": "slaName", "displayName": "SLA", "shape": "string" }, { "name": "isSlaViolated", "displayName": "SLA Violated", "shape": "boolean" }, - { "name": "isSlaRespondByViolated", "displayName": "Respond By Missed", "shape": "boolean", "visible": false }, { "name": "isSlaResolveByViolated", "displayName": "Resolve By Missed", "shape": "boolean", "visible": false }, - { "name": "respondByDate", "displayName": "Respond By", "shape": "date", "visible": false }, { "name": "resolveByDate", "displayName": "Resolve By", "shape": "date" }, { "name": "isOnHold", "displayName": "On Hold", "shape": "boolean" }, - { "name": "goesOffHoldDate", "displayName": "Goes Off Hold", "shape": "date", "visible": false }, { "name": "actualMinutes", "displayName": "Actual", "shape": "minutes" }, - { "name": "estimatedMinutes", "displayName": "Estimated", "shape": "minutes", "visible": false }, - { "name": "formName", "displayName": "Form", "shape": "string", "visible": false }, - { "name": "refCode", "displayName": "Reference Code", "shape": "string", "visible": false }, { "name": "appId", "displayName": "App ID", "shape": "string", "visible": false } ], "defaultShaping": { diff --git a/plugins/TeamDynamix/v1/dataStreams/timeEntries.json b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json index 655cb4f8..9d7f5bcd 100644 --- a/plugins/TeamDynamix/v1/dataStreams/timeEntries.json +++ b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json @@ -8,10 +8,10 @@ "httpMethod": "post", "endpointPath": "api/time/search", "postBody": { - "MaxResults": "{{maxResults || dataSource.maxRecords || 1000}}", + "MaxResults": "{{(typeof maxResults !== 'undefined' && maxResults) || dataSource.maxRecords || 1000}}", "EntryDateFrom": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' ? null : timeframe.start}}", "EntryDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' ? null : timeframe.end}}", - "IsBillable": "{{typeof billable === 'undefined' || billable === '' || billable === 'all' ? null : billable === 'billable'}}" + "IsBillable": "{{typeof billable === 'undefined' || !billable || billable === 'all' ? null : billable === 'billable'}}" }, "paging": { "mode": "none" }, "errorHandling": { diff --git a/plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json b/plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json index cdb900ff..9c464499 100644 --- a/plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json +++ b/plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json @@ -24,11 +24,10 @@ "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { - "id": "{{dataStreams.[assets]}}", - "name": "assets", + "id": "{{dataStreams.[assetCount]}}", + "name": "assetCount", "pluginConfigId": "{{configId}}", - "config": { "inService": "all" }, - "group": { "by": [], "aggregate": [{ "type": "count" }] } + "dataSourceConfig": { "inService": "all" } }, "scope": { "scope": "{{scopes.[Asset Applications]}}", @@ -40,7 +39,7 @@ "type": "data-stream-scalar", "config": { "data-stream-scalar": { - "value": "count", + "value": "assets", "comparisonColumn": "none", "label": "Assets" } @@ -64,11 +63,10 @@ "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { - "id": "{{dataStreams.[assets]}}", - "name": "assets", + "id": "{{dataStreams.[assetCount]}}", + "name": "assetCount", "pluginConfigId": "{{configId}}", - "config": { "inService": "outOfService" }, - "group": { "by": [], "aggregate": [{ "type": "count" }] } + "dataSourceConfig": { "inService": "outOfService" } }, "scope": { "scope": "{{scopes.[Asset Applications]}}", @@ -80,7 +78,7 @@ "type": "data-stream-scalar", "config": { "data-stream-scalar": { - "value": "count", + "value": "assets", "comparisonColumn": "none", "label": "Out of service" } @@ -107,7 +105,7 @@ "id": "{{dataStreams.[assets]}}", "name": "assets", "pluginConfigId": "{{configId}}", - "config": { "inService": "all" }, + "dataSourceConfig": { "inService": "all" }, "group": { "by": [], "aggregate": [{ "type": "sum", "names": ["purchaseCost"] }] @@ -150,7 +148,7 @@ "id": "{{dataStreams.[configurationItems]}}", "name": "configurationItems", "pluginConfigId": "{{configId}}", - "config": { "activeState": "active" }, + "dataSourceConfig": { "activeState": "active" }, "group": { "by": [], "aggregate": [{ "type": "count" }] } }, "scope": { @@ -190,7 +188,7 @@ "id": "{{dataStreams.[assets]}}", "name": "assets", "pluginConfigId": "{{configId}}", - "config": { "inService": "all" }, + "dataSourceConfig": { "inService": "all" }, "group": { "by": [["statusName", "uniqueValues"]], "aggregate": [{ "type": "count" }] @@ -237,7 +235,7 @@ "id": "{{dataStreams.[assets]}}", "name": "assets", "pluginConfigId": "{{configId}}", - "config": { "inService": "all" }, + "dataSourceConfig": { "inService": "all" }, "group": { "by": [["manufacturerName", "uniqueValues"]], "aggregate": [{ "type": "count" }] @@ -294,7 +292,7 @@ "id": "{{dataStreams.[assets]}}", "name": "assets", "pluginConfigId": "{{configId}}", - "config": { "inService": "all" }, + "dataSourceConfig": { "inService": "all" }, "filter": { "multiOperation": "and", "filters": [ @@ -349,7 +347,7 @@ "id": "{{dataStreams.[configurationItems]}}", "name": "configurationItems", "pluginConfigId": "{{configId}}", - "config": { "activeState": "active" }, + "dataSourceConfig": { "activeState": "active" }, "group": { "by": [["typeName", "uniqueValues"]], "aggregate": [{ "type": "count" }] diff --git a/plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json b/plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json index 4d400a99..dcbd48d3 100644 --- a/plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json +++ b/plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json @@ -24,11 +24,10 @@ "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { - "id": "{{dataStreams.[tickets]}}", - "name": "tickets", + "id": "{{dataStreams.[ticketCount]}}", + "name": "ticketCount", "pluginConfigId": "{{configId}}", - "config": { "statusClass": ["1", "2", "5", "6"] }, - "group": { "by": [], "aggregate": [{ "type": "count" }] } + "dataSourceConfig": { "statusClass": ["1", "2", "5", "6"] } }, "scope": { "scope": "{{scopes.[Ticketing Applications]}}", @@ -40,7 +39,7 @@ "type": "data-stream-scalar", "config": { "data-stream-scalar": { - "value": "count", + "value": "tickets", "comparisonColumn": "none", "label": "Open" } @@ -64,21 +63,10 @@ "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { - "id": "{{dataStreams.[tickets]}}", - "name": "tickets", + "id": "{{dataStreams.[ticketCount]}}", + "name": "ticketCount", "pluginConfigId": "{{configId}}", - "config": { "statusClass": ["1", "2", "5", "6"] }, - "filter": { - "multiOperation": "and", - "filters": [ - { - "column": "isSlaViolated", - "operation": "equals", - "value": "true" - } - ] - }, - "group": { "by": [], "aggregate": [{ "type": "count" }] } + "dataSourceConfig": { "statusClass": ["1", "2", "5", "6"], "sla": "violated" } }, "scope": { "scope": "{{scopes.[Ticketing Applications]}}", @@ -90,7 +78,7 @@ "type": "data-stream-scalar", "config": { "data-stream-scalar": { - "value": "count", + "value": "tickets", "comparisonColumn": "none", "label": "SLA breached" } @@ -114,11 +102,10 @@ "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { - "id": "{{dataStreams.[tickets]}}", - "name": "tickets", + "id": "{{dataStreams.[ticketCount]}}", + "name": "ticketCount", "pluginConfigId": "{{configId}}", - "config": { "statusClass": ["5"] }, - "group": { "by": [], "aggregate": [{ "type": "count" }] } + "dataSourceConfig": { "statusClass": ["5"] } }, "scope": { "scope": "{{scopes.[Ticketing Applications]}}", @@ -130,7 +117,7 @@ "type": "data-stream-scalar", "config": { "data-stream-scalar": { - "value": "count", + "value": "tickets", "comparisonColumn": "none", "label": "On hold" } @@ -153,11 +140,10 @@ "description": "Tickets created in the dashboard timeframe", "activePluginConfigIds": ["{{configId}}"], "dataStream": { - "id": "{{dataStreams.[tickets]}}", - "name": "tickets", + "id": "{{dataStreams.[ticketCount]}}", + "name": "ticketCount", "pluginConfigId": "{{configId}}", - "config": { "dateField": "Created" }, - "group": { "by": [], "aggregate": [{ "type": "count" }] } + "dataSourceConfig": { "dateField": "Created" } }, "scope": { "scope": "{{scopes.[Ticketing Applications]}}", @@ -169,7 +155,7 @@ "type": "data-stream-scalar", "config": { "data-stream-scalar": { - "value": "count", + "value": "tickets", "comparisonColumn": "none", "label": "Created" } @@ -196,7 +182,7 @@ "id": "{{dataStreams.[tickets]}}", "name": "tickets", "pluginConfigId": "{{configId}}", - "config": { "statusClass": ["1", "2", "5", "6"] }, + "dataSourceConfig": { "statusClass": ["1", "2", "5", "6"] }, "group": { "by": [["priorityName", "uniqueValues"]], "aggregate": [{ "type": "count" }] @@ -243,7 +229,7 @@ "id": "{{dataStreams.[tickets]}}", "name": "tickets", "pluginConfigId": "{{configId}}", - "config": { "statusClass": ["1", "2", "5", "6"] }, + "dataSourceConfig": { "statusClass": ["1", "2", "5", "6"] }, "group": { "by": [["responsibleGroupName", "uniqueValues"]], "aggregate": [{ "type": "count" }] @@ -299,7 +285,7 @@ "id": "{{dataStreams.[tickets]}}", "name": "tickets", "pluginConfigId": "{{configId}}", - "config": { "dateField": "Created" }, + "dataSourceConfig": { "dateField": "Created" }, "group": { "by": [["createdDate", "byDay"]], "aggregate": [{ "type": "count" }] @@ -348,7 +334,7 @@ "id": "{{dataStreams.[tickets]}}", "name": "tickets", "pluginConfigId": "{{configId}}", - "config": { "statusClass": ["1", "2", "5", "6"] }, + "dataSourceConfig": { "statusClass": ["1", "2", "5", "6"] }, "filter": { "multiOperation": "and", "filters": [ diff --git a/plugins/TeamDynamix/v1/docs/README.md b/plugins/TeamDynamix/v1/docs/README.md index a1977669..1905c53b 100644 --- a/plugins/TeamDynamix/v1/docs/README.md +++ b/plugins/TeamDynamix/v1/docs/README.md @@ -20,6 +20,7 @@ You will need your organization's **BEID** and **Web Services Key**. | **BEID** | A GUID identifying your TeamDynamix organization. Exchanged with the Web Services Key for a session token. | TDAdmin → **Organization detail**. | Yes | | **Web Services Key** | The secret paired with the BEID. Stored encrypted and never displayed again. | TDAdmin → **Organization detail**. | Yes | | **Maximum records per request** | Caps how many records a single search returns. Defaults to 2000. | Advanced options — leave as-is unless you hit the limits described below. | No | +| **Import assets, configuration items, accounts and locations** | Adds each of these as an object in SquaredUp, so they appear in search and can be counted from the imported objects. Off by default: no tile or out-of-the-box dashboard needs them, and a large asset estate adds many objects. | Advanced options. | No | | **Ignore certificate errors** | Accepts self-signed or otherwise untrusted certificates. Enable only when the Web API endpoint cannot present a certificate trusted by a public certificate authority — a self-hosted instance can still have a properly trusted certificate and should not need this. | Added by SquaredUp to every data source. | No | On save, the plugin signs in, then checks that at least one TeamDynamix application is visible to the service account and that accounts and departments can be read. A failure at the first step means the address, BEID or Web Services Key is wrong, or the service account is inactive. A failure at the second means the credentials work but the service account has not been granted access to any application. @@ -37,17 +38,19 @@ The out-of-the-box dashboards are an organization-wide **Overview**, a **Service ## Data streams -- **Tickets** — tickets in a ticketing application, one row per ticket, with status, priority, assignment and SLA. Choose whether the timeframe filters on created, last-modified or closed date, or set the timeframe to **None** for the current backlog regardless of date. +- **Tickets** — tickets in a ticketing application, one row per ticket, with status, priority, assignment and SLA. Choose whether the timeframe filters on created, last-modified or closed date, or set the timeframe to **None** for the current backlog regardless of date. Pick one or more imported groups under **Group(s)** to see only the tickets assigned to those teams; TeamDynamix does the filtering, so a team's tile isn't limited by the rest of the application's volume. Tickets with no assigned person or group show **(Unassigned)**. +- **Ticket Count** — the exact number of tickets matching a status, group, SLA and date filter, as one value. Use it for counts: it isn't limited by **Maximum tickets**, so it stays right on an application with thousands of open tickets, and it costs one small request. - **Ticket Statuses** — the statuses configured in a ticketing application. - **Assets** — assets in an asset application, filterable by in-service state. -- **Configuration Items** — configuration items in the CMDB of an asset application, filterable by active state. +- **Asset Count** — the exact number of assets in an asset application, optionally only those in or out of service, as one value. Like **Ticket Count**, it isn't limited by the maximum records setting. +- **Configuration Items** — standalone configuration items in the CMDB of an asset application, filterable by active state (active by default). TeamDynamix also creates a configuration item for every asset; those mirrors are left out, as the assets themselves are already available. - **Projects** — projects in a project application, with health, schedule and budget. - **Services** — service catalog services in a client portal application. - **Knowledge Base Articles** — knowledge base articles in a client portal application. Article bodies are excluded. - **Time Entries** — time logged against tickets, projects and tasks, account-wide. - **Locations** — active locations with their ticket, asset, configuration item and user counts, account-wide. - **Applications** — every active application the service account can see, with its Type and Class and the object type it's imported as. Use it to check which applications are recognised as ticketing or Assets/CIs applications. -- **Accounts**, **Groups**, **Current User** — account-wide reference data. These back the import and setup checks and are hidden from the tile editor; the imported objects are visible in the graph instead. +- **Accounts**, **Groups**, **Current User** — account-wide reference data. These back the import and setup checks and are hidden from the tile editor; the imported objects are visible in the graph instead. Four more hidden streams, **Accounts (Import)**, **Locations (Import)**, **Assets (Import)** and **Configuration Items (Import)**, feed the optional inventory import and return nothing while it's off. ## What gets indexed @@ -56,11 +59,13 @@ The out-of-the-box dashboards are an organization-wide **Overview**, a **Service | **TeamDynamix Ticketing App** | `GET /api/applications` (class `TDTickets`, or a type starting "Ticket") | A ticketing application, e.g. your service desk. Scope ticket tiles to one of these. | | **TeamDynamix Asset App** | `GET /api/applications` (class `TDAssets`, or a type starting "Asset") | An asset and CMDB application. Scope asset and configuration item tiles to one of these. | | **TeamDynamix Application** | `GET /api/applications` (everything else) | Any other application — client portal, projects or external. Scope project, service and knowledge base tiles to one of these. | -| **TeamDynamix Account** | `GET /api/accounts` | An account or department. | -| **TeamDynamix Location** | `GET /api/locations` | A physical location, with its rolled-up counts and coordinates. | +| **TeamDynamix Account** | `POST /api/accounts/search` | An account or department. Only with **Import assets, configuration items, accounts and locations** on. | +| **TeamDynamix Location** | `POST /api/locations/search` | An active physical location, with its rolled-up counts and coordinates. Only with the import option on. | | **TeamDynamix Group** | `POST /api/groups/search` | A group used to assign and route work. | -| **TeamDynamix Asset** | `POST /api/{appId}/assets/search` | A tracked asset. | -| **TeamDynamix Configuration Item** | `POST /api/{appId}/cmdb/search` | A CMDB configuration item. | +| **TeamDynamix Asset** | `POST /api/{appId}/assets/search` | A tracked asset, up to **Maximum records per request** per asset application. Only with the import option on. | +| **TeamDynamix Configuration Item** | `POST /api/{appId}/cmdb/search/paged` | An active, standalone CMDB configuration item. Items that only mirror an asset are not imported. Only with the import option on. | + +Applications and groups are always imported: every data stream is scoped to an application, and the **Group(s)** ticket filter picks from the imported groups. The other types are optional because nothing in the plugin depends on them. **Relationships:** assets and configuration items are imported per application, so each one carries the `appId` of the asset application it came from. @@ -70,12 +75,31 @@ Tickets are deliberately **not** imported as objects. They are high-volume trans ## Known limitations -- **Searches are not paged, so results are capped.** The TeamDynamix search endpoints return a single unpaged result set — there is no way to walk through pages. Each search is therefore capped (2000 records by default, set per tile for tickets, assets, knowledge base articles and time entries). If a tile might exceed its cap, narrow it with a timeframe or a status filter rather than raising the cap, or the tile will silently show only part of your data. -- **The configuration item search cannot be capped by TeamDynamix at all.** Unlike tickets and assets, the CMDB search endpoint accepts no maximum-results parameter and has no paged alternative that doesn't require a pre-existing saved search in TeamDynamix. The plugin works around this by capping the result to **Maximum records per request** itself, keeping the most recently modified items first. If a very large, unfiltered CMDB still fails to load, filter to **Active** only and lower that setting. -- **Rate limits are per IP address and are easy to hit.** Ticket and time-entry searches allow 30 requests per minute; most other endpoints allow 60. The plugin does not pace its own requests — a dashboard with many TeamDynamix tiles refreshing together can be throttled. A throttled tile reports the time the limit resets and recovers on its own on the next refresh. +- **List tiles are capped; counts aren't.** Each list tile makes one unpaged search, because walking TeamDynamix's paged searches (at most 250 records a page) is too slow for a tile. Each search is therefore capped (2000 records by default, set per tile for tickets, assets, knowledge base articles and time entries). A breakdown such as tickets by priority only covers the first 2000 matches, so narrow it with a status, group or timeframe filter. For a total, use **Ticket Count** or **Asset Count**, which are exact; the out-of-the-box count tiles already do. +- **Ticket tiles have a size ceiling.** Each ticket row is about 2.3 KB once SquaredUp has formatted it, and a tile's response can't exceed about 6 MB, so a ticket tile fails with a response-size error above roughly 2,500 tickets. Keep **Maximum tickets** at 2000 or below. Requestor email, respond-by and goes-off-hold dates, estimates, form and reference code aren't returned, to keep rows small. +- **Configuration item tiles stop at 10,000 CMDB items.** TeamDynamix's CMDB search returns a copy of every asset alongside the standalone items, and the plugin can only remove the copies after loading them. A tile therefore loads at most 10,000 items and shows at most **Maximum records per request** standalone ones, most recently modified first. An asset application with more than 10,000 items in its CMDB (usually one with that many assets) shows an error saying so; turn on the inventory import, which pages through the CMDB, to get its standalone items as objects. +- **The configuration item import has a ceiling too.** An import run allows about 220 requests, and the CMDB import reads 250 items per request, so it covers roughly 50,000 CMDB items (asset copies included) across all asset applications. +- **Imported assets are capped per asset application.** With the import option on, each asset application contributes at most **Maximum records per request** assets (2000 by default). An application with more assets than that is only partly imported. Asset tiles query TeamDynamix directly and aren't affected by the import. +- **Rate limits are per IP address and are easy to hit.** Ticket and time-entry searches allow 30 requests per minute; most other endpoints allow 60. The plugin does not pace its own requests — a dashboard with many TeamDynamix tiles refreshing together can be throttled. Opening the **Service Desk** dashboard makes six ticket searches (tiles with identical settings share one). A throttled tile reports the time the limit resets and recovers on its own on the next refresh. - **Custom attributes are not available.** TeamDynamix's search endpoints (the ones every data stream in this plugin uses) never return custom attributes, attachments, or a few other detail-only fields, regardless of what the underlying record supports — only loading a single ticket, asset, configuration item, project or service individually returns them, which none of these streams do. There is no way to surface a tenant's custom fields without adding a per-record detail call for every row, which the search-based design of this plugin does not do. - **Wide records are trimmed.** A TeamDynamix ticket has over 100 fields, including the full HTML description and its attachment and task lists. Only dashboard-usable fields are returned, so ticket descriptions, attachments and task detail are not available. - **Time entries identify people by UID.** The time search returns the person's unique identifier rather than their name, so time cannot be grouped by person name. -- **Sandbox paths are unverified.** The sandbox Web API path is assumed to be `/SBTDWebApi`, and ticket, asset, configuration item and knowledge base article links for a sandbox connection are built from the matching `/SBTDNext` or `/SBTDClient` web app path, following the same naming convention. Neither is confirmed against a real sandbox tenant, and the API documentation doesn't cover web UI paths at all. If a sandbox connection fails with a "no Web API found" error, or a sandbox record's link doesn't open, check the correct paths with TeamDynamix support. -- **Imports run every 12 hours** by default, so newly added assets and configuration items appear in the graph on the next import. -- **Read-only.** The plugin only ever reads from TeamDynamix. It calls no endpoint that creates, modifies or deletes data — only `GET` endpoints, `POST .../search` endpoints, and the `POST /api/auth/loginadmin` sign-in call. +- **Sandbox paths follow an `SB` prefix.** A sandbox uses `/SBTDWebApi` for the Web API, and its ticket, asset, configuration item and knowledge base article links open in `/SBTDNext` or `/SBTDClient`. This has been checked against a real sandbox, but the API documentation doesn't cover web UI paths. If a sandbox connection fails with a "no Web API found" error, or a sandbox record's link doesn't open, check the paths with TeamDynamix support. +- **Unset dates and names are blank.** TeamDynamix sends `0001-01-01` rather than an empty value for a date that was never set, such as an asset with no expected replacement date or a ticket that isn't completed, and the name `None` for an unset manufacturer, model, location, department or service. The plugin blanks both, so they don't appear as a 1900 date or a "None" bar, and are excluded by **is not empty** filters. +- **Imports run every 12 hours** by default, so newly added groups, assets and configuration items appear in the graph on the next import. Turning the import option off removes the imported assets, configuration items, accounts and locations at the next import. +- **Read-only.** The plugin only ever reads from TeamDynamix. It calls no endpoint that creates, modifies or deletes data — only `GET` endpoints, `POST .../search` and `.../search/paged` endpoints, and the `POST /api/auth/loginadmin` sign-in call. + +## Troubleshooting + +| Message | Likely cause | +| ------- | ------------ | +| TeamDynamix rejected the BEID and Web Services Key (HTTP 401) | The BEID or Web Services Key is wrong, or the admin service account isn't set to **Active** in TDAdmin. | +| No TeamDynamix Web API found at … (HTTP 404) | The **Web API address** doesn't end in `/TDWebApi` (or `/SBTDWebApi` for a sandbox), or has the web app name (`/TDNext`) instead. | +| TeamDynamix didn't respond | The address is wrong or the site isn't reachable from SquaredUp. | +| TeamDynamix denied this request | The service account has no access to that application. Grant it in TDAdmin, then re-run the import. | +| The specified application could not be found | A custom tile is scoped to the wrong kind of application. Services and knowledge base articles need a client portal application, and projects a project application; the tile editor only offers those. | +| TeamDynamix rate limit reached | Too many TeamDynamix requests in the last minute, from this or other dashboards. The tile recovers after the reset time it shows. | +| Function.ResponseSizeTooLarge | A list tile returned more than about 6 MB. Lower **Maximum tickets** (or assets), or add a status, group or timeframe filter. | +| This asset application's CMDB has more than 10,000 configuration items | See **Configuration item tiles stop at 10,000 CMDB items** above. | + +To check the BEID and Web Services Key outside SquaredUp, sign in to TDAdmin and confirm both on the **Organization detail** page, and that the admin service account shows as **Active**. diff --git a/plugins/TeamDynamix/v1/indexDefinitions/default.json b/plugins/TeamDynamix/v1/indexDefinitions/default.json index 05a58881..3f5cbe50 100644 --- a/plugins/TeamDynamix/v1/indexDefinitions/default.json +++ b/plugins/TeamDynamix/v1/indexDefinitions/default.json @@ -13,7 +13,7 @@ }, { "name": "accounts", - "dataStream": { "name": "accounts" }, + "dataStream": { "name": "accountsIndex" }, "timeframe": "none", "objectMapping": { "id": "accountId", @@ -32,7 +32,7 @@ }, { "name": "locations", - "dataStream": { "name": "locations" }, + "dataStream": { "name": "locationsIndex" }, "timeframe": "none", "objectMapping": { "id": "locationId", @@ -68,7 +68,7 @@ }, { "name": "assets", - "dataStream": { "name": "assets" }, + "dataStream": { "name": "assetsIndex" }, "scope": { "query": "g.V().has(\"sourceType\", \"TeamDynamix Asset App\")" }, @@ -99,7 +99,7 @@ }, { "name": "configurationItems", - "dataStream": { "name": "configurationItems" }, + "dataStream": { "name": "configurationItemsIndex" }, "scope": { "query": "g.V().has(\"sourceType\", \"TeamDynamix Asset App\")" }, diff --git a/plugins/TeamDynamix/v1/metadata.json b/plugins/TeamDynamix/v1/metadata.json index 6e5bca12..7a713cf5 100644 --- a/plugins/TeamDynamix/v1/metadata.json +++ b/plugins/TeamDynamix/v1/metadata.json @@ -1,7 +1,7 @@ { "name": "teamdynamix", "displayName": "TeamDynamix", - "version": "1.0.4", + "version": "1.1.0", "author": { "name": "@shawn149", "type": "community" diff --git a/plugins/TeamDynamix/v1/ui.json b/plugins/TeamDynamix/v1/ui.json index dc7db6f6..0a3f115c 100644 --- a/plugins/TeamDynamix/v1/ui.json +++ b/plugins/TeamDynamix/v1/ui.json @@ -47,12 +47,18 @@ "name": "maxRecords", "label": "Maximum records per request", "defaultValue": 2000, - "help": "The TeamDynamix search endpoints return a single unpaged result set, so this caps how much is retrieved in one call. Raise it if you are missing data; lower it if requests time out or fail with a response-size error.\n\nApplies to the assets and configuration item import, and sets the default for ticket and time entry tiles.", + "help": "The TeamDynamix search endpoints return a single unpaged result set, so this caps how much is retrieved in one call. Raise it if you are missing data; lower it if requests time out or fail with a response-size error.\n\nSets the default for ticket, asset, configuration item and time entry tiles, and caps the assets imported from each asset application.", "validation": { "min": 1, "max": 10000, "valueAsNumber": true } + }, + { + "type": "toggle", + "name": "importInventory", + "label": "Import assets, configuration items, accounts and locations", + "help": "Adds each one as an object in SquaredUp, so they appear in search and can be counted without querying TeamDynamix. Tiles don't need these objects, and a large asset estate adds many of them. Applications and groups are always imported." } ] }