Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
39 changes: 39 additions & 0 deletions plugins/TeamDynamix/v1/dataStreams/report.json
Original file line number Diff line number Diff line change
@@ -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"
}
29 changes: 29 additions & 0 deletions plugins/TeamDynamix/v1/dataStreams/reports.json
Original file line number Diff line number Diff line change
@@ -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" }
}
25 changes: 11 additions & 14 deletions plugins/TeamDynamix/v1/dataStreams/scripts/configurationItems.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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}.`;
}
41 changes: 41 additions & 0 deletions plugins/TeamDynamix/v1/dataStreams/scripts/report.js
Original file line number Diff line number Diff line change
@@ -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];
})
)
);
12 changes: 12 additions & 0 deletions plugins/TeamDynamix/v1/dataStreams/scripts/reports.js
Original file line number Diff line number Diff line change
@@ -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));
133 changes: 133 additions & 0 deletions plugins/TeamDynamix/v1/dataStreams/teamTickets.json
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
clarkd marked this conversation as resolved.
Resolved
}
},
"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"
}
13 changes: 12 additions & 1 deletion plugins/TeamDynamix/v1/dataStreams/tickets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading