- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
571 lines
19 KiB
TypeScript
571 lines
19 KiB
TypeScript
/**
|
|
* IT Glue API Client
|
|
* Auth: x-api-key header
|
|
* Base: https://api.itglue.com
|
|
* Format: application/vnd.api+json (JSON:API)
|
|
*/
|
|
|
|
export interface ITGlueConfig {
|
|
apiKey: string;
|
|
baseUrl?: string;
|
|
}
|
|
|
|
export interface ITGlueOrganization {
|
|
id: string;
|
|
name: string;
|
|
shortName: string | null;
|
|
organizationTypeId: number | null;
|
|
organizationTypeName: string | null;
|
|
organizationStatusId: number | null;
|
|
organizationStatusName: string | null;
|
|
psaIntegration: string | null;
|
|
syncActive: boolean;
|
|
primary: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface ITGlueFlexibleAsset {
|
|
id: string;
|
|
organizationId: number;
|
|
organizationName: string;
|
|
flexibleAssetTypeId: number;
|
|
flexibleAssetTypeName: string;
|
|
name: string;
|
|
traits: Record<string, any>;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface ITGlueConfiguration {
|
|
id: string;
|
|
organizationId: number;
|
|
organizationName: string;
|
|
name: string;
|
|
hostname: string | null;
|
|
primaryIp: string | null;
|
|
macAddress: string | null;
|
|
serialNumber: string | null;
|
|
assetTag: string | null;
|
|
configurationTypeId: number | null;
|
|
configurationTypeName: string | null;
|
|
configurationStatusId: number | null;
|
|
configurationStatusName: string | null;
|
|
manufacturerId: number | null;
|
|
manufacturerName: string | null;
|
|
modelId: number | null;
|
|
modelName: string | null;
|
|
operatingSystemId: number | null;
|
|
operatingSystemName: string | null;
|
|
notes: string | null;
|
|
purchasedAt: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface ITGluePassword {
|
|
id: string;
|
|
organizationId: number;
|
|
organizationName: string;
|
|
name: string;
|
|
username: string | null;
|
|
password: string | null;
|
|
url: string | null;
|
|
notes: string | null;
|
|
passwordCategoryId: number | null;
|
|
passwordCategoryName: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface ITGlueContact {
|
|
id: string;
|
|
organizationId: number;
|
|
organizationName: string;
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
title: string | null;
|
|
contactTypeId: number | null;
|
|
contactTypeName: string | null;
|
|
emails: { value: string; primary: boolean; labelName: string }[];
|
|
phones: { value: string; extension: string | null; primary: boolean; labelName: string }[];
|
|
notes: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface ITGlueFlexibleAssetType {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
icon: string | null;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface ITGluePaginatedResponse<T> {
|
|
data: T[];
|
|
meta: {
|
|
currentPage: number;
|
|
nextPage: number | null;
|
|
prevPage: number | null;
|
|
totalPages: number;
|
|
totalCount: number;
|
|
};
|
|
}
|
|
|
|
export class ITGlueClient {
|
|
private readonly apiKey: string;
|
|
private readonly baseUrl: string;
|
|
private readonly DEFAULT_PAGE_SIZE = 50;
|
|
|
|
constructor(config: ITGlueConfig) {
|
|
this.apiKey = config.apiKey;
|
|
this.baseUrl = config.baseUrl || 'https://api.itglue.com';
|
|
}
|
|
|
|
private get headers(): Record<string, string> {
|
|
return {
|
|
'x-api-key': this.apiKey,
|
|
'Content-Type': 'application/vnd.api+json',
|
|
};
|
|
}
|
|
|
|
private async request<T>(path: string, params: Record<string, string | number> = {}): Promise<T> {
|
|
const url = new URL(`${this.baseUrl}${path}`);
|
|
for (const [k, v] of Object.entries(params)) {
|
|
url.searchParams.set(k, String(v));
|
|
}
|
|
|
|
const res = await fetch(url.toString(), { headers: this.headers });
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => '');
|
|
throw new Error(`IT Glue API ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Internal PATCH helper. Used by the audit feature to update flexible assets.
|
|
* Body should be a JSON:API resource object (e.g. `{ data: { type, attributes } }`).
|
|
* Returns the parsed JSON response (typically `{ data: {...} }`).
|
|
*/
|
|
private async patch<T = unknown>(path: string, body: unknown): Promise<T> {
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
method: 'PATCH',
|
|
headers: this.headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(
|
|
`IT Glue PATCH ${path} → ${res.status} ${res.statusText}: ${text.slice(0, 500)}`
|
|
);
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
private async fetchAllPages<T>(
|
|
path: string,
|
|
params: Record<string, string | number> = {},
|
|
mapper: (item: any) => T
|
|
): Promise<T[]> {
|
|
const results: T[] = [];
|
|
let page = 1;
|
|
let totalPages = 1;
|
|
|
|
do {
|
|
const data: any = await this.request(path, {
|
|
...params,
|
|
'page[size]': this.DEFAULT_PAGE_SIZE,
|
|
'page[number]': page,
|
|
});
|
|
results.push(...(data.data || []).map(mapper));
|
|
totalPages = data.meta?.['total-pages'] ?? 1;
|
|
page++;
|
|
} while (page <= totalPages);
|
|
|
|
return results;
|
|
}
|
|
|
|
/** Returns raw JSON:API data array for a single page (used by sync service) */
|
|
async getRaw(path: string, params: Record<string, string | number> = {}): Promise<any[]> {
|
|
const data: any = await this.request(path, params);
|
|
return data.data || [];
|
|
}
|
|
|
|
/**
|
|
* Returns the raw JSON:API resource for a single-resource endpoint
|
|
* (`{ data: {...} }`). Used for per-record refresh after writes so callers
|
|
* can read the un-mapped attributes (created-at, updated-at, etc.) for a
|
|
* faithful upsert.
|
|
*/
|
|
async getRawSingle(
|
|
path: string,
|
|
params: Record<string, string | number> = {}
|
|
): Promise<{ id: string; type: string; attributes: Record<string, unknown> } | null> {
|
|
const data: any = await this.request(path, params);
|
|
return data.data ?? null;
|
|
}
|
|
|
|
/** Returns all raw JSON:API data items across all pages (used by sync service) */
|
|
async getRawAllPages(path: string, params: Record<string, string | number> = {}): Promise<any[]> {
|
|
const results: any[] = [];
|
|
let page = 1;
|
|
let totalPages = 1;
|
|
|
|
do {
|
|
const data: any = await this.request(path, {
|
|
...params,
|
|
'page[size]': this.DEFAULT_PAGE_SIZE,
|
|
'page[number]': page,
|
|
});
|
|
results.push(...(data.data || []));
|
|
totalPages = data.meta?.['total-pages'] ?? 1;
|
|
page++;
|
|
} while (page <= totalPages);
|
|
|
|
return results;
|
|
}
|
|
|
|
// ─── Organizations ────────────────────────────────────────────────────────
|
|
|
|
private mapOrg(item: any): ITGlueOrganization {
|
|
const a = item.attributes;
|
|
return {
|
|
id: item.id,
|
|
name: a['name'],
|
|
shortName: a['short-name'] ?? null,
|
|
organizationTypeId: a['organization-type-id'] ?? null,
|
|
organizationTypeName: a['organization-type-name'] ?? null,
|
|
organizationStatusId: a['organization-status-id'] ?? null,
|
|
organizationStatusName: a['organization-status-name'] ?? null,
|
|
psaIntegration: a['psa-integration'] ?? null,
|
|
syncActive: a['sync-active'] ?? false,
|
|
primary: a['primary'] ?? false,
|
|
createdAt: a['created-at'],
|
|
updatedAt: a['updated-at'],
|
|
};
|
|
}
|
|
|
|
async getOrganizations(filter?: { name?: string; organizationTypeId?: number }): Promise<ITGlueOrganization[]> {
|
|
const params: Record<string, string | number> = {};
|
|
if (filter?.name) params['filter[name]'] = filter.name;
|
|
if (filter?.organizationTypeId) params['filter[organization-type-id]'] = filter.organizationTypeId;
|
|
return this.fetchAllPages('/organizations', params, this.mapOrg);
|
|
}
|
|
|
|
async getOrganization(id: string | number): Promise<ITGlueOrganization> {
|
|
const data: any = await this.request(`/organizations/${id}`);
|
|
return this.mapOrg(data.data);
|
|
}
|
|
|
|
async findOrganizationByName(name: string): Promise<ITGlueOrganization | null> {
|
|
const data: any = await this.request('/organizations', {
|
|
'filter[name]': name,
|
|
'page[size]': 5,
|
|
});
|
|
if (!data.data?.length) return null;
|
|
return this.mapOrg(data.data[0]);
|
|
}
|
|
|
|
// ─── Flexible Assets ──────────────────────────────────────────────────────
|
|
|
|
private mapFlexibleAsset(item: any): ITGlueFlexibleAsset {
|
|
const a = item.attributes;
|
|
return {
|
|
id: item.id,
|
|
organizationId: a['organization-id'],
|
|
organizationName: a['organization-name'],
|
|
flexibleAssetTypeId: a['flexible-asset-type-id'],
|
|
flexibleAssetTypeName: a['flexible-asset-type-name'],
|
|
name: a['name'],
|
|
traits: a['traits'] ?? {},
|
|
createdAt: a['created-at'],
|
|
updatedAt: a['updated-at'],
|
|
};
|
|
}
|
|
|
|
async getFlexibleAssets(params: {
|
|
organizationId?: number | string;
|
|
flexibleAssetTypeId?: number | string;
|
|
filter?: Record<string, string>;
|
|
}): Promise<ITGlueFlexibleAsset[]> {
|
|
const p: Record<string, string | number> = {};
|
|
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
|
|
if (params.flexibleAssetTypeId) p['filter[flexible-asset-type-id]'] = params.flexibleAssetTypeId;
|
|
if (params.filter) {
|
|
for (const [k, v] of Object.entries(params.filter)) {
|
|
p[`filter[${k}]`] = v;
|
|
}
|
|
}
|
|
return this.fetchAllPages('/flexible_assets', p, this.mapFlexibleAsset);
|
|
}
|
|
|
|
async getFlexibleAsset(id: string | number): Promise<ITGlueFlexibleAsset> {
|
|
const data: any = await this.request(`/flexible_assets/${id}`);
|
|
return this.mapFlexibleAsset(data.data);
|
|
}
|
|
|
|
/**
|
|
* Update a flexible asset's traits. Sends PATCH /flexible_assets/:id with a
|
|
* JSON:API body. `traits` is the merged trait map (IT Glue replaces the
|
|
* trait set, so callers must include unchanged traits to preserve them; the
|
|
* audit pipeline always reads then merges).
|
|
*
|
|
* Returns the updated asset as IT Glue returns it.
|
|
*/
|
|
async updateFlexibleAsset(
|
|
id: string | number,
|
|
traits: Record<string, unknown>
|
|
): Promise<ITGlueFlexibleAsset> {
|
|
const body = {
|
|
data: {
|
|
type: 'flexible_assets',
|
|
id: String(id),
|
|
attributes: { traits },
|
|
},
|
|
};
|
|
const res = await this.patch<{ data: any }>(`/flexible_assets/${id}`, body);
|
|
return this.mapFlexibleAsset(res.data);
|
|
}
|
|
|
|
/**
|
|
* Re-fetch a single flexible asset from IT Glue. Thin wrapper around
|
|
* getFlexibleAsset; exists so callers naming "refresh" intent stays clear
|
|
* separate from "read once".
|
|
*/
|
|
async refreshFlexibleAsset(id: string | number): Promise<ITGlueFlexibleAsset> {
|
|
return this.getFlexibleAsset(id);
|
|
}
|
|
|
|
/**
|
|
* Update a configuration's editable attributes. Sends PATCH /configurations/:id
|
|
* with a JSON:API body. Configurations have a flat attribute set (no traits
|
|
* blob), so callers pass the partial map of fields to change — IT Glue
|
|
* merges into the existing record.
|
|
*/
|
|
async updateConfiguration(
|
|
id: string | number,
|
|
attributes: Record<string, unknown>
|
|
): Promise<ITGlueConfiguration> {
|
|
const body = {
|
|
data: {
|
|
type: 'configurations',
|
|
id: String(id),
|
|
attributes,
|
|
},
|
|
};
|
|
const res = await this.patch<{ data: any }>(`/configurations/${id}`, body);
|
|
return this.mapConfiguration(res.data);
|
|
}
|
|
|
|
async refreshConfiguration(id: string | number): Promise<ITGlueConfiguration> {
|
|
return this.getConfiguration(id);
|
|
}
|
|
|
|
async getFlexibleAssetTypes(): Promise<ITGlueFlexibleAssetType[]> {
|
|
return this.fetchAllPages('/flexible_asset_types', {}, (item: any) => ({
|
|
id: item.id,
|
|
name: item.attributes['name'],
|
|
description: item.attributes['description'] ?? null,
|
|
icon: item.attributes['icon'] ?? null,
|
|
enabled: item.attributes['enabled'] ?? true,
|
|
createdAt: item.attributes['created-at'],
|
|
updatedAt: item.attributes['updated-at'],
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Cached, per-instance fetch of enabled flexible asset type ids. IT Glue's
|
|
* /flexible_assets endpoint requires a flexibleAssetTypeId filter (otherwise
|
|
* 422). This caches the type list so callers don't pay the lookup on every
|
|
* call. Cache lives for the life of the process — types rarely change.
|
|
*/
|
|
private flexibleAssetTypesCache: Promise<ITGlueFlexibleAssetType[]> | null = null;
|
|
private async cachedFlexibleAssetTypes(): Promise<ITGlueFlexibleAssetType[]> {
|
|
if (!this.flexibleAssetTypesCache) {
|
|
this.flexibleAssetTypesCache = this.getFlexibleAssetTypes().catch((err) => {
|
|
// Re-throw next call so a transient failure isn't sticky.
|
|
this.flexibleAssetTypesCache = null;
|
|
throw err;
|
|
});
|
|
}
|
|
return this.flexibleAssetTypesCache;
|
|
}
|
|
|
|
/**
|
|
* Get every flexible asset for a given organization across all enabled types.
|
|
* IT Glue's /flexible_assets endpoint requires a per-type filter (the
|
|
* `getFlexibleAssets` raw call returns 422 otherwise), so this helper
|
|
* enumerates types and fans out per-type requests in parallel. Per-type
|
|
* failures are tolerated so a single bad type doesn't poison the whole org.
|
|
*/
|
|
async getFlexibleAssetsForOrganization(
|
|
organizationId: number | string
|
|
): Promise<ITGlueFlexibleAsset[]> {
|
|
const types = await this.cachedFlexibleAssetTypes();
|
|
const enabled = types.filter((t) => t.enabled);
|
|
const results = await Promise.allSettled(
|
|
enabled.map((t) =>
|
|
this.getFlexibleAssets({
|
|
organizationId,
|
|
flexibleAssetTypeId: t.id,
|
|
})
|
|
)
|
|
);
|
|
const out: ITGlueFlexibleAsset[] = [];
|
|
for (const r of results) {
|
|
if (r.status === 'fulfilled') out.push(...r.value);
|
|
// Tolerate per-type failures — common for permission-restricted types.
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ─── Configurations ───────────────────────────────────────────────────────
|
|
|
|
private mapConfiguration(item: any): ITGlueConfiguration {
|
|
const a = item.attributes;
|
|
return {
|
|
id: item.id,
|
|
organizationId: a['organization-id'],
|
|
organizationName: a['organization-name'],
|
|
name: a['name'],
|
|
hostname: a['hostname'] ?? null,
|
|
primaryIp: a['primary-ip'] ?? null,
|
|
macAddress: a['mac-address'] ?? null,
|
|
serialNumber: a['serial-number'] ?? null,
|
|
assetTag: a['asset-tag'] ?? null,
|
|
configurationTypeId: a['configuration-type-id'] ?? null,
|
|
configurationTypeName: a['configuration-type-name'] ?? null,
|
|
configurationStatusId: a['configuration-status-id'] ?? null,
|
|
configurationStatusName: a['configuration-status-name'] ?? null,
|
|
manufacturerId: a['manufacturer-id'] ?? null,
|
|
manufacturerName: a['manufacturer-name'] ?? null,
|
|
modelId: a['model-id'] ?? null,
|
|
modelName: a['model-name'] ?? null,
|
|
operatingSystemId: a['operating-system-id'] ?? null,
|
|
operatingSystemName: a['operating-system-name'] ?? null,
|
|
notes: a['notes'] ?? null,
|
|
purchasedAt: a['purchased-at'] ?? null,
|
|
createdAt: a['created-at'],
|
|
updatedAt: a['updated-at'],
|
|
};
|
|
}
|
|
|
|
async getConfigurations(params: {
|
|
organizationId?: number | string;
|
|
name?: string;
|
|
hostname?: string;
|
|
serialNumber?: string;
|
|
} = {}): Promise<ITGlueConfiguration[]> {
|
|
const p: Record<string, string | number> = {};
|
|
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
|
|
if (params.name) p['filter[name]'] = params.name;
|
|
if (params.hostname) p['filter[hostname]'] = params.hostname;
|
|
if (params.serialNumber) p['filter[serial-number]'] = params.serialNumber;
|
|
return this.fetchAllPages('/configurations', p, this.mapConfiguration);
|
|
}
|
|
|
|
async getConfiguration(id: string | number): Promise<ITGlueConfiguration> {
|
|
const data: any = await this.request(`/configurations/${id}`);
|
|
return this.mapConfiguration(data.data);
|
|
}
|
|
|
|
// ─── Passwords ────────────────────────────────────────────────────────────
|
|
|
|
private mapPassword(item: any): ITGluePassword {
|
|
const a = item.attributes;
|
|
return {
|
|
id: item.id,
|
|
organizationId: a['organization-id'],
|
|
organizationName: a['organization-name'],
|
|
name: a['name'],
|
|
username: a['username'] ?? null,
|
|
password: a['password'] ?? null,
|
|
url: a['url'] ?? null,
|
|
notes: a['notes'] ?? null,
|
|
passwordCategoryId: a['password-category-id'] ?? null,
|
|
passwordCategoryName: a['password-category-name'] ?? null,
|
|
createdAt: a['created-at'],
|
|
updatedAt: a['updated-at'],
|
|
};
|
|
}
|
|
|
|
async getPasswords(params: {
|
|
organizationId?: number | string;
|
|
name?: string;
|
|
} = {}): Promise<ITGluePassword[]> {
|
|
const p: Record<string, string | number> = {};
|
|
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
|
|
if (params.name) p['filter[name]'] = params.name;
|
|
return this.fetchAllPages('/passwords', p, this.mapPassword);
|
|
}
|
|
|
|
// ─── Contacts ─────────────────────────────────────────────────────────────
|
|
|
|
private mapContact(item: any): ITGlueContact {
|
|
const a = item.attributes;
|
|
return {
|
|
id: item.id,
|
|
organizationId: a['organization-id'],
|
|
organizationName: a['organization-name'],
|
|
firstName: a['first-name'] ?? null,
|
|
lastName: a['last-name'] ?? null,
|
|
title: a['title'] ?? null,
|
|
contactTypeId: a['contact-type-id'] ?? null,
|
|
contactTypeName: a['contact-type-name'] ?? null,
|
|
emails: (a['contact-emails'] ?? []).map((e: any) => ({
|
|
value: e.value,
|
|
primary: e.primary,
|
|
labelName: e['label-name'],
|
|
})),
|
|
phones: (a['contact-phones'] ?? []).map((p: any) => ({
|
|
value: p.value,
|
|
extension: p.extension ?? null,
|
|
primary: p.primary,
|
|
labelName: p['label-name'],
|
|
})),
|
|
notes: a['notes'] ?? null,
|
|
createdAt: a['created-at'],
|
|
updatedAt: a['updated-at'],
|
|
};
|
|
}
|
|
|
|
async getContacts(params: {
|
|
organizationId?: number | string;
|
|
name?: string;
|
|
} = {}): Promise<ITGlueContact[]> {
|
|
const p: Record<string, string | number> = {};
|
|
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
|
|
if (params.name) p['filter[name]'] = params.name;
|
|
return this.fetchAllPages('/contacts', p, this.mapContact);
|
|
}
|
|
|
|
// ─── Utility ──────────────────────────────────────────────────────────────
|
|
|
|
async testConnection(): Promise<{ ok: boolean; organizationCount: number; accountName: string }> {
|
|
const data: any = await this.request('/organizations', { 'page[size]': 1 });
|
|
return {
|
|
ok: true,
|
|
organizationCount: data.meta?.['total-count'] ?? 0,
|
|
accountName: data.data?.[0]?.attributes?.name ?? 'Unknown',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Singleton
|
|
let _client: ITGlueClient | null = null;
|
|
export function getITGlueClient(): ITGlueClient {
|
|
if (!_client) {
|
|
const apiKey = process.env.ITGLUE_API_KEY;
|
|
if (!apiKey) throw new Error('ITGLUE_API_KEY environment variable is not set');
|
|
_client = new ITGlueClient({ apiKey });
|
|
}
|
|
return _client;
|
|
}
|
|
|
|
export function isITGlueConfigured(): boolean {
|
|
return !!process.env.ITGLUE_API_KEY;
|
|
}
|