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..d4864bea --- /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", + "plural": "Accounts" + }, + { + "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..e711c4c7 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/accounts.json @@ -0,0 +1,32 @@ +{ + "name": "accounts", + "displayName": "Accounts", + "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..c9610e3e --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/assets.json @@ -0,0 +1,75 @@ +{ + "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 } + ], + "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..a0c5908c --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/configurationItems.json @@ -0,0 +1,61 @@ +{ + "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 } + ], + "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..531e3158 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/projects.json @@ -0,0 +1,91 @@ +{ + "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", "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 } + ], + "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..f73ed344 --- /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 = (Array.isArray(data) ? 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..aa7976c5 --- /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 = (Array.isArray(data) ? 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..fd05baa1 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/assets.js @@ -0,0 +1,51 @@ +// 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 — 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. + +// 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(/\/+$/, ""); +const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); +const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); +const nextApp = isSandbox ? "SBTDNext" : "TDNext"; + +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}`, + assetId: String(asset.ID), + name: asset.Name || asset.Tag || asset.SerialNumber || `Asset ${asset.ID}`, + link: webBaseUrl + ? `${webBaseUrl}/${nextApp}/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" +})); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js new file mode 100644 index 00000000..1a8ac48c --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js @@ -0,0 +1,67 @@ +// Serves double duty: this stream backs both the configurationItems import step and CI tiles. +// +// 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: "Asset", + 47: "Service", + 63: "Configuration Item" +}; + +// No custom-attribute columns: the API's own description for this endpoint says Attachments +// and Attributes are never included in search results — only loading a configuration item +// individually returns them, which this stream doesn't do. + +// A sandbox Web API address (/SBTDWebApi) needs the matching sandbox web app (/SBTDNext) — +// otherwise every sandbox CI link points at the production app instead. SBTDNext is inferred +// from the same SB-prefix convention as SBTDWebApi; the API spec has no web UI paths to +// confirm it against. +const rawBaseUrl = String(context?.dataSources?.[0]?.baseUrl || "") + .trim() + .replace(/\/+$/, ""); +const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); +const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); +const nextApp = isSandbox ? "SBTDNext" : "TDNext"; + +const rows = (Array.isArray(data) ? data : []).map((item) => ({ + // As with assets, CI IDs are only unique within an application. + sourceId: `${item.AppID}-${item.ID}`, + configurationItemId: String(item.ID), + name: item.Name || `Configuration Item ${item.ID}`, + link: webBaseUrl + ? `${webBaseUrl}/${nextApp}/Apps/${item.AppID}/Assets/CIDet?CID=${item.ID}` + : item.Uri || "", + typeName: item.TypeName || "", + isActive: Boolean(item.IsActive), + ownerFullName: item.OwnerFullName || "", + owningDepartmentName: item.OwningDepartmentName || "", + owningGroupName: item.OwningGroupName || "", + locationName: item.LocationName || "", + maintenanceScheduleName: item.MaintenanceScheduleName || "", + backingItemType: BACKING_ITEM_TYPE_NAMES[item.BackingItemType] || "", + isSystemMaintained: Boolean(item.IsSystemMaintained), + externalSourceName: item.ExternalSourceName || "", + externalId: item.ExternalID || "", + // Note the Utc suffix: configuration items use CreatedDateUtc / ModifiedDateUtc where + // tickets and assets use CreatedDate / ModifiedDate. + createdDate: item.CreatedDateUtc || null, + modifiedDate: item.ModifiedDateUtc || null, + appId: String(item.AppID ?? ""), + sourceType: "TeamDynamix Configuration Item" +})); + +// 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/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..2f9f569f --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/knowledgeBaseArticles.js @@ -0,0 +1,35 @@ +// The search request already sets IncludeArticleBodies: false, which is the one big win — +// 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(/\/+$/, ""); +const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); +const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); +const clientApp = isSandbox ? "SBTDClient" : "TDClient"; + +result = (Array.isArray(data) ? data : []).map((article) => ({ + articleId: String(article.ID), + subject: article.Subject || `Article ${article.ID}`, + link: webBaseUrl + ? `${webBaseUrl}/${clientApp}/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..f4dcd76d --- /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 = (Array.isArray(data) ? data : []).map((location) => ({ + locationId: String(location.ID), + name: location.Name, + ticketsCount: location.TicketsCount ?? 0, + assetsCount: location.AssetsCount ?? 0, + configurationItemsCount: location.ConfigurationItemsCount ?? 0, + usersCount: location.UsersCount ?? 0, + roomsCount: location.RoomsCount ?? 0, + 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..fafb05db --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/projects.js @@ -0,0 +1,36 @@ +// A Project has 120 fields, most of them baseline/initial variants of the same value +// (EndDateBaseline, EndDateInitial, EstimatedHoursInitial, TimeBudgetInitial ...) plus +// 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 = (Array.isArray(data) ? 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 ?? "") +})); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/services.js b/plugins/TeamDynamix/v1/dataStreams/scripts/services.js new file mode 100644 index 00000000..d5e634fe --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/services.js @@ -0,0 +1,27 @@ +// 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. +// +// 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 = (Array.isArray(data) ? 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 ?? "") +})); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js new file mode 100644 index 00000000..c15e8478 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/tickets.js @@ -0,0 +1,92 @@ +// 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" +}; + +// 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 +// 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. +// +// 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(/\/+$/, ""); +const isSandbox = /\/SBTDWebApi$/i.test(rawBaseUrl); +const webBaseUrl = rawBaseUrl.replace(/\/(SB)?TDWebApi$/i, ""); +const nextApp = isSandbox ? "SBTDNext" : "TDNext"; + +const ticketLink = (ticket) => + webBaseUrl + ? `${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 +// 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 = (Array.isArray(data) ? 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 ?? "") +})); diff --git a/plugins/TeamDynamix/v1/dataStreams/services.json b/plugins/TeamDynamix/v1/dataStreams/services.json new file mode 100644 index 00000000..93a55031 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/services.json @@ -0,0 +1,57 @@ +{ + "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 } + ], + "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..2cf1ecde --- /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": "number", "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..e4acffa1 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/tickets.json @@ -0,0 +1,128 @@ +{ + "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", "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 } + ], + "defaultShaping": { + "sort": { "by": [["createdDate", "desc"]] } + }, + "timeframes": true, + "supportsNoneTimeframe": true, + "defaultTimeframe": "dashboard" +} diff --git a/plugins/TeamDynamix/v1/dataStreams/timeEntries.json b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json new file mode 100644 index 00000000..655cb4f8 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/timeEntries.json @@ -0,0 +1,74 @@ +{ + "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": "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 }, + { "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": "dashboard" +} 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..0d2989e6 --- /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 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. + +## 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 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. +- **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**, **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 by TeamDynamix at all.** Unlike tickets and assets, the CMDB search endpoint accepts no maximum-results parameter and has no paged alternative that doesn't require a pre-existing saved search in TeamDynamix. The plugin works around this by capping the result to **Maximum records per request** itself, keeping the most recently modified items first. If a very large, unfiltered CMDB still fails to load, filter to **Active** only and lower that setting. +- **Rate limits are per IP address and are easy to hit.** Ticket and time-entry searches allow 30 requests per minute; most other endpoints allow 60. The plugin does not pace its own requests — a dashboard with many TeamDynamix tiles refreshing together can be throttled. A throttled tile reports the time the limit resets and recovers on its own on the next refresh. +- **Custom attributes are not available.** TeamDynamix's search endpoints (the ones every data stream in this plugin uses) never return custom attributes, attachments, or a few other detail-only fields, regardless of what the underlying record supports — only loading a single ticket, asset, configuration item, project or service individually returns them, which none of these streams do. There is no way to surface a tenant's custom fields without adding a per-record detail call for every row, which the search-based design of this plugin does not do. +- **Wide records are trimmed.** A TeamDynamix ticket has over 100 fields, including the full HTML description and its attachment and task lists. Only dashboard-usable fields are returned, so ticket descriptions, attachments and task detail are not available. +- **Time entries identify people by UID.** The time search returns the person's unique identifier rather than their name, so time cannot be grouped by person name. +- **Sandbox paths are unverified.** The sandbox Web API path is assumed to be `/SBTDWebApi`, and ticket, asset, configuration item and knowledge base article links for a sandbox connection are built from the matching `/SBTDNext` or `/SBTDClient` web app path, following the same naming convention. Neither is confirmed against a real sandbox tenant, and the API documentation doesn't cover web UI paths at all. If a sandbox connection fails with a "no Web API found" error, or a sandbox record's link doesn't open, check the correct paths with TeamDynamix support. +- **Imports run every 12 hours** by default, so newly added assets and configuration items appear in the graph on the next import. +- **Read-only.** The plugin only ever reads from TeamDynamix. It calls no endpoint that creates, modifies or deletes data — only `GET` endpoints, `POST .../search` endpoints, and the `POST /api/auth/loginadmin` sign-in call. diff --git a/plugins/TeamDynamix/v1/icon.svg b/plugins/TeamDynamix/v1/icon.svg new file mode 100644 index 00000000..b75b707d --- /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..8652c82e --- /dev/null +++ b/plugins/TeamDynamix/v1/metadata.json @@ -0,0 +1,79 @@ +{ + "name": "teamdynamix", + "displayName": "TeamDynamix", + "version": "1.0.1", + "author": { + "name": "@shawn149", + "type": "community" + }, + "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..38e8ce95 --- /dev/null +++ b/plugins/TeamDynamix/v1/preRequest.js @@ -0,0 +1,105 @@ +// 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 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. +// +// 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, "/"); + +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; + +// --- 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}`; diff --git a/plugins/TeamDynamix/v1/ui.json b/plugins/TeamDynamix/v1/ui.json new file mode 100644 index 00000000..19d4c0c4 --- /dev/null +++ b/plugins/TeamDynamix/v1/ui.json @@ -0,0 +1,65 @@ +[ + { + "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 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." + } + ] + } +]