From ea54289a7027b4e810b4d759ffb2f6b975cc16ae Mon Sep 17 00:00:00 2001 From: shawn149 Date: Fri, 7 Aug 2026 16:57:31 -0400 Subject: [PATCH 01/16] Add TeamDynamix plugin (v1) Introduce TeamDynamix v1 plugin: adds metadata, UI, config validation, index definitions, icons and README. Adds comprehensive dataStreams (tickets, assets, configurationItems, projects, services, KB articles, timeEntries, locations, accounts, groups, applications, ticketStatuses, currentUser) plus post-request scripts to project/flatten results. Implements preRequest.js for BEID/WebServicesKey authentication, token caching, request spacing and rate-limit handling, and a shared error-handling script. Includes default dashboards, scopes, and custom type definitions for out-of-the-box monitoring. --- plugins/TeamDynamix/v1/configValidation.json | 28 ++ plugins/TeamDynamix/v1/custom_types.json | 58 +++ .../TeamDynamix/v1/dataStreams/accounts.json | 32 ++ .../v1/dataStreams/applications.json | 28 ++ .../TeamDynamix/v1/dataStreams/assets.json | 76 ++++ .../v1/dataStreams/configurationItems.json | 62 +++ .../v1/dataStreams/currentUser.json | 27 ++ .../TeamDynamix/v1/dataStreams/groups.json | 30 ++ .../v1/dataStreams/knowledgeBaseArticles.json | 66 +++ .../TeamDynamix/v1/dataStreams/locations.json | 37 ++ .../TeamDynamix/v1/dataStreams/projects.json | 92 ++++ .../v1/dataStreams/scripts/accounts.js | 15 + .../v1/dataStreams/scripts/applications.js | 27 ++ .../v1/dataStreams/scripts/assets.js | 52 +++ .../dataStreams/scripts/configurationItems.js | 56 +++ .../scripts/errorHandling/rateLimit.js | 29 ++ .../scripts/knowledgeBaseArticles.js | 27 ++ .../v1/dataStreams/scripts/locations.js | 21 + .../v1/dataStreams/scripts/projects.js | 42 ++ .../v1/dataStreams/scripts/services.js | 32 ++ .../v1/dataStreams/scripts/tickets.js | 95 +++++ .../TeamDynamix/v1/dataStreams/services.json | 58 +++ .../v1/dataStreams/ticketStatuses.json | 32 ++ .../TeamDynamix/v1/dataStreams/tickets.json | 129 ++++++ .../v1/dataStreams/timeEntries.json | 69 +++ .../defaultContent/assetsDashboard.dash.json | 392 +++++++++++++++++ .../v1/defaultContent/manifest.json | 7 + .../overviewDashboard.dash.json | 288 +++++++++++++ .../TeamDynamix/v1/defaultContent/scopes.json | 26 ++ .../serviceDeskDashboard.dash.json | 393 ++++++++++++++++++ plugins/TeamDynamix/v1/docs/README.md | 80 ++++ plugins/TeamDynamix/v1/icon.png | Bin 0 -> 66584 bytes plugins/TeamDynamix/v1/icon.svg | 1 + .../v1/indexDefinitions/default.json | 128 ++++++ plugins/TeamDynamix/v1/metadata.json | 79 ++++ plugins/TeamDynamix/v1/preRequest.js | 116 ++++++ plugins/TeamDynamix/v1/ui.json | 70 ++++ 37 files changed, 2800 insertions(+) create mode 100644 plugins/TeamDynamix/v1/configValidation.json create mode 100644 plugins/TeamDynamix/v1/custom_types.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/accounts.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/applications.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/assets.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/configurationItems.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/currentUser.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/groups.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/knowledgeBaseArticles.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/locations.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/projects.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js create mode 100644 plugins/TeamDynamix/v1/dataStreams/scripts/applications.js create mode 100644 plugins/TeamDynamix/v1/dataStreams/scripts/assets.js create mode 100644 plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js create mode 100644 plugins/TeamDynamix/v1/dataStreams/scripts/errorHandling/rateLimit.js create mode 100644 plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js create mode 100644 plugins/TeamDynamix/v1/dataStreams/scripts/locations.js create mode 100644 plugins/TeamDynamix/v1/dataStreams/scripts/projects.js create mode 100644 plugins/TeamDynamix/v1/dataStreams/scripts/services.js create mode 100644 plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js create mode 100644 plugins/TeamDynamix/v1/dataStreams/services.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/tickets.json create mode 100644 plugins/TeamDynamix/v1/dataStreams/timeEntries.json create mode 100644 plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json create mode 100644 plugins/TeamDynamix/v1/defaultContent/manifest.json create mode 100644 plugins/TeamDynamix/v1/defaultContent/overviewDashboard.dash.json create mode 100644 plugins/TeamDynamix/v1/defaultContent/scopes.json create mode 100644 plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json create mode 100644 plugins/TeamDynamix/v1/docs/README.md create mode 100644 plugins/TeamDynamix/v1/icon.png create mode 100644 plugins/TeamDynamix/v1/icon.svg create mode 100644 plugins/TeamDynamix/v1/indexDefinitions/default.json create mode 100644 plugins/TeamDynamix/v1/metadata.json create mode 100644 plugins/TeamDynamix/v1/preRequest.js create mode 100644 plugins/TeamDynamix/v1/ui.json diff --git a/plugins/TeamDynamix/v1/configValidation.json b/plugins/TeamDynamix/v1/configValidation.json new file mode 100644 index 00000000..5eba1b18 --- /dev/null +++ b/plugins/TeamDynamix/v1/configValidation.json @@ -0,0 +1,28 @@ +{ + "steps": [ + { + "displayName": "Authenticate", + "dataStream": { "name": "currentUser" }, + "required": true, + "error": "Could not sign in to TeamDynamix. Check the Web API address ends in /TDWebApi, that the BEID and Web Services Key match the values on the organization detail page in TDAdmin, and that the admin service account is set to Active.", + "success": "Signed in to TeamDynamix." + }, + { + "displayName": "Find applications", + "dataStream": { + "name": "applications", + "config": { "errorOnEmptyResults": true } + }, + "required": true, + "error": "Signed in, but no TeamDynamix applications are visible to this service account. Tickets, assets and configuration items are all read per application, so at least one is required.", + "success": "Applications found." + }, + { + "displayName": "Read organization data", + "dataStream": { "name": "accounts" }, + "required": false, + "error": "Signed in, but accounts and departments could not be read. Dashboards will still work, but department breakdowns will be empty.", + "success": "Accounts and departments accessible." + } + ] +} diff --git a/plugins/TeamDynamix/v1/custom_types.json b/plugins/TeamDynamix/v1/custom_types.json new file mode 100644 index 00000000..2d6481c4 --- /dev/null +++ b/plugins/TeamDynamix/v1/custom_types.json @@ -0,0 +1,58 @@ +[ + { + "name": "TeamDynamix Ticketing App", + "sourceType": "TeamDynamix Ticketing App", + "icon": "headset", + "singular": "Ticketing Application", + "plural": "Ticketing Applications" + }, + { + "name": "TeamDynamix Asset App", + "sourceType": "TeamDynamix Asset App", + "icon": "boxes-stacked", + "singular": "Asset Application", + "plural": "Asset Applications" + }, + { + "name": "TeamDynamix Application", + "sourceType": "TeamDynamix Application", + "icon": "grid-2", + "singular": "Application", + "plural": "Applications" + }, + { + "name": "TeamDynamix Account", + "sourceType": "TeamDynamix Account", + "icon": "building", + "singular": "Account / Department", + "plural": "Accounts / Departments" + }, + { + "name": "TeamDynamix Location", + "sourceType": "TeamDynamix Location", + "icon": "location-dot", + "singular": "Location", + "plural": "Locations" + }, + { + "name": "TeamDynamix Group", + "sourceType": "TeamDynamix Group", + "icon": "users", + "singular": "Group", + "plural": "Groups" + }, + { + "name": "TeamDynamix Asset", + "sourceType": "TeamDynamix Asset", + "icon": "laptop", + "singular": "Asset", + "plural": "Assets" + }, + { + "name": "TeamDynamix Configuration Item", + "sourceType": "TeamDynamix Configuration Item", + "icon": "sitemap", + "singular": "Configuration Item", + "plural": "Configuration Items" + } +] diff --git a/plugins/TeamDynamix/v1/dataStreams/accounts.json b/plugins/TeamDynamix/v1/dataStreams/accounts.json new file mode 100644 index 00000000..a831840e --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/accounts.json @@ -0,0 +1,32 @@ +{ + "name": "accounts", + "displayName": "Accounts / Departments", + "description": "Active accounts and departments, one row per account", + "tags": ["Organization"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "api/accounts", + "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": false, + "visibility": { "type": "hidden" } +} diff --git a/plugins/TeamDynamix/v1/dataStreams/applications.json b/plugins/TeamDynamix/v1/dataStreams/applications.json new file mode 100644 index 00000000..11430b76 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/applications.json @@ -0,0 +1,28 @@ +{ + "name": "applications", + "displayName": "Applications", + "description": "TeamDynamix applications available to the service account, one row per application", + "tags": ["Organization"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "api/applications", + "paging": { "mode": "none" }, + "postRequestScript": "applications.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": "none", + "metadata": [ + { "name": "name", "displayName": "Name", "shape": "string", "role": "label" }, + { "name": "appId", "displayName": "App ID", "shape": "string", "role": "id" }, + { "name": "appType", "displayName": "Type", "shape": "string" }, + { "name": "appClass", "displayName": "Class", "shape": "string" }, + { "name": "description", "displayName": "Description", "shape": "string" }, + { "name": "sourceType", "displayName": "Object Type", "shape": "string", "visible": false } + ], + "timeframes": false, + "visibility": { "type": "hidden" } +} diff --git a/plugins/TeamDynamix/v1/dataStreams/assets.json b/plugins/TeamDynamix/v1/dataStreams/assets.json new file mode 100644 index 00000000..241df9d8 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/assets.json @@ -0,0 +1,76 @@ +{ + "name": "assets", + "displayName": "Assets", + "description": "Assets in an asset application, one row per asset, with status, owner, location and purchase detail", + "tags": ["Assets", "Inventory"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "post", + "endpointPath": "api/{{object.rawId}}/assets/search", + "postBody": { + "MaxResults": "{{maxResults || dataSource.maxRecords || 2000}}", + "IsInService": "{{typeof inService === 'undefined' || inService === '' || inService === 'all' ? null : inService === 'inService'}}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "assets.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" } + ] + }, + { + "type": "number", + "name": "maxResults", + "label": "Maximum assets", + "help": "The TeamDynamix asset search returns a single unpaged result set, so this is a hard ceiling on how many assets the tile can see.", + "validation": { "min": 1, "max": 10000, "valueAsNumber": true } + } + ], + "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 }, + { "pattern": "^attr\\..*" } + ], + "defaultShaping": { + "sort": { "by": [["name", "asc"]] } + }, + "timeframes": false +} diff --git a/plugins/TeamDynamix/v1/dataStreams/configurationItems.json b/plugins/TeamDynamix/v1/dataStreams/configurationItems.json new file mode 100644 index 00000000..187ec369 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/configurationItems.json @@ -0,0 +1,62 @@ +{ + "name": "configurationItems", + "displayName": "Configuration Items", + "description": "Configuration items in the CMDB of an asset application, one row per item", + "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'}}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "configurationItems.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Asset App"] } + }, + "ui": [ + { + "type": "switch", + "name": "activeState", + "label": "Active state", + "defaultValue": "active", + "options": [ + { "value": "all", "label": "All" }, + { "value": "active", "label": "Active" }, + { "value": "inactive", "label": "Inactive" } + ] + } + ], + "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 }, + { "pattern": "^attr\\..*" } + ], + "defaultShaping": { + "sort": { "by": [["name", "asc"]] } + }, + "timeframes": false +} diff --git a/plugins/TeamDynamix/v1/dataStreams/currentUser.json b/plugins/TeamDynamix/v1/dataStreams/currentUser.json new file mode 100644 index 00000000..0f3ee2f1 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/currentUser.json @@ -0,0 +1,27 @@ +{ + "name": "currentUser", + "displayName": "Current User", + "description": "Details of the TeamDynamix service account the connection is using", + "tags": ["User"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "api/auth/getuser", + "paging": { "mode": "none" }, + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": "none", + "metadata": [ + { "name": "FullName", "displayName": "Name", "shape": "string", "role": "label" }, + { "name": "UserName", "displayName": "Username", "shape": "string" }, + { "name": "PrimaryEmail", "displayName": "Email", "shape": "string" }, + { "name": "IsActive", "displayName": "Active", "shape": "boolean" }, + { "name": "BEID", "displayName": "BEID", "shape": "string" }, + { "name": "UID", "displayName": "UID", "shape": "string", "visible": false } + ], + "timeframes": false, + "visibility": { "type": "hidden" } +} diff --git a/plugins/TeamDynamix/v1/dataStreams/groups.json b/plugins/TeamDynamix/v1/dataStreams/groups.json new file mode 100644 index 00000000..46c28f26 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/groups.json @@ -0,0 +1,30 @@ +{ + "name": "groups", + "displayName": "Groups", + "description": "Active groups used to assign and route work, one row per group", + "tags": ["Organization"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "post", + "endpointPath": "api/groups/search", + "postBody": { + "IsActive": true + }, + "paging": { "mode": "none" }, + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": "none", + "metadata": [ + { "name": "Name", "displayName": "Group", "shape": "string", "role": "label" }, + { "name": "ID", "displayName": "Group ID", "shape": "string", "role": "id" }, + { "name": "Description", "displayName": "Description", "shape": "string" }, + { "name": "ExternalID", "displayName": "External ID", "shape": "string" }, + { "name": "CreatedDate", "displayName": "Created", "shape": "date" }, + { "name": "ModifiedDate", "displayName": "Modified", "shape": "date" } + ], + "timeframes": false, + "visibility": { "type": "hidden" } +} diff --git a/plugins/TeamDynamix/v1/dataStreams/knowledgeBaseArticles.json b/plugins/TeamDynamix/v1/dataStreams/knowledgeBaseArticles.json new file mode 100644 index 00000000..64a79fa7 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/knowledgeBaseArticles.json @@ -0,0 +1,66 @@ +{ + "name": "knowledgeBaseArticles", + "displayName": "Knowledge Base Articles", + "description": "Knowledge base articles in a client portal application, one row per article", + "tags": ["Knowledge Base", "Service Management"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "post", + "endpointPath": "api/{{object.rawId}}/knowledgebase/search", + "postBody": { + "IncludeArticleBodies": false, + "IncludeShortcuts": false, + "IsPublished": "{{typeof publishedState === 'undefined' || publishedState === '' || publishedState === 'all' ? null : publishedState === 'published'}}", + "ReturnCount": "{{maxResults || dataSource.maxRecords || 1000}}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "knowledgeBaseArticles.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Application"] } + }, + "ui": [ + { + "type": "switch", + "name": "publishedState", + "label": "Published state", + "defaultValue": "all", + "options": [ + { "value": "all", "label": "All" }, + { "value": "published", "label": "Published" }, + { "value": "unpublished", "label": "Unpublished" } + ] + }, + { + "type": "number", + "name": "maxResults", + "label": "Maximum articles", + "validation": { "min": 1, "max": 10000, "valueAsNumber": true } + } + ], + "metadata": [ + { "name": "subject", "displayName": "Article", "shape": "string", "role": "label" }, + { "name": "articleId", "displayName": "Article ID", "shape": "string", "role": "id" }, + { "name": "link", "displayName": "Link", "shape": ["url", { "label": "Open in TeamDynamix" }], "role": "link" }, + { "name": "categoryName", "displayName": "Category", "shape": "string" }, + { "name": "statusName", "displayName": "Status", "shape": "string" }, + { "name": "isPublished", "displayName": "Published", "shape": "boolean" }, + { "name": "isPublic", "displayName": "Public", "shape": "boolean" }, + { "name": "ownerFullName", "displayName": "Owner", "shape": "string" }, + { "name": "owningGroupName", "displayName": "Owning Group", "shape": "string" }, + { "name": "summary", "displayName": "Summary", "shape": "string" }, + { "name": "reviewDate", "displayName": "Review Due", "shape": "date" }, + { "name": "createdDate", "displayName": "Created", "shape": "date", "visible": false }, + { "name": "modifiedDate", "displayName": "Modified", "shape": "date" }, + { "name": "revisionNumber", "displayName": "Revision", "shape": "number", "visible": false }, + { "name": "appId", "displayName": "App ID", "shape": "string", "visible": false } + ], + "defaultShaping": { + "sort": { "by": [["modifiedDate", "desc"]] } + }, + "timeframes": false +} diff --git a/plugins/TeamDynamix/v1/dataStreams/locations.json b/plugins/TeamDynamix/v1/dataStreams/locations.json new file mode 100644 index 00000000..2df5b04f --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/locations.json @@ -0,0 +1,37 @@ +{ + "name": "locations", + "displayName": "Locations", + "description": "Active locations with their ticket, asset, configuration item and user counts", + "tags": ["Organization", "Inventory"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "api/locations", + "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 } + ], + "defaultShaping": { + "sort": { "by": [["ticketsCount", "desc"]] } + }, + "timeframes": false +} diff --git a/plugins/TeamDynamix/v1/dataStreams/projects.json b/plugins/TeamDynamix/v1/dataStreams/projects.json new file mode 100644 index 00000000..cc7d70eb --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/projects.json @@ -0,0 +1,92 @@ +{ + "name": "projects", + "displayName": "Projects", + "description": "Projects in a project application, one row per project, with health, schedule and budget detail", + "tags": ["Projects"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "post", + "endpointPath": "api/{{object.rawId}}/projects/search", + "postBody": { + "IsActive": "{{typeof activeState === 'undefined' || activeState === '' || activeState === 'all' ? null : activeState === 'active'}}", + "IsOpen": "{{typeof openState === 'undefined' || openState === '' || openState === 'all' ? null : openState === 'open'}}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "projects.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Application"] } + }, + "ui": [ + { + "type": "switch", + "name": "activeState", + "label": "Active state", + "defaultValue": "active", + "options": [ + { "value": "all", "label": "All" }, + { "value": "active", "label": "Active" }, + { "value": "inactive", "label": "Inactive" } + ] + }, + { + "type": "switch", + "name": "openState", + "label": "Open state", + "defaultValue": "all", + "options": [ + { "value": "all", "label": "All" }, + { "value": "open", "label": "Open" }, + { "value": "closed", "label": "Closed" } + ] + } + ], + "metadata": [ + { "name": "name", "displayName": "Project", "shape": "string", "role": "label" }, + { "name": "projectId", "displayName": "Project ID", "shape": "string", "role": "id" }, + { + "name": "healthName", + "displayName": "Health", + "shape": [ + "state", + { + "map": { + "success": ["Green", "On Track"], + "warning": ["Yellow", "At Risk"], + "error": ["Red", "Off Track"], + "unknown": ["None", "Not Set", ""] + } + } + ] + }, + { "name": "statusName", "displayName": "Status", "shape": "string" }, + { "name": "percentComplete", "displayName": "Complete", "shape": "percent" }, + { "name": "managerName", "displayName": "Manager", "shape": "string" }, + { "name": "sponsorName", "displayName": "Sponsor", "shape": "string", "visible": false }, + { "name": "accountName", "displayName": "Account / Department", "shape": "string" }, + { "name": "typeName", "displayName": "Type", "shape": "string" }, + { "name": "priorityName", "displayName": "Priority", "shape": "string", "visible": false }, + { "name": "classificationName", "displayName": "Classification", "shape": "string", "visible": false }, + { "name": "serviceName", "displayName": "Service", "shape": "string", "visible": false }, + { "name": "startDate", "displayName": "Starts", "shape": "date" }, + { "name": "endDate", "displayName": "Ends", "shape": "date" }, + { "name": "estimatedHours", "displayName": "Estimated Hours", "shape": "number" }, + { "name": "actualHours", "displayName": "Actual Hours", "shape": "number" }, + { "name": "budget", "displayName": "Budget", "shape": ["number", { "decimalPlaces": 2, "thousandsSeparator": true }] }, + { "name": "expensesBudget", "displayName": "Expenses Budget", "shape": ["number", { "decimalPlaces": 2, "thousandsSeparator": true }], "visible": false }, + { "name": "expensesBudgetUsed", "displayName": "Expenses Used", "shape": ["number", { "decimalPlaces": 2, "thousandsSeparator": true }], "visible": false }, + { "name": "isActive", "displayName": "Active", "shape": "boolean", "visible": false }, + { "name": "statusLastUpdated", "displayName": "Status Updated", "shape": "date", "visible": false }, + { "name": "createdDate", "displayName": "Created", "shape": "date", "visible": false }, + { "name": "appId", "displayName": "App ID", "shape": "string", "visible": false }, + { "pattern": "^attr\\..*" } + ], + "defaultShaping": { + "sort": { "by": [["endDate", "asc"]] } + }, + "timeframes": false +} diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js b/plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js new file mode 100644 index 00000000..fbe76d34 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js @@ -0,0 +1,15 @@ +// 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. +result = (data || []).map((account) => ({ + accountId: String(account.ID), + name: account.Name, + code: account.Code || "", + managerName: account.ManagerFullName || "", + parentName: account.ParentName || "", + industryName: account.IndustryName || "", + city: account.City || "", + stateName: account.StateName || "", + country: account.Country || "", + createdDate: account.CreatedDate || null +})); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/applications.js b/plugins/TeamDynamix/v1/dataStreams/scripts/applications.js new file mode 100644 index 00000000..a3949e51 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/applications.js @@ -0,0 +1,27 @@ +// Every interesting TeamDynamix endpoint is application-scoped — /api/{appId}/tickets/search, +// /api/{appId}/assets/search and so on — so applications are imported as objects and every +// other stream hangs off them. That way a user picks "Service Desk" by name and never has to +// know an app ID. +// +// Splitting the single /api/applications response into three object types is what makes the +// object pickers useful: a ticket stream should only offer Ticketing apps, not the Client +// Portal. Type is documented as one of Standard, Ticketing, Assets/CI, Client Portal or +// External, so anything that isn't Ticketing or Assets/CI falls through to the generic type. +const SOURCE_TYPE_BY_APP_TYPE = { + Ticketing: "TeamDynamix Ticketing App", + "Assets/CI": "TeamDynamix Asset App" +}; + +// Inactive applications are filtered out rather than imported and ignored: the asset and +// configuration item import steps are scoped to Asset App objects, so a retired app left in +// the graph would have requests fired at it on every import. +result = (data || []) + .filter((app) => app.Active !== false) + .map((app) => ({ + appId: String(app.AppID), + name: app.Name, + appType: app.Type || "", + appClass: app.AppClass || "", + description: app.Description || "", + sourceType: SOURCE_TYPE_BY_APP_TYPE[app.Type] || "TeamDynamix Application" + })); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js b/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js new file mode 100644 index 00000000..8b41276a --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js @@ -0,0 +1,52 @@ +// Serves double duty: this stream backs both the assets import step and asset tiles. +// +// Projected rather than passed through for the same reason as tickets — an Asset carries +// Attachments and Attributes arrays, and the asset search is unpaged, so a large estate in +// one response can overrun the ~6MB stream response cap. +const customAttributeColumns = (asset) => { + const columns = {}; + for (const attribute of asset.Attributes || []) { + if (!attribute || !attribute.Name) continue; + columns[`attr.${attribute.Name}`] = + attribute.ValueText ?? (attribute.Value === "" ? null : attribute.Value ?? null); + } + return columns; +}; + +const webBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") + .trim() + .replace(/\/+$/, "") + .replace(/\/(SB)?TDWebApi$/i, ""); + +result = (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. + sourceId: `${asset.AppID}-${asset.ID}`, + assetId: String(asset.ID), + name: asset.Name || asset.Tag || asset.SerialNumber || `Asset ${asset.ID}`, + link: webBaseUrl + ? `${webBaseUrl}/TDNext/Apps/${asset.AppID}/Assets/AssetDet?AssetID=${asset.ID}` + : asset.Uri || "", + 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 || "", + 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 || "", + externalId: asset.ExternalID || "", + configurationItemId: asset.ConfigurationItemID ? String(asset.ConfigurationItemID) : "", + appId: String(asset.AppID ?? ""), + sourceType: "TeamDynamix Asset", + ...customAttributeColumns(asset) +})); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js new file mode 100644 index 00000000..f7e5a7af --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js @@ -0,0 +1,56 @@ +// Serves double duty: this stream backs both the configurationItems import step and CI tiles. +// +// Projection matters more here than anywhere else. ConfigurationItemSearch has no MaxResults +// field at all — unlike ticket and asset search there is no way to cap the result set from the +// request side — so the only defence against overrunning the ~6MB stream response cap on a +// large CMDB is to keep each row small. +// 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". +const BACKING_ITEM_TYPE_NAMES = { + 27: "Asset", + 47: "Service", + 63: "Configuration Item" +}; + +const customAttributeColumns = (item) => { + const columns = {}; + for (const attribute of item.Attributes || []) { + if (!attribute || !attribute.Name) continue; + columns[`attr.${attribute.Name}`] = + attribute.ValueText ?? (attribute.Value === "" ? null : attribute.Value ?? null); + } + return columns; +}; + +const webBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") + .trim() + .replace(/\/+$/, "") + .replace(/\/(SB)?TDWebApi$/i, ""); + +result = (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}/TDNext/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", + ...customAttributeColumns(item) +})); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/errorHandling/rateLimit.js b/plugins/TeamDynamix/v1/dataStreams/scripts/errorHandling/rateLimit.js new file mode 100644 index 00000000..070fcc9e --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/errorHandling/rateLimit.js @@ -0,0 +1,29 @@ +// 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. +const header = (name) => { + const headers = response.headers || {}; + return headers[name] || headers[name.toLowerCase()] || headers[name.toUpperCase()]; +}; + +if (response.status === 429) { + const resetAt = header("X-RateLimit-Reset"); + const limit = header("X-RateLimit-Limit"); + result = + "TeamDynamix rate limit reached" + + (limit ? ` (${limit} requests per period, per IP address)` : "") + + ". " + + (resetAt + ? `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) { + 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 { + // TeamDynamix error bodies are shaped { "Message": "..." }. + result = + (data && (data.Message || data.message)) || + `TeamDynamix request failed with HTTP ${response.status}.`; +} diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js b/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js new file mode 100644 index 00000000..541676ae --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js @@ -0,0 +1,27 @@ +// The search request already sets IncludeArticleBodies: false, which is the one big win — +// article bodies are full HTML documents. This drops the remaining arrays (Attachments, +// Attributes, Tags, WhitelistGroups) that would otherwise render as raw JSON columns. +const webBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") + .trim() + .replace(/\/+$/, "") + .replace(/\/(SB)?TDWebApi$/i, ""); + +result = (data || []).map((article) => ({ + articleId: String(article.ID), + subject: article.Subject || `Article ${article.ID}`, + link: webBaseUrl + ? `${webBaseUrl}/TDClient/Shared/KB/ArticleDet?ID=${article.ID}` + : article.Uri || "", + categoryName: article.CategoryName || "", + statusName: article.StatusName || "", + isPublished: Boolean(article.IsPublished), + isPublic: Boolean(article.IsPublic), + ownerFullName: article.OwnerFullName || "", + owningGroupName: article.OwningGroupName || "", + summary: article.Summary || "", + reviewDate: article.ReviewDateUtc || null, + createdDate: article.CreatedDate || null, + modifiedDate: article.ModifiedDate || null, + 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 new file mode 100644 index 00000000..a28a92a4 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/locations.js @@ -0,0 +1,21 @@ +// Locations are one of the few TeamDynamix records that arrive with counts already rolled up +// (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. +result = (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, + city: location.City || "", + state: location.State || "", + country: location.Country || "", + address: location.Address || "", + latitude: typeof location.Latitude === "number" ? location.Latitude : null, + longitude: typeof location.Longitude === "number" ? location.Longitude : null +})); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js b/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js new file mode 100644 index 00000000..a3187811 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js @@ -0,0 +1,42 @@ +// A Project has 120 fields, most of them baseline/initial variants of the same value +// (EndDateBaseline, EndDateInitial, EstimatedHoursInitial, TimeBudgetInitial ...) plus +// AlternateManagers, CustomColumns, NonWorkingDays and Attributes arrays. Projected to the +// fields that answer "is this project on track", which also keeps the unpaged response +// under the ~6MB stream response cap. +const customAttributeColumns = (project) => { + const columns = {}; + for (const attribute of project.Attributes || []) { + if (!attribute || !attribute.Name) continue; + columns[`attr.${attribute.Name}`] = + attribute.ValueText ?? (attribute.Value === "" ? null : attribute.Value ?? null); + } + return columns; +}; + +result = (data || []).map((project) => ({ + projectId: String(project.ID), + name: project.Name || `Project ${project.ID}`, + healthName: project.HealthName || "None", + statusName: project.StatusName || "", + percentComplete: typeof project.PercentComplete === "number" ? project.PercentComplete : null, + managerName: project.AdminName || "", + sponsorName: project.SponsorName || "", + accountName: project.AccountName || "", + typeName: project.TypeName || "", + priorityName: project.PriorityName || "", + classificationName: project.ClassificationName || "", + serviceName: project.ServiceName || "", + startDate: project.StartDate || null, + endDate: project.EndDate || null, + estimatedHours: typeof project.EstimatedHours === "number" ? project.EstimatedHours : null, + actualHours: typeof project.ActualHours === "number" ? project.ActualHours : null, + budget: typeof project.Budget === "number" ? project.Budget : null, + expensesBudget: typeof project.ExpensesBudget === "number" ? project.ExpensesBudget : null, + expensesBudgetUsed: + typeof project.ExpensesBudgetUsed === "number" ? project.ExpensesBudgetUsed : null, + isActive: Boolean(project.IsActive), + statusLastUpdated: project.StatusModifiedDate || null, + createdDate: project.CreatedDate || null, + appId: String(project.AppID ?? ""), + ...customAttributeColumns(project) +})); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/services.js b/plugins/TeamDynamix/v1/dataStreams/scripts/services.js new file mode 100644 index 00000000..57769c02 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/services.js @@ -0,0 +1,32 @@ +// LongDescription, RequestText and SubmitText are rich-text blocks, and ServiceOfferings is a +// nested array of full offering records — all dropped here. ServiceOfferingsCount already +// carries the only part of that array a dashboard needs. +const customAttributeColumns = (service) => { + const columns = {}; + for (const attribute of service.Attributes || []) { + if (!attribute || !attribute.Name) continue; + columns[`attr.${attribute.Name}`] = + attribute.ValueText ?? (attribute.Value === "" ? null : attribute.Value ?? null); + } + return columns; +}; + +result = (data || []).map((service) => ({ + serviceId: String(service.ID), + name: service.Name || `Service ${service.ID}`, + categoryName: service.CategoryName || "", + fullCategoryText: service.FullCategoryText || "", + managerName: service.ManagerFullName || "", + managingGroupName: service.ManagingGroupName || "", + shortDescription: service.ShortDescription || "", + isActive: Boolean(service.IsActive), + isPublic: Boolean(service.IsPublic), + serviceOfferingsCount: + typeof service.ServiceOfferingsCount === "number" ? service.ServiceOfferingsCount : 0, + maintenanceScheduleName: service.MaintenanceScheduleName || "", + requestTypeName: service.RequestTypeName || "", + createdDate: service.CreatedDateUtc || null, + modifiedDate: service.ModifiedDateUtc || null, + appId: String(service.AppID ?? ""), + ...customAttributeColumns(service) +})); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js new file mode 100644 index 00000000..5c2899fd --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js @@ -0,0 +1,95 @@ +// A TeamDynamix Ticket has 119 fields, several of which are unbounded: Description is full +// HTML, and Attachments, Tasks, TaskTemplateApplications and Notify are arrays. Passed +// 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. + +// StatusClass is an integer enum on the wire; dashboards need to group and read it as text. +const STATUS_CLASS_NAMES = { + 0: "Unknown", + 1: "New", + 2: "In Process", + 3: "Completed", + 4: "Cancelled", + 5: "On Hold", + 6: "Requested" +}; + +// Custom attributes arrive as a name/value array, so turning them into columns needs a +// script — there's no declarative equivalent. Prefixed with "attr." and picked up by the +// metadata pattern, which keeps a tenant's custom fields usable without hardcoding them. +const customAttributeColumns = (ticket) => { + const columns = {}; + for (const attribute of ticket.Attributes || []) { + if (!attribute || !attribute.Name) continue; + columns[`attr.${attribute.Name}`] = + attribute.ValueText ?? (attribute.Value === "" ? null : attribute.Value ?? null); + } + return columns; +}; + +// A ticket is far more useful when you can click through to it. The API's own Uri field +// points at the API resource, not the web UI, so derive a TDNext deep link from the +// configured base address instead. context.dataSources isn't guaranteed to be populated for +// post-request scripts, so fall back to Uri rather than emitting a broken link. +const webBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") + .trim() + .replace(/\/+$/, "") + .replace(/\/(SB)?TDWebApi$/i, ""); + +const ticketLink = (ticket) => + webBaseUrl + ? `${webBaseUrl}/TDNext/Apps/${ticket.AppID}/Tickets/TicketDet?TicketID=${ticket.ID}` + : ticket.Uri || ""; + +// 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) => { + if (ticket.IsSlaViolated) return "SLA violated"; + if (ticket.StatusClass === 3) return "Completed"; + if (ticket.StatusClass === 4) return "Cancelled"; + if (ticket.IsOnHold || ticket.StatusClass === 5) return "On hold"; + return "Open"; +}; + +result = (data || []).map((ticket) => ({ + ticketId: String(ticket.ID), + title: ticket.Title || `Ticket ${ticket.ID}`, + link: ticketLink(ticket), + health: ticketHealth(ticket), + statusName: ticket.StatusName || "", + 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 || "", + requestorName: ticket.RequestorName || "", + requestorEmail: ticket.RequestorEmail || "", + createdDate: ticket.CreatedDate || null, + modifiedDate: ticket.ModifiedDate || null, + completedDate: ticket.CompletedDate || null, + respondedDate: ticket.RespondedDate || null, + 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, + 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 ?? ""), + ...customAttributeColumns(ticket) +})); diff --git a/plugins/TeamDynamix/v1/dataStreams/services.json b/plugins/TeamDynamix/v1/dataStreams/services.json new file mode 100644 index 00000000..0cb292e2 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/services.json @@ -0,0 +1,58 @@ +{ + "name": "services", + "displayName": "Services", + "description": "Service catalog services in a client portal application, one row per service", + "tags": ["Service Management"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "post", + "endpointPath": "api/{{object.rawId}}/services/search", + "postBody": { + "IsActive": "{{typeof activeState === 'undefined' || activeState === '' || activeState === 'all' ? null : activeState === 'active'}}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "services.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Application"] } + }, + "ui": [ + { + "type": "switch", + "name": "activeState", + "label": "Active state", + "defaultValue": "active", + "options": [ + { "value": "all", "label": "All" }, + { "value": "active", "label": "Active" }, + { "value": "inactive", "label": "Inactive" } + ] + } + ], + "metadata": [ + { "name": "name", "displayName": "Service", "shape": "string", "role": "label" }, + { "name": "serviceId", "displayName": "Service ID", "shape": "string", "role": "id" }, + { "name": "categoryName", "displayName": "Category", "shape": "string" }, + { "name": "fullCategoryText", "displayName": "Category Path", "shape": "string", "visible": false }, + { "name": "managerName", "displayName": "Manager", "shape": "string" }, + { "name": "managingGroupName", "displayName": "Managing Group", "shape": "string" }, + { "name": "shortDescription", "displayName": "Description", "shape": "string" }, + { "name": "isActive", "displayName": "Active", "shape": "boolean" }, + { "name": "isPublic", "displayName": "Public", "shape": "boolean" }, + { "name": "serviceOfferingsCount", "displayName": "Offerings", "shape": "number" }, + { "name": "maintenanceScheduleName", "displayName": "Maintenance Window", "shape": "string", "visible": false }, + { "name": "requestTypeName", "displayName": "Request Type", "shape": "string", "visible": false }, + { "name": "createdDate", "displayName": "Created", "shape": "date", "visible": false }, + { "name": "modifiedDate", "displayName": "Modified", "shape": "date", "visible": false }, + { "name": "appId", "displayName": "App ID", "shape": "string", "visible": false }, + { "pattern": "^attr\\..*" } + ], + "defaultShaping": { + "sort": { "by": [["name", "asc"]] } + }, + "timeframes": false +} diff --git a/plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json b/plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json new file mode 100644 index 00000000..f4232de1 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json @@ -0,0 +1,32 @@ +{ + "name": "ticketStatuses", + "displayName": "Ticket Statuses", + "description": "Ticket statuses configured in a ticketing application, one row per status", + "tags": ["Tickets", "Service Management"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "api/{{object.rawId}}/tickets/statuses", + "paging": { "mode": "none" }, + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Ticketing App"] } + }, + "metadata": [ + { "name": "Name", "displayName": "Status", "shape": "string", "role": "label" }, + { "name": "ID", "displayName": "Status ID", "shape": "string", "role": "id" }, + { "name": "Description", "displayName": "Description", "shape": "string" }, + { "name": "Order", "displayName": "Order", "shape": "number" }, + { "name": "IsActive", "displayName": "Active", "shape": "boolean" }, + { "name": "IsDefault", "displayName": "Default", "shape": "boolean" }, + { "name": "StatusClass", "displayName": "Status Class", "shape": "string", "visible": false } + ], + "defaultShaping": { + "sort": { "by": [["Order", "asc"]] } + }, + "timeframes": false +} diff --git a/plugins/TeamDynamix/v1/dataStreams/tickets.json b/plugins/TeamDynamix/v1/dataStreams/tickets.json new file mode 100644 index 00000000..ac851383 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/tickets.json @@ -0,0 +1,129 @@ +{ + "name": "tickets", + "displayName": "Tickets", + "description": "Tickets in a ticketing application, one row per ticket, with status, priority, assignment and SLA detail", + "tags": ["Tickets", "Service Management"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "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}}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "tickets.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Ticketing App"] } + }, + "manualConfigApply": true, + "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": "number", + "name": "maxResults", + "label": "Maximum tickets", + "help": "The TeamDynamix ticket search returns a single unpaged result set, so this is a hard ceiling on how many tickets the tile can see. Narrow the timeframe or status filter rather than raising this if you are hitting the limit.", + "validation": { "min": 1, "max": 10000, "valueAsNumber": true } + } + ], + "metadata": [ + { "name": "title", "displayName": "Title", "shape": "string", "role": "label" }, + { "name": "ticketId", "displayName": "Ticket ID", "shape": "string", "role": "id" }, + { "name": "link", "displayName": "Link", "shape": ["url", { "label": "Open in TeamDynamix" }], "role": "link" }, + { + "name": "health", + "displayName": "Health", + "shape": [ + "state", + { + "map": { + "error": ["SLA violated"], + "warning": ["On hold"], + "success": ["Completed"], + "unknown": ["Open", "Cancelled"] + } + } + ] + }, + { "name": "statusName", "displayName": "Status", "shape": "string" }, + { "name": "statusClassName", "displayName": "Status Class", "shape": "string" }, + { "name": "priorityName", "displayName": "Priority", "shape": "string" }, + { "name": "priorityOrder", "displayName": "Priority Order", "shape": "number", "visible": false }, + { "name": "typeName", "displayName": "Type", "shape": "string" }, + { "name": "classificationName", "displayName": "Classification", "shape": "string" }, + { "name": "urgencyName", "displayName": "Urgency", "shape": "string", "visible": false }, + { "name": "impactName", "displayName": "Impact", "shape": "string", "visible": false }, + { "name": "accountName", "displayName": "Account / Department", "shape": "string" }, + { "name": "serviceName", "displayName": "Service", "shape": "string" }, + { "name": "locationName", "displayName": "Location", "shape": "string" }, + { "name": "sourceName", "displayName": "Source", "shape": "string" }, + { "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" }, + { "name": "respondedDate", "displayName": "Responded", "shape": "date", "visible": false }, + { "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 }, + { "pattern": "^attr\\..*" } + ], + "defaultShaping": { + "sort": { "by": [["createdDate", "desc"]] } + }, + "timeframes": true, + "supportsNoneTimeframe": true, + "defaultTimeframe": "last30days" +} diff --git a/plugins/TeamDynamix/v1/dataStreams/timeEntries.json b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json new file mode 100644 index 00000000..a2119b8c --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json @@ -0,0 +1,69 @@ +{ + "name": "timeEntries", + "displayName": "Time Entries", + "description": "Time logged against tickets, projects and tasks, one row per time entry", + "tags": ["Time", "Service Management"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "post", + "endpointPath": "api/time/search", + "postBody": { + "MaxResults": "{{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'}}" + }, + "paging": { "mode": "none" }, + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": "none", + "manualConfigApply": true, + "ui": [ + { + "type": "switch", + "name": "billable", + "label": "Billable", + "defaultValue": "all", + "options": [ + { "value": "all", "label": "All" }, + { "value": "billable", "label": "Billable only" }, + { "value": "nonBillable", "label": "Non-billable only" } + ] + }, + { + "type": "number", + "name": "maxResults", + "label": "Maximum entries", + "help": "The TeamDynamix time search returns a single unpaged result set, so this is a hard ceiling on how many entries the tile can see.", + "validation": { "min": 1, "max": 10000, "valueAsNumber": true } + } + ], + "metadata": [ + { "name": "ItemTitle", "displayName": "Work Item", "shape": "string", "role": "label" }, + { "name": "TimeID", "displayName": "Time Entry ID", "shape": "string", "role": "id" }, + { "name": "TimeDate", "displayName": "Date", "shape": "date", "role": "timestamp" }, + { "name": "Hours", "displayName": "Hours", "shape": ["number", { "decimalPlaces": 2 }], "role": "value" }, + { "name": "Minutes", "displayName": "Minutes", "shape": "minutes", "visible": false }, + { "name": "Billable", "displayName": "Billable", "shape": "boolean" }, + { "name": "Component", "displayName": "Component", "shape": "string" }, + { "name": "AppName", "displayName": "Application", "shape": "string" }, + { "name": "ProjectName", "displayName": "Project", "shape": "string" }, + { "name": "PortfolioName", "displayName": "Portfolio", "shape": "string", "visible": false }, + { "name": "TicketID", "displayName": "Ticket ID", "shape": "string" }, + { "name": "Description", "displayName": "Description", "shape": "string" }, + { "name": "StatusFullName", "displayName": "Status Set By", "shape": "string", "visible": false }, + { "name": "ApproverFullName", "displayName": "Approver", "shape": "string", "visible": false }, + { "name": "BillRate", "displayName": "Bill Rate", "shape": ["number", { "decimalPlaces": 2 }], "visible": false }, + { "name": "CostRate", "displayName": "Cost Rate", "shape": ["number", { "decimalPlaces": 2 }], "visible": false }, + { "name": "Uid", "displayName": "Person UID", "shape": "string", "visible": false } + ], + "defaultShaping": { + "sort": { "by": [["TimeDate", "desc"]] } + }, + "timeframes": true, + "supportsNoneTimeframe": true, + "defaultTimeframe": "last30days" +} diff --git a/plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json b/plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json new file mode 100644 index 00000000..cdb900ff --- /dev/null +++ b/plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json @@ -0,0 +1,392 @@ +{ + "name": "Assets and CMDB", + "schemaVersion": "1.5", + "timeframe": "last30days", + "variables": ["{{variables.[Asset Application]}}"], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "1693e8ad-1d2c-4066-aa95-da9d9c4ffbdc", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Total Assets", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[assets]}}", + "name": "assets", + "pluginConfigId": "{{configId}}", + "config": { "inService": "all" }, + "group": { "by": [], "aggregate": [{ "type": "count" }] } + }, + "scope": { + "scope": "{{scopes.[Asset Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Asset Application]}}" + }, + "variables": ["{{variables.[Asset Application]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Assets" + } + } + } + } + }, + { + "i": "879c30bc-34c6-4adb-bd41-63236d7c0842", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Out of Service", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[assets]}}", + "name": "assets", + "pluginConfigId": "{{configId}}", + "config": { "inService": "outOfService" }, + "group": { "by": [], "aggregate": [{ "type": "count" }] } + }, + "scope": { + "scope": "{{scopes.[Asset Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Asset Application]}}" + }, + "variables": ["{{variables.[Asset Application]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Out of service" + } + } + } + } + }, + { + "i": "bcd11473-8d8c-4bc7-9ae9-e01fff2f8409", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Purchase Value", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[assets]}}", + "name": "assets", + "pluginConfigId": "{{configId}}", + "config": { "inService": "all" }, + "group": { + "by": [], + "aggregate": [{ "type": "sum", "names": ["purchaseCost"] }] + } + }, + "scope": { + "scope": "{{scopes.[Asset Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Asset Application]}}" + }, + "variables": ["{{variables.[Asset Application]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "purchaseCost_sum", + "comparisonColumn": "none", + "label": "Purchase value" + } + } + } + } + }, + { + "i": "e1b34373-2552-4195-b292-9642cfcc2d0c", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Configuration Items", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[configurationItems]}}", + "name": "configurationItems", + "pluginConfigId": "{{configId}}", + "config": { "activeState": "active" }, + "group": { "by": [], "aggregate": [{ "type": "count" }] } + }, + "scope": { + "scope": "{{scopes.[Asset Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Asset Application]}}" + }, + "variables": ["{{variables.[Asset Application]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Active CIs" + } + } + } + } + }, + { + "i": "bda757cd-e7bc-4d29-8fa1-6d61f9adb609", + "x": 0, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Assets by Status", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[assets]}}", + "name": "assets", + "pluginConfigId": "{{configId}}", + "config": { "inService": "all" }, + "group": { + "by": [["statusName", "uniqueValues"]], + "aggregate": [{ "type": "count" }] + }, + "sort": { "by": [["count", "desc"]] } + }, + "scope": { + "scope": "{{scopes.[Asset Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Asset Application]}}" + }, + "variables": ["{{variables.[Asset Application]}}"], + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "statusName_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "2ff99b02-46a4-490b-8048-6ad8d1dcfe53", + "x": 2, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Top Manufacturers", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[assets]}}", + "name": "assets", + "pluginConfigId": "{{configId}}", + "config": { "inService": "all" }, + "group": { + "by": [["manufacturerName", "uniqueValues"]], + "aggregate": [{ "type": "count" }] + }, + "sort": { "by": [["count", "desc"]], "top": 10 } + }, + "scope": { + "scope": "{{scopes.[Asset Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Asset Application]}}" + }, + "variables": ["{{variables.[Asset Application]}}"], + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "manufacturerName_uniqueValues", + "yAxisData": ["count"], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Assets", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "horizontal", + "displayMode": "actual", + "showTotals": false, + "showValue": true, + "grouping": false, + "range": { "type": "auto" } + } + } + } + } + }, + { + "i": "4499825c-46c9-4641-b5ed-99db8f6e9375", + "x": 0, + "y": 5, + "w": 2, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Assets Due for Replacement", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[assets]}}", + "name": "assets", + "pluginConfigId": "{{configId}}", + "config": { "inService": "all" }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "expectedReplacementDate", + "operation": "notempty" + } + ] + }, + "sort": { "by": [["expectedReplacementDate", "asc"]] } + }, + "scope": { + "scope": "{{scopes.[Asset Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Asset Application]}}" + }, + "variables": ["{{variables.[Asset Application]}}"], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "name", + "productModelName", + "statusName", + "expectedReplacementDate", + "owningCustomerName", + "link" + ] + } + } + } + } + }, + { + "i": "240ba067-9e1d-4fce-821b-bec89c6d3b24", + "x": 2, + "y": 5, + "w": 2, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Configuration Items by Type", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[configurationItems]}}", + "name": "configurationItems", + "pluginConfigId": "{{configId}}", + "config": { "activeState": "active" }, + "group": { + "by": [["typeName", "uniqueValues"]], + "aggregate": [{ "type": "count" }] + }, + "sort": { "by": [["count", "desc"]], "top": 15 } + }, + "scope": { + "scope": "{{scopes.[Asset Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Asset Application]}}" + }, + "variables": ["{{variables.[Asset Application]}}"], + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "typeName_uniqueValues", + "yAxisData": ["count"], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Configuration items", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "horizontal", + "displayMode": "actual", + "showTotals": false, + "showValue": true, + "grouping": false, + "range": { "type": "auto" } + } + } + } + } + } + ] + } +} diff --git a/plugins/TeamDynamix/v1/defaultContent/manifest.json b/plugins/TeamDynamix/v1/defaultContent/manifest.json new file mode 100644 index 00000000..3b40208f --- /dev/null +++ b/plugins/TeamDynamix/v1/defaultContent/manifest.json @@ -0,0 +1,7 @@ +{ + "items": [ + { "name": "overviewDashboard", "type": "dashboard" }, + { "name": "serviceDeskDashboard", "type": "dashboard" }, + { "name": "assetsDashboard", "type": "dashboard" } + ] +} diff --git a/plugins/TeamDynamix/v1/defaultContent/overviewDashboard.dash.json b/plugins/TeamDynamix/v1/defaultContent/overviewDashboard.dash.json new file mode 100644 index 00000000..0a7ad1c3 --- /dev/null +++ b/plugins/TeamDynamix/v1/defaultContent/overviewDashboard.dash.json @@ -0,0 +1,288 @@ +{ + "name": "Overview", + "schemaVersion": "1.5", + "timeframe": "last30days", + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "c397a656-c436-4979-8114-a78d0d792c7b", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Locations", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[locations]}}", + "name": "locations", + "pluginConfigId": "{{configId}}", + "group": { "by": [], "aggregate": [{ "type": "count" }] } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Locations" + } + } + } + } + }, + { + "i": "0e2513d4-cabf-4e26-976b-1d9e044132a6", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Tickets", + "description": "Open tickets across all locations", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[locations]}}", + "name": "locations", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [{ "type": "sum", "names": ["ticketsCount"] }] + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "ticketsCount_sum", + "comparisonColumn": "none", + "label": "Tickets" + } + } + } + } + }, + { + "i": "6ffd71ef-1408-4789-95e9-24daa3420fb6", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Assets", + "description": "Assets across all locations", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[locations]}}", + "name": "locations", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [{ "type": "sum", "names": ["assetsCount"] }] + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "assetsCount_sum", + "comparisonColumn": "none", + "label": "Assets" + } + } + } + } + }, + { + "i": "ff1c8589-a7b3-4ea4-af46-133cf5ac4d7e", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Configuration Items", + "description": "Configuration items across all locations", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[locations]}}", + "name": "locations", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [ + { "type": "sum", "names": ["configurationItemsCount"] } + ] + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "configurationItemsCount_sum", + "comparisonColumn": "none", + "label": "Configuration items" + } + } + } + } + }, + { + "i": "70f7052b-71cd-46f5-8f21-d5fe776759f0", + "x": 0, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Busiest Locations", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[locations]}}", + "name": "locations", + "pluginConfigId": "{{configId}}", + "group": { + "by": [["name", "uniqueValues"]], + "aggregate": [{ "type": "sum", "names": ["ticketsCount"] }] + }, + "sort": { "by": [["ticketsCount_sum", "desc"]], "top": 10 } + }, + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "name_uniqueValues", + "yAxisData": ["ticketsCount_sum"], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Tickets", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "horizontal", + "displayMode": "actual", + "showTotals": false, + "showValue": true, + "grouping": false, + "range": { "type": "auto" } + } + } + } + } + }, + { + "i": "eb488b62-5f32-4c46-8b6a-17613980db00", + "x": 2, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Time Logged Per Day", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[timeEntries]}}", + "name": "timeEntries", + "pluginConfigId": "{{configId}}", + "group": { + "by": [["TimeDate", "byDay"]], + "aggregate": [{ "type": "sum", "names": ["Hours"] }] + }, + "sort": { "by": [["TimeDate_byDay", "asc"]] } + }, + "visualisation": { + "type": "data-stream-line-graph", + "config": { + "data-stream-line-graph": { + "xAxisColumn": "TimeDate_byDay", + "yAxisColumn": ["Hours_sum"], + "seriesColumn": "none", + "showLegend": false, + "legendPosition": "bottom", + "yAxisLabel": "Hours", + "showYAxisLabel": true, + "showTrendLine": false + } + } + } + } + }, + { + "i": "cdf51f8b-88f0-42cd-9a55-40178e6c3469", + "x": 0, + "y": 5, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Locations", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[locations]}}", + "name": "locations", + "pluginConfigId": "{{configId}}", + "sort": { "by": [["ticketsCount", "desc"]] } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "name", + "city", + "state", + "country", + "ticketsCount", + "assetsCount", + "configurationItemsCount", + "usersCount", + "roomsCount" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/TeamDynamix/v1/defaultContent/scopes.json b/plugins/TeamDynamix/v1/defaultContent/scopes.json new file mode 100644 index 00000000..e8209d48 --- /dev/null +++ b/plugins/TeamDynamix/v1/defaultContent/scopes.json @@ -0,0 +1,26 @@ +[ + { + "name": "Ticketing Applications", + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Ticketing App"] } + }, + "variable": { + "name": "Ticketing Application", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + }, + { + "name": "Asset Applications", + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Asset App"] } + }, + "variable": { + "name": "Asset Application", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + } +] diff --git a/plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json b/plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json new file mode 100644 index 00000000..4d400a99 --- /dev/null +++ b/plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json @@ -0,0 +1,393 @@ +{ + "name": "Service Desk", + "schemaVersion": "1.5", + "timeframe": "last30days", + "variables": ["{{variables.[Ticketing Application]}}"], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "1a93ed23-ca7b-48de-887c-97c8b4070682", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Tickets", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[tickets]}}", + "name": "tickets", + "pluginConfigId": "{{configId}}", + "config": { "statusClass": ["1", "2", "5", "6"] }, + "group": { "by": [], "aggregate": [{ "type": "count" }] } + }, + "scope": { + "scope": "{{scopes.[Ticketing Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Ticketing Application]}}" + }, + "variables": ["{{variables.[Ticketing Application]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Open" + } + } + } + } + }, + { + "i": "7f8eac15-622c-4816-b114-4d83afc1a3f4", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "SLA Breaches", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[tickets]}}", + "name": "tickets", + "pluginConfigId": "{{configId}}", + "config": { "statusClass": ["1", "2", "5", "6"] }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "isSlaViolated", + "operation": "equals", + "value": "true" + } + ] + }, + "group": { "by": [], "aggregate": [{ "type": "count" }] } + }, + "scope": { + "scope": "{{scopes.[Ticketing Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Ticketing Application]}}" + }, + "variables": ["{{variables.[Ticketing Application]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "SLA breached" + } + } + } + } + }, + { + "i": "2349d425-f193-421b-8b5e-0a0417bd7ab8", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "On Hold", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[tickets]}}", + "name": "tickets", + "pluginConfigId": "{{configId}}", + "config": { "statusClass": ["5"] }, + "group": { "by": [], "aggregate": [{ "type": "count" }] } + }, + "scope": { + "scope": "{{scopes.[Ticketing Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Ticketing Application]}}" + }, + "variables": ["{{variables.[Ticketing Application]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "On hold" + } + } + } + } + }, + { + "i": "5160f23a-da1c-4523-ac49-bfa7919fa6af", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Created", + "description": "Tickets created in the dashboard timeframe", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[tickets]}}", + "name": "tickets", + "pluginConfigId": "{{configId}}", + "config": { "dateField": "Created" }, + "group": { "by": [], "aggregate": [{ "type": "count" }] } + }, + "scope": { + "scope": "{{scopes.[Ticketing Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Ticketing Application]}}" + }, + "variables": ["{{variables.[Ticketing Application]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Created" + } + } + } + } + }, + { + "i": "240f7252-44c7-4563-9446-7ce78d77582b", + "x": 0, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Tickets by Priority", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[tickets]}}", + "name": "tickets", + "pluginConfigId": "{{configId}}", + "config": { "statusClass": ["1", "2", "5", "6"] }, + "group": { + "by": [["priorityName", "uniqueValues"]], + "aggregate": [{ "type": "count" }] + }, + "sort": { "by": [["count", "desc"]] } + }, + "scope": { + "scope": "{{scopes.[Ticketing Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Ticketing Application]}}" + }, + "variables": ["{{variables.[Ticketing Application]}}"], + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "priorityName_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "963e11ca-5351-44b4-87f1-167c0b67f1a3", + "x": 2, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Tickets by Assigned Group", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[tickets]}}", + "name": "tickets", + "pluginConfigId": "{{configId}}", + "config": { "statusClass": ["1", "2", "5", "6"] }, + "group": { + "by": [["responsibleGroupName", "uniqueValues"]], + "aggregate": [{ "type": "count" }] + }, + "sort": { "by": [["count", "desc"]], "top": 10 } + }, + "scope": { + "scope": "{{scopes.[Ticketing Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Ticketing Application]}}" + }, + "variables": ["{{variables.[Ticketing Application]}}"], + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "responsibleGroupName_uniqueValues", + "yAxisData": ["count"], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Tickets", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "horizontal", + "displayMode": "actual", + "showTotals": false, + "showValue": true, + "grouping": false, + "range": { "type": "auto" } + } + } + } + } + }, + { + "i": "7e5dfe99-2b6c-4d13-a1e5-0b7dcb12dd9a", + "x": 0, + "y": 5, + "w": 4, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Tickets Created Over Time", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[tickets]}}", + "name": "tickets", + "pluginConfigId": "{{configId}}", + "config": { "dateField": "Created" }, + "group": { + "by": [["createdDate", "byDay"]], + "aggregate": [{ "type": "count" }] + }, + "sort": { "by": [["createdDate_byDay", "asc"]] } + }, + "scope": { + "scope": "{{scopes.[Ticketing Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Ticketing Application]}}" + }, + "variables": ["{{variables.[Ticketing Application]}}"], + "visualisation": { + "type": "data-stream-line-graph", + "config": { + "data-stream-line-graph": { + "xAxisColumn": "createdDate_byDay", + "yAxisColumn": ["count"], + "seriesColumn": "none", + "showLegend": false, + "legendPosition": "bottom", + "yAxisLabel": "Tickets created", + "showYAxisLabel": true, + "showTrendLine": false + } + } + } + } + }, + { + "i": "3e7fc79b-be7f-400f-b628-442c440462e5", + "x": 0, + "y": 8, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Tickets Breaching SLA", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[tickets]}}", + "name": "tickets", + "pluginConfigId": "{{configId}}", + "config": { "statusClass": ["1", "2", "5", "6"] }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "isSlaViolated", + "operation": "equals", + "value": "true" + } + ] + }, + "sort": { "by": [["resolveByDate", "asc"]] } + }, + "scope": { + "scope": "{{scopes.[Ticketing Applications]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Ticketing Application]}}" + }, + "variables": ["{{variables.[Ticketing Application]}}"], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "health", + "title", + "statusName", + "priorityName", + "responsibleFullName", + "responsibleGroupName", + "resolveByDate", + "daysOld", + "link" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/TeamDynamix/v1/docs/README.md b/plugins/TeamDynamix/v1/docs/README.md new file mode 100644 index 00000000..2152f96d --- /dev/null +++ b/plugins/TeamDynamix/v1/docs/README.md @@ -0,0 +1,80 @@ +Monitor your [TeamDynamix](https://www.teamdynamix.com/) service desk, asset estate and CMDB in SquaredUp — ticket volumes, SLA breaches, assignment backlogs, asset inventory and configuration items — via the TeamDynamix Web API. + +> ⚠️ Requires an administrative service account (a **BEID** and **Web Services Key** pair). Tokens obtained by signing in as a normal user will not work, and reading these values in TDAdmin needs the **Add BE Administrators** permission — so a TeamDynamix administrator may need to supply them. + +## Setup + +You will need your organization's **BEID** and **Web Services Key**. + +1. Sign in to **TDAdmin** on your TeamDynamix site (the **Admin** link in TDNext, or `https://yourorg.teamdynamix.com/TDAdmin`). +2. Open the **Organization detail** page. The **BEID** and **Web Services Key** are both shown there. If you can't see them, your account is missing the **Add BE Administrators** permission. +3. Confirm the admin service account is set to **Active** — an inactive account is rejected even when the key is correct. +4. Work out your Web API address: take the address you normally sign in at and replace the trailing application name with `TDWebApi`. If you sign in at `https://yourorg.teamdynamix.com/TDNext`, the Web API address is `https://yourorg.teamdynamix.com/TDWebApi`. For a sandbox environment, use the sandbox Web API path instead (usually `/SBTDWebApi`). +5. Paste all three values into the fields below and save. + +## Configuration fields + +| Field | What it is | Where to find it | Required | +| ----- | ---------- | ---------------- | -------- | +| **Web API address** | The base address of the TeamDynamix Web API, ending in `/TDWebApi`. | Your TeamDynamix site address with the application name replaced by `TDWebApi`. | Yes | +| **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 | +| **Ignore certificate errors** | Accepts self-signed certificates. Only needed for a self-hosted instance. | Advanced options. | 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. + +## What this plugin monitors + +- **Service desk performance** — open backlog, tickets created and closed over time, SLA breaches, on-hold work, and how tickets break down by priority, type, service, department and assigned group. +- **Asset inventory** — assets by status, manufacturer and model, purchase value, ownership, and assets approaching their expected replacement date. +- **CMDB** — configuration items by type, owner and maintenance window. +- **Service delivery** — service catalog entries, knowledge base article coverage and review dates, and project health. +- **Time and effort** — time logged against tickets and projects, billable and non-billable. +- **Locations** — per-site ticket, asset, configuration item and user counts, which TeamDynamix rolls up for you. + +The out-of-the-box dashboards are an organization-wide **Overview**, a **Service Desk** perspective for each ticketing application, and an **Assets and CMDB** perspective for each asset application. + +## Data streams + +- **Tickets** — tickets in a ticketing application, one row per ticket, with status, priority, assignment, SLA and custom-attribute columns. 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. +- **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. +- **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. +- **Accounts / Departments**, **Groups**, **Applications**, **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. + +## What gets indexed + +| Object type | API source | Represents | +| ----------- | ---------- | ---------- | +| **TeamDynamix Ticketing App** | `GET /api/applications` (type `Ticketing`) | A ticketing application, e.g. your service desk. Scope ticket tiles to one of these. | +| **TeamDynamix Asset App** | `GET /api/applications` (type `Assets/CI`) | An asset and CMDB application. Scope asset and configuration item tiles to one of these. | +| **TeamDynamix Application** | `GET /api/applications` (all other types) | 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 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. | + +**Relationships:** assets and configuration items are imported per application, so each one carries the `appId` of the asset application it came from. + +Because every ticket, asset and configuration item endpoint in TeamDynamix is scoped to an application, applications are imported first and everything else hangs off them. That is what lets you pick **Service Desk** by name in a tile rather than having to know its numeric application ID. + +Tickets are deliberately **not** imported as objects. They are high-volume transactional records that change constantly, so they are queried live through the **Tickets** data stream instead. People are not imported either. + +## 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 at all.** Unlike tickets and assets, the CMDB search endpoint accepts no maximum-results parameter, so a very large CMDB may exceed the response size limit. If the Configuration Items tile fails to load, filter to **Active** only. +- **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 paces its own requests to stay inside these limits, but a dashboard with many TeamDynamix tiles refreshing together can still be throttled. A throttled tile reports the time the limit resets and recovers on its own. +- **Custom fields appear as `attr.` columns.** Ticket, asset, configuration item, project and service custom attributes are flattened into columns prefixed `attr.` — for example `attr.Cost Centre`. They are hidden by default; add them from the tile's column picker. +- **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 path is unverified.** The sandbox Web API path is assumed to be `/SBTDWebApi`. If a sandbox connection fails with a "no Web API found" error, check the correct path 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. diff --git a/plugins/TeamDynamix/v1/icon.png b/plugins/TeamDynamix/v1/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3e3ca81acc38c10ef8f141125e3cd2e1111b8efd GIT binary patch literal 66584 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⋙K7idWPJ_=L|*L?;d1GG_KU_0AT1 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 literal 0 HcmV?d00001 diff --git a/plugins/TeamDynamix/v1/icon.svg b/plugins/TeamDynamix/v1/icon.svg new file mode 100644 index 00000000..5207fb0a --- /dev/null +++ b/plugins/TeamDynamix/v1/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/TeamDynamix/v1/indexDefinitions/default.json b/plugins/TeamDynamix/v1/indexDefinitions/default.json new file mode 100644 index 00000000..7f760702 --- /dev/null +++ b/plugins/TeamDynamix/v1/indexDefinitions/default.json @@ -0,0 +1,128 @@ +{ + "steps": [ + { + "name": "applications", + "dataStream": { "name": "applications" }, + "timeframe": "none", + "objectMapping": { + "id": "appId", + "name": "name", + "type": "sourceType", + "properties": ["appType", "appClass", "description"] + } + }, + { + "name": "accounts", + "dataStream": { "name": "accounts" }, + "timeframe": "none", + "objectMapping": { + "id": "accountId", + "name": "name", + "type": { "value": "TeamDynamix Account" }, + "properties": [ + "code", + "managerName", + "parentName", + "industryName", + "city", + "stateName", + "country" + ] + } + }, + { + "name": "locations", + "dataStream": { "name": "locations" }, + "timeframe": "none", + "objectMapping": { + "id": "locationId", + "name": "name", + "type": { "value": "TeamDynamix Location" }, + "properties": [ + "city", + "state", + "country", + "address", + "latitude", + "longitude", + "ticketsCount", + "assetsCount", + "configurationItemsCount", + "usersCount" + ] + } + }, + { + "name": "groups", + "dataStream": { "name": "groups" }, + "timeframe": "none", + "objectMapping": { + "id": "ID", + "name": "Name", + "type": { "value": "TeamDynamix Group" }, + "properties": [ + { "description": "Description" }, + { "externalId": "ExternalID" } + ] + } + }, + { + "name": "assets", + "dataStream": { "name": "assets" }, + "scope": { + "query": "g.V().has(\"sourceType\", \"TeamDynamix Asset App\")" + }, + "timeframe": "none", + "objectMapping": { + "id": "sourceId", + "name": "name", + "type": { "value": "TeamDynamix Asset" }, + "properties": [ + "assetId", + "appId", + "statusName", + "tag", + "serialNumber", + "manufacturerName", + "productModelName", + "locationName", + "owningCustomerName", + "owningDepartmentName", + "purchaseCost", + "acquisitionDate", + "expectedReplacementDate", + "link" + ] + }, + "optional": true, + "dependsOn": ["applications"] + }, + { + "name": "configurationItems", + "dataStream": { "name": "configurationItems" }, + "scope": { + "query": "g.V().has(\"sourceType\", \"TeamDynamix Asset App\")" + }, + "timeframe": "none", + "objectMapping": { + "id": "sourceId", + "name": "name", + "type": { "value": "TeamDynamix Configuration Item" }, + "properties": [ + "configurationItemId", + "appId", + "typeName", + "isActive", + "ownerFullName", + "owningDepartmentName", + "owningGroupName", + "locationName", + "maintenanceScheduleName", + "link" + ] + }, + "optional": true, + "dependsOn": ["applications"] + } + ] +} diff --git a/plugins/TeamDynamix/v1/metadata.json b/plugins/TeamDynamix/v1/metadata.json new file mode 100644 index 00000000..07e64a46 --- /dev/null +++ b/plugins/TeamDynamix/v1/metadata.json @@ -0,0 +1,79 @@ +{ + "name": "teamdynamix", + "displayName": "TeamDynamix", + "version": "1.0.0", + "author": { + "name": "SquaredUp Labs", + "type": "labs" + }, + "description": "Monitor TeamDynamix tickets, assets, configuration items and service delivery in SquaredUp.", + "category": "Service Management", + "type": "hybrid", + "schemaVersion": "2.1", + "importNotSupported": false, + "restrictedToPlatforms": [], + "keywords": [ + "teamdynamix", + "tdx", + "itsm", + "service desk", + "help desk", + "tickets", + "incidents", + "assets", + "cmdb", + "itam" + ], + "objectTypes": [ + "TeamDynamix Ticketing App", + "TeamDynamix Asset App", + "TeamDynamix Application", + "TeamDynamix Account", + "TeamDynamix Location", + "TeamDynamix Group", + "TeamDynamix Asset", + "TeamDynamix Configuration Item" + ], + "links": [ + { + "category": "documentation", + "url": "https://github.com/squaredup/plugins/blob/main/plugins/TeamDynamix/v1/docs/README.md", + "label": "Help adding this plugin" + }, + { + "category": "source", + "url": "https://github.com/squaredup/plugins/tree/main/plugins/TeamDynamix/v1", + "label": "Repository" + } + ], + "base": { + "plugin": "WebAPI", + "majorVersion": "1", + "config": { + "baseUrl": "{{apiBaseUrl}}", + "authMode": "none", + "headers": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "queryArgs": [], + "scriptingVariables": [ + { + "key": "beid", + "value": "{{beid}}" + }, + { + "key": "webServicesKey", + "value": "{{webServicesKey}}" + }, + { + "key": "apiBaseUrl", + "value": "{{apiBaseUrl}}" + } + ], + "preRequestScript": "preRequest.js" + } + } +} diff --git a/plugins/TeamDynamix/v1/preRequest.js b/plugins/TeamDynamix/v1/preRequest.js new file mode 100644 index 00000000..c13827b6 --- /dev/null +++ b/plugins/TeamDynamix/v1/preRequest.js @@ -0,0 +1,116 @@ +// TeamDynamix authenticates with a JWT obtained by POSTing a BEID / Web Services Key +// pair to /api/auth/loginadmin. No built-in authMode covers that exchange, so it happens +// here and the resulting token is cached in `state`. +// +// This script also does two things that aren't strictly authentication: +// +// 1. Request spacing. TeamDynamix rate limits are enforced per IP address, and the +// tightest documented limit is 30 requests/60s on ticket and time-entry search +// (most other endpoints allow 60/60s). A single import that walks several +// applications can breach that on its own, so we hold a minimum gap between +// consecutive requests rather than relying on the API to forgive us. +// +// 2. Path normalisation. The user pastes a full Web API address, which may or may not +// have a trailing slash, so collapse any doubled separator before sending. + +url.pathname = url.pathname.replace(/\/{2,}/g, "/"); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// The rate-limiting guide asks clients to wait until X-RateLimit-Reset and to enforce a +// 5 second floor on top, to absorb clock skew and time-zone differences. +const RATE_LIMIT_FLOOR_MS = 5000; + +// --- request spacing ------------------------------------------------------------- + +// Ticket and time-entry search allow 30 req/60s (one per 2s); everything else 60 req/60s. +const isTightlyLimited = /\/(tickets|time)\/search\/?$/i.test(url.pathname); +const minGapMs = isTightlyLimited ? 2100 : 1100; + +const sinceLastRequest = Date.now() - (state?.lastRequestAt ?? 0); +if (sinceLastRequest >= 0 && sinceLastRequest < minGapMs) { + await sleep(minGapMs - sinceLastRequest); +} + +// --- token ----------------------------------------------------------------------- + +// Read the lifetime out of the JWT's own `exp` claim rather than assuming the documented +// 24 hours: an instance configured with a shorter lifetime then still refreshes in time. +// Returns 0 if the token can't be decoded, which falls back to a conservative 12 hours. +const expiryFromJwt = (token) => { + try { + const segment = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"); + const payload = JSON.parse(atob(segment)); + return typeof payload.exp === "number" ? payload.exp * 1000 : 0; + } catch { + return 0; + } +}; + +if (typeof state?.token !== "string" || (state?.expiryTime ?? 0) <= Date.now()) { + const authUrl = `${String(secrets.apiBaseUrl || "") + .trim() + .replace(/\/+$/, "")}/api/auth/loginadmin`; + + const request = { + method: "post", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + BEID: String(secrets.beid || "").trim(), + WebServicesKey: String(secrets.webServicesKey || "").trim() + }) + }; + + let response = await fetch(authUrl, request); + + // The auth endpoint is itself rate limited (60/60s per IP). Retry once, honouring the + // reset time the API reports, capped so a bad clock can't stall the request forever. + if (response.status === 429) { + const resetAt = Date.parse(response.headers.get("X-RateLimit-Reset") || ""); + const waitMs = Number.isNaN(resetAt) + ? RATE_LIMIT_FLOOR_MS + : Math.max(RATE_LIMIT_FLOOR_MS, resetAt - Date.now() + RATE_LIMIT_FLOOR_MS); + await sleep(Math.min(waitMs, 65000)); + response = await fetch(authUrl, request); + } + + if (!response.ok) { + if (response.status === 400 || response.status === 401 || response.status === 403) { + api.report.error( + `TeamDynamix rejected the BEID and Web Services Key (HTTP ${response.status}). ` + + "Check both values on the organization detail page in TDAdmin, and that the " + + "admin service account is set to Active." + ); + } else if (response.status === 404) { + api.report.error( + `No TeamDynamix Web API found at ${authUrl} (HTTP 404). Check the Web API address ` + + "ends in /TDWebApi (or /SBTDWebApi for a sandbox)." + ); + } else { + api.report.error( + `Could not obtain a TeamDynamix token: HTTP ${response.status} ${response.statusText}` + ); + } + return; + } + + // loginadmin returns the bare token rather than an object wrapping it. Instances have + // been seen to send it both as a JSON-quoted string and as raw text, so read it as + // text and strip any surrounding quotes instead of assuming a JSON body. + const token = (await response.text()).trim().replace(/^"|"$/g, ""); + if (!token) { + api.report.error("TeamDynamix returned an empty authentication token."); + return; + } + + const expiresAt = expiryFromJwt(token); + state = { + ...state, + token, + // Refresh 5 minutes early so a long-running import can't expire mid-flight. + expiryTime: expiresAt ? expiresAt - 300000 : Date.now() + 12 * 60 * 60 * 1000 + }; +} + +headers["Authorization"] = `Bearer ${state.token}`; +state = { ...state, lastRequestAt: Date.now() }; diff --git a/plugins/TeamDynamix/v1/ui.json b/plugins/TeamDynamix/v1/ui.json new file mode 100644 index 00000000..58476cea --- /dev/null +++ b/plugins/TeamDynamix/v1/ui.json @@ -0,0 +1,70 @@ +[ + { + "type": "markdown", + "name": "intro", + "content": "Connect SquaredUp to your TeamDynamix instance.\n\nYou need a **BEID** and a **Web Services Key**. Both are on the organization detail page in **TDAdmin**, and reading them requires the *Add BE Administrators* permission — so a TeamDynamix administrator may need to supply them.\n\nThis plugin only ever reads from TeamDynamix. It never creates, updates or deletes anything." + }, + { + "type": "text", + "name": "apiBaseUrl", + "label": "Web API address", + "placeholder": "https://yourorg.teamdynamix.com/TDWebApi", + "help": "The address of the TeamDynamix Web API, ending in `/TDWebApi`. This is your normal TeamDynamix site address with `/TDWebApi` on the end — for example, if you sign in at `https://yourorg.teamdynamix.com/TDNext`, enter `https://yourorg.teamdynamix.com/TDWebApi`.\n\nFor a **sandbox** environment, use the sandbox Web API path instead (usually `/SBTDWebApi`).", + "validation": { + "required": true, + "pattern": { + "value": "^https://[^\\s/]+(/[^\\s]*)?$", + "message": "Enter the full https address of your TeamDynamix Web API, e.g. https://yourorg.teamdynamix.com/TDWebApi" + } + } + }, + { + "type": "text", + "name": "beid", + "label": "BEID", + "placeholder": "00000000-0000-0000-0000-000000000000", + "help": "Found in **TDAdmin > Organization detail**. This is a GUID identifying your TeamDynamix organization.", + "validation": { + "required": true, + "pattern": { + "value": "^\\s*\\{?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\}?\\s*$", + "message": "The BEID should be a GUID, e.g. 1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + } + } + }, + { + "type": "password", + "name": "webServicesKey", + "label": "Web Services Key", + "help": "Found alongside the BEID in **TDAdmin > Organization detail**. The admin service account must be set to **Active** for this key to work.", + "validation": { + "required": true + } + }, + { + "type": "fieldGroup", + "name": "advanced", + "label": "Advanced options", + "displayAs": "fieldGroupToggle", + "fields": [ + { + "type": "number", + "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.", + "validation": { + "min": 1, + "max": 10000, + "valueAsNumber": true + } + }, + { + "type": "checkbox", + "name": "ignoreCertificateErrors", + "label": "Ignore certificate errors", + "help": "Enable when connecting to an instance with a self-signed certificate." + } + ] + } +] From eecaab1b6988c02f1c7a681e18bc498dcc9fc3f7 Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 09:32:46 -0400 Subject: [PATCH 02/16] Remove defaultTimeframe from TeamDynamix dataStreams Remove the defaultTimeframe property from tickets and timeEntries data stream configurations. The timeframes feature is still supported, allowing consumers to handle timeframe defaults independently. --- plugins/TeamDynamix/v1/dataStreams/tickets.json | 3 +-- plugins/TeamDynamix/v1/dataStreams/timeEntries.json | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/TeamDynamix/v1/dataStreams/tickets.json b/plugins/TeamDynamix/v1/dataStreams/tickets.json index ac851383..d3c30098 100644 --- a/plugins/TeamDynamix/v1/dataStreams/tickets.json +++ b/plugins/TeamDynamix/v1/dataStreams/tickets.json @@ -124,6 +124,5 @@ "sort": { "by": [["createdDate", "desc"]] } }, "timeframes": true, - "supportsNoneTimeframe": true, - "defaultTimeframe": "last30days" + "supportsNoneTimeframe": true } diff --git a/plugins/TeamDynamix/v1/dataStreams/timeEntries.json b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json index a2119b8c..6918597e 100644 --- a/plugins/TeamDynamix/v1/dataStreams/timeEntries.json +++ b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json @@ -64,6 +64,5 @@ "sort": { "by": [["TimeDate", "desc"]] } }, "timeframes": true, - "supportsNoneTimeframe": true, - "defaultTimeframe": "last30days" + "supportsNoneTimeframe": true } From 3e8bcf4cbb3fe74c4deca668aa8bae8658b935b8 Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 09:43:13 -0400 Subject: [PATCH 03/16] Cap configuration item results; update docs Enforce a client-side cap for the Configuration Items data stream and clarify the limitation in docs. The script now collects rows, sorts by modifiedDate (newest first) and slices to the plugin's "Maximum records per request" (context.dataSources[0].maxRecords, default 2000) to avoid exceeding the ~6MB stream response limit because the TeamDynamix CMDB search has no maxResults or usable paging. README updated to explain the lack of paging, the plugin's workaround, and guidance to filter to Active or lower the cap if needed. --- .../dataStreams/scripts/configurationItems.js | 22 ++++++++++++++----- plugins/TeamDynamix/v1/docs/README.md | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js index f7e5a7af..b6a345c5 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js @@ -1,9 +1,12 @@ // Serves double duty: this stream backs both the configurationItems import step and CI tiles. // -// Projection matters more here than anywhere else. ConfigurationItemSearch has no MaxResults -// field at all — unlike ticket and asset search there is no way to cap the result set from the -// request side — so the only defence against overrunning the ~6MB stream response cap on a -// large CMDB is to keep each row small. +// 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". const BACKING_ITEM_TYPE_NAMES = { @@ -27,7 +30,7 @@ const webBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") .replace(/\/+$/, "") .replace(/\/(SB)?TDWebApi$/i, ""); -result = (data || []).map((item) => ({ +const rows = (data || []).map((item) => ({ // As with assets, CI IDs are only unique within an application. sourceId: `${item.AppID}-${item.ID}`, configurationItemId: String(item.ID), @@ -54,3 +57,12 @@ result = (data || []).map((item) => ({ sourceType: "TeamDynamix Configuration Item", ...customAttributeColumns(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. +const maxRecords = Number(context?.dataSources?.[0]?.maxRecords) || 2000; +result = rows + .sort((a, b) => (b.modifiedDate || "").localeCompare(a.modifiedDate || "")) + .slice(0, maxRecords); diff --git a/plugins/TeamDynamix/v1/docs/README.md b/plugins/TeamDynamix/v1/docs/README.md index 2152f96d..d0338c3d 100644 --- a/plugins/TeamDynamix/v1/docs/README.md +++ b/plugins/TeamDynamix/v1/docs/README.md @@ -70,7 +70,7 @@ 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 at all.** Unlike tickets and assets, the CMDB search endpoint accepts no maximum-results parameter, so a very large CMDB may exceed the response size limit. If the Configuration Items tile fails to load, filter to **Active** only. +- **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 paces its own requests to stay inside these limits, but a dashboard with many TeamDynamix tiles refreshing together can still be throttled. A throttled tile reports the time the limit resets and recovers on its own. - **Custom fields appear as `attr.` columns.** Ticket, asset, configuration item, project and service custom attributes are flattened into columns prefixed `attr.` — for example `attr.Cost Centre`. They are hidden by default; add them from the tile's column picker. - **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. From e6cf344b8cd44262e3a70142035d6f4c6d4f3f8e Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 09:57:05 -0400 Subject: [PATCH 04/16] Remove custom-attribute columns from TeamDynamix Remove dynamic `attr.*` custom-attribute columns from TeamDynamix data streams and metadata. The scripts no longer build or spread customAttributeColumns and the JSON metadata patterns matching `^attr\..*` were removed because TeamDynamix search endpoints do not return Attributes/Attachments (only individual record loads do). README updated to document that custom attributes are not available without per-record detail calls. Affected files: assets, tickets, services, projects, configurationItems (scripts + JSON) and docs/README.md. --- .../TeamDynamix/v1/dataStreams/assets.json | 3 +-- .../v1/dataStreams/configurationItems.json | 3 +-- .../TeamDynamix/v1/dataStreams/projects.json | 3 +-- .../v1/dataStreams/scripts/assets.js | 21 ++++++------------ .../dataStreams/scripts/configurationItems.js | 15 ++++--------- .../v1/dataStreams/scripts/projects.js | 22 +++++++------------ .../v1/dataStreams/scripts/services.js | 17 +++++--------- .../v1/dataStreams/scripts/tickets.js | 18 ++++----------- .../TeamDynamix/v1/dataStreams/services.json | 3 +-- .../TeamDynamix/v1/dataStreams/tickets.json | 3 +-- plugins/TeamDynamix/v1/docs/README.md | 4 ++-- 11 files changed, 36 insertions(+), 76 deletions(-) diff --git a/plugins/TeamDynamix/v1/dataStreams/assets.json b/plugins/TeamDynamix/v1/dataStreams/assets.json index 241df9d8..c9610e3e 100644 --- a/plugins/TeamDynamix/v1/dataStreams/assets.json +++ b/plugins/TeamDynamix/v1/dataStreams/assets.json @@ -66,8 +66,7 @@ { "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 }, - { "pattern": "^attr\\..*" } + { "name": "sourceType", "displayName": "Object Type", "shape": "string", "visible": false } ], "defaultShaping": { "sort": { "by": [["name", "asc"]] } diff --git a/plugins/TeamDynamix/v1/dataStreams/configurationItems.json b/plugins/TeamDynamix/v1/dataStreams/configurationItems.json index 187ec369..a0c5908c 100644 --- a/plugins/TeamDynamix/v1/dataStreams/configurationItems.json +++ b/plugins/TeamDynamix/v1/dataStreams/configurationItems.json @@ -52,8 +52,7 @@ { "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 }, - { "pattern": "^attr\\..*" } + { "name": "sourceType", "displayName": "Object Type", "shape": "string", "visible": false } ], "defaultShaping": { "sort": { "by": [["name", "asc"]] } diff --git a/plugins/TeamDynamix/v1/dataStreams/projects.json b/plugins/TeamDynamix/v1/dataStreams/projects.json index cc7d70eb..723dcabd 100644 --- a/plugins/TeamDynamix/v1/dataStreams/projects.json +++ b/plugins/TeamDynamix/v1/dataStreams/projects.json @@ -82,8 +82,7 @@ { "name": "isActive", "displayName": "Active", "shape": "boolean", "visible": false }, { "name": "statusLastUpdated", "displayName": "Status Updated", "shape": "date", "visible": false }, { "name": "createdDate", "displayName": "Created", "shape": "date", "visible": false }, - { "name": "appId", "displayName": "App ID", "shape": "string", "visible": false }, - { "pattern": "^attr\\..*" } + { "name": "appId", "displayName": "App ID", "shape": "string", "visible": false } ], "defaultShaping": { "sort": { "by": [["endDate", "asc"]] } diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js b/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js index 8b41276a..2f78d3cd 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js @@ -1,17 +1,11 @@ // Serves double duty: this stream backs both the assets import step and asset tiles. // -// Projected rather than passed through for the same reason as tickets — an Asset carries -// Attachments and Attributes arrays, and the asset search is unpaged, so a large estate in -// one response can overrun the ~6MB stream response cap. -const customAttributeColumns = (asset) => { - const columns = {}; - for (const attribute of asset.Attributes || []) { - if (!attribute || !attribute.Name) continue; - columns[`attr.${attribute.Name}`] = - attribute.ValueText ?? (attribute.Value === "" ? null : attribute.Value ?? null); - } - return columns; -}; +// Projected rather than passed through for the same reason as tickets — the asset search is +// unpaged, so a large estate in one response can overrun the ~6MB stream response cap. +// +// No custom-attribute columns: the API's own description for this endpoint says Attachments +// and Attributes are never included in search results — only loading an asset individually +// returns them, which this stream doesn't do. const webBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") .trim() @@ -47,6 +41,5 @@ result = (data || []).map((asset) => ({ externalId: asset.ExternalID || "", configurationItemId: asset.ConfigurationItemID ? String(asset.ConfigurationItemID) : "", appId: String(asset.AppID ?? ""), - sourceType: "TeamDynamix Asset", - ...customAttributeColumns(asset) + sourceType: "TeamDynamix Asset" })); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js index b6a345c5..da56deb7 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js @@ -15,15 +15,9 @@ const BACKING_ITEM_TYPE_NAMES = { 63: "Configuration Item" }; -const customAttributeColumns = (item) => { - const columns = {}; - for (const attribute of item.Attributes || []) { - if (!attribute || !attribute.Name) continue; - columns[`attr.${attribute.Name}`] = - attribute.ValueText ?? (attribute.Value === "" ? null : attribute.Value ?? null); - } - return columns; -}; +// 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. const webBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") .trim() @@ -54,8 +48,7 @@ const rows = (data || []).map((item) => ({ createdDate: item.CreatedDateUtc || null, modifiedDate: item.ModifiedDateUtc || null, appId: String(item.AppID ?? ""), - sourceType: "TeamDynamix Configuration Item", - ...customAttributeColumns(item) + sourceType: "TeamDynamix Configuration Item" })); // The API gave us no way to ask for fewer rows, so the cap is enforced here instead, using diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js b/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js index a3187811..c135ce75 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js @@ -1,17 +1,12 @@ // A Project has 120 fields, most of them baseline/initial variants of the same value // (EndDateBaseline, EndDateInitial, EstimatedHoursInitial, TimeBudgetInitial ...) plus -// AlternateManagers, CustomColumns, NonWorkingDays and Attributes arrays. Projected to the -// fields that answer "is this project on track", which also keeps the unpaged response -// under the ~6MB stream response cap. -const customAttributeColumns = (project) => { - const columns = {}; - for (const attribute of project.Attributes || []) { - if (!attribute || !attribute.Name) continue; - columns[`attr.${attribute.Name}`] = - attribute.ValueText ?? (attribute.Value === "" ? null : attribute.Value ?? null); - } - return columns; -}; +// AlternateManagers, CustomColumns and NonWorkingDays arrays. Projected to the fields that +// answer "is this project on track", which also keeps the unpaged response under the ~6MB +// stream response cap. +// +// No custom-attribute columns: the API's own description for this endpoint says Attributes, +// CustomColumns and NonWorkingDays are never included in search results — only loading a +// project individually returns them, which this stream doesn't do. result = (data || []).map((project) => ({ projectId: String(project.ID), @@ -37,6 +32,5 @@ result = (data || []).map((project) => ({ isActive: Boolean(project.IsActive), statusLastUpdated: project.StatusModifiedDate || null, createdDate: project.CreatedDate || null, - appId: String(project.AppID ?? ""), - ...customAttributeColumns(project) + appId: String(project.AppID ?? "") })); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/services.js b/plugins/TeamDynamix/v1/dataStreams/scripts/services.js index 57769c02..296ab944 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/services.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/services.js @@ -1,15 +1,11 @@ // LongDescription, RequestText and SubmitText are rich-text blocks, and ServiceOfferings is a // nested array of full offering records — all dropped here. ServiceOfferingsCount already // carries the only part of that array a dashboard needs. -const customAttributeColumns = (service) => { - const columns = {}; - for (const attribute of service.Attributes || []) { - if (!attribute || !attribute.Name) continue; - columns[`attr.${attribute.Name}`] = - attribute.ValueText ?? (attribute.Value === "" ? null : attribute.Value ?? null); - } - return columns; -}; +// +// 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 service individually +// returns them, which this stream doesn't do. Attempting to project them here would only ever +// yield empty columns. result = (data || []).map((service) => ({ serviceId: String(service.ID), @@ -27,6 +23,5 @@ result = (data || []).map((service) => ({ requestTypeName: service.RequestTypeName || "", createdDate: service.CreatedDateUtc || null, modifiedDate: service.ModifiedDateUtc || null, - appId: String(service.AppID ?? ""), - ...customAttributeColumns(service) + appId: String(service.AppID ?? "") })); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js index 5c2899fd..3105cce2 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js @@ -15,18 +15,9 @@ const STATUS_CLASS_NAMES = { 6: "Requested" }; -// Custom attributes arrive as a name/value array, so turning them into columns needs a -// script — there's no declarative equivalent. Prefixed with "attr." and picked up by the -// metadata pattern, which keeps a tenant's custom fields usable without hardcoding them. -const customAttributeColumns = (ticket) => { - const columns = {}; - for (const attribute of ticket.Attributes || []) { - if (!attribute || !attribute.Name) continue; - columns[`attr.${attribute.Name}`] = - attribute.ValueText ?? (attribute.Value === "" ? null : attribute.Value ?? null); - } - return columns; -}; +// 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. // A ticket is far more useful when you can click through to it. The API's own Uri field // points at the API resource, not the web UI, so derive a TDNext deep link from the @@ -90,6 +81,5 @@ result = (data || []).map((ticket) => ({ estimatedMinutes: typeof ticket.EstimatedMinutes === "number" ? ticket.EstimatedMinutes : null, formName: ticket.FormName || "", refCode: ticket.RefCode || "", - appId: String(ticket.AppID ?? ""), - ...customAttributeColumns(ticket) + appId: String(ticket.AppID ?? "") })); diff --git a/plugins/TeamDynamix/v1/dataStreams/services.json b/plugins/TeamDynamix/v1/dataStreams/services.json index 0cb292e2..93a55031 100644 --- a/plugins/TeamDynamix/v1/dataStreams/services.json +++ b/plugins/TeamDynamix/v1/dataStreams/services.json @@ -48,8 +48,7 @@ { "name": "requestTypeName", "displayName": "Request Type", "shape": "string", "visible": false }, { "name": "createdDate", "displayName": "Created", "shape": "date", "visible": false }, { "name": "modifiedDate", "displayName": "Modified", "shape": "date", "visible": false }, - { "name": "appId", "displayName": "App ID", "shape": "string", "visible": false }, - { "pattern": "^attr\\..*" } + { "name": "appId", "displayName": "App ID", "shape": "string", "visible": false } ], "defaultShaping": { "sort": { "by": [["name", "asc"]] } diff --git a/plugins/TeamDynamix/v1/dataStreams/tickets.json b/plugins/TeamDynamix/v1/dataStreams/tickets.json index d3c30098..b16d2ee5 100644 --- a/plugins/TeamDynamix/v1/dataStreams/tickets.json +++ b/plugins/TeamDynamix/v1/dataStreams/tickets.json @@ -117,8 +117,7 @@ { "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 }, - { "pattern": "^attr\\..*" } + { "name": "appId", "displayName": "App ID", "shape": "string", "visible": false } ], "defaultShaping": { "sort": { "by": [["createdDate", "desc"]] } diff --git a/plugins/TeamDynamix/v1/docs/README.md b/plugins/TeamDynamix/v1/docs/README.md index d0338c3d..35d1c5b5 100644 --- a/plugins/TeamDynamix/v1/docs/README.md +++ b/plugins/TeamDynamix/v1/docs/README.md @@ -37,7 +37,7 @@ 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, SLA and custom-attribute columns. 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. - **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. @@ -72,7 +72,7 @@ Tickets are deliberately **not** imported as objects. They are high-volume trans - **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 paces its own requests to stay inside these limits, but a dashboard with many TeamDynamix tiles refreshing together can still be throttled. A throttled tile reports the time the limit resets and recovers on its own. -- **Custom fields appear as `attr.` columns.** Ticket, asset, configuration item, project and service custom attributes are flattened into columns prefixed `attr.` — for example `attr.Cost Centre`. They are hidden by default; add them from the tile's column picker. +- **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 path is unverified.** The sandbox Web API path is assumed to be `/SBTDWebApi`. If a sandbox connection fails with a "no Web API found" error, check the correct path with TeamDynamix support. From cce6b053533b0b7dc4ea3ca1d1ad5dad854b5d79 Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 12:54:06 -0400 Subject: [PATCH 05/16] Infer sandbox web app for TeamDynamix links Detect SBTDWebApi base URLs and derive the matching sandbox web app when building record links. Update assets, configurationItems, knowledgeBaseArticles and tickets scripts to use a rawBaseUrl/isSandbox check and choose SBTDNext/TDNext or SBTDClient/TDClient as appropriate. Trim trailing slashes as before and fall back to record Uri when no base URL is available. Update plugin README to document that sandbox web paths are inferred and unverified. --- .../v1/dataStreams/scripts/assets.js | 14 +++++++++---- .../dataStreams/scripts/configurationItems.js | 14 +++++++++---- .../scripts/knowledgeBaseArticles.js | 20 +++++++++++++------ .../v1/dataStreams/scripts/tickets.js | 15 ++++++++++---- plugins/TeamDynamix/v1/docs/README.md | 2 +- 5 files changed, 46 insertions(+), 19 deletions(-) diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js b/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js index 2f78d3cd..27b49602 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. -const webBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") +// 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. +const rawBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") .trim() - .replace(/\/+$/, "") - .replace(/\/(SB)?TDWebApi$/i, ""); + .replace(/\/+$/, ""); +const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); +const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); +const nextApp = isSandbox ? "SBTDNext" : "TDNext"; result = (data || []).map((asset) => ({ // Asset IDs are only unique within an application, so the graph id has to carry the app @@ -19,7 +25,7 @@ result = (data || []).map((asset) => ({ assetId: String(asset.ID), name: asset.Name || asset.Tag || asset.SerialNumber || `Asset ${asset.ID}`, link: webBaseUrl - ? `${webBaseUrl}/TDNext/Apps/${asset.AppID}/Assets/AssetDet?AssetID=${asset.ID}` + ? `${webBaseUrl}/${nextApp}/Apps/${asset.AppID}/Assets/AssetDet?AssetID=${asset.ID}` : asset.Uri || "", statusName: asset.StatusName || "", tag: asset.Tag || "", diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js index da56deb7..ba563987 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js @@ -19,10 +19,16 @@ const BACKING_ITEM_TYPE_NAMES = { // and Attributes are never included in search results — only loading a configuration item // individually returns them, which this stream doesn't do. -const webBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") +// 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. +const rawBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") .trim() - .replace(/\/+$/, "") - .replace(/\/(SB)?TDWebApi$/i, ""); + .replace(/\/+$/, ""); +const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); +const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); +const nextApp = isSandbox ? "SBTDNext" : "TDNext"; const rows = (data || []).map((item) => ({ // As with assets, CI IDs are only unique within an application. @@ -30,7 +36,7 @@ const rows = (data || []).map((item) => ({ configurationItemId: String(item.ID), name: item.Name || `Configuration Item ${item.ID}`, link: webBaseUrl - ? `${webBaseUrl}/TDNext/Apps/${item.AppID}/Assets/CIDet?CID=${item.ID}` + ? `${webBaseUrl}/${nextApp}/Apps/${item.AppID}/Assets/CIDet?CID=${item.ID}` : item.Uri || "", typeName: item.TypeName || "", isActive: Boolean(item.IsActive), diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js b/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js index 541676ae..45182ba0 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js @@ -1,16 +1,24 @@ // The search request already sets IncludeArticleBodies: false, which is the one big win — -// article bodies are full HTML documents. This drops the remaining arrays (Attachments, -// Attributes, Tags, WhitelistGroups) that would otherwise render as raw JSON columns. -const webBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") +// article bodies are full HTML documents. Attachments, Attributes and Tags are never in a +// 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. +// +// 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. +const rawBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") .trim() - .replace(/\/+$/, "") - .replace(/\/(SB)?TDWebApi$/i, ""); + .replace(/\/+$/, ""); +const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); +const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); +const clientApp = isSandbox ? "SBTDClient" : "TDClient"; result = (data || []).map((article) => ({ articleId: String(article.ID), subject: article.Subject || `Article ${article.ID}`, link: webBaseUrl - ? `${webBaseUrl}/TDClient/Shared/KB/ArticleDet?ID=${article.ID}` + ? `${webBaseUrl}/${clientApp}/Shared/KB/ArticleDet?ID=${article.ID}` : article.Uri || "", categoryName: article.CategoryName || "", statusName: article.StatusName || "", diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js index 3105cce2..3dbbf674 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js @@ -23,14 +23,21 @@ const STATUS_CLASS_NAMES = { // points at the API resource, not the web UI, so derive a TDNext deep link from the // configured base address instead. context.dataSources isn't guaranteed to be populated for // post-request scripts, so fall back to Uri rather than emitting a broken link. -const webBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") +// +// 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. +const rawBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") .trim() - .replace(/\/+$/, "") - .replace(/\/(SB)?TDWebApi$/i, ""); + .replace(/\/+$/, ""); +const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); +const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); +const nextApp = isSandbox ? "SBTDNext" : "TDNext"; const ticketLink = (ticket) => webBaseUrl - ? `${webBaseUrl}/TDNext/Apps/${ticket.AppID}/Tickets/TicketDet?TicketID=${ticket.ID}` + ? `${webBaseUrl}/${nextApp}/Apps/${ticket.AppID}/Tickets/TicketDet?TicketID=${ticket.ID}` : ticket.Uri || ""; // Rolled up into one health value so a status tile works without shaping. SLA breach is the diff --git a/plugins/TeamDynamix/v1/docs/README.md b/plugins/TeamDynamix/v1/docs/README.md index 35d1c5b5..e0f7c71b 100644 --- a/plugins/TeamDynamix/v1/docs/README.md +++ b/plugins/TeamDynamix/v1/docs/README.md @@ -75,6 +75,6 @@ Tickets are deliberately **not** imported as objects. They are high-volume trans - **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 path is unverified.** The sandbox Web API path is assumed to be `/SBTDWebApi`. If a sandbox connection fails with a "no Web API found" error, check the correct path with TeamDynamix support. +- **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. From 911aee1673fd0c864067a583561151509db8220e Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 12:57:28 -0400 Subject: [PATCH 06/16] Guard TeamDynamix data maps with Array.isArray Replace usages of (data || []) with Array.isArray(data) ? data : [] across TeamDynamix data stream scripts to ensure .map is only called on arrays and avoid runtime errors when the API returns non-array or null values. Updated files: accounts.js, applications.js, assets.js, configurationItems.js, knowledgeBaseArticles.js, locations.js, projects.js, services.js, tickets.js. --- plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js | 2 +- plugins/TeamDynamix/v1/dataStreams/scripts/applications.js | 2 +- plugins/TeamDynamix/v1/dataStreams/scripts/assets.js | 2 +- .../TeamDynamix/v1/dataStreams/scripts/configurationItems.js | 2 +- .../TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js | 2 +- plugins/TeamDynamix/v1/dataStreams/scripts/locations.js | 2 +- plugins/TeamDynamix/v1/dataStreams/scripts/projects.js | 2 +- plugins/TeamDynamix/v1/dataStreams/scripts/services.js | 2 +- plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js b/plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js index fbe76d34..f73ed344 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/accounts.js @@ -1,7 +1,7 @@ // 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. -result = (data || []).map((account) => ({ +result = (Array.isArray(data) ? data : []).map((account) => ({ accountId: String(account.ID), name: account.Name, code: account.Code || "", diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/applications.js b/plugins/TeamDynamix/v1/dataStreams/scripts/applications.js index a3949e51..aa7976c5 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/applications.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/applications.js @@ -15,7 +15,7 @@ const SOURCE_TYPE_BY_APP_TYPE = { // Inactive applications are filtered out rather than imported and ignored: the asset and // configuration item import steps are scoped to Asset App objects, so a retired app left in // the graph would have requests fired at it on every import. -result = (data || []) +result = (Array.isArray(data) ? data : []) .filter((app) => app.Active !== false) .map((app) => ({ appId: String(app.AppID), diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js b/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js index 27b49602..fd05baa1 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js @@ -18,7 +18,7 @@ const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); const nextApp = isSandbox ? "SBTDNext" : "TDNext"; -result = (data || []).map((asset) => ({ +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. sourceId: `${asset.AppID}-${asset.ID}`, diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js index ba563987..1a8ac48c 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js @@ -30,7 +30,7 @@ const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); const nextApp = isSandbox ? "SBTDNext" : "TDNext"; -const rows = (data || []).map((item) => ({ +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), diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js b/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js index 45182ba0..2f9f569f 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js @@ -14,7 +14,7 @@ const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); const clientApp = isSandbox ? "SBTDClient" : "TDClient"; -result = (data || []).map((article) => ({ +result = (Array.isArray(data) ? data : []).map((article) => ({ articleId: String(article.ID), subject: article.Subject || `Article ${article.ID}`, link: webBaseUrl diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/locations.js b/plugins/TeamDynamix/v1/dataStreams/scripts/locations.js index a28a92a4..f4dcd76d 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/locations.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/locations.js @@ -4,7 +4,7 @@ // // 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. -result = (data || []).map((location) => ({ +result = (Array.isArray(data) ? data : []).map((location) => ({ locationId: String(location.ID), name: location.Name, ticketsCount: location.TicketsCount ?? 0, diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js b/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js index c135ce75..fafb05db 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js @@ -8,7 +8,7 @@ // CustomColumns and NonWorkingDays are never included in search results — only loading a // project individually returns them, which this stream doesn't do. -result = (data || []).map((project) => ({ +result = (Array.isArray(data) ? data : []).map((project) => ({ projectId: String(project.ID), name: project.Name || `Project ${project.ID}`, healthName: project.HealthName || "None", diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/services.js b/plugins/TeamDynamix/v1/dataStreams/scripts/services.js index 296ab944..d5e634fe 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/services.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/services.js @@ -7,7 +7,7 @@ // returns them, which this stream doesn't do. Attempting to project them here would only ever // yield empty columns. -result = (data || []).map((service) => ({ +result = (Array.isArray(data) ? data : []).map((service) => ({ serviceId: String(service.ID), name: service.Name || `Service ${service.ID}`, categoryName: service.CategoryName || "", diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js index 3dbbf674..c15e8478 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js @@ -50,7 +50,7 @@ const ticketHealth = (ticket) => { return "Open"; }; -result = (data || []).map((ticket) => ({ +result = (Array.isArray(data) ? data : []).map((ticket) => ({ ticketId: String(ticket.ID), title: ticket.Title || `Ticket ${ticket.ID}`, link: ticketLink(ticket), From 64b857c5245c0975ef2551ea2fe395bbcf674355 Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 13:01:23 -0400 Subject: [PATCH 07/16] Set defaultTimeframe for TeamDynamix streams Set defaultTimeframe to 'dashboard' for the TeamDynamix tickets and timeEntries data streams so dashboards use the dashboard timeframe when none is specified. Preserves existing timeframes and supportsNoneTimeframe settings. --- plugins/TeamDynamix/v1/dataStreams/tickets.json | 3 ++- plugins/TeamDynamix/v1/dataStreams/timeEntries.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/TeamDynamix/v1/dataStreams/tickets.json b/plugins/TeamDynamix/v1/dataStreams/tickets.json index b16d2ee5..efec8d08 100644 --- a/plugins/TeamDynamix/v1/dataStreams/tickets.json +++ b/plugins/TeamDynamix/v1/dataStreams/tickets.json @@ -123,5 +123,6 @@ "sort": { "by": [["createdDate", "desc"]] } }, "timeframes": true, - "supportsNoneTimeframe": true + "supportsNoneTimeframe": true, + "defaultTimeframe": "dashboard" } diff --git a/plugins/TeamDynamix/v1/dataStreams/timeEntries.json b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json index 6918597e..521ebd74 100644 --- a/plugins/TeamDynamix/v1/dataStreams/timeEntries.json +++ b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json @@ -64,5 +64,6 @@ "sort": { "by": [["TimeDate", "desc"]] } }, "timeframes": true, - "supportsNoneTimeframe": true + "supportsNoneTimeframe": true, + "defaultTimeframe": "dashboard" } From fa503ef090d15ac09566850708af5f34adf3f6fb Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 13:06:32 -0400 Subject: [PATCH 08/16] Make StatusClass & Component numeric; add Component format Update ticketStatuses.json to use a numeric shape for StatusClass. Update timeEntries.json to use a numeric shape for Component and add a formatExpression that maps common numeric component codes to human-readable labels (with a fallback to the raw value). These changes align shaping with API types and improve display of component values. --- plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json | 2 +- plugins/TeamDynamix/v1/dataStreams/timeEntries.json | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json b/plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json index f4232de1..2cf1ecde 100644 --- a/plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json +++ b/plugins/TeamDynamix/v1/dataStreams/ticketStatuses.json @@ -23,7 +23,7 @@ { "name": "Order", "displayName": "Order", "shape": "number" }, { "name": "IsActive", "displayName": "Active", "shape": "boolean" }, { "name": "IsDefault", "displayName": "Default", "shape": "boolean" }, - { "name": "StatusClass", "displayName": "Status Class", "shape": "string", "visible": false } + { "name": "StatusClass", "displayName": "Status Class", "shape": "number", "visible": false } ], "defaultShaping": { "sort": { "by": [["Order", "asc"]] } diff --git a/plugins/TeamDynamix/v1/dataStreams/timeEntries.json b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json index 521ebd74..655cb4f8 100644 --- a/plugins/TeamDynamix/v1/dataStreams/timeEntries.json +++ b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json @@ -48,7 +48,12 @@ { "name": "Hours", "displayName": "Hours", "shape": ["number", { "decimalPlaces": 2 }], "role": "value" }, { "name": "Minutes", "displayName": "Minutes", "shape": "minutes", "visible": false }, { "name": "Billable", "displayName": "Billable", "shape": "boolean" }, - { "name": "Component", "displayName": "Component", "shape": "string" }, + { + "name": "Component", + "displayName": "Component", + "shape": "number", + "formatExpression": "{{ ({1:'Project Time',2:'Task Time',3:'Issue Time',9:'Ticket Time',17:'Time Off',23:'Portfolio Time',25:'Ticket Task Time',45:'Workspace Time',83:'Portfolio Issue Time'})[$['Component']] || $['Component'] }}" + }, { "name": "AppName", "displayName": "Application", "shape": "string" }, { "name": "ProjectName", "displayName": "Project", "shape": "string" }, { "name": "PortfolioName", "displayName": "Portfolio", "shape": "string", "visible": false }, From 4af7fb19bf9879a74a1f01639e4eb7e22efb2b14 Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 13:11:11 -0400 Subject: [PATCH 09/16] Bump TeamDynamix plugin to v1.0.1 Patch release for TeamDynamix plugin. --- plugins/TeamDynamix/v1/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/TeamDynamix/v1/metadata.json b/plugins/TeamDynamix/v1/metadata.json index 07e64a46..98b00e8e 100644 --- a/plugins/TeamDynamix/v1/metadata.json +++ b/plugins/TeamDynamix/v1/metadata.json @@ -1,7 +1,7 @@ { "name": "teamdynamix", "displayName": "TeamDynamix", - "version": "1.0.0", + "version": "1.0.1", "author": { "name": "SquaredUp Labs", "type": "labs" From 7a6eba603af78cb2a88c007bf9654e864ff04129 Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 13:14:26 -0400 Subject: [PATCH 10/16] Clarify 'Ignore certificate errors' guidance Update README and UI help text to clarify that the "Ignore certificate errors" option should only be enabled when the Web API endpoint presents a self-signed or otherwise untrusted certificate. Also note that self-hosted instances can still have properly trusted certificates and therefore typically do not need this option. Files updated: plugins/TeamDynamix/v1/docs/README.md, plugins/TeamDynamix/v1/ui.json. --- plugins/TeamDynamix/v1/docs/README.md | 2 +- plugins/TeamDynamix/v1/ui.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/TeamDynamix/v1/docs/README.md b/plugins/TeamDynamix/v1/docs/README.md index e0f7c71b..67e3bd5b 100644 --- a/plugins/TeamDynamix/v1/docs/README.md +++ b/plugins/TeamDynamix/v1/docs/README.md @@ -20,7 +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 | -| **Ignore certificate errors** | Accepts self-signed certificates. Only needed for a self-hosted instance. | 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. | Advanced options. | 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. diff --git a/plugins/TeamDynamix/v1/ui.json b/plugins/TeamDynamix/v1/ui.json index 58476cea..3f8aa30b 100644 --- a/plugins/TeamDynamix/v1/ui.json +++ b/plugins/TeamDynamix/v1/ui.json @@ -63,7 +63,7 @@ "type": "checkbox", "name": "ignoreCertificateErrors", "label": "Ignore certificate errors", - "help": "Enable when connecting to an instance with a self-signed certificate." + "help": "Enable only if the Web API endpoint presents a self-signed or otherwise untrusted certificate. A self-hosted instance can still have a properly trusted certificate and should not need this." } ] } From cd9edb5dded6ad68ec15f3ddf85437176cdebde4 Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 13:19:21 -0400 Subject: [PATCH 11/16] Fix TeamDynamix URL validation regex Update the URL validation pattern to prevent invalid formats including query parameters, fragments, and authentication credentials. The new pattern uses a non-capturing group and explicitly excludes @, ?, and # characters to ensure valid TeamDynamix Web API URLs. --- plugins/TeamDynamix/v1/ui.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/TeamDynamix/v1/ui.json b/plugins/TeamDynamix/v1/ui.json index 3f8aa30b..16f31f7f 100644 --- a/plugins/TeamDynamix/v1/ui.json +++ b/plugins/TeamDynamix/v1/ui.json @@ -13,7 +13,7 @@ "validation": { "required": true, "pattern": { - "value": "^https://[^\\s/]+(/[^\\s]*)?$", + "value": "^https://[^\\s/@?#]+(?:/[^\\s?#]*)?$", "message": "Enter the full https address of your TeamDynamix Web API, e.g. https://yourorg.teamdynamix.com/TDWebApi" } } From 3fcd9ca4c186d2be6c424bf541a32afb67645114 Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 13:24:08 -0400 Subject: [PATCH 12/16] Remove 'Departments' from account display names Simplify account terminology across TeamDynamix plugin by removing '/ Departments' suffix from account display names and labels in custom types, data streams, and documentation. --- plugins/TeamDynamix/v1/custom_types.json | 4 ++-- plugins/TeamDynamix/v1/dataStreams/accounts.json | 2 +- plugins/TeamDynamix/v1/dataStreams/projects.json | 2 +- plugins/TeamDynamix/v1/dataStreams/tickets.json | 2 +- plugins/TeamDynamix/v1/docs/README.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/TeamDynamix/v1/custom_types.json b/plugins/TeamDynamix/v1/custom_types.json index 2d6481c4..d4864bea 100644 --- a/plugins/TeamDynamix/v1/custom_types.json +++ b/plugins/TeamDynamix/v1/custom_types.json @@ -24,8 +24,8 @@ "name": "TeamDynamix Account", "sourceType": "TeamDynamix Account", "icon": "building", - "singular": "Account / Department", - "plural": "Accounts / Departments" + "singular": "Account", + "plural": "Accounts" }, { "name": "TeamDynamix Location", diff --git a/plugins/TeamDynamix/v1/dataStreams/accounts.json b/plugins/TeamDynamix/v1/dataStreams/accounts.json index a831840e..e711c4c7 100644 --- a/plugins/TeamDynamix/v1/dataStreams/accounts.json +++ b/plugins/TeamDynamix/v1/dataStreams/accounts.json @@ -1,6 +1,6 @@ { "name": "accounts", - "displayName": "Accounts / Departments", + "displayName": "Accounts", "description": "Active accounts and departments, one row per account", "tags": ["Organization"], "baseDataSourceName": "httpRequestUnscoped", diff --git a/plugins/TeamDynamix/v1/dataStreams/projects.json b/plugins/TeamDynamix/v1/dataStreams/projects.json index 723dcabd..531e3158 100644 --- a/plugins/TeamDynamix/v1/dataStreams/projects.json +++ b/plugins/TeamDynamix/v1/dataStreams/projects.json @@ -67,7 +67,7 @@ { "name": "percentComplete", "displayName": "Complete", "shape": "percent" }, { "name": "managerName", "displayName": "Manager", "shape": "string" }, { "name": "sponsorName", "displayName": "Sponsor", "shape": "string", "visible": false }, - { "name": "accountName", "displayName": "Account / Department", "shape": "string" }, + { "name": "accountName", "displayName": "Account", "shape": "string" }, { "name": "typeName", "displayName": "Type", "shape": "string" }, { "name": "priorityName", "displayName": "Priority", "shape": "string", "visible": false }, { "name": "classificationName", "displayName": "Classification", "shape": "string", "visible": false }, diff --git a/plugins/TeamDynamix/v1/dataStreams/tickets.json b/plugins/TeamDynamix/v1/dataStreams/tickets.json index efec8d08..e4acffa1 100644 --- a/plugins/TeamDynamix/v1/dataStreams/tickets.json +++ b/plugins/TeamDynamix/v1/dataStreams/tickets.json @@ -92,7 +92,7 @@ { "name": "classificationName", "displayName": "Classification", "shape": "string" }, { "name": "urgencyName", "displayName": "Urgency", "shape": "string", "visible": false }, { "name": "impactName", "displayName": "Impact", "shape": "string", "visible": false }, - { "name": "accountName", "displayName": "Account / Department", "shape": "string" }, + { "name": "accountName", "displayName": "Account", "shape": "string" }, { "name": "serviceName", "displayName": "Service", "shape": "string" }, { "name": "locationName", "displayName": "Location", "shape": "string" }, { "name": "sourceName", "displayName": "Source", "shape": "string" }, diff --git a/plugins/TeamDynamix/v1/docs/README.md b/plugins/TeamDynamix/v1/docs/README.md index 67e3bd5b..c6ca8d51 100644 --- a/plugins/TeamDynamix/v1/docs/README.md +++ b/plugins/TeamDynamix/v1/docs/README.md @@ -46,7 +46,7 @@ The out-of-the-box dashboards are an organization-wide **Overview**, a **Service - **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. -- **Accounts / Departments**, **Groups**, **Applications**, **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**, **Applications**, **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. ## What gets indexed From e35300dc683d416ea637d48fc1e3ee407d74281e Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 13:34:09 -0400 Subject: [PATCH 13/16] Remove PNG icon and update SVG Delete plugins/TeamDynamix/v1/icon.png and modify plugins/TeamDynamix/v1/icon.svg. The SVG was cleaned up (removed the white background and adjusted defs/order) so it can be used as a transparent, single vector asset instead of keeping a raster PNG. This reduces redundancy and keeps the icon scalable. --- plugins/TeamDynamix/v1/icon.png | Bin 66584 -> 0 bytes plugins/TeamDynamix/v1/icon.svg | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 plugins/TeamDynamix/v1/icon.png diff --git a/plugins/TeamDynamix/v1/icon.png b/plugins/TeamDynamix/v1/icon.png deleted file mode 100644 index 3e3ca81acc38c10ef8f141125e3cd2e1111b8efd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 66584 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⋙K7idWPJ_=L|*L?;d1GG_KU_0AT1 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 diff --git a/plugins/TeamDynamix/v1/icon.svg b/plugins/TeamDynamix/v1/icon.svg index 5207fb0a..b75b707d 100644 --- a/plugins/TeamDynamix/v1/icon.svg +++ b/plugins/TeamDynamix/v1/icon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From 35a66f7d0c9decf2ad526ea8afbe80b14d11e5f0 Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 13:37:01 -0400 Subject: [PATCH 14/16] Update TeamDynamix plugin author to community Transfer plugin ownership from SquaredUp Labs to community maintainer @shawn149. Update metadata to reflect community-maintained status. --- plugins/TeamDynamix/v1/metadata.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/TeamDynamix/v1/metadata.json b/plugins/TeamDynamix/v1/metadata.json index 98b00e8e..8652c82e 100644 --- a/plugins/TeamDynamix/v1/metadata.json +++ b/plugins/TeamDynamix/v1/metadata.json @@ -3,8 +3,8 @@ "displayName": "TeamDynamix", "version": "1.0.1", "author": { - "name": "SquaredUp Labs", - "type": "labs" + "name": "@shawn149", + "type": "community" }, "description": "Monitor TeamDynamix tickets, assets, configuration items and service delivery in SquaredUp.", "category": "Service Management", From 53e384ed26b54cb03d0cb4bcc4636fd37943c601 Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 13:42:10 -0400 Subject: [PATCH 15/16] Stop internal request pacing for TeamDynamix Remove the preRequest request-spacing logic and state tracking from plugins/TeamDynamix/v1/preRequest.js and update docs to match. The script no longer enforces a minimum gap or updates state.lastRequestAt (sleep/minGap logic removed); comments explain rate limiting is handled by the platform and the stream-level rateLimit.js handler. plugins/TeamDynamix/v1/docs/README.md wording updated to reflect that the plugin does not pace requests and throttled tiles recover on the next refresh. --- plugins/TeamDynamix/v1/docs/README.md | 2 +- plugins/TeamDynamix/v1/preRequest.js | 31 +++++++++------------------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/plugins/TeamDynamix/v1/docs/README.md b/plugins/TeamDynamix/v1/docs/README.md index c6ca8d51..0d2989e6 100644 --- a/plugins/TeamDynamix/v1/docs/README.md +++ b/plugins/TeamDynamix/v1/docs/README.md @@ -71,7 +71,7 @@ Tickets are deliberately **not** imported as objects. They are high-volume trans - **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 paces its own requests to stay inside these limits, but a dashboard with many TeamDynamix tiles refreshing together can still be throttled. A throttled tile reports the time the limit resets and recovers on its own. +- **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. - **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. diff --git a/plugins/TeamDynamix/v1/preRequest.js b/plugins/TeamDynamix/v1/preRequest.js index c13827b6..38e8ce95 100644 --- a/plugins/TeamDynamix/v1/preRequest.js +++ b/plugins/TeamDynamix/v1/preRequest.js @@ -2,16 +2,17 @@ // pair to /api/auth/loginadmin. No built-in authMode covers that exchange, so it happens // here and the resulting token is cached in `state`. // -// This script also does two things that aren't strictly authentication: +// This script also normalises the request path: the user pastes a full Web API address, +// which may or may not have a trailing slash, so collapse any doubled separator before +// sending. // -// 1. Request spacing. TeamDynamix rate limits are enforced per IP address, and the -// tightest documented limit is 30 requests/60s on ticket and time-entry search -// (most other endpoints allow 60/60s). A single import that walks several -// applications can breach that on its own, so we hold a minimum gap between -// consecutive requests rather than relying on the API to forgive us. -// -// 2. Path normalisation. The user pastes a full Web API address, which may or may not -// have a trailing slash, so collapse any doubled separator before sending. +// Rate limiting itself is left to the platform rather than paced here. TeamDynamix returns +// proper X-RateLimit-* headers, and the errorHandling script on every stream (rateLimit.js) +// already turns a 429 into a clear, self-healing message naming when it resets. An earlier +// version of this script also held a minimum gap between consecutive requests via `state`, +// but that read-then-write isn't atomic across genuinely concurrent invocations — several +// tiles refreshing at once, or the platform fanning out one call per matched object — so it +// couldn't reliably prevent the bursts it was meant to smooth out, and was removed. url.pathname = url.pathname.replace(/\/{2,}/g, "/"); @@ -21,17 +22,6 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // 5 second floor on top, to absorb clock skew and time-zone differences. const RATE_LIMIT_FLOOR_MS = 5000; -// --- request spacing ------------------------------------------------------------- - -// Ticket and time-entry search allow 30 req/60s (one per 2s); everything else 60 req/60s. -const isTightlyLimited = /\/(tickets|time)\/search\/?$/i.test(url.pathname); -const minGapMs = isTightlyLimited ? 2100 : 1100; - -const sinceLastRequest = Date.now() - (state?.lastRequestAt ?? 0); -if (sinceLastRequest >= 0 && sinceLastRequest < minGapMs) { - await sleep(minGapMs - sinceLastRequest); -} - // --- token ----------------------------------------------------------------------- // Read the lifetime out of the JWT's own `exp` claim rather than assuming the documented @@ -113,4 +103,3 @@ if (typeof state?.token !== "string" || (state?.expiryTime ?? 0) <= Date.now()) } headers["Authorization"] = `Bearer ${state.token}`; -state = { ...state, lastRequestAt: Date.now() }; From 65fb6098c65bd5a21c207db8ccbead9c301fbb0f Mon Sep 17 00:00:00 2001 From: shawn149 Date: Mon, 10 Aug 2026 13:45:04 -0400 Subject: [PATCH 16/16] Remove intro markdown from TeamDynamix UI Remove the introductory markdown section from the TeamDynamix v1 UI configuration that contained setup instructions and permission requirements. --- plugins/TeamDynamix/v1/ui.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/plugins/TeamDynamix/v1/ui.json b/plugins/TeamDynamix/v1/ui.json index 16f31f7f..19d4c0c4 100644 --- a/plugins/TeamDynamix/v1/ui.json +++ b/plugins/TeamDynamix/v1/ui.json @@ -1,9 +1,4 @@ [ - { - "type": "markdown", - "name": "intro", - "content": "Connect SquaredUp to your TeamDynamix instance.\n\nYou need a **BEID** and a **Web Services Key**. Both are on the organization detail page in **TDAdmin**, and reading them requires the *Add BE Administrators* permission — so a TeamDynamix administrator may need to supply them.\n\nThis plugin only ever reads from TeamDynamix. It never creates, updates or deletes anything." - }, { "type": "text", "name": "apiBaseUrl",