diff --git a/plugins/TeamDynamix/v1/dataStreams/configurationItems.json b/plugins/TeamDynamix/v1/dataStreams/configurationItems.json index 097f94fa..82567ac7 100644 --- a/plugins/TeamDynamix/v1/dataStreams/configurationItems.json +++ b/plugins/TeamDynamix/v1/dataStreams/configurationItems.json @@ -9,6 +9,7 @@ "endpointPath": "api/{{object.rawId}}/cmdb/search", "postBody": { "MaxResults": 10000, + "ExcludeAssetCopies": true, "IsActive": "{{typeof activeState === 'undefined' || !activeState ? true : activeState === 'all' ? null : activeState === 'active'}}" }, "paging": { "mode": "none" }, diff --git a/plugins/TeamDynamix/v1/dataStreams/configurationItemsIndex.json b/plugins/TeamDynamix/v1/dataStreams/configurationItemsIndex.json index 3f5016b5..4d823f18 100644 --- a/plugins/TeamDynamix/v1/dataStreams/configurationItemsIndex.json +++ b/plugins/TeamDynamix/v1/dataStreams/configurationItemsIndex.json @@ -9,6 +9,7 @@ "endpointPath": "api/{{object.rawId}}/cmdb/search/paged", "postBody": { "IsActive": true, + "ExcludeAssetCopies": true, "NameLike": "{{ dataSource.importInventory === true || dataSource.importInventory === 'true' ? null : 'squaredup-inventory-import-disabled' }}" }, "paging": { diff --git a/plugins/TeamDynamix/v1/dataStreams/report.json b/plugins/TeamDynamix/v1/dataStreams/report.json new file mode 100644 index 00000000..0a224f65 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/report.json @@ -0,0 +1,39 @@ +{ + "name": "report", + "displayName": "Report", + "description": "Rows of a Report Builder report, with the report's own columns (including custom attributes)", + "tags": ["Reports"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "api/reports/{{typeof report === 'undefined' || !report ? 0 : Array.isArray(report) ? (report[0] && report[0].value) || 0 : report}}", + "getArgs": [{ "key": "withData", "value": "true" }], + "paging": { "mode": "none" }, + "postRequestScript": "report.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/report.js" + } + }, + "matches": "none", + "manualConfigApply": true, + "ui": [ + { + "type": "autocomplete", + "name": "report", + "label": "Report", + "help": "Build the report in TeamDynamix Report Builder, adding custom attributes as columns. Only reports the TeamDynamix admin service account can see are listed. A report ID can also be typed in.", + "allowCustomValues": true, + "data": { + "source": "dataStream", + "dataStreamName": "reports", + "dataSourceConfig": {} + }, + "validation": { "required": true } + } + ], + "metadata": [], + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none" +} diff --git a/plugins/TeamDynamix/v1/dataStreams/reports.json b/plugins/TeamDynamix/v1/dataStreams/reports.json new file mode 100644 index 00000000..bd5f1925 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/reports.json @@ -0,0 +1,29 @@ +{ + "name": "reports", + "displayName": "Reports", + "description": "Report Builder reports visible to the service account (used to pick a report)", + "tags": ["Reports"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "api/reports", + "paging": { "mode": "none" }, + "postRequestScript": "reports.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": "none", + "metadata": [ + { "name": "label", "displayName": "Report", "shape": "string", "role": "label" }, + { "name": "value", "displayName": "Report ID", "shape": "string", "role": "value" }, + { "name": "sourceName", "displayName": "Source", "shape": "string" }, + { "name": "appName", "displayName": "Application", "shape": "string" }, + { "name": "owningGroupName", "displayName": "Owning Group", "shape": "string" } + ], + "timeframes": ["none"], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none", + "visibility": { "type": "hidden" } +} diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js index d60e7f43..f95002ff 100644 --- a/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js @@ -1,13 +1,12 @@ // Serves both CI tiles (configurationItems, the unpaged cmdb/search) and the import // (configurationItemsIndex, cmdb/search/paged, which wraps each page in { Data: [...] }). // -// The import has to page. TeamDynamix creates a configuration item for every asset, so a big -// asset application's CMDB is almost all asset mirrors (49,000+ in one real tenant), and one -// unpaged search for it ran the stream out of memory before this script could drop them. -// Tiles stay on the unpaged search, capped below: walking hundreds of 250-row pages (the API's -// maximum page size) is fine every 12 hours but far too slow for a tile. The unpaged search's -// MaxResults counts the asset mirrors dropped below, so it only guards memory; the number of -// rows a tile shows is capped at the end of this script instead. +// TeamDynamix creates a configuration item for every asset, so a big asset application's CMDB +// is almost all asset mirrors (49,000+ in one real tenant). Both streams set ExcludeAssetCopies, +// which preRequest.js turns into a TypeIDs filter so TeamDynamix leaves the mirrors out. The +// BackingItemType check below stays as a backstop for when that filter can't be applied (the +// type list request failed), in which case the mirrors still arrive and are dropped here. +// The import still pages, as a large standalone CMDB would otherwise be one huge response. // BackingItemType is an integer enum on the wire. A CI can be backed by a service record // elsewhere in TeamDynamix, which is worth showing — but not as "47". 87 isn't in the @@ -50,16 +49,14 @@ const nextApp = isSandbox ? "SBTDNext" : "TDNext"; const items = Array.isArray(data) ? data : Array.isArray(data?.Data) ? data.Data : []; -// The tile stream asks for at most 10,000 items (its postBody MaxResults). Unlimited, a CMDB -// of 49,000 asset mirrors ran the stream out of memory with no useful message. If the limit is -// reached, standalone items may be missing, so say so rather than show a silently short list. +// The tile stream asks for at most 10,000 items (its postBody MaxResults), as a guard on +// memory. Reaching it means items may be missing, so say so rather than show a short list. const TILE_SEARCH_LIMIT = 10000; if (Array.isArray(data) && data.length >= TILE_SEARCH_LIMIT) { throw new Error( - "This asset application's CMDB has more than 10,000 configuration items, mostly copies " + - "of its assets, which is too many to load in a tile. Turn on Import assets, " + - "configuration items, accounts and locations in the data source's Advanced options " + - "to import its standalone configuration items instead." + "This asset application's CMDB returned 10,000 or more configuration items, which is " + + "too many to load in a tile. Turn on Import assets, configuration items, accounts " + + "and locations in the data source's Advanced options to import them instead." ); } diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/errorHandling/report.js b/plugins/TeamDynamix/v1/dataStreams/scripts/errorHandling/report.js new file mode 100644 index 00000000..f377ce34 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/errorHandling/report.js @@ -0,0 +1,25 @@ +// The Report stream's own error script. rateLimit.js, shared by every other stream, explains a +// 404 as a tile scoped to the wrong kind of application, which is wrong here: a report that +// can't be found is one that was deleted or that the admin service account can't see. +const status = response?.status; +const header = (name) => { + const headers = response?.headers || {}; + return headers[name] || headers[name.toLowerCase()] || headers[name.toUpperCase()]; +}; + +if (!status) { + result = + "TeamDynamix didn't respond. Check the Web API address and that the site is reachable " + + "from SquaredUp."; +} else if (status === 404 || status === 403) { + result = + "TeamDynamix couldn't open this report. Check it still exists, and that it is shared " + + "with the TeamDynamix admin service account the data source signs in as."; +} else if (status === 429) { + const resetAt = header("X-RateLimit-Reset"); + result = + "TeamDynamix rate limit reached" + + (resetAt ? `. The limit resets at ${resetAt} — this tile will load again after that.` : ". Wait a minute and refresh."); +} else { + result = data?.Message || data?.message || `TeamDynamix request failed with HTTP ${status}.`; +} diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/report.js b/plugins/TeamDynamix/v1/dataStreams/scripts/report.js new file mode 100644 index 00000000..2df48dbe --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/report.js @@ -0,0 +1,41 @@ +// Search results never include custom attributes, and loading assets one by one is out of reach +// on a large estate (one request each, under a 30-a-minute rate limit). A Report Builder report +// can include custom attributes as columns and returns every row in one request, so this stream +// runs a report the customer has built and passes its columns through. +// +// Rows key custom attribute values by attribute ID ("3177"), so columns are renamed to the +// report's own header text. Two columns can share a header (a report can show the same +// attribute twice, or an attribute named like a built-in field), so a repeat gets its key +// appended. A dot would be read as a nested path, so it is replaced. + +// TeamDynamix never sends null for an unset date: it sends 0001-01-01T00:00:00 (.NET's minimum +// date), which SquaredUp displays as 12/31/1900. Anything before 1901-01-02 is treated as unset. +const UNSET_DATE_BEFORE = Date.parse("1901-01-02T00:00:00Z"); +const ISO_DATE = /^\d{4}-\d{2}-\d{2}T/; +const cell = (value) => + typeof value === "string" && ISO_DATE.test(value) && Date.parse(value) < UNSET_DATE_BEFORE + ? null + : value; + +const columns = Array.isArray(data?.DisplayedColumns) ? data.DisplayedColumns : []; +const used = new Set(); +const names = columns.map((column) => { + const header = String(column.HeaderText || column.ColumnName).replace(/\./g, " ").trim(); + let name = header; + for (let n = 1; used.has(name); n++) { + name = n === 1 ? `${header} (${column.ColumnName})` : `${header} (${column.ColumnName} ${n})`; + } + used.add(name); + // Built-in ID columns (AssetID, TicketID…) are numbers on the wire, which would be shown + // with a thousands separator ("42,665"), so they are passed as text. + return { key: column.ColumnName, name, isId: /ID$/.test(column.ColumnName) }; +}); + +result = (Array.isArray(data?.DataRows) ? data.DataRows : []).map((row) => + Object.fromEntries( + names.map(({ key, name, isId }) => { + const value = cell(row[key] ?? null); + return [name, isId && typeof value === "number" ? String(value) : value]; + }) + ) +); diff --git a/plugins/TeamDynamix/v1/dataStreams/scripts/reports.js b/plugins/TeamDynamix/v1/dataStreams/scripts/reports.js new file mode 100644 index 00000000..a82ed1d7 --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/scripts/reports.js @@ -0,0 +1,12 @@ +// Feeds the report picker on the Report stream. Names alone aren't unique (a tenant can hold +// hundreds of reports, several with the same name in different applications), so the label +// carries the report's source and application. +result = (Array.isArray(data) ? data : []) + .map((report) => ({ + label: `${report.Name} (${[report.ReportSourceName, report.PlatformAppName].filter(Boolean).join(", ")})`, + value: String(report.ID), + sourceName: report.ReportSourceName || "", + appName: report.PlatformAppName || "", + owningGroupName: report.OwningGroupName || "" + })) + .sort((a, b) => a.label.localeCompare(b.label)); diff --git a/plugins/TeamDynamix/v1/dataStreams/teamTickets.json b/plugins/TeamDynamix/v1/dataStreams/teamTickets.json new file mode 100644 index 00000000..7be4e0cf --- /dev/null +++ b/plugins/TeamDynamix/v1/dataStreams/teamTickets.json @@ -0,0 +1,133 @@ +{ + "name": "teamTickets", + "displayName": "Team Tickets", + "description": "Tickets assigned to one or more groups, one row per ticket, searched in the ticketing application the groups work in", + "tags": ["Tickets", "Service Management"], + "baseDataSourceName": "httpRequestScoped", + "config": { + "httpMethod": "post", + "endpointPath": "api/team/tickets/search", + "postBody": { + "MaxResults": "{{(typeof maxResults !== 'undefined' && maxResults) || dataSource.maxRecords || 2000}}", + "StatusClassIDs": "{{typeof statusClass === 'undefined' || !statusClass || !statusClass.length ? null : [].concat(statusClass).map(Number)}}", + "CreatedDateFrom": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Created' ? null : timeframe.start}}", + "CreatedDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Created' ? null : timeframe.end}}", + "ModifiedDateFrom": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Modified' ? null : timeframe.start}}", + "ModifiedDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Modified' ? null : timeframe.end}}", + "ClosedDateFrom": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Closed' ? null : timeframe.start}}", + "ClosedDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Closed' ? null : timeframe.end}}", + "PrimaryResponsibilityGroupIDs": "{{objects.map((o) => Number(o.rawId))}}", + "SlaViolationStatus": "{{typeof sla !== 'undefined' && sla === 'violated' ? true : null}}" + }, + "paging": { "mode": "none" }, + "postRequestScript": "tickets.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/rateLimit.js" + } + }, + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Group"] } + }, + "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.", + "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": "switch", + "name": "sla", + "label": "SLA", + "defaultValue": "all", + "options": [ + { "value": "all", "label": "All" }, + { "value": "violated", "label": "Violated only" } + ] + }, + { + "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.", + "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": "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": "isSlaResolveByViolated", "displayName": "Resolve By Missed", "shape": "boolean", "visible": false }, + { "name": "resolveByDate", "displayName": "Resolve By", "shape": "date" }, + { "name": "isOnHold", "displayName": "On Hold", "shape": "boolean" }, + { "name": "actualMinutes", "displayName": "Actual", "shape": "minutes" }, + { "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/tickets.json b/plugins/TeamDynamix/v1/dataStreams/tickets.json index 1108a1b2..a017d2e2 100644 --- a/plugins/TeamDynamix/v1/dataStreams/tickets.json +++ b/plugins/TeamDynamix/v1/dataStreams/tickets.json @@ -16,7 +16,8 @@ "ModifiedDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Modified' ? null : timeframe.end}}", "ClosedDateFrom": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Closed' ? null : timeframe.start}}", "ClosedDateTo": "{{typeof timeframe === 'undefined' || timeframe.enum === 'none' || ((typeof dateField !== 'undefined' && dateField) || 'Created') !== 'Closed' ? null : timeframe.end}}", - "PrimaryResponsibilityGroupIDs": "{{typeof groups !== 'undefined' && Array.isArray(groups) && groups.length ? groups.flatMap((o) => o.rawId).map(Number) : null}}" + "PrimaryResponsibilityGroupIDs": "{{typeof groups !== 'undefined' && Array.isArray(groups) && groups.length ? groups.flatMap((o) => o.rawId).map(Number) : null}}", + "SlaViolationStatus": "{{typeof sla !== 'undefined' && sla === 'violated' ? true : null}}" }, "paging": { "mode": "none" }, "postRequestScript": "tickets.js", @@ -67,6 +68,16 @@ "sourceType": { "type": "oneOf", "values": ["TeamDynamix Group"] } } }, + { + "type": "switch", + "name": "sla", + "label": "SLA", + "defaultValue": "all", + "options": [ + { "value": "all", "label": "All" }, + { "value": "violated", "label": "Violated only" } + ] + }, { "type": "number", "name": "maxResults", diff --git a/plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json b/plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json index 9c464499..ddf0d04d 100644 --- a/plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json +++ b/plugins/TeamDynamix/v1/defaultContent/assetsDashboard.dash.json @@ -98,7 +98,7 @@ "config": { "_type": "tile/data-stream", "title": "Purchase Value", - "description": "", + "description": "Purchase cost of the assets loaded (up to Maximum records per request)", "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { @@ -181,7 +181,7 @@ "config": { "_type": "tile/data-stream", "title": "Assets by Status", - "description": "", + "description": "Assets loaded, up to Maximum records per request, by status", "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { @@ -228,7 +228,7 @@ "config": { "_type": "tile/data-stream", "title": "Top Manufacturers", - "description": "", + "description": "Assets loaded, up to Maximum records per request, by manufacturer", "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { @@ -285,7 +285,7 @@ "config": { "_type": "tile/data-stream", "title": "Assets Due for Replacement", - "description": "", + "description": "Assets loaded, up to Maximum records per request, with an expected replacement date", "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { diff --git a/plugins/TeamDynamix/v1/defaultContent/manifest.json b/plugins/TeamDynamix/v1/defaultContent/manifest.json index 3b40208f..50373c04 100644 --- a/plugins/TeamDynamix/v1/defaultContent/manifest.json +++ b/plugins/TeamDynamix/v1/defaultContent/manifest.json @@ -2,6 +2,7 @@ "items": [ { "name": "overviewDashboard", "type": "dashboard" }, { "name": "serviceDeskDashboard", "type": "dashboard" }, + { "name": "teamPerformance", "type": "dashboard" }, { "name": "assetsDashboard", "type": "dashboard" } ] } diff --git a/plugins/TeamDynamix/v1/defaultContent/overviewDashboard.dash.json b/plugins/TeamDynamix/v1/defaultContent/overviewDashboard.dash.json index 0a7ad1c3..0b9a3b8c 100644 --- a/plugins/TeamDynamix/v1/defaultContent/overviewDashboard.dash.json +++ b/plugins/TeamDynamix/v1/defaultContent/overviewDashboard.dash.json @@ -52,7 +52,7 @@ "config": { "_type": "tile/data-stream", "title": "Tickets", - "description": "Open tickets across all locations", + "description": "Open tickets that have a location", "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { @@ -88,7 +88,7 @@ "config": { "_type": "tile/data-stream", "title": "Assets", - "description": "Assets across all locations", + "description": "Assets that have a location", "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { @@ -124,7 +124,7 @@ "config": { "_type": "tile/data-stream", "title": "Configuration Items", - "description": "Configuration items across all locations", + "description": "Configuration items that have a location", "timeframe": "none", "activePluginConfigIds": ["{{configId}}"], "dataStream": { diff --git a/plugins/TeamDynamix/v1/defaultContent/scopes.json b/plugins/TeamDynamix/v1/defaultContent/scopes.json index e8209d48..a90e00e6 100644 --- a/plugins/TeamDynamix/v1/defaultContent/scopes.json +++ b/plugins/TeamDynamix/v1/defaultContent/scopes.json @@ -11,6 +11,18 @@ "type": "object" } }, + { + "name": "Teams", + "matches": { + "sourceType": { "type": "oneOf", "values": ["TeamDynamix Group"] } + }, + "variable": { + "name": "Team", + "allowMultipleSelection": true, + "default": "none", + "type": "object" + } + }, { "name": "Asset Applications", "matches": { diff --git a/plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json b/plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json index dcbd48d3..0543510c 100644 --- a/plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json +++ b/plugins/TeamDynamix/v1/defaultContent/serviceDeskDashboard.dash.json @@ -334,16 +334,9 @@ "id": "{{dataStreams.[tickets]}}", "name": "tickets", "pluginConfigId": "{{configId}}", - "dataSourceConfig": { "statusClass": ["1", "2", "5", "6"] }, - "filter": { - "multiOperation": "and", - "filters": [ - { - "column": "isSlaViolated", - "operation": "equals", - "value": "true" - } - ] + "dataSourceConfig": { + "statusClass": ["1", "2", "5", "6"], + "sla": "violated" }, "sort": { "by": [["resolveByDate", "asc"]] } }, diff --git a/plugins/TeamDynamix/v1/defaultContent/teamPerformance.dash.json b/plugins/TeamDynamix/v1/defaultContent/teamPerformance.dash.json new file mode 100644 index 00000000..b8307cbf --- /dev/null +++ b/plugins/TeamDynamix/v1/defaultContent/teamPerformance.dash.json @@ -0,0 +1,1414 @@ +{ + "name": "Team Performance", + "schemaVersion": "1.5", + "timeframe": "last30days", + "variables": [ + "{{variables.[Team]}}" + ], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "b4644805-b175-57cd-9c8c-bcaea7adfac8", + "x": 0, + "y": 0, + "w": 2, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Backlog Now", + "description": "Open tickets for the selected teams right now, whatever the dashboard timeframe", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"o\" AS (SELECT *, CASE WHEN COALESCE(\"responsibleFullName\", '') IN ('', '(Unassigned)') THEN '(Unassigned)' ELSE \"responsibleFullName\" END AS \"tech\", \"statusClassName\" = 'On Hold' AS \"onHold\" FROM \"open\")\nSELECT COUNT(*) AS \"open\",\n COUNT(*) FILTER (WHERE NOT \"onHold\") AS \"active\",\n COUNT(*) FILTER (WHERE \"onHold\") AS \"onHold\",\n COUNT(*) FILTER (WHERE \"tech\" = '(Unassigned)') AS \"unassigned\",\n COUNT(*) FILTER (WHERE NOT \"onHold\" AND \"daysOld\" > 7) AS \"activeOlderThan7d\",\n ROUND(MEDIAN(\"daysOld\"), 1) AS \"medianAgeDays\"\nFROM \"o\"", + "tables": [ + { + "tableName": "open", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "statusClass": [ + "1", + "2", + "5", + "6" + ] + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + }, + "timeframe": "none" + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "scalar", + "encoding": { + "values": [ + { + "field": "open", + "type": "quantitative", + "title": "Open", + "aggregate": "sum" + }, + { + "field": "active", + "type": "quantitative", + "title": "Active", + "aggregate": "sum" + }, + { + "field": "onHold", + "type": "quantitative", + "title": "On hold", + "aggregate": "sum" + }, + { + "field": "unassigned", + "type": "quantitative", + "title": "Unassigned", + "aggregate": "sum" + }, + { + "field": "activeOlderThan7d", + "type": "quantitative", + "title": "Active > 7 days", + "aggregate": "sum" + }, + { + "field": "medianAgeDays", + "type": "quantitative", + "title": "Median age (days)", + "aggregate": "sum" + } + ] + }, + "style": { + "scalarColumns": 3 + } + } + }, + "timeframe": "none" + } + }, + { + "i": "abb1ac20-1abc-5918-96de-498be40604b9", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "This Period", + "description": "Tickets opened and closed in the dashboard timeframe, and the median time to resolve", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"c\" AS (SELECT * FROM \"created\"),\n\"d\" AS (SELECT *, CASE WHEN COALESCE(\"responsibleFullName\", '') IN ('', '(Unassigned)') THEN '(Unassigned)' ELSE \"responsibleFullName\" END AS \"tech\" FROM \"closed\")\nSELECT (SELECT COUNT(*) FROM \"c\") AS \"opened\",\n (SELECT COUNT(*) FROM \"d\") AS \"closed\",\n ROUND((SELECT MEDIAN(DATE_DIFF('minute', \"createdDate\", \"completedDate\")) / 1440.0 FROM \"d\" WHERE \"statusClassName\" = 'Completed' AND \"completedDate\" IS NOT NULL), 1) AS \"medianResolveDays\"", + "tables": [ + { + "tableName": "created", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dateField": "Created" + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + } + } + }, + { + "tableName": "closed", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dateField": "Closed" + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + } + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "scalar", + "encoding": { + "values": [ + { + "field": "opened", + "type": "quantitative", + "title": "Opened", + "aggregate": "sum" + }, + { + "field": "closed", + "type": "quantitative", + "title": "Closed", + "aggregate": "sum" + }, + { + "field": "medianResolveDays", + "type": "quantitative", + "title": "Median resolve (days)", + "aggregate": "sum" + } + ] + }, + "style": { + "scalarColumns": 1 + } + } + } + } + }, + { + "i": "bad5ed24-d763-574f-8820-4524eda3f411", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Resolved by Due Date", + "description": "Tickets resolved in the dashboard timeframe that had a due date, and the share resolved by it", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"d\" AS (SELECT *, CASE WHEN COALESCE(\"responsibleFullName\", '') IN ('', '(Unassigned)') THEN '(Unassigned)' ELSE \"responsibleFullName\" END AS \"tech\" FROM \"closed\")\nSELECT ROUND(100.0 * COUNT(*) FILTER (WHERE \"completedDate\" <= \"resolveByDate\") / NULLIF(COUNT(*), 0), 1) AS \"resolvedByDuePct\"\nFROM \"d\" WHERE \"statusClassName\" = 'Completed' AND \"completedDate\" IS NOT NULL AND \"resolveByDate\" IS NOT NULL", + "tables": [ + { + "tableName": "closed", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dateField": "Closed" + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + } + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "gauge", + "encoding": { + "value": { + "field": "resolvedByDuePct", + "type": "quantitative", + "title": "Resolved by due date" + } + }, + "style": { + "gaugeMin": 0, + "gaugeMax": 100, + "gaugeLabelText": "% on time", + "gaugeBands": [ + { + "to": 0.8, + "color": "#ef4444" + }, + { + "to": 0.95, + "color": "#f59e0b" + }, + { + "to": 1, + "color": "#22c55e" + } + ] + } + } + } + } + }, + { + "i": "65abfdbe-fd7d-5e64-8f90-43e3df2b58b4", + "x": 0, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Insights", + "description": "Rules over the open backlog and the period's arrivals and closures", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"o\" AS (SELECT *, CASE WHEN COALESCE(\"responsibleFullName\", '') IN ('', '(Unassigned)') THEN '(Unassigned)' ELSE \"responsibleFullName\" END AS \"tech\", \"statusClassName\" = 'On Hold' AS \"onHold\" FROM \"open\"),\n\"c\" AS (SELECT * FROM \"created\"),\n\"d\" AS (SELECT *, CASE WHEN COALESCE(\"responsibleFullName\", '') IN ('', '(Unassigned)') THEN '(Unassigned)' ELSE \"responsibleFullName\" END AS \"tech\" FROM \"closed\"),\n\"t\" AS (SELECT \"tech\", COUNT(*) AS \"n\" FROM \"o\" WHERE \"tech\" <> '(Unassigned)' GROUP BY \"tech\"),\n\"top\" AS (SELECT \"tech\", \"n\" FROM \"t\" ORDER BY \"n\" DESC, \"tech\" LIMIT 1),\n\"oldest\" AS (SELECT \"ticketId\", \"daysOld\", \"tech\" FROM \"o\" WHERE NOT \"onHold\" ORDER BY \"daysOld\" DESC NULLS LAST LIMIT 1),\n\"s\" AS (\n SELECT\n (SELECT COUNT(*) FROM \"o\" WHERE NOT \"onHold\") AS \"active\",\n (SELECT COUNT(*) FROM \"o\" WHERE \"onHold\") AS \"onHold\",\n (SELECT COUNT(*) FROM \"c\") AS \"opened\",\n (SELECT COUNT(*) FROM \"d\") AS \"closed\",\n (SELECT COUNT(*) FROM \"o\" WHERE NOT \"onHold\" AND \"daysOld\" > 7) AS \"gt7\",\n (SELECT COUNT(*) FROM \"o\" WHERE NOT \"onHold\" AND \"daysOld\" > 30) AS \"gt30\",\n (SELECT COUNT(*) FROM \"o\" WHERE \"onHold\" AND \"daysOld\" > 30) AS \"holdGt30\",\n (SELECT COUNT(*) FROM \"o\" WHERE \"tech\" = '(Unassigned)') AS \"unassigned\",\n (SELECT COUNT(*) FROM \"o\" WHERE (LOWER(\"priorityName\") LIKE '%high%' OR LOWER(\"priorityName\") LIKE '%critical%'\n OR LOWER(\"priorityName\") LIKE '%urgent%' OR LOWER(\"priorityName\") LIKE '%emergency%')) AS \"high\",\n (SELECT COUNT(*) FROM \"o\" WHERE \"isSlaViolated\") AS \"slaViolated\",\n (SELECT MEDIAN(\"n\") FROM \"t\") AS \"medianLoad\"\n)\nSELECT \"severity\", \"rule\", \"insight\" FROM (\n SELECT 1 AS \"ord\", 'Capacity' AS \"rule\",\n CASE WHEN \"opened\" > 1.1 * \"closed\" THEN 'warning' ELSE 'success' END AS \"severity\",\n CASE WHEN \"opened\" > \"closed\"\n THEN 'Arrivals outpaced closures: ' || \"opened\" || ' opened, ' || \"closed\" || ' closed (net +' || (\"opened\" - \"closed\") || ').'\n ELSE 'Closures kept pace with arrivals: ' || \"opened\" || ' opened, ' || \"closed\" || ' closed (net ' || (\"opened\" - \"closed\") || ').' END AS \"insight\"\n FROM \"s\"\n UNION ALL\n SELECT 2, 'SLA',\n CASE WHEN \"slaViolated\" > 0 THEN 'error' ELSE 'success' END,\n CASE WHEN \"slaViolated\" = 0 THEN 'No open ticket has breached its SLA.'\n WHEN \"slaViolated\" = 1 THEN '1 open ticket has breached its SLA.'\n ELSE \"slaViolated\" || ' open tickets have breached their SLA.' END\n FROM \"s\"\n UNION ALL\n SELECT 3, 'Aging',\n CASE WHEN \"gt7\" > 0.5 * \"active\" THEN 'error' WHEN \"gt7\" > 0 THEN 'warning' ELSE 'success' END,\n \"gt7\" || ' of ' || \"active\" || ' active tickets (' || COALESCE(ROUND(100.0 * \"gt7\" / NULLIF(\"active\", 0))::INTEGER, 0)\n || '%) are older than 7 days. ' || \"onHold\" || ' more are on hold, waiting on someone else.'\n FROM \"s\"\n UNION ALL\n SELECT 4, 'Stale',\n CASE WHEN \"gt30\" > 0 THEN 'error' WHEN \"holdGt30\" > 0 THEN 'warning' ELSE 'success' END,\n CASE WHEN \"gt30\" = 0 THEN 'No active ticket is' WHEN \"gt30\" = 1 THEN '1 active ticket is' ELSE \"gt30\" || ' active tickets are' END\n || ' older than 30 days'\n || COALESCE('; the oldest active ticket is #' || (SELECT \"ticketId\" FROM \"oldest\") || ' (' || (SELECT ROUND(\"daysOld\")::INTEGER FROM \"oldest\")\n || ' days, ' || (SELECT \"tech\" FROM \"oldest\") || ')', '')\n || '. ' || CASE WHEN \"holdGt30\" = 0 THEN 'No on-hold ticket has' WHEN \"holdGt30\" = 1 THEN '1 on-hold ticket has' ELSE \"holdGt30\" || ' on-hold tickets have' END\n || ' been open over 30 days.'\n FROM \"s\"\n UNION ALL\n SELECT 5, 'Ownership',\n CASE WHEN \"unassigned\" > 0 THEN 'warning' ELSE 'success' END,\n CASE WHEN \"unassigned\" = 0 THEN 'Every open ticket has a named technician.'\n WHEN \"unassigned\" = 1 THEN '1 open ticket has no named technician.'\n ELSE \"unassigned\" || ' open tickets have no named technician.' END\n FROM \"s\"\n UNION ALL\n SELECT 6, 'Balance',\n CASE WHEN (SELECT \"n\" FROM \"top\") >= 2 * \"medianLoad\" THEN 'warning' ELSE 'success' END,\n COALESCE((SELECT \"tech\" || ' holds ' || \"n\" FROM \"top\") || ' open tickets against a team median of ' || ROUND(\"medianLoad\", 1) || '.',\n 'No open tickets are assigned to a named technician.')\n FROM \"s\"\n UNION ALL\n SELECT 7, 'Priority',\n CASE WHEN \"high\" > 0 THEN 'warning' ELSE 'success' END,\n CASE WHEN \"high\" = 1 THEN '1 high or critical priority ticket is open.'\n ELSE \"high\" || ' high or critical priority tickets are open.' END\n FROM \"s\"\n) ORDER BY \"ord\"", + "tables": [ + { + "tableName": "open", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "statusClass": [ + "1", + "2", + "5", + "6" + ] + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "created", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dateField": "Created" + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + } + } + }, + { + "tableName": "closed", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dateField": "Closed" + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + } + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "severity", + "title": "", + "renderer": { + "type": "status", + "showLabel": false + }, + "width": 40 + }, + { + "field": "rule", + "title": "Rule", + "renderer": { + "type": "badge", + "colors": { + "Capacity": "#3b82f6", + "SLA": "#ef4444", + "Aging": "#f59e0b", + "Stale": "#ef4444", + "Ownership": "#f59e0b", + "Balance": "#a855f7", + "Priority": "#f97316" + } + }, + "width": 110 + }, + { + "field": "insight", + "title": "Insight", + "renderer": { + "type": "text" + } + } + ] + }, + "style": { + "richTableWrapText": true, + "richTableNoHeader": true + } + } + } + } + }, + { + "i": "8568b64a-4517-555a-b294-b1481a349ad3", + "x": 2, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Where Tickets Went", + "description": "Tickets opened in the dashboard timeframe: team, then classification, then where each ticket is now", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"c\" AS (SELECT * FROM \"created\"),\n\"x\" AS (SELECT \"responsibleGroupName\" AS \"team\",\n CASE WHEN \"classificationName\" = '' THEN '(No classification)' ELSE \"classificationName\" END AS \"cls\",\n CASE WHEN \"statusClassName\" = 'Completed' THEN 'Resolved' WHEN \"statusClassName\" = 'Cancelled' THEN 'Cancelled'\n WHEN \"statusClassName\" = 'On Hold' THEN 'On hold' ELSE 'Still open' END AS \"outcome\"\n FROM \"c\"),\n\"topCls\" AS (SELECT \"cls\" FROM \"x\" GROUP BY \"cls\" ORDER BY COUNT(*) DESC, \"cls\" LIMIT 6),\n\"y\" AS (SELECT \"team\", CASE WHEN \"cls\" IN (SELECT \"cls\" FROM \"topCls\") THEN \"cls\" ELSE 'Other' END AS \"cls\", \"outcome\" FROM \"x\")\nSELECT \"team\" AS \"source\", \"cls\" AS \"target\", COUNT(*) AS \"tickets\" FROM \"y\" GROUP BY 1, 2\nUNION ALL\nSELECT \"cls\", \"outcome\", COUNT(*) FROM \"y\" GROUP BY 1, 2", + "tables": [ + { + "tableName": "created", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dateField": "Created" + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + } + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "sankey", + "encoding": { + "source": { + "field": "source", + "type": "nominal" + }, + "target": { + "field": "target", + "type": "nominal" + }, + "value": { + "field": "tickets", + "type": "quantitative", + "title": "Tickets" + } + }, + "style": { + "labelFontSize": 12, + "colors": [ + "#6366f1", + "#8b5cf6", + "#ec4899", + "#14b8a6", + "#0ea5e9", + "#a855f7", + "#06b6d4", + "#f97316" + ], + "mappedColors": { + "Resolved": "#22c55e", + "Cancelled": "#94a3b8", + "On hold": "#f59e0b", + "Still open": "#3b82f6", + "Other": "#64748b" + } + } + } + } + } + }, + { + "i": "9529b9c8-3bec-5485-bdf6-b1ab1601cdce", + "x": 0, + "y": 5, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Arrivals vs Closures", + "description": "Tickets opened and closed per day", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"c\" AS (SELECT * FROM \"created\"),\n\"d\" AS (SELECT *, CASE WHEN COALESCE(\"responsibleFullName\", '') IN ('', '(Unassigned)') THEN '(Unassigned)' ELSE \"responsibleFullName\" END AS \"tech\" FROM \"closed\")\nSELECT STRFTIME(DATE_TRUNC('day', \"day\"), '%Y-%m-%dT%H:%M:%SZ') AS \"day\",\n SUM(\"opened\") AS \"opened\", SUM(\"closed\") AS \"closed\" FROM (\n SELECT \"createdDate\" AS \"day\", 1 AS \"opened\", 0 AS \"closed\" FROM \"c\"\n UNION ALL\n SELECT \"completedDate\", 0, 1 FROM \"d\" WHERE \"completedDate\" IS NOT NULL)\nGROUP BY 1 ORDER BY 1", + "tables": [ + { + "tableName": "created", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dateField": "Created" + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + } + } + }, + { + "tableName": "closed", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dateField": "Closed" + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + } + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "day", + "type": "temporal", + "title": "Day" + } + }, + "style": { + "cartesianAreaGradient": true, + "cartesianSmooth": true, + "cartesianSymbol": "none", + "mappedColors": { + "opened": "#f59e0b", + "closed": "#22c55e" + }, + "legendPosition": "bottom" + } + } + } + } + }, + { + "i": "401d0404-1707-579b-ba51-49abc1d89e3e", + "x": 2, + "y": 5, + "w": 1, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Backlog Aging", + "description": "Open tickets by age: active, and on hold waiting on someone else", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"o\" AS (SELECT *, CASE WHEN COALESCE(\"responsibleFullName\", '') IN ('', '(Unassigned)') THEN '(Unassigned)' ELSE \"responsibleFullName\" END AS \"tech\", \"statusClassName\" = 'On Hold' AS \"onHold\" FROM \"open\")\nSELECT \"bucket\", COUNT(*) FILTER (WHERE NOT \"onHold\") AS \"active\", COUNT(*) FILTER (WHERE \"onHold\") AS \"onHold\" FROM (\n SELECT *, CASE WHEN \"daysOld\" <= 2 THEN '0-2 days' WHEN \"daysOld\" <= 7 THEN '3-7 days'\n WHEN \"daysOld\" <= 14 THEN '8-14 days' WHEN \"daysOld\" <= 30 THEN '15-30 days'\n ELSE 'Over 30 days' END AS \"bucket\",\n CASE WHEN \"daysOld\" <= 2 THEN 1 WHEN \"daysOld\" <= 7 THEN 2 WHEN \"daysOld\" <= 14 THEN 3\n WHEN \"daysOld\" <= 30 THEN 4 ELSE 5 END AS \"ord\"\n FROM \"o\")\nGROUP BY \"bucket\", \"ord\" ORDER BY \"ord\"", + "tables": [ + { + "tableName": "open", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "statusClass": [ + "1", + "2", + "5", + "6" + ] + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + }, + "timeframe": "none" + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "bar", + "encoding": { + "x": { + "field": "bucket", + "type": "nominal", + "title": "Age" + } + }, + "style": { + "cartesianStack": "on", + "mappedColors": { + "active": "#3b82f6", + "onHold": "#94a3b8" + }, + "legendPosition": "bottom", + "cartesianBarBorderRadius": 3 + } + } + }, + "timeframe": "none" + } + }, + { + "i": "833f3bb4-c4ea-5f9d-abfd-ffecd949a524", + "x": 3, + "y": 5, + "w": 1, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Priority Mix", + "description": "Open tickets by priority", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"o\" AS (SELECT *, CASE WHEN COALESCE(\"responsibleFullName\", '') IN ('', '(Unassigned)') THEN '(Unassigned)' ELSE \"responsibleFullName\" END AS \"tech\", \"statusClassName\" = 'On Hold' AS \"onHold\" FROM \"open\")\nSELECT CASE WHEN \"priorityName\" = '' THEN '(None)' ELSE \"priorityName\" END AS \"priority\", COUNT(*) AS \"open\"\nFROM \"o\" GROUP BY 1 ORDER BY MIN(\"priorityOrder\"), \"priority\"", + "tables": [ + { + "tableName": "open", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "statusClass": [ + "1", + "2", + "5", + "6" + ] + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + }, + "timeframe": "none" + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "pie", + "encoding": { + "label": { + "field": "priority", + "type": "nominal" + }, + "value": { + "field": "open", + "type": "quantitative", + "aggregate": "sum" + } + }, + "style": { + "pieInnerRadius": 70, + "pieDonutCenterValue": "total", + "pieLabels": "none", + "piePadAngle": 2, + "pieBorderRadius": 4, + "mappedColors": { + "Critical": "#ef4444", + "Emergency": "#ef4444", + "High": "#f97316", + "Medium": "#f59e0b", + "Low": "#94a3b8" + } + } + } + }, + "timeframe": "none" + } + }, + { + "i": "88ed00c0-03d0-58e9-b89a-9d2ffe551dc3", + "x": 0, + "y": 8, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Staff Workload", + "description": "Open work per technician against the team median, and what each closed in the dashboard timeframe", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"o\" AS (SELECT *, CASE WHEN COALESCE(\"responsibleFullName\", '') IN ('', '(Unassigned)') THEN '(Unassigned)' ELSE \"responsibleFullName\" END AS \"tech\", \"statusClassName\" = 'On Hold' AS \"onHold\" FROM \"open\"),\n\"d\" AS (SELECT *, CASE WHEN COALESCE(\"responsibleFullName\", '') IN ('', '(Unassigned)') THEN '(Unassigned)' ELSE \"responsibleFullName\" END AS \"tech\" FROM \"closed\"),\n\"ow\" AS (SELECT \"tech\", COUNT(*) AS \"open\", COUNT(*) FILTER (WHERE \"onHold\") AS \"onHold\",\n COUNT(*) FILTER (WHERE NOT \"onHold\" AND \"daysOld\" > 7) AS \"activeOlderThan7d\",\n COUNT(*) FILTER (WHERE NOT \"onHold\" AND \"daysOld\" > 30) AS \"activeOlderThan30d\",\n MAX(\"daysOld\") AS \"oldestDays\"\n FROM \"o\" GROUP BY \"tech\"),\n\"cw\" AS (SELECT \"tech\", COUNT(*) AS \"closed\",\n MEDIAN(DATE_DIFF('minute', \"createdDate\", \"completedDate\")) FILTER (WHERE \"statusClassName\" = 'Completed' AND \"completedDate\" IS NOT NULL) / 60.0 AS \"medianResolveHours\"\n FROM \"d\" GROUP BY \"tech\"),\n\"g\" AS (SELECT \"tech\", STRING_AGG(DISTINCT \"responsibleGroupName\", ', ' ORDER BY \"responsibleGroupName\") AS \"team\" FROM (\n SELECT \"tech\", \"responsibleGroupName\" FROM \"o\" UNION ALL SELECT \"tech\", \"responsibleGroupName\" FROM \"d\") GROUP BY \"tech\"),\n\"m\" AS (SELECT MEDIAN(\"open\") AS \"teamMedian\" FROM \"ow\" WHERE \"tech\" <> '(Unassigned)')\nSELECT \"g\".\"tech\" AS \"technician\", \"g\".\"team\",\n COALESCE(\"open\", 0) AS \"open\", COALESCE(\"onHold\", 0) AS \"onHold\",\n COALESCE(\"activeOlderThan7d\", 0) AS \"activeOlderThan7d\", COALESCE(\"activeOlderThan30d\", 0) AS \"activeOlderThan30d\",\n ROUND(\"oldestDays\") AS \"oldestDays\",\n COALESCE(\"cw\".\"closed\", 0) AS \"closedInPeriod\", ROUND(\"medianResolveHours\", 1) AS \"medianResolveHours\",\n ROUND(100.0 * COALESCE(\"open\", 0) / NULLIF((SELECT \"teamMedian\" FROM \"m\"), 0)) AS \"loadVsMedianPct\"\nFROM \"g\" LEFT JOIN \"ow\" ON \"ow\".\"tech\" = \"g\".\"tech\" LEFT JOIN \"cw\" ON \"cw\".\"tech\" = \"g\".\"tech\"\nORDER BY \"open\" DESC, \"closedInPeriod\" DESC", + "tables": [ + { + "tableName": "open", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "statusClass": [ + "1", + "2", + "5", + "6" + ] + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "closed", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dateField": "Closed" + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + } + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "technician", + "title": "Technician", + "renderer": { + "type": "text", + "colors": { + "(Unassigned)": "#ef4444" + } + } + }, + { + "field": "team", + "title": "Team", + "renderer": { + "type": "text" + } + }, + { + "field": "open", + "title": "Open", + "renderer": { + "type": "bar", + "color": "#3b82f6" + } + }, + { + "field": "onHold", + "title": "On hold", + "renderer": { + "type": "number" + }, + "align": "right" + }, + { + "field": "activeOlderThan7d", + "title": "Active > 7d", + "renderer": { + "type": "number", + "thresholds": [ + { + "to": 0, + "color": "#22c55e" + }, + { + "to": 3, + "color": "#f59e0b" + }, + { + "to": 100000, + "color": "#ef4444" + } + ] + }, + "align": "right" + }, + { + "field": "activeOlderThan30d", + "title": "Active > 30d", + "renderer": { + "type": "number", + "thresholds": [ + { + "to": 0, + "color": "#22c55e" + }, + { + "to": 100000, + "color": "#ef4444" + } + ] + }, + "align": "right" + }, + { + "field": "oldestDays", + "title": "Oldest (days)", + "renderer": { + "type": "number", + "decimals": 0 + }, + "align": "right" + }, + { + "field": "closedInPeriod", + "title": "Closed", + "renderer": { + "type": "bar", + "color": "#22c55e" + } + }, + { + "field": "medianResolveHours", + "title": "Median resolve (h)", + "renderer": { + "type": "number", + "decimals": 1 + }, + "align": "right" + }, + { + "field": "loadVsMedianPct", + "title": "Load vs median", + "renderer": { + "type": "number", + "suffix": "%", + "decimals": 0, + "thresholds": [ + { + "to": 150, + "color": "#22c55e" + }, + { + "to": 200, + "color": "#f59e0b" + }, + { + "to": 100000, + "color": "#ef4444" + } + ] + }, + "align": "right" + } + ] + }, + "style": { + "richTableSort": { + "field": "open", + "direction": "desc" + }, + "richTableCellPadding": 4 + } + } + } + } + }, + { + "i": "1a1b29f3-4c48-5965-8249-b1c31bdf5f7b", + "x": 0, + "y": 12, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Demand by Service", + "description": "Tickets opened in the dashboard timeframe for the eight busiest services, split by classification", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"c\" AS (SELECT * FROM \"created\"),\n\"x\" AS (SELECT CASE WHEN \"serviceName\" = '' THEN '(No service)' ELSE \"serviceName\" END AS \"service\",\n CASE WHEN \"classificationName\" = '' THEN '(No classification)' ELSE \"classificationName\" END AS \"classification\"\n FROM \"c\"),\n\"topSvc\" AS (SELECT \"service\" FROM \"x\" GROUP BY \"service\" ORDER BY COUNT(*) DESC, \"service\" LIMIT 8)\nSELECT \"service\", \"classification\", COUNT(*) AS \"tickets\"\nFROM \"x\" WHERE \"service\" IN (SELECT \"service\" FROM \"topSvc\") GROUP BY 1, 2 ORDER BY \"tickets\" DESC", + "tables": [ + { + "tableName": "created", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dateField": "Created" + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + } + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "treemap", + "encoding": { + "label": { + "field": "classification", + "type": "nominal" + }, + "parent": { + "field": "service", + "type": "nominal" + }, + "value": { + "field": "tickets", + "type": "quantitative", + "title": "Tickets" + } + }, + "style": { + "colors": [ + "#6366f1", + "#8b5cf6", + "#ec4899", + "#14b8a6", + "#0ea5e9", + "#a855f7", + "#06b6d4", + "#f97316" + ], + "showLabels": true, + "treemapGap": 2 + } + } + } + } + }, + { + "i": "82017584-55c1-553e-bb21-fb854b89feb6", + "x": 2, + "y": 12, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "When Tickets Arrive", + "description": "Tickets opened in the dashboard timeframe by weekday and hour (UTC)", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"c\" AS (SELECT * FROM \"created\")\nSELECT STRFTIME(\"createdDate\", '%w %a') AS \"weekday\", LPAD(HOUR(\"createdDate\")::VARCHAR, 2, '0') || ':00' AS \"hourUtc\",\n COUNT(*) AS \"tickets\"\nFROM \"c\" GROUP BY 1, 2 ORDER BY 1, 2", + "tables": [ + { + "tableName": "created", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dateField": "Created" + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + } + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "heatmap", + "encoding": { + "x": { + "field": "hourUtc", + "type": "nominal", + "title": "Hour (UTC)" + }, + "y": { + "field": "weekday", + "type": "nominal", + "title": "Weekday" + }, + "color": { + "field": "tickets", + "type": "quantitative", + "title": "Tickets" + } + }, + "style": { + "colorScheme": "squaredUpBlue", + "heatmapCellBorderRadius": 2, + "heatmapCellBorderWidth": 2, + "heatmapShowColorScale": false + } + } + } + } + }, + { + "i": "ed46736a-3403-5250-b730-e35174e33619", + "x": 0, + "y": 15, + "w": 4, + "h": 5, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Open Tickets", + "description": "Open tickets for the selected teams (up to Maximum records per request), active work first, oldest first", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH \"o\" AS (SELECT *, CASE WHEN COALESCE(\"responsibleFullName\", '') IN ('', '(Unassigned)') THEN '(Unassigned)' ELSE \"responsibleFullName\" END AS \"tech\", \"statusClassName\" = 'On Hold' AS \"onHold\" FROM \"open\")\nSELECT \"link\", \"ticketId\", \"title\", \"responsibleGroupName\" AS \"team\", \"tech\" AS \"owner\",\n \"priorityName\" AS \"priority\", \"statusName\" AS \"status\", ROUND(\"daysOld\", 1) AS \"ageDays\",\n \"createdDate\", \"resolveByDate\",\n CASE WHEN \"isSlaViolated\" THEN 'SLA violated'\n WHEN NOT \"onHold\" AND \"daysOld\" > 30 THEN 'Stale'\n WHEN \"tech\" = '(Unassigned)' THEN 'Unassigned'\n WHEN \"onHold\" THEN 'On hold'\n ELSE 'OK' END AS \"flag\"\nFROM \"o\" ORDER BY \"onHold\", \"daysOld\" DESC", + "tables": [ + { + "tableName": "open", + "config": { + "variables": [ + "{{variables.[Team]}}" + ], + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[teamTickets]}}", + "name": "teamTickets", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "statusClass": [ + "1", + "2", + "5", + "6" + ] + } + }, + "scope": { + "scope": "{{scopes.[Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Team]}}" + }, + "timeframe": "none" + } + } + ] + } + }, + "variables": [ + "{{variables.[Team]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "link", + "title": "ID", + "renderer": { + "type": "link", + "labelField": "ticketId" + }, + "width": 90 + }, + { + "field": "title", + "title": "Title", + "renderer": { + "type": "text" + } + }, + { + "field": "team", + "title": "Team", + "renderer": { + "type": "text" + } + }, + { + "field": "owner", + "title": "Owner", + "renderer": { + "type": "text", + "colors": { + "(Unassigned)": "#ef4444" + } + } + }, + { + "field": "priority", + "title": "Priority", + "renderer": { + "type": "badge", + "colors": { + "*Critical*": "#ef4444", + "*Emergency*": "#ef4444", + "*High*": "#f97316", + "*Medium*": "#f59e0b" + } + } + }, + { + "field": "status", + "title": "Status", + "renderer": { + "type": "text" + } + }, + { + "field": "ageDays", + "title": "Age (days)", + "renderer": { + "type": "number", + "decimals": 1 + }, + "align": "right" + }, + { + "field": "createdDate", + "title": "Created", + "renderer": { + "type": "date" + } + }, + { + "field": "resolveByDate", + "title": "Due", + "renderer": { + "type": "date" + } + }, + { + "field": "flag", + "title": "Flag", + "renderer": { + "type": "badge", + "colors": { + "SLA violated": "#ef4444", + "Stale": "#ef4444", + "Unassigned": "#f59e0b", + "On hold": "#94a3b8", + "OK": "#22c55e" + } + } + } + ] + }, + "style": { + "richTableRowHighlight": { + "field": "flag", + "colors": { + "SLA violated": "rgba(239,68,68,0.15)", + "Stale": "rgba(239,68,68,0.10)", + "Unassigned": "rgba(245,158,11,0.12)" + } + }, + "richTableCellPadding": 4 + } + } + }, + "timeframe": "none" + } + } + ] + } +} diff --git a/plugins/TeamDynamix/v1/docs/README.md b/plugins/TeamDynamix/v1/docs/README.md index 1905c53b..7024f8e1 100644 --- a/plugins/TeamDynamix/v1/docs/README.md +++ b/plugins/TeamDynamix/v1/docs/README.md @@ -34,11 +34,14 @@ On save, the plugin signs in, then checks that at least one TeamDynamix applicat - **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. +The out-of-the-box dashboards are an organization-wide **Overview**, a **Service Desk** perspective for each ticketing application, a **Team Performance** dashboard for one or more teams, and an **Assets and CMDB** perspective for each asset application. + +**Team Performance** is for a team lead or service manager. Pick one or more teams (TeamDynamix groups) in its **Team** variable and it shows their open backlog, what they opened and closed in the timeframe, how much was resolved by its due date, plain-language insights (capacity, SLA, aging, stale work, ownership, balance and priority), where the period's tickets went from team to classification to outcome, backlog aging, priority mix, per-technician workload, demand by service, when tickets arrive, and a list of open tickets with links back to TeamDynamix. It doesn't need the ticketing application picked: the plugin finds the application the teams work in. Each of its searches returns at most **Maximum records per request** tickets (2,000 by default), so for teams with more open tickets, or more opened or closed in the timeframe, than that, the list and the figures built from it are incomplete. ## 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. Pick one or more imported groups under **Group(s)** to see only the tickets assigned to those teams; TeamDynamix does the filtering, so a team's tile isn't limited by the rest of the application's volume. Tickets with no assigned person or group show **(Unassigned)**. +- **Tickets** — tickets in a ticketing application, one row per ticket, with status, priority, assignment and SLA. Choose whether the timeframe filters on created, last-modified or closed date, or set the timeframe to **None** for the current backlog regardless of date. Pick one or more imported groups under **Group(s)** to see only the tickets assigned to those teams; TeamDynamix does the filtering, so a team's tile isn't limited by the rest of the application's volume. Set **SLA** to **Violated only** to have TeamDynamix return only tickets that have breached their SLA. Tickets with no assigned person or group show **(Unassigned)**. +- **Team Tickets** — the same rows and options as **Tickets**, but scoped to one or more imported groups instead of an application. TeamDynamix ties tickets to an application rather than a group, so the plugin looks up which ticketing application the groups work in (one small request per group, remembered for an hour) and searches there, once for all the selected groups. If the groups work in different applications, the one most of them share is searched. A group with no ticketing application, such as a managers' group, has no tickets, so it shows no data. - **Ticket Count** — the exact number of tickets matching a status, group, SLA and date filter, as one value. Use it for counts: it isn't limited by **Maximum tickets**, so it stays right on an application with thousands of open tickets, and it costs one small request. - **Ticket Statuses** — the statuses configured in a ticketing application. - **Assets** — assets in an asset application, filterable by in-service state. @@ -50,6 +53,7 @@ The out-of-the-box dashboards are an organization-wide **Overview**, a **Service - **Time Entries** — time logged against tickets, projects and tasks, account-wide. - **Locations** — active locations with their ticket, asset, configuration item and user counts, account-wide. - **Applications** — every active application the service account can see, with its Type and Class and the object type it's imported as. Use it to check which applications are recognised as ticketing or Assets/CIs applications. +- **Report** — the rows of a TeamDynamix Report Builder report, with the report's own columns. This is the way to get **custom attributes**: build a report in TeamDynamix (for example assets with a replacement-year attribute), add the attributes as columns, and pick it here. Only reports the admin service account can see are listed. The report's own filters and row limit apply, so filter it in Report Builder rather than in SquaredUp, and keep it well under 2,000 rows to stay inside the response size limit. - **Accounts**, **Groups**, **Current User** — account-wide reference data. These back the import and setup checks and are hidden from the tile editor; the imported objects are visible in the graph instead. Four more hidden streams, **Accounts (Import)**, **Locations (Import)**, **Assets (Import)** and **Configuration Items (Import)**, feed the optional inventory import and return nothing while it's off. ## What gets indexed @@ -65,7 +69,7 @@ The out-of-the-box dashboards are an organization-wide **Overview**, a **Service | **TeamDynamix Asset** | `POST /api/{appId}/assets/search` | A tracked asset, up to **Maximum records per request** per asset application. Only with the import option on. | | **TeamDynamix Configuration Item** | `POST /api/{appId}/cmdb/search/paged` | An active, standalone CMDB configuration item. Items that only mirror an asset are not imported. Only with the import option on. | -Applications and groups are always imported: every data stream is scoped to an application, and the **Group(s)** ticket filter picks from the imported groups. The other types are optional because nothing in the plugin depends on them. +Applications and groups are always imported: every data stream is scoped to an application, and the **Group(s)** ticket filter, the **Team Tickets** stream and the **Team Performance** dashboard pick from the imported groups. The other types are optional because nothing in the plugin depends on them. **Relationships:** assets and configuration items are imported per application, so each one carries the `appId` of the asset application it came from. @@ -77,11 +81,11 @@ Tickets are deliberately **not** imported as objects. They are high-volume trans - **List tiles are capped; counts aren't.** Each list tile makes one unpaged search, because walking TeamDynamix's paged searches (at most 250 records a page) is too slow for a tile. Each search is therefore capped (2000 records by default, set per tile for tickets, assets, knowledge base articles and time entries). A breakdown such as tickets by priority only covers the first 2000 matches, so narrow it with a status, group or timeframe filter. For a total, use **Ticket Count** or **Asset Count**, which are exact; the out-of-the-box count tiles already do. - **Ticket tiles have a size ceiling.** Each ticket row is about 2.3 KB once SquaredUp has formatted it, and a tile's response can't exceed about 6 MB, so a ticket tile fails with a response-size error above roughly 2,500 tickets. Keep **Maximum tickets** at 2000 or below. Requestor email, respond-by and goes-off-hold dates, estimates, form and reference code aren't returned, to keep rows small. -- **Configuration item tiles stop at 10,000 CMDB items.** TeamDynamix's CMDB search returns a copy of every asset alongside the standalone items, and the plugin can only remove the copies after loading them. A tile therefore loads at most 10,000 items and shows at most **Maximum records per request** standalone ones, most recently modified first. An asset application with more than 10,000 items in its CMDB (usually one with that many assets) shows an error saying so; turn on the inventory import, which pages through the CMDB, to get its standalone items as objects. -- **The configuration item import has a ceiling too.** An import run allows about 220 requests, and the CMDB import reads 250 items per request, so it covers roughly 50,000 CMDB items (asset copies included) across all asset applications. +- **Configuration item tiles stop when TeamDynamix returns 10,000 or more CMDB rows.** TeamDynamix keeps a copy of every asset in the CMDB. The plugin normally asks for every configuration item type except **Asset**, so those copies are not returned, at the cost of one extra request per asset application each hour to read its list of types. If that lookup fails, asset mirrors can be included in the response and the tile checks the row count before removing them, so fewer than 10,000 standalone items can still produce the error. A tile shows at most **Maximum records per request** of the remaining items, most recently modified first. +- **The configuration item import has a ceiling too.** An import run allows about 220 requests, and the CMDB import reads 250 standalone items per request, so it covers roughly 50,000 standalone configuration items across all asset applications. - **Imported assets are capped per asset application.** With the import option on, each asset application contributes at most **Maximum records per request** assets (2000 by default). An application with more assets than that is only partly imported. Asset tiles query TeamDynamix directly and aren't affected by the import. -- **Rate limits are per IP address and are easy to hit.** Ticket and time-entry searches allow 30 requests per minute; most other endpoints allow 60. The plugin does not pace its own requests — a dashboard with many TeamDynamix tiles refreshing together can be throttled. Opening the **Service Desk** dashboard makes six ticket searches (tiles with identical settings share one). A throttled tile reports the time the limit resets and recovers on its own on the next refresh. -- **Custom attributes are not available.** TeamDynamix's search endpoints (the ones every data stream in this plugin uses) never return custom attributes, attachments, or a few other detail-only fields, regardless of what the underlying record supports — only loading a single ticket, asset, configuration item, project or service individually returns them, which none of these streams do. There is no way to surface a tenant's custom fields without adding a per-record detail call for every row, which the search-based design of this plugin does not do. +- **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. TeamDynamix sets these per organization, and some are lower (one organization allows 30 for every endpoint, including sign-in). The plugin does not pace its own requests — a dashboard with many TeamDynamix tiles refreshing together can be throttled. Opening the **Service Desk** dashboard makes seven ticket searches (tiles with identical settings share one), and **Team Performance** makes three, because all twelve of its tiles read the same three searches (changing its timeframe makes two, and a forced refresh about five). A custom dashboard of SQL tiles makes one search per table per tile, so a dashboard of a dozen such tiles can exceed the limit on every load: share settings between tiles, and use **Ticket Count** for totals. A throttled tile reports the time the limit resets and recovers on its own on the next refresh. +- **Custom attributes need a report.** TeamDynamix's search endpoints, which every other data stream uses, never return custom attributes or attachments; only loading one record at a time does, which is far too many requests for a tile. Build a Report Builder report with the attributes as columns and use the **Report** data stream instead. Custom attributes are not added to imported objects. - **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 follow an `SB` prefix.** A sandbox uses `/SBTDWebApi` for the Web API, and its ticket, asset, configuration item and knowledge base article links open in `/SBTDNext` or `/SBTDClient`. This has been checked against a real sandbox, but the API documentation doesn't cover web UI paths. If a sandbox connection fails with a "no Web API found" error, or a sandbox record's link doesn't open, check the paths with TeamDynamix support. @@ -100,6 +104,8 @@ Tickets are deliberately **not** imported as objects. They are high-volume trans | The specified application could not be found | A custom tile is scoped to the wrong kind of application. Services and knowledge base articles need a client portal application, and projects a project application; the tile editor only offers those. | | TeamDynamix rate limit reached | Too many TeamDynamix requests in the last minute, from this or other dashboards. The tile recovers after the reset time it shows. | | Function.ResponseSizeTooLarge | A list tile returned more than about 6 MB. Lower **Maximum tickets** (or assets), or add a status, group or timeframe filter. | -| This asset application's CMDB has more than 10,000 configuration items | See **Configuration item tiles stop at 10,000 CMDB items** above. | +| TeamDynamix rate limit reached while signing in | As above, but the sign-in request itself was throttled. The tile recovers after the reset time it shows. | +| This asset application's CMDB returned 10,000 or more configuration items | See **Configuration item tiles stop when TeamDynamix returns 10,000 or more CMDB rows** above. | +| No TeamDynamix ticketing application is visible to the admin service account | A **Team Tickets** tile found no ticketing application at all. Grant the service account access to one in TDAdmin. | To check the BEID and Web Services Key outside SquaredUp, sign in to TDAdmin and confirm both on the **Organization detail** page, and that the admin service account shows as **Active**. diff --git a/plugins/TeamDynamix/v1/metadata.json b/plugins/TeamDynamix/v1/metadata.json index 7a713cf5..0bc7a6c5 100644 --- a/plugins/TeamDynamix/v1/metadata.json +++ b/plugins/TeamDynamix/v1/metadata.json @@ -1,7 +1,7 @@ { "name": "teamdynamix", "displayName": "TeamDynamix", - "version": "1.1.0", + "version": "1.2.0", "author": { "name": "@shawn149", "type": "community" @@ -46,6 +46,12 @@ "label": "Repository" } ], + "screenshots": [ + "https://github.com/squaredup/plugins/blob/main/plugins/TeamDynamix/v1/screenshots/overview.png", + "https://github.com/squaredup/plugins/blob/main/plugins/TeamDynamix/v1/screenshots/service-desk.png", + "https://github.com/squaredup/plugins/blob/main/plugins/TeamDynamix/v1/screenshots/team-performance.png", + "https://github.com/squaredup/plugins/blob/main/plugins/TeamDynamix/v1/screenshots/assets-and-cmdb.png" + ], "base": { "plugin": "WebAPI", "majorVersion": "1", diff --git a/plugins/TeamDynamix/v1/preRequest.js b/plugins/TeamDynamix/v1/preRequest.js index 38e8ce95..f3837ca7 100644 --- a/plugins/TeamDynamix/v1/preRequest.js +++ b/plugins/TeamDynamix/v1/preRequest.js @@ -21,6 +21,7 @@ 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; +const MAX_AUTH_RETRY_WAIT_MS = 10000; // --- token ----------------------------------------------------------------------- @@ -53,14 +54,26 @@ if (typeof state?.token !== "string" || (state?.expiryTime ?? 0) <= Date.now()) 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. + // The auth endpoint is itself rate limited, per IP address, and the limit is set per + // tenant (one real tenant allows 30 requests a period, not the documented 60). Retry once + // only if the limit resets soon: a tile gives up after about 30 seconds, so waiting out a + // full period just turns a clear rate-limit error into "Data source timed out". if (response.status === 429) { - const resetAt = Date.parse(response.headers.get("X-RateLimit-Reset") || ""); + const resetHeader = response.headers.get("X-RateLimit-Reset") || ""; + const resetAt = Date.parse(resetHeader); 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)); + if (waitMs > MAX_AUTH_RETRY_WAIT_MS) { + api.report.error( + "TeamDynamix rate limit reached while signing in" + + (resetHeader ? ` (resets at ${resetHeader})` : "") + + ". This will load again after that. If this happens often, reduce the number " + + "of TeamDynamix tiles refreshing at once." + ); + return; + } + await sleep(waitMs); response = await fetch(authUrl, request); } @@ -103,3 +116,192 @@ if (typeof state?.token !== "string" || (state?.expiryTime ?? 0) <= Date.now()) } headers["Authorization"] = `Bearer ${state.token}`; + +// --- standalone configuration items -------------------------------------------- +// +// TeamDynamix creates a configuration item for every asset, and the CMDB search has no way to +// exclude them, so an application with 49,000 assets returns 49,000 copies before the first +// standalone item. The search does take TypeIDs, and asset copies always have the system +// "Asset" type (ID 1), so a CMDB search that sets ExcludeAssetCopies is rewritten to ask for +// every other type instead. That needs the application's type list, which is a second request, +// so it is cached per application. +const CMDB_SEARCH_PATH = /\/api\/(\d+)\/cmdb\/search(\/paged)?\/?$/i; +const ASSET_CI_TYPE_ID = 1; +const CI_TYPES_CACHE_MS = 60 * 60 * 1000; + +const cmdbSearch = String(method).toLowerCase() === "post" ? url.pathname.match(CMDB_SEARCH_PATH) : null; +if (cmdbSearch) { + const bodyIsString = typeof body === "string"; + let search = null; + try { + search = bodyIsString ? JSON.parse(body) : body; + } catch { + search = null; + } + + if (search && typeof search === "object" && "ExcludeAssetCopies" in search) { + const exclude = search.ExcludeAssetCopies === true || search.ExcludeAssetCopies === "true"; + delete search.ExcludeAssetCopies; + + if (exclude) { + const appId = cmdbSearch[1]; + const cached = state?.ciTypes?.[appId]; + let typeIds = cached && cached.expiryTime > Date.now() ? cached.typeIds : null; + + if (!typeIds) { + const typesUrl = new URL(url.href); + typesUrl.pathname = typesUrl.pathname.replace(CMDB_SEARCH_PATH, `/api/${appId}/cmdb/types/search`); + // An empty search returns inactive types too: an active item can still have a + // type that has since been retired. + const response = await fetch(typesUrl.href, { + method: "post", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${state.token}` + }, + body: "{}" + }); + if (response.ok) { + const types = await response.json(); + typeIds = (Array.isArray(types) ? types : []) + .map((type) => type.ID) + .filter((id) => typeof id === "number" && id !== ASSET_CI_TYPE_ID); + state = { + ...state, + ciTypes: { + ...(state?.ciTypes || {}), + [appId]: { typeIds, expiryTime: Date.now() + CI_TYPES_CACHE_MS } + } + }; + } + } + + // Without the type list the search runs unfiltered, and configurationItems.js still + // drops asset copies from what comes back. An application whose only type is + // "Asset" gets an ID no type has: an empty TypeIDs list means "any type". + if (typeIds) { + search.TypeIDs = typeIds.length ? typeIds : [-1]; + } + } + + body = bodyIsString ? JSON.stringify(search) : search; + } +} + +// --- team tickets -------------------------------------------------------------- +// +// A ticket search belongs to one ticketing application, but the Team Tickets stream is scoped to +// groups, which TeamDynamix doesn't tie to a single application. So that stream posts to a +// placeholder path, and this swaps in the ticketing application the selected groups work in. +// Searching every application instead would cost one search each against a rate limit shared +// by the whole organization. Groups from a different application than most of the selection +// match nothing in this search. +const TEAM_TICKETS_PATH = /\/api\/team\/tickets\/search\/?$/i; +const GROUP_APPS_CACHE_MS = 60 * 60 * 1000; + +if (String(method).toLowerCase() === "post" && TEAM_TICKETS_PATH.test(url.pathname)) { + const bodyIsString = typeof body === "string"; + let search = null; + try { + search = bodyIsString ? JSON.parse(body) : body; + } catch { + search = null; + } + const groupIds = [].concat(search?.PrimaryResponsibilityGroupIDs || []).filter((id) => Number(id) > 0); + + // A failed lookup must not be mistaken for "no ticketing application": that path searches + // an arbitrary application and the tiles show no data, as if the team had no tickets. + const lookupError = (response) => { + const resetAt = response.headers.get("X-RateLimit-Reset"); + return response.status === 429 + ? "TeamDynamix rate limit reached while finding the team's ticketing application" + + (resetAt ? ` (resets at ${resetAt})` : "") + + ". This will load again after that." + : `TeamDynamix couldn't look up the team's ticketing application (HTTP ${response.status}).`; + }; + let failedLookup = null; + + // Votes per application, so a mixed selection searches where most of the groups work. A tie + // goes to the lowest ID, so the choice is stable between refreshes. + const votes = {}; + for (const groupId of groupIds) { + const cached = state?.groupApps?.[groupId]; + let appIds = cached && cached.expiryTime > Date.now() ? cached.appIds : null; + + if (!appIds) { + const appsUrl = new URL(url.href); + appsUrl.pathname = appsUrl.pathname.replace(TEAM_TICKETS_PATH, `/api/groups/${groupId}/applications`); + const response = await fetch(appsUrl.href, { + headers: { Accept: "application/json", Authorization: `Bearer ${state.token}` } + }); + if (response.ok) { + const apps = await response.json(); + appIds = (Array.isArray(apps) ? apps : []) + .filter((app) => app.AppClass === "TDTickets") + .map((app) => app.AppID); + state = { + ...state, + groupApps: { + ...(state?.groupApps || {}), + [groupId]: { appIds, expiryTime: Date.now() + GROUP_APPS_CACHE_MS } + } + }; + } else { + failedLookup = failedLookup || response; + } + } + + for (const appId of appIds || []) { + votes[appId] = (votes[appId] || 0) + 1; + } + } + + let appId = Object.keys(votes) + .map(Number) + .sort((a, b) => votes[b] - votes[a] || a - b)[0]; + + // Some lookups can fail and still leave an answer from the rest, which is searched. + if (!appId && failedLookup) { + api.report.error(lookupError(failedLookup)); + return; + } + + // A group with no ticketing application, such as a managers' group, can't have tickets + // assigned to it. Raising an error for that shows every tile on the Team Performance + // dashboard as "Script error", so search any ticketing application instead: the group + // filter matches nothing and the tiles show no data, which is the true answer. + if (!appId && groupIds.length) { + let ticketAppIds = state?.ticketApps?.expiryTime > Date.now() ? state.ticketApps.appIds : null; + if (!ticketAppIds) { + const appsUrl = new URL(url.href); + appsUrl.pathname = appsUrl.pathname.replace(TEAM_TICKETS_PATH, "/api/applications"); + const response = await fetch(appsUrl.href, { + headers: { Accept: "application/json", Authorization: `Bearer ${state.token}` } + }); + if (response.ok) { + const apps = await response.json(); + ticketAppIds = (Array.isArray(apps) ? apps : []) + .filter((app) => app.AppClass === "TDTickets" && app.Active !== false) + .map((app) => app.AppID) + .sort((a, b) => a - b); + state = { ...state, ticketApps: { appIds: ticketAppIds, expiryTime: Date.now() + GROUP_APPS_CACHE_MS } }; + } else { + api.report.error(lookupError(response)); + return; + } + } + appId = ticketAppIds?.[0]; + } + + if (!appId) { + api.report.error( + groupIds.length + ? "No TeamDynamix ticketing application is visible to the admin service account." + : "Select one or more TeamDynamix groups." + ); + return; + } + + url.pathname = url.pathname.replace(TEAM_TICKETS_PATH, `/api/${appId}/tickets/search`); +} diff --git a/plugins/TeamDynamix/v1/screenshots/assets-and-cmdb.png b/plugins/TeamDynamix/v1/screenshots/assets-and-cmdb.png new file mode 100644 index 00000000..e196b2dc Binary files /dev/null and b/plugins/TeamDynamix/v1/screenshots/assets-and-cmdb.png differ diff --git a/plugins/TeamDynamix/v1/screenshots/overview.png b/plugins/TeamDynamix/v1/screenshots/overview.png new file mode 100644 index 00000000..f05ce484 Binary files /dev/null and b/plugins/TeamDynamix/v1/screenshots/overview.png differ diff --git a/plugins/TeamDynamix/v1/screenshots/service-desk.png b/plugins/TeamDynamix/v1/screenshots/service-desk.png new file mode 100644 index 00000000..7f5de82e Binary files /dev/null and b/plugins/TeamDynamix/v1/screenshots/service-desk.png differ diff --git a/plugins/TeamDynamix/v1/screenshots/team-performance.png b/plugins/TeamDynamix/v1/screenshots/team-performance.png new file mode 100644 index 00000000..d477a580 Binary files /dev/null and b/plugins/TeamDynamix/v1/screenshots/team-performance.png differ diff --git a/plugins/TeamDynamix/v1/ui.json b/plugins/TeamDynamix/v1/ui.json index 0a3f115c..982912e6 100644 --- a/plugins/TeamDynamix/v1/ui.json +++ b/plugins/TeamDynamix/v1/ui.json @@ -36,11 +36,15 @@ "required": true } }, + { + "type": "toggle", + "name": "showAdvanced", + "label": "Advanced settings" + }, { "type": "fieldGroup", "name": "advanced", - "label": "Advanced options", - "displayAs": "fieldGroupToggle", + "visible": { "showAdvanced": "true" }, "fields": [ { "type": "number",