feat: IT Glue integration, workflow engine, pipelines, Zabbix WAN, notification channels, backup status UI improvements, nav alignment fixes
This commit is contained in:
parent
ed6c4a8b65
commit
19605f82aa
97 changed files with 17080 additions and 304 deletions
|
|
@ -466,4 +466,89 @@ export class DattoRMMClient {
|
|||
|
||||
return allAlerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available automation components (scripts/tasks) for quick jobs.
|
||||
* GET /api/v2/account/components
|
||||
*/
|
||||
async getComponents(): Promise<any[]> {
|
||||
const allComponents: any[] = [];
|
||||
let page = 1;
|
||||
const max = 100;
|
||||
|
||||
while (true) {
|
||||
const response = await this.makeApiCall<any>(
|
||||
`/account/components?page=${page}&max=${max}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
const components = response.components || [];
|
||||
allComponents.push(...components);
|
||||
|
||||
if (components.length < max) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return allComponents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a quick job on a device.
|
||||
* PUT /api/v2/device/{deviceUid}/quickjob
|
||||
*/
|
||||
async runQuickJob(
|
||||
deviceUid: string,
|
||||
payload: {
|
||||
jobName: string;
|
||||
jobComponent: {
|
||||
componentUid: string;
|
||||
variables?: Array<{ name: string; value: string }>;
|
||||
};
|
||||
}
|
||||
): Promise<any> {
|
||||
const token = await this.getAccessToken();
|
||||
const url = `https://concord-api.centrastage.net/api/v2/device/${deviceUid}/quickjob`;
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
throw new Error(`Datto RMM quick job failed (${resp.status}): ${errText.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const text = await resp.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job results for a device.
|
||||
* GET /api/v2/job/{jobUid}/results/device/{deviceUid}
|
||||
*/
|
||||
async getJobResults(jobUid: string, deviceUid: string): Promise<any> {
|
||||
const token = await this.getAccessToken();
|
||||
const url = `https://concord-api.centrastage.net/api/v2/job/${jobUid}/results/${deviceUid}`;
|
||||
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
throw new Error(`Datto RMM job results failed (${resp.status}): ${errText.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
430
lib/services/itglue-client.ts
Normal file
430
lib/services/itglue-client.ts
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
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'],
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
554
lib/services/itglue-sync-service.ts
Normal file
554
lib/services/itglue-sync-service.ts
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
/**
|
||||
* IT Glue Sync Service — Part 1 of 2
|
||||
* Syncs all IT Glue data to local itg_* PostgreSQL tables
|
||||
*/
|
||||
|
||||
import postgresClient from './postgres-client';
|
||||
import { getITGlueClient } from './itglue-client';
|
||||
|
||||
export interface ITGlueSyncEntityResult {
|
||||
entity: string;
|
||||
success: boolean;
|
||||
recordsUpserted: number;
|
||||
duration: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ITGlueSyncResult {
|
||||
syncId: number;
|
||||
syncType: 'full';
|
||||
status: 'completed' | 'failed';
|
||||
startedAt: Date;
|
||||
completedAt: Date;
|
||||
duration: number;
|
||||
entities: ITGlueSyncEntityResult[];
|
||||
totalUpserted: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class ITGlueSyncService {
|
||||
private isSyncing = false;
|
||||
|
||||
isSyncInProgress(): boolean { return this.isSyncing; }
|
||||
|
||||
async fullSync(triggeredBy = 'system'): Promise<ITGlueSyncResult> {
|
||||
if (this.isSyncing) throw new Error('IT Glue sync already in progress');
|
||||
this.isSyncing = true;
|
||||
|
||||
const startedAt = new Date();
|
||||
const entities: ITGlueSyncEntityResult[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const { rows } = await postgresClient.query(
|
||||
`INSERT INTO itg_sync_history (sync_type, status, triggered_by, started_at)
|
||||
VALUES ('full','running',$1,NOW()) RETURNING id`,
|
||||
[triggeredBy]
|
||||
);
|
||||
const syncId = rows[0].id;
|
||||
|
||||
const run = async (name: string, fn: () => Promise<number>) => {
|
||||
const t = Date.now();
|
||||
try {
|
||||
const count = await fn();
|
||||
entities.push({ entity: name, success: true, recordsUpserted: count, duration: Date.now() - t });
|
||||
console.log(`[ITGlue] ${name}: ${count} records`);
|
||||
} catch (err: any) {
|
||||
errors.push(`${name}: ${err.message}`);
|
||||
entities.push({ entity: name, success: false, recordsUpserted: 0, duration: Date.now() - t, error: err.message });
|
||||
console.error(`[ITGlue] ${name} FAILED:`, err.message);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await run('organization_types', () => this.syncSimpleTable('/organization_types', 'itg_organization_types'));
|
||||
await run('organization_statuses', () => this.syncSimpleTable('/organization_statuses','itg_organization_statuses'));
|
||||
await run('configuration_types', () => this.syncSimpleTable('/configuration_types', 'itg_configuration_types'));
|
||||
await run('configuration_statuses', () => this.syncSimpleTable('/configuration_statuses','itg_configuration_statuses'));
|
||||
await run('contact_types', () => this.syncSimpleTable('/contact_types', 'itg_contact_types'));
|
||||
await run('password_categories', () => this.syncSimpleTable('/password_categories', 'itg_password_categories'));
|
||||
await run('manufacturers', () => this.syncSimpleTable('/manufacturers', 'itg_manufacturers'));
|
||||
await run('operating_systems', () => this.syncSimpleTable('/operating_systems', 'itg_operating_systems'));
|
||||
await run('platforms', () => this.syncSimpleTable('/platforms', 'itg_platforms'));
|
||||
await run('countries', () => this.syncCountries());
|
||||
await run('models', () => this.syncModels());
|
||||
await run('flexible_asset_types', () => this.syncFlexibleAssetTypes());
|
||||
await run('flexible_asset_fields', () => this.syncFlexibleAssetFields());
|
||||
await run('organizations', () => this.syncOrganizations());
|
||||
await run('locations', () => this.syncLocations());
|
||||
await run('contacts', () => this.syncContacts());
|
||||
await run('configurations', () => this.syncConfigurations());
|
||||
// configuration_interfaces skipped — no flat API endpoint; per-config calls are too slow for 14k+ configs
|
||||
await run('flexible_assets', () => this.syncFlexibleAssets());
|
||||
await run('password_folders', () => this.syncPasswordFolders());
|
||||
await run('passwords', () => this.syncPasswords());
|
||||
await run('documents', () => this.syncDocuments());
|
||||
await run('domains', () => this.syncDomains());
|
||||
await run('expirations', () => this.syncExpirations());
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
|
||||
const completedAt = new Date();
|
||||
const duration = completedAt.getTime() - startedAt.getTime();
|
||||
const totalUpserted = entities.reduce((s, e) => s + e.recordsUpserted, 0);
|
||||
const status = errors.length === 0 ? 'completed' : 'failed';
|
||||
|
||||
await postgresClient.query(
|
||||
`UPDATE itg_sync_history
|
||||
SET status=$1, completed_at=NOW(), duration_ms=$2, entities=$3, error=$4, total_upserted=$5
|
||||
WHERE id=$6`,
|
||||
[status, duration, JSON.stringify(entities), errors.join('\n') || null, totalUpserted, syncId]
|
||||
);
|
||||
|
||||
return { syncId, syncType: 'full', status, startedAt, completedAt, duration, entities, totalUpserted, errors };
|
||||
}
|
||||
|
||||
// ─── Generic simple-table upsert (id, name, created_at, updated_at) ──────────
|
||||
|
||||
private async syncSimpleTable(path: string, table: string): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
const items = await client.getRawAllPages(path);
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO ${table} (id, name, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET name=$2, updated_at=$4, synced_at=NOW()`,
|
||||
[item.id, a.name, a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncCountries(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
const items = await client.getRawAllPages('/countries');
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_countries (id, name, iso_code, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET name=$2, iso_code=$3, updated_at=$5, synced_at=NOW()`,
|
||||
[item.id, a.name, a['iso-code'] || null, a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncModels(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
const mfrs = await client.getRawAllPages('/manufacturers');
|
||||
let count = 0;
|
||||
for (const mfr of mfrs) {
|
||||
const models = await client.getRawAllPages(`/manufacturers/${mfr.id}/relationships/models`);
|
||||
for (const item of models) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_models (id, manufacturer_id, name, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET manufacturer_id=$2, name=$3, updated_at=$5, synced_at=NOW()`,
|
||||
[item.id, mfr.id, a.name, a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncFlexibleAssetTypes(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
const items = await client.getRawAllPages('/flexible_asset_types');
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_flexible_asset_types (id, name, description, icon, enabled, builtin, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET name=$2, description=$3, icon=$4, enabled=$5, builtin=$6, updated_at=$8, synced_at=NOW()`,
|
||||
[item.id, a.name, a.description || null, a.icon || null, a.enabled ?? true, a.builtin ?? false,
|
||||
a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncFlexibleAssetFields(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
const types = await client.getRawAllPages('/flexible_asset_types');
|
||||
let count = 0;
|
||||
for (const type of types) {
|
||||
const fields = await client.getRawAllPages(
|
||||
`/flexible_asset_types/${type.id}/relationships/flexible_asset_fields`
|
||||
);
|
||||
for (const item of fields) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_flexible_asset_fields
|
||||
(id, flexible_asset_type_id, name, kind, hint, decimals, tag_type,
|
||||
required, use_for_title, expiration, show_in_list, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
flexible_asset_type_id=$2, name=$3, kind=$4, hint=$5, decimals=$6, tag_type=$7,
|
||||
required=$8, use_for_title=$9, expiration=$10, show_in_list=$11, updated_at=$13, synced_at=NOW()`,
|
||||
[item.id, type.id, a.name, a.kind || null, a.hint || null, a.decimals || 0,
|
||||
a['tag-type'] || null, a.required ?? false, a['use-for-title'] ?? false,
|
||||
a.expiration ?? false, a['show-in-list'] ?? false,
|
||||
a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncOrganizations(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
const items = await client.getRawAllPages('/organizations');
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_organizations
|
||||
(id, name, short_name, organization_type_id, organization_type_name,
|
||||
organization_status_id, organization_status_name, psa_integration, psa_id,
|
||||
sync_active, primary_org, quick_notes, description, alert, parent_id,
|
||||
created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name=$2, short_name=$3, organization_type_id=$4, organization_type_name=$5,
|
||||
organization_status_id=$6, organization_status_name=$7, psa_integration=$8,
|
||||
psa_id=$9, sync_active=$10, primary_org=$11, quick_notes=$12, description=$13,
|
||||
alert=$14, parent_id=$15, updated_at=$17, synced_at=NOW()`,
|
||||
[item.id, a.name, a['short-name'] || null,
|
||||
a['organization-type-id'] || null, a['organization-type-name'] || null,
|
||||
a['organization-status-id'] || null, a['organization-status-name'] || null,
|
||||
a['psa-integration'] || null, a['psa-id'] || null,
|
||||
a['sync-active'] ?? false, a.primary ?? false,
|
||||
a['quick-notes'] || null, a.description || null, a.alert || null,
|
||||
a['parent-id'] || null, a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncLocations(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
const items = await client.getRawAllPages('/locations');
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_locations
|
||||
(id, organization_id, organization_name, name, primary_location,
|
||||
address_1, address_2, city, region_name, postal_code, country_name,
|
||||
phone, fax, notes, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, name=$4, primary_location=$5,
|
||||
address_1=$6, address_2=$7, city=$8, region_name=$9, postal_code=$10,
|
||||
country_name=$11, phone=$12, fax=$13, notes=$14, updated_at=$16, synced_at=NOW()`,
|
||||
[item.id, a['organization-id'], a['organization-name'] || null, a.name,
|
||||
a['primary-location'] ?? false, a['address-1'] || null, a['address-2'] || null,
|
||||
a.city || null, a['region-name'] || null, a['postal-code'] || null,
|
||||
a['country-name'] || null, a.phone || null, a.fax || null, a.notes || null,
|
||||
a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncContacts(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
const items = await client.getRawAllPages('/contacts');
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_contacts
|
||||
(id, organization_id, organization_name, first_name, last_name, name,
|
||||
title, contact_type_id, contact_type_name, location_id, important,
|
||||
notes, emails, phones, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, first_name=$4, last_name=$5,
|
||||
name=$6, title=$7, contact_type_id=$8, contact_type_name=$9,
|
||||
location_id=$10, important=$11, notes=$12, emails=$13, phones=$14,
|
||||
updated_at=$16, synced_at=NOW()`,
|
||||
[item.id, a['organization-id'], a['organization-name'] || null,
|
||||
a['first-name'] || null, a['last-name'] || null, a.name || null,
|
||||
a.title || null, a['contact-type-id'] || null, a['contact-type-name'] || null,
|
||||
a['location-id'] || null, a.important ?? false, a.notes || null,
|
||||
JSON.stringify(a['contact-emails'] || []),
|
||||
JSON.stringify(a['contact-phones'] || []),
|
||||
a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncConfigurations(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
const items = await client.getRawAllPages('/configurations');
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_configurations
|
||||
(id, organization_id, organization_name, name, hostname, primary_ip,
|
||||
mac_address, serial_number, asset_tag, position, installed_by, purchased_by,
|
||||
notes, operating_system_notes, warranty_expires_at, installed_at, purchased_at,
|
||||
end_of_life_at, configuration_type_id, configuration_type_name,
|
||||
configuration_status_id, configuration_status_name,
|
||||
manufacturer_id, manufacturer_name, model_id, model_name,
|
||||
operating_system_id, operating_system_name, location_id, contact_id,
|
||||
rmm_id, rmm_integration_type, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, name=$4, hostname=$5, primary_ip=$6,
|
||||
mac_address=$7, serial_number=$8, asset_tag=$9, position=$10, installed_by=$11,
|
||||
purchased_by=$12, notes=$13, operating_system_notes=$14, warranty_expires_at=$15,
|
||||
installed_at=$16, purchased_at=$17, end_of_life_at=$18,
|
||||
configuration_type_id=$19, configuration_type_name=$20,
|
||||
configuration_status_id=$21, configuration_status_name=$22,
|
||||
manufacturer_id=$23, manufacturer_name=$24, model_id=$25, model_name=$26,
|
||||
operating_system_id=$27, operating_system_name=$28,
|
||||
location_id=$29, contact_id=$30, rmm_id=$31, rmm_integration_type=$32,
|
||||
updated_at=$34, synced_at=NOW()`,
|
||||
[item.id, a['organization-id'], a['organization-name'] || null,
|
||||
a.name, a.hostname || null, a['primary-ip'] || null,
|
||||
a['mac-address'] || null, a['serial-number'] || null, a['asset-tag'] || null,
|
||||
a.position || null, a['installed-by'] || null, a['purchased-by'] || null,
|
||||
a.notes || null, a['operating-system-notes'] || null,
|
||||
a['warranty-expires-at'] || null, a['installed-at'] || null,
|
||||
a['purchased-at'] || null, a['end-of-life-at'] || null,
|
||||
a['configuration-type-id'] || null, a['configuration-type-name'] || null,
|
||||
a['configuration-status-id'] || null, a['configuration-status-name'] || null,
|
||||
a['manufacturer-id'] || null, a['manufacturer-name'] || null,
|
||||
a['model-id'] || null, a['model-name'] || null,
|
||||
a['operating-system-id'] || null, a['operating-system-name'] || null,
|
||||
a['location-id'] || null, a['contact-id'] || null,
|
||||
a['rmm-id'] || null, a['rmm-integration-type'] || null,
|
||||
a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncConfigurationInterfaces(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
// No flat endpoint — must iterate per configuration
|
||||
const configs = await client.getRawAllPages('/configurations');
|
||||
let count = 0;
|
||||
for (const cfg of configs) {
|
||||
const items = await client.getRawAllPages(
|
||||
`/configurations/${cfg.id}/relationships/configuration_interfaces`
|
||||
);
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_configuration_interfaces
|
||||
(id, configuration_id, organization_id, name, ip_address, mac_address,
|
||||
primary_interface, notes, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
configuration_id=$2, organization_id=$3, name=$4, ip_address=$5,
|
||||
mac_address=$6, primary_interface=$7, notes=$8, updated_at=$10, synced_at=NOW()`,
|
||||
[item.id, cfg.id, a['organization-id'] || null,
|
||||
a.name || null, a['ip-address'] || null, a['mac-address'] || null,
|
||||
a.primary ?? false, a.notes || null,
|
||||
a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncFlexibleAssets(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
// API requires filter[flexible-asset-type-id] — iterate per type
|
||||
const types = await client.getRawAllPages('/flexible_asset_types');
|
||||
let count = 0;
|
||||
for (const type of types) {
|
||||
const items = await client.getRawAllPages('/flexible_assets', {
|
||||
'filter[flexible-asset-type-id]': type.id,
|
||||
});
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_flexible_assets
|
||||
(id, organization_id, organization_name, flexible_asset_type_id,
|
||||
flexible_asset_type_name, name, traits, archived, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, flexible_asset_type_id=$4,
|
||||
flexible_asset_type_name=$5, name=$6, traits=$7, archived=$8,
|
||||
updated_at=$10, synced_at=NOW()`,
|
||||
[item.id, a['organization-id'], a['organization-name'] || null,
|
||||
a['flexible-asset-type-id'], a['flexible-asset-type-name'] || null,
|
||||
a.name || null, JSON.stringify(a.traits || {}), a.archived ?? false,
|
||||
a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncPasswordFolders(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
// No flat endpoint — must iterate per organization
|
||||
const orgs = await postgresClient.query('SELECT id FROM itg_organizations');
|
||||
let count = 0;
|
||||
for (const org of orgs.rows) {
|
||||
const items = await client.getRawAllPages(
|
||||
`/organizations/${org.id}/relationships/password_folders`
|
||||
);
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_password_folders
|
||||
(id, organization_id, organization_name, name, inherited, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, name=$4, inherited=$5,
|
||||
updated_at=$7, synced_at=NOW()`,
|
||||
[item.id, org.id, a['organization-name'] || null,
|
||||
a.name, a.inherited ?? false, a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncPasswords(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
const items = await client.getRawAllPages('/passwords');
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_passwords
|
||||
(id, organization_id, organization_name, name, username, password, url,
|
||||
notes, password_category_id, password_category_name, password_folder_id,
|
||||
autofill_selectors, otp_enabled, archived, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, name=$4, username=$5, password=$6,
|
||||
url=$7, notes=$8, password_category_id=$9, password_category_name=$10,
|
||||
password_folder_id=$11, autofill_selectors=$12, otp_enabled=$13, archived=$14,
|
||||
updated_at=$16, synced_at=NOW()`,
|
||||
[item.id, a['organization-id'], a['organization-name'] || null,
|
||||
a.name, a.username || null, a.password || null, a.url || null,
|
||||
a.notes || null, a['password-category-id'] || null, a['password-category-name'] || null,
|
||||
a['password-folder-id'] || null, a['autofill-selectors'] || null,
|
||||
a['otp-enabled'] ?? false, a.archived ?? false,
|
||||
a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncDocuments(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
// No flat endpoint — must iterate per organization
|
||||
const orgs = await postgresClient.query('SELECT id FROM itg_organizations');
|
||||
let count = 0;
|
||||
for (const org of orgs.rows) {
|
||||
const items = await client.getRawAllPages(
|
||||
`/organizations/${org.id}/relationships/documents`
|
||||
);
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_documents
|
||||
(id, organization_id, organization_name, name, content, draft, archived, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, name=$4, content=$5,
|
||||
draft=$6, archived=$7, updated_at=$9, synced_at=NOW()`,
|
||||
[item.id, org.id, a['organization-name'] || null,
|
||||
a.name, a.content || null, a.draft ?? false, a.archived ?? false,
|
||||
a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncDomains(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
const items = await client.getRawAllPages('/domains');
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_domains
|
||||
(id, organization_id, organization_name, name, screenshot, whois_updated_at,
|
||||
expires_at, registrar_name, notes, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, name=$4, screenshot=$5,
|
||||
whois_updated_at=$6, expires_at=$7, registrar_name=$8, notes=$9,
|
||||
updated_at=$11, synced_at=NOW()`,
|
||||
[item.id, a['organization-id'], a['organization-name'] || null,
|
||||
a.name, a.screenshot || null, a['whois-updated-at'] || null,
|
||||
a['expires-at'] || null, a['registrar-name'] || null, a.notes || null,
|
||||
a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncExpirations(): Promise<number> {
|
||||
const client = getITGlueClient();
|
||||
// No flat endpoint — must iterate per organization
|
||||
const orgs = await postgresClient.query('SELECT id FROM itg_organizations');
|
||||
let count = 0;
|
||||
for (const org of orgs.rows) {
|
||||
const items = await client.getRawAllPages(
|
||||
`/organizations/${org.id}/relationships/expirations`
|
||||
);
|
||||
for (const item of items) {
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_expirations
|
||||
(id, organization_id, organization_name, resource_id, resource_type,
|
||||
resource_name, expiration_type, description, expiration_date, notify,
|
||||
created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, resource_id=$4, resource_type=$5,
|
||||
resource_name=$6, expiration_type=$7, description=$8, expiration_date=$9,
|
||||
notify=$10, updated_at=$12, synced_at=NOW()`,
|
||||
[item.id, org.id, a['organization-name'] || null,
|
||||
a['resource-id'] || null, a['resource-type'] || null, a['resource-name'] || null,
|
||||
a['expiration-type'] || null, a.description || null,
|
||||
a['expiration-date'] || null, a.notify ?? false,
|
||||
a['created-at'] || null, a['updated-at'] || null]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: ITGlueSyncService | null = null;
|
||||
export function getITGlueSyncService(): ITGlueSyncService {
|
||||
if (!_instance) _instance = new ITGlueSyncService();
|
||||
return _instance;
|
||||
}
|
||||
408
lib/services/pipeline-engine.ts
Normal file
408
lib/services/pipeline-engine.ts
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
/**
|
||||
* Pipeline Engine
|
||||
* Matches incoming webhooks to pipelines, executes steps sequentially,
|
||||
* resolves template variables, and accumulates context between steps.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
import {
|
||||
WebhookPipeline,
|
||||
PipelineStep,
|
||||
PipelineWithSteps,
|
||||
PipelineContext,
|
||||
PipelineStatus,
|
||||
StepExecutorResult,
|
||||
TriggerCondition,
|
||||
} from '../types/pipeline';
|
||||
|
||||
// Step executor registry — populated by individual step files
|
||||
type StepExecutorFn = (
|
||||
step: PipelineStep,
|
||||
context: PipelineContext,
|
||||
executionId: number
|
||||
) => Promise<StepExecutorResult>;
|
||||
|
||||
const stepExecutors: Map<string, StepExecutorFn> = new Map();
|
||||
|
||||
export function registerStepExecutor(stepType: string, executor: StepExecutorFn): void {
|
||||
stepExecutors.set(stepType, executor);
|
||||
}
|
||||
|
||||
export class PipelineEngine {
|
||||
/**
|
||||
* Find and execute all matching pipelines for a trigger source + payload.
|
||||
* Called from webhook routes after raw logging.
|
||||
*/
|
||||
async processTrigger(
|
||||
triggerSource: string,
|
||||
payload: Record<string, any>
|
||||
): Promise<number[]> {
|
||||
const pipelines = await this.findMatchingPipelines(triggerSource, payload);
|
||||
|
||||
if (pipelines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`[PIPELINE] ${pipelines.length} pipeline(s) matched for ${triggerSource}`);
|
||||
|
||||
const executionIds: number[] = [];
|
||||
for (const pipeline of pipelines) {
|
||||
try {
|
||||
const execId = await this.executePipeline(pipeline, triggerSource, payload);
|
||||
executionIds.push(execId);
|
||||
} catch (err) {
|
||||
console.error(`[PIPELINE] Failed to execute pipeline "${pipeline.name}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return executionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find active pipelines matching the trigger source and conditions.
|
||||
*/
|
||||
async findMatchingPipelines(
|
||||
triggerSource: string,
|
||||
payload: Record<string, any>
|
||||
): Promise<PipelineWithSteps[]> {
|
||||
const result = await postgresClient.query<WebhookPipeline>(
|
||||
`SELECT * FROM webhook_pipelines
|
||||
WHERE is_active = true AND trigger_source = $1
|
||||
ORDER BY sort_order`,
|
||||
[triggerSource]
|
||||
);
|
||||
|
||||
const matched: PipelineWithSteps[] = [];
|
||||
|
||||
for (const pipeline of result.rows) {
|
||||
const conditions: TriggerCondition[] = Array.isArray(pipeline.trigger_conditions)
|
||||
? pipeline.trigger_conditions
|
||||
: [];
|
||||
|
||||
if (this.evaluateConditions(conditions, payload)) {
|
||||
const stepsResult = await postgresClient.query<PipelineStep>(
|
||||
`SELECT * FROM pipeline_steps
|
||||
WHERE pipeline_id = $1 AND is_active = true
|
||||
ORDER BY step_order`,
|
||||
[pipeline.id]
|
||||
);
|
||||
matched.push({ ...pipeline, steps: stepsResult.rows });
|
||||
}
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single pipeline: create execution record, run steps, update status.
|
||||
*/
|
||||
async executePipeline(
|
||||
pipeline: PipelineWithSteps,
|
||||
triggerSource: string,
|
||||
payload: Record<string, any>
|
||||
): Promise<number> {
|
||||
const execResult = await postgresClient.query<{ id: number }>(
|
||||
`INSERT INTO pipeline_executions (pipeline_id, trigger_source, trigger_payload, status)
|
||||
VALUES ($1, $2, $3, 'running')
|
||||
RETURNING id`,
|
||||
[pipeline.id, triggerSource, JSON.stringify(payload)]
|
||||
);
|
||||
const executionId = execResult.rows[0].id;
|
||||
|
||||
const context: PipelineContext = { trigger: payload };
|
||||
let finalStatus: PipelineStatus = 'completed';
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
console.log(`[PIPELINE] Executing "${pipeline.name}" (exec #${executionId}), ${pipeline.steps.length} steps`);
|
||||
|
||||
for (const step of pipeline.steps) {
|
||||
// Update current step
|
||||
await postgresClient.query(
|
||||
`UPDATE pipeline_executions SET current_step = $1, context = $2 WHERE id = $3`,
|
||||
[step.step_order, JSON.stringify(context), executionId]
|
||||
);
|
||||
|
||||
const stepStart = Date.now();
|
||||
|
||||
// Log step start
|
||||
await postgresClient.query(
|
||||
`INSERT INTO pipeline_execution_steps (execution_id, step_order, step_type, step_name, status, started_at, input_data)
|
||||
VALUES ($1, $2, $3, $4, 'running', NOW(), $5)`,
|
||||
[executionId, step.step_order, step.step_type, step.name, JSON.stringify({ config: step.config })]
|
||||
);
|
||||
|
||||
const executor = stepExecutors.get(step.step_type);
|
||||
if (!executor) {
|
||||
const err = `No executor registered for step type: ${step.step_type}`;
|
||||
console.error(`[PIPELINE] ${err}`);
|
||||
await this.updateStepLog(executionId, step.step_order, 'failed', null, err, Date.now() - stepStart);
|
||||
|
||||
if (step.on_failure === 'stop') {
|
||||
finalStatus = 'failed';
|
||||
errorMessage = err;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve template variables in step config
|
||||
const resolvedConfig = this.resolveTemplates(step.config, context);
|
||||
const resolvedStep = { ...step, config: resolvedConfig };
|
||||
|
||||
const result = await executor(resolvedStep, context, executionId);
|
||||
const duration = Date.now() - stepStart;
|
||||
|
||||
if (result.waiting) {
|
||||
await this.updateStepLog(executionId, step.step_order, 'waiting', result.output, null, duration);
|
||||
finalStatus = 'waiting';
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
// Merge output into context
|
||||
if (result.output) {
|
||||
Object.assign(context, result.output);
|
||||
}
|
||||
await this.updateStepLog(executionId, step.step_order, 'completed', result.output, null, duration);
|
||||
console.log(`[PIPELINE] Step ${step.step_order} "${step.name}" completed (${duration}ms)`);
|
||||
} else {
|
||||
await this.updateStepLog(executionId, step.step_order, 'failed', result.output, result.error || null, duration);
|
||||
console.error(`[PIPELINE] Step ${step.step_order} "${step.name}" failed: ${result.error}`);
|
||||
|
||||
if (step.on_failure === 'stop') {
|
||||
finalStatus = 'failed';
|
||||
errorMessage = `Step ${step.step_order} "${step.name}": ${result.error}`;
|
||||
break;
|
||||
} else if (step.on_failure === 'skip_to' && step.skip_to_step) {
|
||||
// Skip ahead — handled by finding the next step with matching order
|
||||
// For simplicity, we just continue; the skip_to logic would need step reordering
|
||||
continue;
|
||||
}
|
||||
// on_failure === 'continue' → keep going
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const duration = Date.now() - stepStart;
|
||||
await this.updateStepLog(executionId, step.step_order, 'failed', null, errMsg, duration);
|
||||
console.error(`[PIPELINE] Step ${step.step_order} "${step.name}" threw: ${errMsg}`);
|
||||
|
||||
if (step.on_failure === 'stop') {
|
||||
finalStatus = 'failed';
|
||||
errorMessage = errMsg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize execution
|
||||
await postgresClient.query(
|
||||
`UPDATE pipeline_executions
|
||||
SET status = $1, context = $2, completed_at = NOW(),
|
||||
duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
|
||||
error_message = $3
|
||||
WHERE id = $4`,
|
||||
[finalStatus, JSON.stringify(context), errorMessage, executionId]
|
||||
);
|
||||
|
||||
console.log(`[PIPELINE] Execution #${executionId} finished: ${finalStatus}`);
|
||||
return executionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a waiting pipeline (e.g., after approval callback).
|
||||
*/
|
||||
async resumeExecution(executionId: number, approvalResult: Record<string, any>): Promise<void> {
|
||||
const execResult = await postgresClient.query<any>(
|
||||
`SELECT pe.*, wp.name as pipeline_name FROM pipeline_executions pe
|
||||
JOIN webhook_pipelines wp ON wp.id = pe.pipeline_id
|
||||
WHERE pe.id = $1 AND pe.status = 'waiting'`,
|
||||
[executionId]
|
||||
);
|
||||
|
||||
if (execResult.rows.length === 0) {
|
||||
throw new Error(`Execution #${executionId} not found or not in waiting state`);
|
||||
}
|
||||
|
||||
const execution = execResult.rows[0];
|
||||
const context: PipelineContext = execution.context || {};
|
||||
context.approval_result = approvalResult;
|
||||
|
||||
// Get remaining steps after the current waiting step
|
||||
const stepsResult = await postgresClient.query<PipelineStep>(
|
||||
`SELECT * FROM pipeline_steps
|
||||
WHERE pipeline_id = $1 AND step_order > $2 AND is_active = true
|
||||
ORDER BY step_order`,
|
||||
[execution.pipeline_id, execution.current_step]
|
||||
);
|
||||
|
||||
// Update execution to running
|
||||
await postgresClient.query(
|
||||
`UPDATE pipeline_executions SET status = 'running', context = $1 WHERE id = $2`,
|
||||
[JSON.stringify(context), executionId]
|
||||
);
|
||||
|
||||
// Mark the waiting step as completed
|
||||
await this.updateStepLog(executionId, execution.current_step, 'completed', approvalResult, null, 0);
|
||||
|
||||
// Continue executing remaining steps
|
||||
let finalStatus: PipelineStatus = 'completed';
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
for (const step of stepsResult.rows) {
|
||||
await postgresClient.query(
|
||||
`UPDATE pipeline_executions SET current_step = $1, context = $2 WHERE id = $3`,
|
||||
[step.step_order, JSON.stringify(context), executionId]
|
||||
);
|
||||
|
||||
const stepStart = Date.now();
|
||||
await postgresClient.query(
|
||||
`INSERT INTO pipeline_execution_steps (execution_id, step_order, step_type, step_name, status, started_at, input_data)
|
||||
VALUES ($1, $2, $3, $4, 'running', NOW(), $5)`,
|
||||
[executionId, step.step_order, step.step_type, step.name, JSON.stringify({ config: step.config })]
|
||||
);
|
||||
|
||||
const executor = stepExecutors.get(step.step_type);
|
||||
if (!executor) {
|
||||
const err = `No executor for: ${step.step_type}`;
|
||||
await this.updateStepLog(executionId, step.step_order, 'failed', null, err, Date.now() - stepStart);
|
||||
if (step.on_failure === 'stop') { finalStatus = 'failed'; errorMessage = err; break; }
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolvedConfig = this.resolveTemplates(step.config, context);
|
||||
const result = await executor({ ...step, config: resolvedConfig }, context, executionId);
|
||||
const duration = Date.now() - stepStart;
|
||||
|
||||
if (result.waiting) {
|
||||
await this.updateStepLog(executionId, step.step_order, 'waiting', result.output, null, duration);
|
||||
finalStatus = 'waiting';
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
if (result.output) Object.assign(context, result.output);
|
||||
await this.updateStepLog(executionId, step.step_order, 'completed', result.output, null, duration);
|
||||
} else {
|
||||
await this.updateStepLog(executionId, step.step_order, 'failed', result.output, result.error || null, duration);
|
||||
if (step.on_failure === 'stop') { finalStatus = 'failed'; errorMessage = result.error ?? null; break; }
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
await this.updateStepLog(executionId, step.step_order, 'failed', null, errMsg, Date.now() - stepStart);
|
||||
if (step.on_failure === 'stop') { finalStatus = 'failed'; errorMessage = errMsg; break; }
|
||||
}
|
||||
}
|
||||
|
||||
await postgresClient.query(
|
||||
`UPDATE pipeline_executions
|
||||
SET status = $1, context = $2, completed_at = NOW(),
|
||||
duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
|
||||
error_message = $3
|
||||
WHERE id = $4`,
|
||||
[finalStatus, JSON.stringify(context), errorMessage, executionId]
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Template Resolution
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Recursively resolve {{...}} template variables in any value.
|
||||
*/
|
||||
resolveTemplates(value: any, context: PipelineContext): any {
|
||||
if (typeof value === 'string') {
|
||||
return this.resolveStringTemplate(value, context);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(v => this.resolveTemplates(v, context));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const resolved: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
resolved[k] = this.resolveTemplates(v, context);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private resolveStringTemplate(template: string, context: PipelineContext): string {
|
||||
return template.replace(/\{\{([^}]+)\}\}/g, (match, path: string) => {
|
||||
const value = this.getNestedValue(context, path.trim());
|
||||
if (value === undefined || value === null) return '';
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
private getNestedValue(obj: any, path: string): any {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null) return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Condition Evaluation
|
||||
// ============================================================================
|
||||
|
||||
evaluateConditions(conditions: TriggerCondition[], payload: Record<string, any>): boolean {
|
||||
if (conditions.length === 0) return true;
|
||||
return conditions.every(cond => this.evaluateCondition(cond, payload));
|
||||
}
|
||||
|
||||
private evaluateCondition(cond: TriggerCondition, payload: Record<string, any>): boolean {
|
||||
const fieldValue = this.getNestedValue(payload, cond.field);
|
||||
|
||||
switch (cond.operator) {
|
||||
case 'equals':
|
||||
return String(fieldValue) === String(cond.value);
|
||||
case 'not_equals':
|
||||
return String(fieldValue) !== String(cond.value);
|
||||
case 'contains':
|
||||
return fieldValue != null && String(fieldValue).toLowerCase().includes(String(cond.value).toLowerCase());
|
||||
case 'not_contains':
|
||||
return fieldValue == null || !String(fieldValue).toLowerCase().includes(String(cond.value).toLowerCase());
|
||||
case 'in':
|
||||
return Array.isArray(cond.value) && cond.value.some((v: any) => String(v) === String(fieldValue));
|
||||
case 'not_in':
|
||||
return !Array.isArray(cond.value) || !cond.value.some((v: any) => String(v) === String(fieldValue));
|
||||
case 'regex':
|
||||
try { return fieldValue != null && new RegExp(String(cond.value), 'i').test(String(fieldValue)); }
|
||||
catch { return false; }
|
||||
case 'exists':
|
||||
return fieldValue != null && fieldValue !== '';
|
||||
case 'not_exists':
|
||||
return fieldValue == null || fieldValue === '';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
private async updateStepLog(
|
||||
executionId: number,
|
||||
stepOrder: number,
|
||||
status: string,
|
||||
outputData: any,
|
||||
errorMessage: string | null,
|
||||
durationMs: number
|
||||
): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`UPDATE pipeline_execution_steps
|
||||
SET status = $1, output_data = $2, error_message = $3, duration_ms = $4, completed_at = NOW()
|
||||
WHERE execution_id = $5 AND step_order = $6`,
|
||||
[status, outputData ? JSON.stringify(outputData) : null, errorMessage, durationMs, executionId, stepOrder]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const pipelineEngine = new PipelineEngine();
|
||||
116
lib/services/pipeline-steps/ai-analyze.ts
Normal file
116
lib/services/pipeline-steps/ai-analyze.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* AI Analyze Step — send data to AI for analysis/summary.
|
||||
* Config: { purpose: "summarize_alert", prompt: "...", system_prompt: "...", prompt_template_id?: 1 }
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { postgresClient } from '../postgres-client';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
async function executeAiAnalyze(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const userPrompt = step.config.prompt || '';
|
||||
let systemPrompt = step.config.system_prompt || 'You are a helpful IT operations assistant.';
|
||||
|
||||
// If a prompt_template_id is provided, load from DB
|
||||
if (step.config.prompt_template_id) {
|
||||
const tplResult = await postgresClient.query(
|
||||
`SELECT system_prompt, user_prompt_template, provider, model, temperature, max_tokens
|
||||
FROM ai_prompt_templates WHERE id = $1 AND is_active = true`,
|
||||
[step.config.prompt_template_id]
|
||||
);
|
||||
if (tplResult.rows.length > 0) {
|
||||
systemPrompt = tplResult.rows[0].system_prompt || systemPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userPrompt) {
|
||||
return { success: false, error: 'Missing prompt for AI analysis' };
|
||||
}
|
||||
|
||||
// Load AI settings
|
||||
const settingsResult = await postgresClient.query(
|
||||
`SELECT key, value FROM workflow_settings WHERE key IN ('default_ai_provider', 'openai_api_key', 'openai_model', 'anthropic_api_key', 'anthropic_model')`
|
||||
);
|
||||
|
||||
const settings: Record<string, any> = {};
|
||||
for (const row of settingsResult.rows) {
|
||||
try { settings[row.key] = JSON.parse(row.value); } catch { settings[row.key] = row.value; }
|
||||
}
|
||||
|
||||
const provider = step.config.provider || settings.default_ai_provider || 'openai';
|
||||
const model = step.config.model || (provider === 'anthropic' ? settings.anthropic_model : settings.openai_model) || 'gpt-4o';
|
||||
const apiKey = provider === 'anthropic' ? settings.anthropic_api_key : settings.openai_api_key;
|
||||
|
||||
if (!apiKey) {
|
||||
return { success: false, error: `No API key configured for ${provider}` };
|
||||
}
|
||||
|
||||
console.log(`[PIPELINE:ai_analyze] Calling ${provider}/${model}`);
|
||||
|
||||
let aiResponse: string;
|
||||
|
||||
if (provider === 'anthropic') {
|
||||
const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: Number(step.config.max_tokens) || 2000,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
return { success: false, error: `Anthropic API error (${resp.status}): ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
aiResponse = data.content?.[0]?.text || '';
|
||||
} else {
|
||||
const resp = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: Number(step.config.temperature) || 0.3,
|
||||
max_tokens: Number(step.config.max_tokens) || 2000,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
return { success: false, error: `OpenAI API error (${resp.status}): ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
aiResponse = data.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ai_response: aiResponse,
|
||||
ai_provider: provider,
|
||||
ai_model: model,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerStepExecutor('ai_analyze', executeAiAnalyze);
|
||||
138
lib/services/pipeline-steps/approval.ts
Normal file
138
lib/services/pipeline-steps/approval.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* Approval Step — send approval request, pause pipeline until callback.
|
||||
* Config: { channel_id: 1, message: "...", options: ["Approve","Reject","Escalate"], timeout_min: 60 }
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { postgresClient } from '../postgres-client';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult, NotificationChannel } from '../../types/pipeline';
|
||||
|
||||
async function executeApproval(
|
||||
step: PipelineStep,
|
||||
context: PipelineContext,
|
||||
executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const channelId = Number(step.config.channel_id);
|
||||
const message = step.config.message || 'Approval required';
|
||||
const options = step.config.options || ['Approve', 'Reject'];
|
||||
const timeoutMin = Number(step.config.timeout_min) || 60;
|
||||
|
||||
const expiresAt = new Date(Date.now() + timeoutMin * 60 * 1000);
|
||||
|
||||
// Create approval request record
|
||||
const result = await postgresClient.query<{ id: number }>(
|
||||
`INSERT INTO approval_requests (execution_id, step_order, channel_id, message, options, status, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending', $6)
|
||||
RETURNING id`,
|
||||
[executionId, step.step_order, channelId || null, message, JSON.stringify(options), expiresAt]
|
||||
);
|
||||
|
||||
const approvalId = result.rows[0].id;
|
||||
const callbackUrl = `${process.env.WEBHOOK_BASE_URL || ''}/api/pipelines/approval/${approvalId}`;
|
||||
|
||||
console.log(`[PIPELINE:approval] Created approval #${approvalId}, callback: ${callbackUrl}`);
|
||||
|
||||
// Send notification with approval buttons if channel is configured
|
||||
if (channelId) {
|
||||
const chResult = await postgresClient.query<NotificationChannel>(
|
||||
`SELECT * FROM notification_channels WHERE id = $1 AND is_active = true`,
|
||||
[channelId]
|
||||
);
|
||||
|
||||
if (chResult.rows.length > 0) {
|
||||
const channel = chResult.rows[0];
|
||||
await sendApprovalNotification(channel, message, options, approvalId, callbackUrl, context);
|
||||
}
|
||||
}
|
||||
|
||||
// Return waiting — pipeline will pause here
|
||||
return {
|
||||
success: true,
|
||||
waiting: true,
|
||||
output: { approval_id: approvalId, callback_url: callbackUrl },
|
||||
};
|
||||
}
|
||||
|
||||
async function sendApprovalNotification(
|
||||
channel: NotificationChannel,
|
||||
message: string,
|
||||
options: string[],
|
||||
approvalId: number,
|
||||
callbackUrl: string,
|
||||
context: PipelineContext
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (channel.channel_type === 'teams') {
|
||||
const actions = options.map(opt => ({
|
||||
type: 'Action.OpenUrl',
|
||||
title: opt,
|
||||
url: `${callbackUrl}?response=${encodeURIComponent(opt)}`,
|
||||
}));
|
||||
|
||||
const card = {
|
||||
type: 'message',
|
||||
attachments: [{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
content: {
|
||||
type: 'AdaptiveCard',
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
version: '1.4',
|
||||
body: [
|
||||
{ type: 'TextBlock', text: 'Approval Required', weight: 'bolder', size: 'medium' },
|
||||
{ type: 'TextBlock', text: message, wrap: true },
|
||||
{ type: 'TextBlock', text: `Approval #${approvalId}`, size: 'small', isSubtle: true },
|
||||
],
|
||||
actions,
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
await fetch(channel.config.webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(card),
|
||||
});
|
||||
} else if (channel.channel_type === 'telegram') {
|
||||
const keyboard = {
|
||||
inline_keyboard: [options.map(opt => ({
|
||||
text: opt,
|
||||
callback_data: JSON.stringify({ approval_id: approvalId, response: opt }),
|
||||
}))],
|
||||
};
|
||||
|
||||
await fetch(`https://api.telegram.org/bot${channel.config.bot_token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: channel.config.chat_id,
|
||||
text: `🔔 *Approval Required*\n\n${message}\n\n_Approval #${approvalId}_`,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: keyboard,
|
||||
}),
|
||||
});
|
||||
} else if (channel.channel_type === 'ntfy') {
|
||||
const serverUrl = channel.config.server_url || 'https://ntfy.sh';
|
||||
const headers: Record<string, string> = {
|
||||
'Title': 'Approval Required',
|
||||
'Priority': 'high',
|
||||
'Tags': 'warning',
|
||||
'Actions': options.map(opt =>
|
||||
`http, ${opt}, ${callbackUrl}?response=${encodeURIComponent(opt)}, method=POST`
|
||||
).join('; '),
|
||||
};
|
||||
if (channel.config.auth_token) {
|
||||
headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
|
||||
}
|
||||
|
||||
await fetch(`${serverUrl}/${channel.config.topic}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: message,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[PIPELINE:approval] Failed to send notification:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
registerStepExecutor('approval', executeApproval);
|
||||
52
lib/services/pipeline-steps/create-note.ts
Normal file
52
lib/services/pipeline-steps/create-note.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Create Note Step — add a note to an Autotask ticket.
|
||||
* Config: { ticket_id: "{{context.ticket_id}}", title: "...", body: "...", note_type: 1, publish: 1 }
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { AutotaskClient } from '../autotask-client';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
let _client: AutotaskClient | null = null;
|
||||
function getClient(): AutotaskClient {
|
||||
if (!_client) {
|
||||
_client = new AutotaskClient({
|
||||
apiUrl: process.env.AUTOTASK_API_URL || '',
|
||||
username: process.env.AUTOTASK_USERNAME || '',
|
||||
password: process.env.AUTOTASK_SECRET || '',
|
||||
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
});
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
async function executeCreateNote(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const ticketId = Number(step.config.ticket_id);
|
||||
const title = step.config.title || 'Pipeline Note';
|
||||
const body = step.config.body || '';
|
||||
const noteType = Number(step.config.note_type) || 1;
|
||||
const publish = Number(step.config.publish) || 1;
|
||||
|
||||
if (!ticketId || isNaN(ticketId)) {
|
||||
return { success: false, error: 'Missing or invalid ticket_id' };
|
||||
}
|
||||
|
||||
console.log(`[PIPELINE:create_note] Adding note to ticket #${ticketId}: "${title}"`);
|
||||
|
||||
const client = getClient();
|
||||
await client.createEntity('TicketNotes', {
|
||||
ticketID: ticketId,
|
||||
title,
|
||||
description: body,
|
||||
noteType,
|
||||
publish,
|
||||
});
|
||||
|
||||
return { success: true, output: { note_created: true, ticket_id: ticketId } };
|
||||
}
|
||||
|
||||
registerStepExecutor('create_note', executeCreateNote);
|
||||
68
lib/services/pipeline-steps/create-ticket.ts
Normal file
68
lib/services/pipeline-steps/create-ticket.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Create Ticket Step — create an Autotask ticket from context.
|
||||
* Config: { template: { title, description, companyID, ticketType, priority, queueID, ... } }
|
||||
* All template values are pre-resolved by the engine.
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { AutotaskClient } from '../autotask-client';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
let _client: AutotaskClient | null = null;
|
||||
function getClient(): AutotaskClient {
|
||||
if (!_client) {
|
||||
_client = new AutotaskClient({
|
||||
apiUrl: process.env.AUTOTASK_API_URL || '',
|
||||
username: process.env.AUTOTASK_USERNAME || '',
|
||||
password: process.env.AUTOTASK_SECRET || '',
|
||||
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
});
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
async function executeCreateTicket(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const template = step.config.template;
|
||||
|
||||
if (!template || !template.title) {
|
||||
return { success: false, error: 'Missing ticket template or title' };
|
||||
}
|
||||
|
||||
// Build ticket payload — convert numeric strings to numbers
|
||||
const ticketPayload: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
if (['companyID', 'ticketType', 'priority', 'queueID', 'ticketCategory', 'issueType', 'subIssueType', 'status'].includes(key)) {
|
||||
const num = Number(value);
|
||||
if (!isNaN(num) && num > 0) {
|
||||
ticketPayload[key] = num;
|
||||
}
|
||||
} else {
|
||||
ticketPayload[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Default status to New (1) if not set
|
||||
if (!ticketPayload.status) {
|
||||
ticketPayload.status = 1;
|
||||
}
|
||||
|
||||
console.log(`[PIPELINE:create_ticket] Creating ticket: "${ticketPayload.title}"`);
|
||||
|
||||
const client = getClient();
|
||||
const ticket = await client.createTicket(ticketPayload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ticket_id: ticket.id,
|
||||
ticket_number: (ticket as any).ticketNumber,
|
||||
created_ticket: ticket,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerStepExecutor('create_ticket', executeCreateTicket);
|
||||
76
lib/services/pipeline-steps/db-query.ts
Normal file
76
lib/services/pipeline-steps/db-query.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Step Executor: db_query
|
||||
* Run a parameterized read-only SQL query against local Postgres.
|
||||
* Useful for trend analysis, history lookups, aggregations.
|
||||
*
|
||||
* Config:
|
||||
* query: SQL string with $1, $2 etc. placeholders
|
||||
* params: array of template strings for parameter values
|
||||
* output_key: context key to store results (default: 'query_result')
|
||||
* single_row: if true, store only first row instead of array
|
||||
*
|
||||
* Security: Only SELECT statements allowed. No mutations.
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { postgresClient } from '../postgres-client';
|
||||
import { StepExecutorResult, PipelineStep, PipelineContext } from '../../types/pipeline';
|
||||
|
||||
const FORBIDDEN_KEYWORDS = [
|
||||
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'CREATE', 'TRUNCATE',
|
||||
'GRANT', 'REVOKE', 'COPY', 'EXECUTE', 'CALL',
|
||||
];
|
||||
|
||||
registerStepExecutor('db_query', async (
|
||||
step: PipelineStep,
|
||||
context: PipelineContext,
|
||||
executionId: number
|
||||
): Promise<StepExecutorResult> => {
|
||||
const config = step.config as {
|
||||
query?: string;
|
||||
params?: string[];
|
||||
output_key?: string;
|
||||
single_row?: boolean;
|
||||
};
|
||||
|
||||
const query = config.query || '';
|
||||
const params = config.params || [];
|
||||
const outputKey = config.output_key || 'query_result';
|
||||
const singleRow = config.single_row ?? false;
|
||||
|
||||
if (!query) {
|
||||
return { success: false, output: {}, error: 'query is required' };
|
||||
}
|
||||
|
||||
// Security: block mutations
|
||||
const upperQuery = query.toUpperCase().replace(/\s+/g, ' ');
|
||||
for (const keyword of FORBIDDEN_KEYWORDS) {
|
||||
// Check for keyword as a standalone word (not inside a string literal)
|
||||
const regex = new RegExp(`\\b${keyword}\\b`);
|
||||
if (regex.test(upperQuery)) {
|
||||
return {
|
||||
success: false,
|
||||
output: {},
|
||||
error: `Forbidden SQL keyword: ${keyword}. Only SELECT queries are allowed.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await postgresClient.query(query, params);
|
||||
|
||||
const rows = result.rows;
|
||||
const value = singleRow ? (rows[0] || null) : rows;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
[outputKey]: value,
|
||||
[`${outputKey}_count`]: rows.length,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, output: { [outputKey]: null }, error: `DB query failed: ${msg}` };
|
||||
}
|
||||
});
|
||||
24
lib/services/pipeline-steps/delay.ts
Normal file
24
lib/services/pipeline-steps/delay.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Delay Step — wait N seconds before continuing.
|
||||
* Config: { seconds: 30 }
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
async function executeDelay(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const seconds = Number(step.config.seconds) || 0;
|
||||
|
||||
if (seconds > 0) {
|
||||
console.log(`[PIPELINE:delay] Waiting ${seconds}s`);
|
||||
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
||||
}
|
||||
|
||||
return { success: true, output: { delayed_seconds: seconds } };
|
||||
}
|
||||
|
||||
registerStepExecutor('delay', executeDelay);
|
||||
71
lib/services/pipeline-steps/enrich-company.ts
Normal file
71
lib/services/pipeline-steps/enrich-company.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Enrich Company Step — lookup Autotask company from site name or site_uid.
|
||||
* Config: { lookup_by: "site_name", source_field: "{{context.site_name}}" }
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { postgresClient } from '../postgres-client';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
async function executeEnrichCompany(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const lookupBy = step.config.lookup_by || 'site_name';
|
||||
const sourceValue = step.config.source_field;
|
||||
|
||||
if (!sourceValue) {
|
||||
return { success: false, error: `No source value for company lookup by ${lookupBy}` };
|
||||
}
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (lookupBy === 'site_uid') {
|
||||
query = `SELECT s.autotask_company_id as company_id, s.autotask_company_name as company_name, s.name as site_name
|
||||
FROM datto_rmm_sites s WHERE s.uid = $1 LIMIT 1`;
|
||||
params = [sourceValue];
|
||||
} else {
|
||||
// site_name — fuzzy match
|
||||
query = `SELECT s.autotask_company_id as company_id, s.autotask_company_name as company_name, s.name as site_name
|
||||
FROM datto_rmm_sites s WHERE LOWER(s.name) = LOWER($1) LIMIT 1`;
|
||||
params = [sourceValue];
|
||||
}
|
||||
|
||||
const result = await postgresClient.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
// Try companies table directly
|
||||
const compResult = await postgresClient.query(
|
||||
`SELECT id as company_id, company_name FROM companies
|
||||
WHERE LOWER(company_name) LIKE LOWER($1) AND is_deleted = false LIMIT 1`,
|
||||
[`%${sourceValue}%`]
|
||||
);
|
||||
|
||||
if (compResult.rows.length > 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
company_id: compResult.rows[0].company_id,
|
||||
company_name: compResult.rows[0].company_name,
|
||||
company_found: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, output: { company_id: null, company_name: null, company_found: false } };
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
company_id: row.company_id,
|
||||
company_name: row.company_name,
|
||||
company_found: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerStepExecutor('enrich_company', executeEnrichCompany);
|
||||
50
lib/services/pipeline-steps/enrich-device.ts
Normal file
50
lib/services/pipeline-steps/enrich-device.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Enrich Device Step — fetch device details from Datto RMM or local DB.
|
||||
* Config: { lookup_by: "device_uid", source_field: "{{context.device_uid}}" }
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { postgresClient } from '../postgres-client';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
async function executeEnrichDevice(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const deviceUid = step.config.source_field || step.config.device_uid;
|
||||
|
||||
if (!deviceUid) {
|
||||
return { success: false, error: 'No device_uid provided for enrichment' };
|
||||
}
|
||||
|
||||
// Try local DB first
|
||||
const result = await postgresClient.query(
|
||||
`SELECT d.*, s.name as site_name, s.autotask_company_id, s.autotask_company_name
|
||||
FROM datto_rmm_devices d
|
||||
LEFT JOIN datto_rmm_sites s ON s.id = d.site_id
|
||||
WHERE d.uid = $1
|
||||
LIMIT 1`,
|
||||
[deviceUid]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return { success: true, output: { device: null, device_found: false } };
|
||||
}
|
||||
|
||||
const device = result.rows[0];
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
device,
|
||||
device_found: true,
|
||||
device_hostname: device.hostname,
|
||||
device_os: device.operating_system,
|
||||
device_ip: device.int_ip_address || device.ext_ip_address,
|
||||
company_id: device.autotask_company_id,
|
||||
company_name: device.autotask_company_name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerStepExecutor('enrich_device', executeEnrichDevice);
|
||||
48
lib/services/pipeline-steps/enrich-ticket.ts
Normal file
48
lib/services/pipeline-steps/enrich-ticket.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Enrich Ticket Step — fetch ticket from local DB or Autotask.
|
||||
* Config: { lookup_by: "ticket_number"|"ticket_id", source_field: "{{context.ticket_number}}" }
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { postgresClient } from '../postgres-client';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
async function executeEnrichTicket(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const lookupBy = step.config.lookup_by || 'ticket_number';
|
||||
const sourceValue = step.config.source_field;
|
||||
|
||||
if (!sourceValue) {
|
||||
return { success: false, error: `No source value for ticket lookup by ${lookupBy}` };
|
||||
}
|
||||
|
||||
const field = lookupBy === 'ticket_id' ? 'id' : 'ticket_number';
|
||||
const result = await postgresClient.query(
|
||||
`SELECT id, ticket_number, title, description, status, priority, queue_id,
|
||||
company_id, contact_id, assigned_resource_id, ticket_type,
|
||||
issue_type, sub_issue_type, ticket_category
|
||||
FROM tickets WHERE ${field} = $1 AND is_deleted = false LIMIT 1`,
|
||||
[sourceValue]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return { success: true, output: { ticket: null, ticket_found: false } };
|
||||
}
|
||||
|
||||
const ticket = result.rows[0];
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ticket,
|
||||
ticket_found: true,
|
||||
ticket_id: ticket.id,
|
||||
ticket_number: ticket.ticket_number,
|
||||
ticket_title: ticket.title,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerStepExecutor('enrich_ticket', executeEnrichTicket);
|
||||
195
lib/services/pipeline-steps/enrich-vspc.ts
Normal file
195
lib/services/pipeline-steps/enrich-vspc.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* Step Executor: enrich_vspc
|
||||
* Queries Veeam VSPC (API + local DB) for backup status of a device.
|
||||
*
|
||||
* Config:
|
||||
* lookup_by: 'device_name' | 'organization_uid'
|
||||
* source_field: template string for the lookup value
|
||||
*
|
||||
* Outputs to context:
|
||||
* vspc_found, vspc_agent_jobs, vspc_server_jobs, vspc_workloads,
|
||||
* vspc_last_job_status, vspc_last_success, vspc_failure_message,
|
||||
* vspc_restore_points, vspc_backed_up_size, vspc_free_space,
|
||||
* vspc_alarms, vspc_summary
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { postgresClient } from '../postgres-client';
|
||||
import { StepExecutorResult, PipelineStep, PipelineContext } from '../../types/pipeline';
|
||||
|
||||
registerStepExecutor('enrich_vspc', async (
|
||||
step: PipelineStep,
|
||||
context: PipelineContext,
|
||||
executionId: number
|
||||
): Promise<StepExecutorResult> => {
|
||||
const config = step.config as {
|
||||
lookup_by?: string;
|
||||
source_field?: string;
|
||||
};
|
||||
|
||||
const lookupBy = config.lookup_by || 'device_name';
|
||||
const sourceValue = config.source_field || '';
|
||||
|
||||
if (!sourceValue) {
|
||||
return { success: false, output: { vspc_found: false }, error: 'source_field is required' };
|
||||
}
|
||||
|
||||
try {
|
||||
// ---- 1. Query local DB for backup agent jobs matching this device ----
|
||||
let agentJobs: any[] = [];
|
||||
let serverJobs: any[] = [];
|
||||
let workloads: any[] = [];
|
||||
let alarms: any[] = [];
|
||||
|
||||
if (lookupBy === 'device_name') {
|
||||
// Match by agent name (agent name often = hostname)
|
||||
const agentResult = await postgresClient.query(
|
||||
`SELECT baj.*, ba.name as agent_name, ba.status as agent_status,
|
||||
ba.agent_platform, ba.version as agent_version,
|
||||
vo.name as org_name, vo.company_id
|
||||
FROM veeam_backup_agent_jobs baj
|
||||
LEFT JOIN veeam_backup_agents ba ON ba.instance_uid = baj.backup_agent_uid
|
||||
LEFT JOIN veeam_organizations vo ON vo.instance_uid = baj.organization_uid
|
||||
WHERE LOWER(ba.name) LIKE LOWER($1)
|
||||
OR LOWER(baj.name) LIKE LOWER($1)
|
||||
ORDER BY baj.last_run DESC NULLS LAST`,
|
||||
[`%${sourceValue}%`]
|
||||
);
|
||||
agentJobs = agentResult.rows;
|
||||
|
||||
// Check protected workloads (VM-level)
|
||||
const workloadResult = await postgresClient.query(
|
||||
`SELECT pw.*, bj.name as job_name, bj.status as job_status,
|
||||
bj.last_run as job_last_run, bj.failure_message as job_failure_message
|
||||
FROM veeam_protected_workloads pw
|
||||
LEFT JOIN veeam_backup_jobs bj ON bj.instance_uid = pw.job_uid
|
||||
WHERE LOWER(pw.name) LIKE LOWER($1)
|
||||
ORDER BY pw.latest_restore_point_date DESC NULLS LAST`,
|
||||
[`%${sourceValue}%`]
|
||||
);
|
||||
workloads = workloadResult.rows;
|
||||
|
||||
// Check backup server jobs that might reference this device
|
||||
const serverJobResult = await postgresClient.query(
|
||||
`SELECT bj.*, vo.name as org_name, vo.company_id
|
||||
FROM veeam_backup_jobs bj
|
||||
LEFT JOIN veeam_organizations vo ON vo.instance_uid = bj.organization_uid
|
||||
WHERE LOWER(bj.name) LIKE LOWER($1)
|
||||
OR LOWER(bj.destination) LIKE LOWER($1)
|
||||
ORDER BY bj.last_run DESC NULLS LAST`,
|
||||
[`%${sourceValue}%`]
|
||||
);
|
||||
serverJobs = serverJobResult.rows;
|
||||
|
||||
// Check active alarms for this device
|
||||
const alarmResult = await postgresClient.query(
|
||||
`SELECT * FROM veeam_alarms
|
||||
WHERE LOWER(object_name) LIKE LOWER($1)
|
||||
OR LOWER(object_computer_name) LIKE LOWER($1)
|
||||
ORDER BY last_activation_time DESC NULLS LAST`,
|
||||
[`%${sourceValue}%`]
|
||||
);
|
||||
alarms = alarmResult.rows;
|
||||
}
|
||||
|
||||
// ---- 2. Build summary ----
|
||||
const allJobs = [...agentJobs, ...serverJobs];
|
||||
const latestJob = allJobs[0] || null;
|
||||
const failedJobs = allJobs.filter(j => j.status === 'Failed');
|
||||
const warningJobs = allJobs.filter(j => j.status === 'Warning');
|
||||
const successJobs = allJobs.filter(j => j.status === 'Success');
|
||||
|
||||
// Find last successful backup across all job types
|
||||
const lastSuccess = allJobs.find(j => j.status === 'Success');
|
||||
const lastSuccessDate = lastSuccess?.last_run || lastSuccess?.last_end_time || null;
|
||||
|
||||
// Calculate hours since last success
|
||||
let hoursSinceSuccess: number | null = null;
|
||||
if (lastSuccessDate) {
|
||||
hoursSinceSuccess = Math.round((Date.now() - new Date(lastSuccessDate).getTime()) / 3600000);
|
||||
}
|
||||
|
||||
// Aggregate restore points and sizes
|
||||
const totalRestorePoints = agentJobs.reduce((sum, j) => sum + (j.restore_points || 0), 0)
|
||||
+ workloads.reduce((sum, w) => sum + (w.restore_points || 0), 0);
|
||||
|
||||
const totalBackedUpSize = agentJobs.reduce((sum, j) => sum + (j.backed_up_size || 0), 0);
|
||||
|
||||
const summary = [
|
||||
`Device: ${sourceValue}`,
|
||||
`Agent Jobs: ${agentJobs.length} (${successJobs.length} success, ${failedJobs.length} failed, ${warningJobs.length} warning)`,
|
||||
`Server Jobs: ${serverJobs.length}`,
|
||||
`Protected Workloads: ${workloads.length}`,
|
||||
`Active Alarms: ${alarms.length}`,
|
||||
`Total Restore Points: ${totalRestorePoints}`,
|
||||
latestJob ? `Latest Job: "${latestJob.name}" — ${latestJob.status} at ${latestJob.last_run || 'never'}` : 'No jobs found',
|
||||
latestJob?.failure_message ? `Failure: ${latestJob.failure_message}` : null,
|
||||
lastSuccessDate ? `Last Success: ${lastSuccessDate} (${hoursSinceSuccess}h ago)` : 'No successful backup found',
|
||||
alarms.length > 0 ? `Alarms: ${alarms.map((a: any) => `${a.alarm_area}: ${a.last_activation_message}`).join('; ')}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const found = agentJobs.length > 0 || serverJobs.length > 0 || workloads.length > 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
vspc_found: found,
|
||||
vspc_agent_jobs: agentJobs.map(j => ({
|
||||
name: j.name,
|
||||
status: j.status,
|
||||
last_run: j.last_run,
|
||||
last_end_time: j.last_end_time,
|
||||
last_duration: j.last_duration,
|
||||
failure_message: j.failure_message,
|
||||
backup_mode: j.backup_mode,
|
||||
destination: j.destination,
|
||||
restore_points: j.restore_points,
|
||||
backed_up_size: j.backed_up_size,
|
||||
free_space: j.free_space,
|
||||
is_enabled: j.is_enabled,
|
||||
agent_name: j.agent_name,
|
||||
agent_status: j.agent_status,
|
||||
org_name: j.org_name,
|
||||
})),
|
||||
vspc_server_jobs: serverJobs.map(j => ({
|
||||
name: j.name,
|
||||
status: j.status,
|
||||
last_run: j.last_run,
|
||||
failure_message: j.failure_message,
|
||||
type: j.type,
|
||||
destination: j.destination,
|
||||
bottleneck: j.bottleneck,
|
||||
backup_chain_size: j.backup_chain_size,
|
||||
})),
|
||||
vspc_workloads: workloads.map(w => ({
|
||||
name: w.name,
|
||||
restore_points: w.restore_points,
|
||||
latest_restore_point_date: w.latest_restore_point_date,
|
||||
latest_restore_point_size: w.latest_restore_point_size,
|
||||
malware_state: w.malware_state,
|
||||
job_name: w.job_name,
|
||||
job_status: w.job_status,
|
||||
})),
|
||||
vspc_alarms: alarms.map(a => ({
|
||||
area: a.alarm_area,
|
||||
message: a.last_activation_message,
|
||||
status: a.last_activation_status,
|
||||
time: a.last_activation_time,
|
||||
object_name: a.object_name,
|
||||
})),
|
||||
vspc_last_job_status: latestJob?.status || null,
|
||||
vspc_last_success: lastSuccessDate,
|
||||
vspc_hours_since_success: hoursSinceSuccess,
|
||||
vspc_failure_message: latestJob?.failure_message || null,
|
||||
vspc_restore_points: totalRestorePoints,
|
||||
vspc_backed_up_size: totalBackedUpSize,
|
||||
vspc_alarm_count: alarms.length,
|
||||
vspc_failed_job_count: failedJobs.length,
|
||||
vspc_summary: summary,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, output: { vspc_found: false }, error: `VSPC enrichment failed: ${msg}` };
|
||||
}
|
||||
});
|
||||
147
lib/services/pipeline-steps/fetch-b2-result.ts
Normal file
147
lib/services/pipeline-steps/fetch-b2-result.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* Fetch B2 Result Step — download a JSON result from Backblaze B2 via S3-compatible presigned URL.
|
||||
* Config: {
|
||||
* object_key: "{{context.diagnostic_object_key}}", // key in the bucket
|
||||
* output_key: "diagnostic_results", // context key for parsed JSON
|
||||
* bucket?: "wulf-audits", // defaults to B2_BUCKET env
|
||||
* }
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
const B2_DEFAULTS = {
|
||||
bucket: process.env.B2_BUCKET || 'wulf-audits',
|
||||
region: process.env.B2_REGION || 'us-west-002',
|
||||
endpoint: process.env.B2_ENDPOINT || 's3.us-west-002.backblazeb2.com',
|
||||
keyId: process.env.B2_KEY_ID || '',
|
||||
appKey: process.env.B2_APP_KEY || '',
|
||||
};
|
||||
|
||||
function hmacSha256(key: string | Buffer, data: string): Buffer {
|
||||
return crypto.createHmac('sha256', key).update(data).digest();
|
||||
}
|
||||
|
||||
function generatePresignedUrl(objectKey: string, bucket?: string): string {
|
||||
const { region, endpoint, keyId, appKey } = B2_DEFAULTS;
|
||||
const bkt = bucket || B2_DEFAULTS.bucket;
|
||||
|
||||
if (!keyId || !appKey) {
|
||||
throw new Error('B2_KEY_ID and B2_APP_KEY environment variables are required');
|
||||
}
|
||||
|
||||
const expiresIn = 600; // 10 minutes
|
||||
const method = 'GET';
|
||||
const host = endpoint;
|
||||
const canonicalUri = `/${bkt}/${objectKey}`;
|
||||
const algorithm = 'AWS4-HMAC-SHA256';
|
||||
|
||||
const now = new Date();
|
||||
const amzDate = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
const dateStamp = amzDate.slice(0, 8);
|
||||
|
||||
const credentialScope = `${dateStamp}/${region}/s3/aws4_request`;
|
||||
const canonicalHeaders = `host:${host}\n`;
|
||||
const signedHeaders = 'host';
|
||||
|
||||
const qs: Record<string, string> = {
|
||||
'X-Amz-Algorithm': algorithm,
|
||||
'X-Amz-Credential': encodeURIComponent(`${keyId}/${credentialScope}`),
|
||||
'X-Amz-Date': amzDate,
|
||||
'X-Amz-Expires': expiresIn.toString(),
|
||||
'X-Amz-SignedHeaders': signedHeaders,
|
||||
};
|
||||
|
||||
const canonicalQueryString = Object.keys(qs)
|
||||
.sort()
|
||||
.map((k) => `${k}=${qs[k]}`)
|
||||
.join('&');
|
||||
|
||||
const payloadHash = 'UNSIGNED-PAYLOAD';
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
canonicalUri,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash,
|
||||
].join('\n');
|
||||
|
||||
const stringToSign = [
|
||||
algorithm,
|
||||
amzDate,
|
||||
credentialScope,
|
||||
crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
|
||||
].join('\n');
|
||||
|
||||
// Derive signing key
|
||||
const kDate = hmacSha256('AWS4' + appKey, dateStamp);
|
||||
const kRegion = hmacSha256(kDate, region);
|
||||
const kService = hmacSha256(kRegion, 's3');
|
||||
const kSigning = hmacSha256(kService, 'aws4_request');
|
||||
|
||||
const signature = crypto
|
||||
.createHmac('sha256', kSigning)
|
||||
.update(stringToSign)
|
||||
.digest('hex');
|
||||
|
||||
return `https://${host}${canonicalUri}?${canonicalQueryString}&X-Amz-Signature=${signature}`;
|
||||
}
|
||||
|
||||
async function executeFetchB2Result(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const objectKey = step.config.object_key;
|
||||
const outputKey = step.config.output_key || 'b2_result';
|
||||
const bucket = step.config.bucket;
|
||||
|
||||
if (!objectKey) {
|
||||
return { success: false, error: 'Missing object_key in fetch_b2_result config' };
|
||||
}
|
||||
|
||||
console.log(`[PIPELINE:fetch_b2_result] Fetching ${objectKey} from B2`);
|
||||
|
||||
try {
|
||||
const url = generatePresignedUrl(objectKey, bucket);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: `B2 download failed: ${response.status} ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let parsed: any;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
// Not JSON — store as raw text
|
||||
parsed = text;
|
||||
}
|
||||
|
||||
console.log(`[PIPELINE:fetch_b2_result] Downloaded ${text.length} bytes, parsed as ${typeof parsed}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
[outputKey]: parsed,
|
||||
[`${outputKey}_raw_length`]: text.length,
|
||||
b2_object_key: objectKey,
|
||||
},
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: `B2 fetch error: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
registerStepExecutor('fetch_b2_result', executeFetchB2Result);
|
||||
29
lib/services/pipeline-steps/filter.ts
Normal file
29
lib/services/pipeline-steps/filter.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Filter Step — evaluate conditions, skip pipeline if not met.
|
||||
* Config: { conditions: [{ field, operator, value }] }
|
||||
*/
|
||||
|
||||
import { registerStepExecutor, pipelineEngine } from '../pipeline-engine';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
async function executeFilter(
|
||||
step: PipelineStep,
|
||||
context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const conditions = step.config.conditions || [];
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return { success: true, output: { filter_result: 'passed' } };
|
||||
}
|
||||
|
||||
const passed = pipelineEngine.evaluateConditions(conditions, context);
|
||||
|
||||
if (!passed) {
|
||||
return { success: false, error: 'Filter conditions not met — pipeline skipped' };
|
||||
}
|
||||
|
||||
return { success: true, output: { filter_result: 'passed' } };
|
||||
}
|
||||
|
||||
registerStepExecutor('filter', executeFilter);
|
||||
22
lib/services/pipeline-steps/index.ts
Normal file
22
lib/services/pipeline-steps/index.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Pipeline Step Executors — import all to register them with the engine.
|
||||
* This file must be imported once at app startup or when the pipeline engine is used.
|
||||
*/
|
||||
|
||||
import './filter';
|
||||
import './transform';
|
||||
import './set-variable';
|
||||
import './delay';
|
||||
import './enrich-device';
|
||||
import './enrich-company';
|
||||
import './enrich-ticket';
|
||||
import './create-ticket';
|
||||
import './update-ticket';
|
||||
import './create-note';
|
||||
import './ai-analyze';
|
||||
import './notify';
|
||||
import './approval';
|
||||
import './rmm-quick-job';
|
||||
import './enrich-vspc';
|
||||
import './db-query';
|
||||
import './fetch-b2-result';
|
||||
192
lib/services/pipeline-steps/notify.ts
Normal file
192
lib/services/pipeline-steps/notify.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* Notify Step — send notification to a configured channel.
|
||||
* Config: { channel_id: 1, message: "...", card_template?: {...} }
|
||||
* Channel config is loaded from notification_channels table.
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { postgresClient } from '../postgres-client';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult, NotificationChannel } from '../../types/pipeline';
|
||||
|
||||
async function executeNotify(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const channelId = Number(step.config.channel_id);
|
||||
|
||||
if (!channelId || isNaN(channelId)) {
|
||||
return { success: false, error: 'Missing or invalid channel_id' };
|
||||
}
|
||||
|
||||
const result = await postgresClient.query<NotificationChannel>(
|
||||
`SELECT * FROM notification_channels WHERE id = $1 AND is_active = true`,
|
||||
[channelId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return { success: false, error: `Notification channel #${channelId} not found or inactive` };
|
||||
}
|
||||
|
||||
const channel = result.rows[0];
|
||||
const message = step.config.message || '';
|
||||
|
||||
switch (channel.channel_type) {
|
||||
case 'teams':
|
||||
return await sendTeams(channel, step.config, message);
|
||||
case 'telegram':
|
||||
return await sendTelegram(channel, message);
|
||||
case 'ntfy':
|
||||
return await sendNtfy(channel, step.config, message);
|
||||
case 'webhook':
|
||||
return await sendWebhook(channel, step.config, message);
|
||||
default:
|
||||
return { success: false, error: `Unknown channel type: ${channel.channel_type}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTeams(
|
||||
channel: NotificationChannel,
|
||||
config: Record<string, any>,
|
||||
message: string
|
||||
): Promise<StepExecutorResult> {
|
||||
const webhookUrl = channel.config.webhook_url;
|
||||
if (!webhookUrl) {
|
||||
return { success: false, error: 'Teams channel missing webhook_url' };
|
||||
}
|
||||
|
||||
// If a card_template is provided, use it as Adaptive Card
|
||||
const body = config.card_template || {
|
||||
type: 'message',
|
||||
attachments: [{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
content: {
|
||||
type: 'AdaptiveCard',
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
version: '1.4',
|
||||
body: [
|
||||
{ type: 'TextBlock', text: config.title || 'Pulse Notification', weight: 'bolder', size: 'medium' },
|
||||
{ type: 'TextBlock', text: message, wrap: true },
|
||||
],
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
const resp = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
return { success: false, error: `Teams webhook failed (${resp.status}): ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
return { success: true, output: { notified: true, channel: 'teams' } };
|
||||
}
|
||||
|
||||
async function sendTelegram(
|
||||
channel: NotificationChannel,
|
||||
message: string
|
||||
): Promise<StepExecutorResult> {
|
||||
const botToken = channel.config.bot_token;
|
||||
const chatId = channel.config.chat_id;
|
||||
|
||||
if (!botToken || !chatId) {
|
||||
return { success: false, error: 'Telegram channel missing bot_token or chat_id' };
|
||||
}
|
||||
|
||||
const resp = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: message,
|
||||
parse_mode: channel.config.parse_mode || 'HTML',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
return { success: false, error: `Telegram API failed (${resp.status}): ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
return { success: true, output: { notified: true, channel: 'telegram' } };
|
||||
}
|
||||
|
||||
async function sendNtfy(
|
||||
channel: NotificationChannel,
|
||||
config: Record<string, any>,
|
||||
message: string
|
||||
): Promise<StepExecutorResult> {
|
||||
const serverUrl = channel.config.server_url || 'https://ntfy.sh';
|
||||
const topic = channel.config.topic;
|
||||
|
||||
if (!topic) {
|
||||
return { success: false, error: 'ntfy channel missing topic' };
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/plain',
|
||||
};
|
||||
|
||||
if (config.title || channel.config.default_title) {
|
||||
headers['Title'] = config.title || channel.config.default_title;
|
||||
}
|
||||
if (config.priority || channel.config.default_priority) {
|
||||
headers['Priority'] = config.priority || channel.config.default_priority;
|
||||
}
|
||||
if (channel.config.auth_token) {
|
||||
headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
|
||||
}
|
||||
|
||||
const resp = await fetch(`${serverUrl}/${topic}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: message,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
return { success: false, error: `ntfy failed (${resp.status}): ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
return { success: true, output: { notified: true, channel: 'ntfy' } };
|
||||
}
|
||||
|
||||
async function sendWebhook(
|
||||
channel: NotificationChannel,
|
||||
config: Record<string, any>,
|
||||
message: string
|
||||
): Promise<StepExecutorResult> {
|
||||
const url = channel.config.url;
|
||||
if (!url) {
|
||||
return { success: false, error: 'Webhook channel missing url' };
|
||||
}
|
||||
|
||||
const method = channel.config.method || 'POST';
|
||||
const customHeaders = channel.config.headers || {};
|
||||
|
||||
const body = config.body_template
|
||||
? config.body_template
|
||||
: { message, timestamp: new Date().toISOString() };
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
return { success: false, error: `Webhook failed (${resp.status}): ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
return { success: true, output: { notified: true, channel: 'webhook' } };
|
||||
}
|
||||
|
||||
registerStepExecutor('notify', executeNotify);
|
||||
89
lib/services/pipeline-steps/rmm-quick-job.ts
Normal file
89
lib/services/pipeline-steps/rmm-quick-job.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* RMM Quick Job Step — run a Datto RMM quick job on a device.
|
||||
* Config: { device_uid: "{{context.device_uid}}", component_uid: "comp-xxx", variables: [...] }
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { DattoRMMClient } from '../datto-rmm-client';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
let _client: DattoRMMClient | null = null;
|
||||
function getClient(): DattoRMMClient {
|
||||
if (!_client) {
|
||||
_client = new DattoRMMClient({
|
||||
apiUrl: process.env.DATTO_RMM_API_URL || 'https://concord-api.centrastage.net/api/v2',
|
||||
apiKey: process.env.DATTO_RMM_API_KEY || '',
|
||||
apiSecret: process.env.DATTO_RMM_API_SECRET || '',
|
||||
});
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
async function executeRmmQuickJob(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const deviceUid = step.config.device_uid;
|
||||
const componentUid = step.config.component_uid;
|
||||
const jobName = step.config.job_name || 'Pipeline Quick Job';
|
||||
const variables = step.config.variables || [];
|
||||
|
||||
if (!deviceUid) {
|
||||
return { success: false, error: 'Missing device_uid for quick job' };
|
||||
}
|
||||
if (!componentUid) {
|
||||
return { success: false, error: 'Missing component_uid for quick job' };
|
||||
}
|
||||
|
||||
console.log(`[PIPELINE:rmm_quick_job] Running "${jobName}" on device ${deviceUid}`);
|
||||
|
||||
const client = getClient();
|
||||
const result = await client.runQuickJob(deviceUid, {
|
||||
jobName,
|
||||
jobComponent: {
|
||||
componentUid,
|
||||
variables,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
quick_job_result: result,
|
||||
job_uid: result?.uid || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerStepExecutor('rmm_quick_job', executeRmmQuickJob);
|
||||
|
||||
/**
|
||||
* RMM Get Job Results Step — poll for quick job results.
|
||||
* Config: { job_uid: "{{context.job_uid}}", device_uid: "{{context.device_uid}}" }
|
||||
*/
|
||||
async function executeRmmGetJobResults(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const jobUid = step.config.job_uid;
|
||||
const deviceUid = step.config.device_uid;
|
||||
|
||||
if (!jobUid || !deviceUid) {
|
||||
return { success: false, error: 'Missing job_uid or device_uid' };
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
const result = await client.getJobResults(jobUid, deviceUid);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
job_results: result,
|
||||
job_status: result?.jobDeploymentStatus || 'unknown',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerStepExecutor('rmm_get_job_results', executeRmmGetJobResults);
|
||||
23
lib/services/pipeline-steps/set-variable.ts
Normal file
23
lib/services/pipeline-steps/set-variable.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Set Variable Step — set a single context variable.
|
||||
* Config: { key: "my_var", value: "{{trigger.something}}" }
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
async function executeSetVariable(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const { key, value } = step.config;
|
||||
|
||||
if (!key) {
|
||||
return { success: false, error: 'Missing "key" in set_variable config' };
|
||||
}
|
||||
|
||||
return { success: true, output: { [key]: value } };
|
||||
}
|
||||
|
||||
registerStepExecutor('set_variable', executeSetVariable);
|
||||
22
lib/services/pipeline-steps/transform.ts
Normal file
22
lib/services/pipeline-steps/transform.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Transform Step — map/reshape payload fields into context variables.
|
||||
* Config: { mappings: { key: "{{trigger.field}}" } }
|
||||
* Template variables are already resolved by the engine before this runs.
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
async function executeTransform(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const mappings = step.config.mappings || {};
|
||||
|
||||
// Config values are already template-resolved by the engine,
|
||||
// so we just pass them through as context output.
|
||||
return { success: true, output: mappings };
|
||||
}
|
||||
|
||||
registerStepExecutor('transform', executeTransform);
|
||||
47
lib/services/pipeline-steps/update-ticket.ts
Normal file
47
lib/services/pipeline-steps/update-ticket.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Update Ticket Step — update an existing Autotask ticket.
|
||||
* Config: { ticket_id: "{{context.ticket_id}}", fields: { priority: 4, queueID: 123 } }
|
||||
*/
|
||||
|
||||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { AutotaskClient } from '../autotask-client';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
|
||||
let _client: AutotaskClient | null = null;
|
||||
function getClient(): AutotaskClient {
|
||||
if (!_client) {
|
||||
_client = new AutotaskClient({
|
||||
apiUrl: process.env.AUTOTASK_API_URL || '',
|
||||
username: process.env.AUTOTASK_USERNAME || '',
|
||||
password: process.env.AUTOTASK_SECRET || '',
|
||||
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
});
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
async function executeUpdateTicket(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const ticketId = Number(step.config.ticket_id);
|
||||
const fields = step.config.fields || {};
|
||||
|
||||
if (!ticketId || isNaN(ticketId)) {
|
||||
return { success: false, error: 'Missing or invalid ticket_id' };
|
||||
}
|
||||
|
||||
if (Object.keys(fields).length === 0) {
|
||||
return { success: true, output: { updated: false, reason: 'No fields to update' } };
|
||||
}
|
||||
|
||||
console.log(`[PIPELINE:update_ticket] Updating ticket #${ticketId}: ${Object.keys(fields).join(', ')}`);
|
||||
|
||||
const client = getClient();
|
||||
await client.updateTicket(ticketId, fields);
|
||||
|
||||
return { success: true, output: { updated: true, ticket_id: ticketId, fields_updated: Object.keys(fields) } };
|
||||
}
|
||||
|
||||
registerStepExecutor('update_ticket', executeUpdateTicket);
|
||||
|
|
@ -8,13 +8,14 @@ import { SyncService, createSyncService } from './sync-service';
|
|||
import { postgresClient } from './postgres-client';
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
import { VeeamSyncService } from './veeam-sync-service';
|
||||
import { VeeamRpoService } from './veeam-rpo-service';
|
||||
|
||||
export interface ScheduleConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cron_expression: string;
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full';
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check';
|
||||
years_back?: number;
|
||||
is_enabled: boolean;
|
||||
last_run?: Date;
|
||||
|
|
@ -38,6 +39,7 @@ class SyncScheduler {
|
|||
private initialized = false;
|
||||
private syncService: SyncService;
|
||||
private _veeamSyncService: VeeamSyncService | null = null;
|
||||
private _veeamRpoService: VeeamRpoService | null = null;
|
||||
|
||||
private getVeeamSyncService(): VeeamSyncService {
|
||||
if (!this._veeamSyncService) {
|
||||
|
|
@ -46,6 +48,13 @@ class SyncScheduler {
|
|||
return this._veeamSyncService;
|
||||
}
|
||||
|
||||
private getVeeamRpoService(): VeeamRpoService {
|
||||
if (!this._veeamRpoService) {
|
||||
this._veeamRpoService = new VeeamRpoService();
|
||||
}
|
||||
return this._veeamRpoService;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Create sync service instance
|
||||
const autotaskClient = new AutotaskClient({
|
||||
|
|
@ -163,6 +172,14 @@ class SyncScheduler {
|
|||
sync_type: 'veeam-full',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'veeam-rpo-check',
|
||||
name: 'Veeam RPO Check',
|
||||
description: 'RPO-based workstation backup alerting — creates/resolves Autotask tickets every 30 minutes',
|
||||
cron_expression: '*/30 * * * *',
|
||||
sync_type: 'veeam-rpo-check',
|
||||
is_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const schedule of defaultSchedules) {
|
||||
|
|
@ -270,6 +287,8 @@ class SyncScheduler {
|
|||
await this.getVeeamSyncService().incrementalSync('scheduled');
|
||||
} else if (config.sync_type === 'veeam-full') {
|
||||
await this.getVeeamSyncService().fullSync('scheduled');
|
||||
} else if (config.sync_type === 'veeam-rpo-check') {
|
||||
await this.getVeeamRpoService().runCheck();
|
||||
} else if (config.sync_type === 'incremental') {
|
||||
await this.syncService.incrementalSync('scheduled');
|
||||
} else {
|
||||
|
|
|
|||
503
lib/services/ticket-workflow-engine.ts
Normal file
503
lib/services/ticket-workflow-engine.ts
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
/**
|
||||
* Ticket Workflow Engine
|
||||
* Refactored table-driven workflow engine with per-workflow and per-step toggles.
|
||||
* Matches ticket events to workflows, executes steps sequentially, resolves template
|
||||
* variables, and accumulates context between steps.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
import {
|
||||
TicketWorkflow,
|
||||
TicketWorkflowStep,
|
||||
TicketWorkflowWithSteps,
|
||||
TicketWorkflowExecution,
|
||||
WorkflowStepContext,
|
||||
WorkflowStepExecutorFn,
|
||||
WorkflowStepResult,
|
||||
TriggerCondition,
|
||||
StepCondition,
|
||||
} from '../types/ticket-workflow';
|
||||
import { TicketData, WorkflowSettings } from '../types/workflow';
|
||||
|
||||
// Step executor registry — populated by individual step files
|
||||
const workflowStepExecutors: Map<string, WorkflowStepExecutorFn> = new Map();
|
||||
|
||||
export function registerWorkflowStepExecutor(stepType: string, executor: WorkflowStepExecutorFn): void {
|
||||
workflowStepExecutors.set(stepType, executor);
|
||||
}
|
||||
|
||||
export class TicketWorkflowEngine {
|
||||
/**
|
||||
* Main entry point: process a ticket event and execute matching workflows.
|
||||
*/
|
||||
async processTrigger(
|
||||
triggerEvent: string,
|
||||
ticket: TicketData
|
||||
): Promise<number[]> {
|
||||
// Check global kill switch
|
||||
const settings = await this.getSettings();
|
||||
if (!settings.workflow_engine_enabled) {
|
||||
console.log('[TICKET-WORKFLOW] Engine is disabled globally, skipping');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find matching workflows
|
||||
const workflows = await this.findMatchingWorkflows(triggerEvent, ticket);
|
||||
|
||||
if (workflows.length === 0) {
|
||||
console.log(`[TICKET-WORKFLOW] No workflows matched for ${triggerEvent} on ticket #${ticket.ticket_number}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`[TICKET-WORKFLOW] ${workflows.length} workflow(s) matched for ticket #${ticket.ticket_number}`);
|
||||
|
||||
// Execute each matching workflow
|
||||
const executionIds: number[] = [];
|
||||
for (const workflow of workflows) {
|
||||
try {
|
||||
const execId = await this.executeWorkflow(workflow, ticket, settings);
|
||||
executionIds.push(execId);
|
||||
} catch (err) {
|
||||
console.error(`[TICKET-WORKFLOW] Failed to execute workflow "${workflow.name}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return executionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load workflow settings from DB.
|
||||
*/
|
||||
async getSettings(): Promise<WorkflowSettings> {
|
||||
const result = await postgresClient.query(
|
||||
`SELECT key, value FROM workflow_settings`
|
||||
);
|
||||
|
||||
const raw: Record<string, any> = {};
|
||||
for (const row of result.rows) {
|
||||
try {
|
||||
raw[row.key] = JSON.parse(row.value);
|
||||
} catch {
|
||||
raw[row.key] = row.value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workflow_engine_enabled: raw.workflow_engine_enabled ?? false,
|
||||
default_ai_provider: raw.default_ai_provider ?? 'openai',
|
||||
openai_api_key: raw.openai_api_key ?? '',
|
||||
openai_model: raw.openai_model ?? 'gpt-4o',
|
||||
anthropic_api_key: raw.anthropic_api_key ?? '',
|
||||
anthropic_model: raw.anthropic_model ?? 'claude-sonnet-4-20250514',
|
||||
ai_for_title_cleanup: raw.ai_for_title_cleanup ?? true,
|
||||
ai_for_description_rewrite: raw.ai_for_description_rewrite ?? true,
|
||||
ai_for_ambiguous_classification: raw.ai_for_ambiguous_classification ?? true,
|
||||
ai_for_troubleshooting: raw.ai_for_troubleshooting ?? true,
|
||||
autotask_update_delay_ms: Number(raw.autotask_update_delay_ms) || 30000,
|
||||
max_ai_retries: Number(raw.max_ai_retries) || 2,
|
||||
classification_confidence_threshold: raw.classification_confidence_threshold ?? 'medium',
|
||||
log_retention_days: Number(raw.log_retention_days) || 90,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find active workflows matching the trigger event and conditions.
|
||||
*/
|
||||
async findMatchingWorkflows(
|
||||
triggerEvent: string,
|
||||
ticket: TicketData
|
||||
): Promise<TicketWorkflowWithSteps[]> {
|
||||
const result = await postgresClient.query<TicketWorkflow>(
|
||||
`SELECT * FROM ticket_workflows
|
||||
WHERE is_active = true AND trigger_event = $1
|
||||
ORDER BY sort_order`,
|
||||
[triggerEvent]
|
||||
);
|
||||
|
||||
const matched: TicketWorkflowWithSteps[] = [];
|
||||
|
||||
for (const workflow of result.rows) {
|
||||
const conditions: TriggerCondition[] = Array.isArray(workflow.trigger_conditions)
|
||||
? workflow.trigger_conditions
|
||||
: [];
|
||||
|
||||
if (this.evaluateTriggerConditions(conditions, ticket)) {
|
||||
const stepsResult = await postgresClient.query<TicketWorkflowStep>(
|
||||
`SELECT * FROM ticket_workflow_steps
|
||||
WHERE workflow_id = $1 AND is_active = true
|
||||
ORDER BY step_order`,
|
||||
[workflow.id]
|
||||
);
|
||||
matched.push({ ...workflow, steps: stepsResult.rows });
|
||||
}
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single workflow: create execution record, run steps, update status.
|
||||
*/
|
||||
async executeWorkflow(
|
||||
workflow: TicketWorkflowWithSteps,
|
||||
ticket: TicketData,
|
||||
settings: WorkflowSettings
|
||||
): Promise<number> {
|
||||
const execResult = await postgresClient.query<{ id: number }>(
|
||||
`INSERT INTO ticket_workflow_executions (workflow_id, ticket_id, ticket_number, status)
|
||||
VALUES ($1, $2, $3, 'running')
|
||||
RETURNING id`,
|
||||
[workflow.id, ticket.id, ticket.ticket_number]
|
||||
);
|
||||
const executionId = execResult.rows[0].id;
|
||||
|
||||
const context: WorkflowStepContext = {
|
||||
ticket,
|
||||
_settings: settings,
|
||||
field_changes: {},
|
||||
};
|
||||
|
||||
let finalStatus: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' = 'completed';
|
||||
let classificationMethod: 'robotic' | 'ai' | 'hybrid' = 'robotic';
|
||||
let branch: 'service_desk' | 'noc' | 'soc' = 'service_desk';
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
console.log(`[TICKET-WORKFLOW] Executing "${workflow.name}" (exec #${executionId}), ${workflow.steps.length} steps`);
|
||||
|
||||
for (const step of workflow.steps) {
|
||||
// Check step condition
|
||||
if (step.condition && !this.evaluateStepCondition(step.condition, context)) {
|
||||
console.log(`[TICKET-WORKFLOW] Step ${step.step_order} "${step.name}" skipped (condition not met)`);
|
||||
await this.logStepExecution(executionId, step, 'skipped', null, null, 0, 'Condition not met');
|
||||
continue;
|
||||
}
|
||||
|
||||
const stepStart = Date.now();
|
||||
|
||||
// Log step start
|
||||
await this.logStepExecution(executionId, step, 'running', { config: step.config }, null, 0);
|
||||
|
||||
const executor = workflowStepExecutors.get(step.step_type);
|
||||
if (!executor) {
|
||||
const err = `No executor registered for step type: ${step.step_type}`;
|
||||
console.error(`[TICKET-WORKFLOW] ${err}`);
|
||||
await this.logStepExecution(executionId, step, 'failed', null, null, Date.now() - stepStart, err);
|
||||
|
||||
if (step.on_failure === 'stop') {
|
||||
finalStatus = 'failed';
|
||||
errorMessage = err;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve template variables in step config
|
||||
const resolvedConfig = this.resolveTemplates(step.config, context);
|
||||
const resolvedStep = { ...step, config: resolvedConfig };
|
||||
|
||||
const result = await executor(resolvedStep, context, executionId);
|
||||
const duration = Date.now() - stepStart;
|
||||
|
||||
if (result.success) {
|
||||
// Merge output into context
|
||||
if (result.output) {
|
||||
Object.assign(context, result.output);
|
||||
|
||||
// Track if AI was used
|
||||
if (result.output.method === 'ai') {
|
||||
classificationMethod = classificationMethod === 'robotic' ? 'hybrid' : 'ai';
|
||||
}
|
||||
}
|
||||
|
||||
await this.logStepExecution(executionId, step, 'completed', null, result.output, duration);
|
||||
console.log(`[TICKET-WORKFLOW] Step ${step.step_order} "${step.name}" completed (${duration}ms)`);
|
||||
} else {
|
||||
await this.logStepExecution(executionId, step, 'failed', null, result.output, duration, result.error || null);
|
||||
console.error(`[TICKET-WORKFLOW] Step ${step.step_order} "${step.name}" failed: ${result.error}`);
|
||||
|
||||
if (step.on_failure === 'stop') {
|
||||
finalStatus = 'failed';
|
||||
errorMessage = `Step ${step.step_order} "${step.name}": ${result.error}`;
|
||||
break;
|
||||
} else if (step.on_failure === 'skip_to' && step.skip_to_step) {
|
||||
// Skip ahead (simplified: just continue, full implementation would jump to specific step)
|
||||
continue;
|
||||
}
|
||||
// on_failure === 'continue' → keep going
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const duration = Date.now() - stepStart;
|
||||
await this.logStepExecution(executionId, step, 'failed', null, null, duration, errMsg);
|
||||
console.error(`[TICKET-WORKFLOW] Step ${step.step_order} "${step.name}" threw: ${errMsg}`);
|
||||
|
||||
if (step.on_failure === 'stop') {
|
||||
finalStatus = 'failed';
|
||||
errorMessage = errMsg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract branch from context if set
|
||||
if (context.classification?.branch_routing) {
|
||||
branch = (context.classification.branch_routing.value as any) || 'service_desk';
|
||||
}
|
||||
|
||||
// Finalize execution
|
||||
await postgresClient.query(
|
||||
`UPDATE ticket_workflow_executions
|
||||
SET status = $1, classification_method = $2, branch = $3, context = $4,
|
||||
field_changes = $5, completed_at = NOW(),
|
||||
duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
|
||||
error_message = $6
|
||||
WHERE id = $7`,
|
||||
[
|
||||
finalStatus,
|
||||
classificationMethod,
|
||||
branch,
|
||||
JSON.stringify(context),
|
||||
JSON.stringify(context.field_changes || {}),
|
||||
errorMessage,
|
||||
executionId
|
||||
]
|
||||
);
|
||||
|
||||
console.log(`[TICKET-WORKFLOW] Execution #${executionId} finished: ${finalStatus}`);
|
||||
return executionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run: execute a workflow on a ticket without actually updating Autotask.
|
||||
*/
|
||||
async dryRun(workflowId: number, ticketId: number): Promise<any> {
|
||||
// Load workflow
|
||||
const workflowResult = await postgresClient.query<TicketWorkflow>(
|
||||
`SELECT * FROM ticket_workflows WHERE id = $1`,
|
||||
[workflowId]
|
||||
);
|
||||
|
||||
if (workflowResult.rows.length === 0) {
|
||||
throw new Error(`Workflow #${workflowId} not found`);
|
||||
}
|
||||
|
||||
const workflow = workflowResult.rows[0];
|
||||
|
||||
// Load steps
|
||||
const stepsResult = await postgresClient.query<TicketWorkflowStep>(
|
||||
`SELECT * FROM ticket_workflow_steps
|
||||
WHERE workflow_id = $1 AND is_active = true
|
||||
ORDER BY step_order`,
|
||||
[workflowId]
|
||||
);
|
||||
|
||||
const workflowWithSteps: TicketWorkflowWithSteps = {
|
||||
...workflow,
|
||||
steps: stepsResult.rows
|
||||
};
|
||||
|
||||
// Load ticket
|
||||
const ticketResult = await postgresClient.query<TicketData>(
|
||||
`SELECT * FROM tickets WHERE id = $1`,
|
||||
[ticketId]
|
||||
);
|
||||
|
||||
if (ticketResult.rows.length === 0) {
|
||||
throw new Error(`Ticket #${ticketId} not found`);
|
||||
}
|
||||
|
||||
const ticket = ticketResult.rows[0];
|
||||
|
||||
// Load settings
|
||||
const settings = await this.getSettings();
|
||||
|
||||
// Execute workflow (will create a real execution record)
|
||||
// For dry-run, we could skip the update_ticket step or mark it differently
|
||||
// For now, we'll execute normally but return the execution ID for inspection
|
||||
const executionId = await this.executeWorkflow(workflowWithSteps, ticket, settings);
|
||||
|
||||
// Fetch execution result
|
||||
const execResult = await postgresClient.query<TicketWorkflowExecution>(
|
||||
`SELECT * FROM ticket_workflow_executions WHERE id = $1`,
|
||||
[executionId]
|
||||
);
|
||||
|
||||
// Fetch execution steps
|
||||
const stepsExecResult = await postgresClient.query(
|
||||
`SELECT * FROM ticket_workflow_execution_steps WHERE execution_id = $1 ORDER BY step_order`,
|
||||
[executionId]
|
||||
);
|
||||
|
||||
return {
|
||||
execution: execResult.rows[0],
|
||||
steps: stepsExecResult.rows
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate trigger conditions (AND logic).
|
||||
*/
|
||||
private evaluateTriggerConditions(conditions: TriggerCondition[], ticket: TicketData): boolean {
|
||||
for (const condition of conditions) {
|
||||
const value = (ticket as any)[condition.field];
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'equals':
|
||||
if (value !== condition.value) return false;
|
||||
break;
|
||||
case 'not_equals':
|
||||
if (value === condition.value) return false;
|
||||
break;
|
||||
case 'in':
|
||||
if (!Array.isArray(condition.value) || !condition.value.includes(value)) return false;
|
||||
break;
|
||||
case 'not_in':
|
||||
if (!Array.isArray(condition.value) || condition.value.includes(value)) return false;
|
||||
break;
|
||||
case 'contains':
|
||||
if (typeof value !== 'string' || !value.includes(String(condition.value))) return false;
|
||||
break;
|
||||
case 'not_contains':
|
||||
if (typeof value === 'string' && value.includes(String(condition.value))) return false;
|
||||
break;
|
||||
case 'gt':
|
||||
if (!(Number(value) > Number(condition.value))) return false;
|
||||
break;
|
||||
case 'lt':
|
||||
if (!(Number(value) < Number(condition.value))) return false;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate step condition.
|
||||
*/
|
||||
private evaluateStepCondition(condition: StepCondition, context: WorkflowStepContext): boolean {
|
||||
const value = this.getNestedValue(context, condition.field);
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'equals':
|
||||
return value === condition.value;
|
||||
case 'not_equals':
|
||||
return value !== condition.value;
|
||||
case 'in':
|
||||
return Array.isArray(condition.value) && condition.value.includes(value);
|
||||
case 'not_in':
|
||||
return Array.isArray(condition.value) && !condition.value.includes(value);
|
||||
case 'contains':
|
||||
if (typeof value === 'string') {
|
||||
return value.includes(String(condition.value));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(v => String(v).includes(String(condition.value)));
|
||||
}
|
||||
return false;
|
||||
case 'not_contains':
|
||||
if (typeof value === 'string') {
|
||||
return !value.includes(String(condition.value));
|
||||
}
|
||||
return true;
|
||||
case 'gt':
|
||||
return Number(value) > Number(condition.value);
|
||||
case 'lt':
|
||||
return Number(value) < Number(condition.value);
|
||||
case 'is_null':
|
||||
return value === null || value === undefined;
|
||||
case 'is_not_null':
|
||||
return value !== null && value !== undefined;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve template variables in config (supports {{context.field}} syntax).
|
||||
*/
|
||||
private resolveTemplates(value: any, context: WorkflowStepContext): any {
|
||||
if (typeof value === 'string') {
|
||||
return this.resolveStringTemplate(value, context);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(v => this.resolveTemplates(v, context));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const resolved: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
resolved[k] = this.resolveTemplates(v, context);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private resolveStringTemplate(template: string, context: WorkflowStepContext): string {
|
||||
return template.replace(/\{\{([^}]+)\}\}/g, (match, path: string) => {
|
||||
const trimmedPath = path.trim();
|
||||
|
||||
// Handle special "settings.*" paths
|
||||
if (trimmedPath.startsWith('settings.')) {
|
||||
const settingKey = trimmedPath.substring('settings.'.length);
|
||||
const value = (context._settings as any)[settingKey];
|
||||
return value !== undefined && value !== null ? String(value) : '';
|
||||
}
|
||||
|
||||
// Handle "context.*" paths
|
||||
if (trimmedPath.startsWith('context.')) {
|
||||
const contextKey = trimmedPath.substring('context.'.length);
|
||||
const value = this.getNestedValue(context, contextKey);
|
||||
return value !== undefined && value !== null ? String(value) : '';
|
||||
}
|
||||
|
||||
// Direct context access
|
||||
const value = this.getNestedValue(context, trimmedPath);
|
||||
return value !== undefined && value !== null ? String(value) : '';
|
||||
});
|
||||
}
|
||||
|
||||
private getNestedValue(obj: any, path: string): any {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null) return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log step execution to database.
|
||||
*/
|
||||
private async logStepExecution(
|
||||
executionId: number,
|
||||
step: TicketWorkflowStep,
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped',
|
||||
inputData: any,
|
||||
outputData: any,
|
||||
durationMs: number,
|
||||
errorMessage?: string | null
|
||||
): Promise<void> {
|
||||
if (status === 'running') {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO ticket_workflow_execution_steps
|
||||
(execution_id, step_order, step_type, step_name, status, started_at, input_data)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), $6)`,
|
||||
[executionId, step.step_order, step.step_type, step.name, status, JSON.stringify(inputData)]
|
||||
);
|
||||
} else {
|
||||
await postgresClient.query(
|
||||
`UPDATE ticket_workflow_execution_steps
|
||||
SET status = $1, output_data = $2, completed_at = NOW(), duration_ms = $3, error_message = $4
|
||||
WHERE execution_id = $5 AND step_order = $6`,
|
||||
[status, JSON.stringify(outputData), durationMs, errorMessage, executionId, step.step_order]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const ticketWorkflowEngine = new TicketWorkflowEngine();
|
||||
481
lib/services/veeam-rpo-service.ts
Normal file
481
lib/services/veeam-rpo-service.ts
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
/**
|
||||
* Veeam RPO Service
|
||||
* Outcome-based backup alerting: one deduped Autotask ticket per job
|
||||
* that has missed its RPO, auto-resolved when backup succeeds.
|
||||
*/
|
||||
|
||||
import postgresClient from './postgres-client';
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
|
||||
const AT_QUEUE_ID = 29832283; // Operations Triage
|
||||
const AT_ISSUE_TYPE = 38; // Backups
|
||||
const AT_SUB_ISSUE = 637; // Backup: Veeam Agent for Microsoft Windows
|
||||
const AT_PRIORITY_MED = 3; // Medium
|
||||
const AT_PRIORITY_HIGH = 2; // High
|
||||
const AT_PRIORITY_CRIT = 1; // Critical
|
||||
const AT_STATUS_NEW = 1;
|
||||
const AT_STATUS_DONE = 5;
|
||||
|
||||
export interface RpoCheckResult {
|
||||
checked: number;
|
||||
newTickets: number;
|
||||
escalated: number;
|
||||
resolved: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
runAt: Date;
|
||||
}
|
||||
|
||||
export interface RpoJobSummary {
|
||||
job_instance_uid: string;
|
||||
job_name: string;
|
||||
org_name: string;
|
||||
status: string;
|
||||
schedule_type: string;
|
||||
last_end_time: string | null;
|
||||
hours_since_backup: number | null;
|
||||
rpo_hours: number;
|
||||
is_breached: boolean;
|
||||
failure_category: string | null;
|
||||
failure_message: string | null;
|
||||
open_ticket: {
|
||||
at_ticket_id: number;
|
||||
at_ticket_number: string;
|
||||
priority_level: string;
|
||||
hours_overdue: number;
|
||||
opened_at: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function getRpoThresholds(scheduleType: string): { grace: number; high: number; critical: number } {
|
||||
// grace = hours after next_run before we alert (buffer for slow jobs)
|
||||
// high/critical = hours after next_run for escalation
|
||||
const s = (scheduleType ?? '').toLowerCase();
|
||||
if (s.includes('weekly')) {
|
||||
return { grace: 4, high: 48, critical: 7 * 24 };
|
||||
}
|
||||
if (s.includes('continuous') || s.includes('real')) {
|
||||
return { grace: 1, high: 4, critical: 12 };
|
||||
}
|
||||
// Daily (default) — alert 4h after missed window, escalate at 48h / 7 days
|
||||
return { grace: 4, high: 48, critical: 7 * 24 };
|
||||
}
|
||||
|
||||
function categorizeFailure(failureMessage: string | null): string {
|
||||
if (!failureMessage) return 'No recent successful backup';
|
||||
const msg = failureMessage.toLowerCase();
|
||||
if (msg.includes('license') && (msg.includes('expired') || msg.includes('grace period'))) {
|
||||
return 'License Expired — renew via VSPC';
|
||||
}
|
||||
if (msg.includes('vcg01') || msg.includes('cloud gateway') || msg.includes('cloud connect')) {
|
||||
return 'Cloud Gateway Unreachable — check vcg01.wulfconsulting.com';
|
||||
}
|
||||
if (msg.includes('repository') && (msg.includes('inaccessible') || msg.includes('not accessible'))) {
|
||||
return 'Backup Repository Inaccessible';
|
||||
}
|
||||
if (msg.includes('maintenance')) {
|
||||
return 'Service Provider Maintenance';
|
||||
}
|
||||
if (msg.includes('ssl') || msg.includes('resolve host') || msg.includes('connection')) {
|
||||
return 'Network/Connectivity Error';
|
||||
}
|
||||
if (msg.includes('timeout')) {
|
||||
return 'Backup Job Timeout';
|
||||
}
|
||||
return failureMessage.trim().substring(0, 200);
|
||||
}
|
||||
|
||||
function buildTicketTitle(jobName: string, orgName: string, hoursOverdue: number): string {
|
||||
const h = Math.round(hoursOverdue);
|
||||
const display = h >= 48 ? `${Math.round(h / 24)}d` : `${h}h`;
|
||||
return `[Veeam RPO] ${jobName} @ ${orgName} — ${display} since last backup`;
|
||||
}
|
||||
|
||||
function buildTicketDescription(
|
||||
jobName: string,
|
||||
orgName: string,
|
||||
scheduleType: string,
|
||||
lastEndTime: string | null,
|
||||
hoursOverdue: number,
|
||||
failureCategory: string,
|
||||
failureMessage: string | null,
|
||||
restorePoints: number | null,
|
||||
): string {
|
||||
const lastBackup = lastEndTime
|
||||
? new Date(lastEndTime).toLocaleString('en-US', { timeZone: 'America/New_York' }) + ' ET'
|
||||
: 'Never';
|
||||
const lines = [
|
||||
`Job: ${jobName}`,
|
||||
`Organization: ${orgName}`,
|
||||
`Schedule: ${scheduleType ?? 'Unknown'}`,
|
||||
`Last Successful Backup: ${lastBackup}`,
|
||||
`Hours Since Backup: ${Math.round(hoursOverdue)}h`,
|
||||
`Restore Points Available: ${restorePoints ?? 'Unknown'}`,
|
||||
``,
|
||||
`Failure Reason: ${failureCategory}`,
|
||||
];
|
||||
if (failureMessage && failureCategory !== failureMessage.trim().substring(0, 200)) {
|
||||
lines.push(``, `Raw Error: ${failureMessage.trim().substring(0, 500)}`);
|
||||
}
|
||||
lines.push(``, `Generated by Pulse RPO Monitor — ${new Date().toISOString()}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getAutotaskClient(): AutotaskClient {
|
||||
return new AutotaskClient({
|
||||
apiUrl: process.env.AUTOTASK_API_URL || '',
|
||||
username: process.env.AUTOTASK_USERNAME || '',
|
||||
password: process.env.AUTOTASK_SECRET || '',
|
||||
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
});
|
||||
}
|
||||
|
||||
export class VeeamRpoService {
|
||||
async runCheck(): Promise<RpoCheckResult> {
|
||||
const result: RpoCheckResult = {
|
||||
checked: 0,
|
||||
newTickets: 0,
|
||||
escalated: 0,
|
||||
resolved: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
runAt: new Date(),
|
||||
};
|
||||
|
||||
const client = getAutotaskClient();
|
||||
|
||||
// Fetch all enabled workstation jobs with org info
|
||||
const jobsRes = await postgresClient.query(`
|
||||
SELECT
|
||||
j.instance_uid,
|
||||
j.name as job_name,
|
||||
j.status,
|
||||
j.schedule_type,
|
||||
j.last_end_time,
|
||||
j.next_run,
|
||||
j.restore_points,
|
||||
j.failure_message,
|
||||
j.is_enabled,
|
||||
j.operation_mode,
|
||||
o.name as org_name,
|
||||
EXTRACT(EPOCH FROM (NOW() - j.last_end_time)) / 3600.0 as hours_since_backup
|
||||
FROM veeam_backup_agent_jobs j
|
||||
JOIN veeam_organizations o ON o.instance_uid = j.organization_uid
|
||||
WHERE j.operation_mode = 'Workstation'
|
||||
AND j.is_enabled = true
|
||||
ORDER BY hours_since_backup DESC NULLS LAST
|
||||
`);
|
||||
|
||||
const jobs = jobsRes.rows;
|
||||
result.checked = jobs.length;
|
||||
|
||||
// Fetch all currently open RPO tickets in one query
|
||||
const openTicketsRes = await postgresClient.query(`
|
||||
SELECT * FROM veeam_rpo_tickets WHERE resolved_at IS NULL
|
||||
`);
|
||||
const openByJobUid: Record<string, any> = {};
|
||||
for (const row of openTicketsRes.rows) {
|
||||
openByJobUid[row.job_instance_uid] = row;
|
||||
}
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
await this.processJob(job, openByJobUid, client, result);
|
||||
} catch (err: any) {
|
||||
result.errors.push(`${job.job_name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_checked_at for all processed jobs
|
||||
await postgresClient.query(`
|
||||
UPDATE veeam_rpo_tickets SET last_checked_at = NOW() WHERE resolved_at IS NULL
|
||||
`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async processJob(
|
||||
job: any,
|
||||
openByJobUid: Record<string, any>,
|
||||
client: AutotaskClient,
|
||||
result: RpoCheckResult,
|
||||
): Promise<void> {
|
||||
const thresholds = getRpoThresholds(job.schedule_type);
|
||||
const openTicket = openByJobUid[job.instance_uid] ?? null;
|
||||
|
||||
// Skip jobs that are currently running — they haven't failed yet
|
||||
if (job.status === 'Running') {
|
||||
result.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
const hoursAgo: number | null = job.hours_since_backup !== null ? parseFloat(job.hours_since_backup) : null;
|
||||
const intervalHours = (job.schedule_type ?? '').toLowerCase().includes('weekly') ? 168
|
||||
: (job.schedule_type ?? '').toLowerCase().includes('continuous') ? 1
|
||||
: 24;
|
||||
|
||||
// Breach rules:
|
||||
// - Failed/Warning: always breached — a failed backup is a failed backup regardless of recency
|
||||
// - None (never run): always breached
|
||||
// - Success: only breach if last success is older than (interval + grace) — daily = 28h
|
||||
// Use 3x for the >30d cap logic only; the alert threshold is interval+grace
|
||||
const rpoWindowHours = intervalHours + thresholds.grace;
|
||||
const isBreached = job.status === 'Failed' || job.status === 'Warning'
|
||||
|| hoursAgo === null
|
||||
|| hoursAgo > rpoWindowHours;
|
||||
|
||||
if (!isBreached) {
|
||||
if (openTicket) {
|
||||
await this.resolveTicket(openTicket, client);
|
||||
result.resolved++;
|
||||
} else {
|
||||
result.skipped++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// hoursOverdue = hours past the RPO window
|
||||
const hoursOverdue = hoursAgo !== null ? hoursAgo - rpoWindowHours : thresholds.grace;
|
||||
const failureCategory = categorizeFailure(job.failure_message);
|
||||
const targetPriority = hoursOverdue >= thresholds.critical ? 'critical'
|
||||
: hoursOverdue >= thresholds.high ? 'high'
|
||||
: 'medium';
|
||||
|
||||
// Don't create new tickets for jobs broken longer than 30 days on first encounter.
|
||||
// These are likely abandoned machines — show as breached in UI but don't flood AT.
|
||||
const MAX_NEW_TICKET_AGE_HOURS = 720; // 30 days
|
||||
const tooOldForNewTicket = !openTicket && (hoursAgo ?? 0) > MAX_NEW_TICKET_AGE_HOURS;
|
||||
|
||||
if (!openTicket) {
|
||||
if (tooOldForNewTicket) {
|
||||
result.skipped++;
|
||||
return;
|
||||
}
|
||||
// Create new ticket
|
||||
await this.createTicket(job, hoursOverdue, failureCategory, targetPriority, client, result);
|
||||
} else {
|
||||
// Escalate if needed
|
||||
if (openTicket.priority_level !== targetPriority && this.isPriorityHigher(targetPriority, openTicket.priority_level)) {
|
||||
await this.escalateTicket(openTicket, job, hoursOverdue, failureCategory, targetPriority, client, result);
|
||||
} else {
|
||||
result.skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isPriorityHigher(a: string, b: string): boolean {
|
||||
const rank: Record<string, number> = { medium: 1, high: 2, critical: 3 };
|
||||
return (rank[a] ?? 0) > (rank[b] ?? 0);
|
||||
}
|
||||
|
||||
private async createTicket(
|
||||
job: any,
|
||||
hoursOverdue: number,
|
||||
failureCategory: string,
|
||||
priorityLevel: string,
|
||||
client: AutotaskClient,
|
||||
result: RpoCheckResult,
|
||||
): Promise<void> {
|
||||
const atPriority = priorityLevel === 'critical' ? AT_PRIORITY_CRIT
|
||||
: priorityLevel === 'high' ? AT_PRIORITY_HIGH
|
||||
: AT_PRIORITY_MED;
|
||||
|
||||
const title = buildTicketTitle(job.job_name, job.org_name, hoursOverdue);
|
||||
const description = buildTicketDescription(
|
||||
job.job_name, job.org_name, job.schedule_type,
|
||||
job.last_end_time, hoursOverdue, failureCategory,
|
||||
job.failure_message, job.restore_points,
|
||||
);
|
||||
|
||||
// Look up Autotask company ID from org name mapping
|
||||
const companyRes = await postgresClient.query(`
|
||||
SELECT c.id FROM companies c
|
||||
JOIN veeam_organizations vo ON vo.autotask_company_id = c.id
|
||||
WHERE vo.name = $1
|
||||
LIMIT 1
|
||||
`, [job.org_name]);
|
||||
|
||||
const companyId = companyRes.rows[0]?.id ?? null;
|
||||
|
||||
const ticketPayload: Record<string, any> = {
|
||||
title,
|
||||
description,
|
||||
status: AT_STATUS_NEW,
|
||||
queueID: AT_QUEUE_ID,
|
||||
issueType: AT_ISSUE_TYPE,
|
||||
subIssueType: AT_SUB_ISSUE,
|
||||
priority: atPriority,
|
||||
};
|
||||
if (companyId) ticketPayload.companyID = companyId;
|
||||
|
||||
const ticket = await client.createTicket(ticketPayload);
|
||||
|
||||
await postgresClient.query(`
|
||||
INSERT INTO veeam_rpo_tickets
|
||||
(job_instance_uid, job_name, org_name, at_ticket_id, at_ticket_number,
|
||||
priority_level, hours_overdue, failure_category, opened_at, last_checked_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
ON CONFLICT (job_instance_uid) DO UPDATE SET
|
||||
at_ticket_id = EXCLUDED.at_ticket_id,
|
||||
at_ticket_number = EXCLUDED.at_ticket_number,
|
||||
priority_level = EXCLUDED.priority_level,
|
||||
hours_overdue = EXCLUDED.hours_overdue,
|
||||
failure_category = EXCLUDED.failure_category,
|
||||
resolved_at = NULL,
|
||||
opened_at = NOW(),
|
||||
last_checked_at = NOW(),
|
||||
updated_at = NOW()
|
||||
`, [
|
||||
job.instance_uid,
|
||||
job.job_name,
|
||||
job.org_name,
|
||||
ticket.id,
|
||||
(ticket as any).ticketNumber ?? null,
|
||||
priorityLevel,
|
||||
Math.round(hoursOverdue),
|
||||
failureCategory,
|
||||
]);
|
||||
|
||||
result.newTickets++;
|
||||
console.log(`[RPO] Created ticket ${(ticket as any).ticketNumber} for ${job.job_name} (${Math.round(hoursOverdue)}h overdue)`);
|
||||
}
|
||||
|
||||
private async escalateTicket(
|
||||
openTicket: any,
|
||||
job: any,
|
||||
hoursOverdue: number,
|
||||
failureCategory: string,
|
||||
targetPriority: string,
|
||||
client: AutotaskClient,
|
||||
result: RpoCheckResult,
|
||||
): Promise<void> {
|
||||
const atPriority = targetPriority === 'critical' ? AT_PRIORITY_CRIT
|
||||
: targetPriority === 'high' ? AT_PRIORITY_HIGH
|
||||
: AT_PRIORITY_MED;
|
||||
|
||||
const note = `RPO Escalation: ${Math.round(hoursOverdue)}h since last successful backup (escalated to ${targetPriority}).\nFailure reason: ${failureCategory}`;
|
||||
|
||||
await client.updateTicket(openTicket.at_ticket_id, { priority: atPriority });
|
||||
|
||||
// Add a note to the ticket
|
||||
try {
|
||||
await (client as any).createEntity('TicketNotes', {
|
||||
ticketID: openTicket.at_ticket_id,
|
||||
title: `RPO Escalation — ${targetPriority.toUpperCase()}`,
|
||||
description: note,
|
||||
noteType: 1,
|
||||
publish: 1,
|
||||
});
|
||||
} catch {
|
||||
// Note creation failure is non-fatal
|
||||
}
|
||||
|
||||
await postgresClient.query(`
|
||||
UPDATE veeam_rpo_tickets SET
|
||||
priority_level = $1,
|
||||
hours_overdue = $2,
|
||||
failure_category = $3,
|
||||
last_checked_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $4
|
||||
`, [targetPriority, Math.round(hoursOverdue), failureCategory, openTicket.id]);
|
||||
|
||||
result.escalated++;
|
||||
console.log(`[RPO] Escalated ticket ${openTicket.at_ticket_number} to ${targetPriority} (${Math.round(hoursOverdue)}h overdue)`);
|
||||
}
|
||||
|
||||
private async resolveTicket(openTicket: any, client: AutotaskClient): Promise<void> {
|
||||
await client.updateTicket(openTicket.at_ticket_id, { status: AT_STATUS_DONE });
|
||||
|
||||
await postgresClient.query(`
|
||||
UPDATE veeam_rpo_tickets SET
|
||||
resolved_at = NOW(),
|
||||
last_checked_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, [openTicket.id]);
|
||||
|
||||
console.log(`[RPO] Resolved ticket ${openTicket.at_ticket_number} — backup succeeded`);
|
||||
}
|
||||
|
||||
async getStatus(): Promise<{ summary: Record<string, number>; jobs: RpoJobSummary[] }> {
|
||||
const jobsRes = await postgresClient.query(`
|
||||
SELECT
|
||||
j.instance_uid,
|
||||
j.name as job_name,
|
||||
j.status,
|
||||
j.schedule_type,
|
||||
j.last_end_time,
|
||||
j.next_run,
|
||||
j.restore_points,
|
||||
j.failure_message,
|
||||
o.name as org_name,
|
||||
EXTRACT(EPOCH FROM (NOW() - j.last_end_time)) / 3600.0 as hours_since_backup,
|
||||
EXTRACT(EPOCH FROM (j.next_run - NOW())) / 3600.0 as hours_until_next_run
|
||||
FROM veeam_backup_agent_jobs j
|
||||
JOIN veeam_organizations o ON o.instance_uid = j.organization_uid
|
||||
LEFT JOIN veeam_rpo_tickets rt
|
||||
ON rt.job_instance_uid = j.instance_uid AND rt.resolved_at IS NULL
|
||||
WHERE j.operation_mode = 'Workstation'
|
||||
AND j.is_enabled = true
|
||||
ORDER BY hours_since_backup DESC NULLS LAST
|
||||
`);
|
||||
|
||||
const jobs: RpoJobSummary[] = jobsRes.rows.map((row) => {
|
||||
const thresholds = getRpoThresholds(row.schedule_type);
|
||||
const hoursAgo: number | null = row.hours_since_backup !== null ? parseFloat(row.hours_since_backup) : null;
|
||||
const intervalHours = (row.schedule_type ?? '').toLowerCase().includes('weekly') ? 168
|
||||
: (row.schedule_type ?? '').toLowerCase().includes('continuous') ? 1
|
||||
: 24;
|
||||
const rpoWindowHours = intervalHours + thresholds.grace;
|
||||
const isBreached = row.status !== 'Running'
|
||||
&& (row.status === 'Failed' || row.status === 'Warning'
|
||||
|| hoursAgo === null
|
||||
|| hoursAgo > rpoWindowHours);
|
||||
return {
|
||||
job_instance_uid: row.instance_uid,
|
||||
job_name: row.job_name,
|
||||
org_name: row.org_name,
|
||||
status: row.status,
|
||||
schedule_type: row.schedule_type,
|
||||
last_end_time: row.last_end_time,
|
||||
hours_since_backup: hoursAgo !== null ? Math.round(hoursAgo * 10) / 10 : null,
|
||||
rpo_hours: thresholds.grace,
|
||||
is_breached: isBreached,
|
||||
failure_category: row.failure_message ? categorizeFailure(row.failure_message) : null,
|
||||
failure_message: row.failure_message,
|
||||
open_ticket: row.at_ticket_id ? {
|
||||
at_ticket_id: (row as any).at_ticket_id,
|
||||
at_ticket_number: (row as any).at_ticket_number,
|
||||
priority_level: (row as any).priority_level,
|
||||
hours_overdue: (row as any).hours_overdue,
|
||||
opened_at: (row as any).opened_at,
|
||||
} : null,
|
||||
};
|
||||
});
|
||||
|
||||
const breached = jobs.filter(j => j.is_breached).length;
|
||||
const withTicket = jobs.filter(j => j.open_ticket !== null).length;
|
||||
const healthy = jobs.filter(j => !j.is_breached).length;
|
||||
const critical = jobs.filter(j => j.open_ticket?.priority_level === 'critical').length;
|
||||
const high = jobs.filter(j => j.open_ticket?.priority_level === 'high').length;
|
||||
|
||||
return {
|
||||
summary: {
|
||||
total: jobs.length,
|
||||
healthy,
|
||||
breached,
|
||||
withOpenTicket: withTicket,
|
||||
critical,
|
||||
high,
|
||||
},
|
||||
jobs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: VeeamRpoService | null = null;
|
||||
export function getVeeamRpoService(): VeeamRpoService {
|
||||
if (!_instance) _instance = new VeeamRpoService();
|
||||
return _instance;
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ import { mapAutotaskToDatabase } from '../utils/entity-mapper';
|
|||
import { getTableName, getAutotaskEntityName } from '../utils/sync-helpers';
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
import { workflowEngine } from './workflow-engine';
|
||||
import { ticketWorkflowEngine } from './ticket-workflow-engine';
|
||||
import '../services/workflow-steps'; // Register all workflow step executors
|
||||
import { WorkflowEvent, TicketData } from '../types/workflow';
|
||||
|
||||
export class WebhookService {
|
||||
|
|
@ -424,7 +426,14 @@ export class WebhookService {
|
|||
}
|
||||
|
||||
console.log(`[WEBHOOK] Triggering workflow engine for ticket ${payload.entityId}`);
|
||||
await workflowEngine.process(event);
|
||||
// Fire-and-forget: trigger new ticket workflow engine
|
||||
if (event.ticket_data) {
|
||||
ticketWorkflowEngine.processTrigger(event.trigger_event, event.ticket_data).catch(err => {
|
||||
console.error('[WEBHOOK] Ticket workflow engine error:', err);
|
||||
});
|
||||
}
|
||||
// DEPRECATED: old workflow engine (will be removed after testing period)
|
||||
// await workflowEngine.process(event);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
99
lib/services/workflow-steps/ai-classify.ts
Normal file
99
lib/services/workflow-steps/ai-classify.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* AI Classify Step — uses AI to classify ambiguous fields when robotic classification fails.
|
||||
* Config: {
|
||||
* template_purpose: 'ambiguous_classification',
|
||||
* skip_if_valid?: boolean // skip if validation passed
|
||||
* }
|
||||
* Condition: typically checks context.validation.is_valid === false
|
||||
*/
|
||||
|
||||
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
|
||||
import { aiTriageService } from '../ai-triage-service';
|
||||
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
|
||||
|
||||
async function executeAiClassify(
|
||||
step: TicketWorkflowStep,
|
||||
context: WorkflowStepContext,
|
||||
_executionId: number
|
||||
): Promise<WorkflowStepResult> {
|
||||
// Check if we should skip (validation passed)
|
||||
if (step.config.skip_if_valid && context.validation?.is_valid) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
skipped: true,
|
||||
reason: 'Validation passed, AI not needed'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get failed fields from validation
|
||||
const validationFailedFields = context.validation?.errors?.map(e => e.field) || [];
|
||||
|
||||
if (validationFailedFields.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
skipped: true,
|
||||
reason: 'No failed fields to classify'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Build ticket with current classification context
|
||||
const ticket = {
|
||||
...context.ticket,
|
||||
...(context.field_changes || {})
|
||||
};
|
||||
|
||||
// Call AI classification
|
||||
const aiResult = await aiTriageService.classifyAmbiguous(
|
||||
ticket,
|
||||
validationFailedFields,
|
||||
context._settings
|
||||
);
|
||||
|
||||
// Merge AI results into context
|
||||
if (aiResult.classification) {
|
||||
if (!context.field_changes) {
|
||||
context.field_changes = {};
|
||||
}
|
||||
|
||||
// Update field changes with AI results
|
||||
if (aiResult.classification.issue_type !== undefined) {
|
||||
context.field_changes['issue_type'] = {
|
||||
before: ticket.issue_type || null,
|
||||
after: aiResult.classification.issue_type
|
||||
};
|
||||
}
|
||||
if (aiResult.classification.sub_issue_type !== undefined) {
|
||||
context.field_changes['sub_issue_type'] = {
|
||||
before: ticket.sub_issue_type || null,
|
||||
after: aiResult.classification.sub_issue_type
|
||||
};
|
||||
}
|
||||
if (aiResult.classification.ticket_type !== undefined) {
|
||||
context.field_changes['ticket_type'] = {
|
||||
before: ticket.ticket_type || null,
|
||||
after: aiResult.classification.ticket_type
|
||||
};
|
||||
}
|
||||
if (aiResult.classification.priority !== undefined) {
|
||||
context.field_changes['priority'] = {
|
||||
before: ticket.priority || null,
|
||||
after: aiResult.classification.priority
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
method: 'ai',
|
||||
classification: aiResult.classification,
|
||||
failed_fields_addressed: validationFailedFields
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerWorkflowStepExecutor('ai_classify', executeAiClassify);
|
||||
53
lib/services/workflow-steps/ai-title.ts
Normal file
53
lib/services/workflow-steps/ai-title.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* AI Title Step — uses AI to clean up messy ticket titles.
|
||||
* Config: {
|
||||
* template_purpose: 'title_cleanup'
|
||||
* }
|
||||
* Condition: typically checks if title needs cleanup (from classification.ai_reasons)
|
||||
*/
|
||||
|
||||
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
|
||||
import { aiTriageService } from '../ai-triage-service';
|
||||
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
|
||||
|
||||
async function executeAiTitle(
|
||||
step: TicketWorkflowStep,
|
||||
context: WorkflowStepContext,
|
||||
_executionId: number
|
||||
): Promise<WorkflowStepResult> {
|
||||
const ticket = context.ticket;
|
||||
|
||||
// Call AI title cleanup
|
||||
const cleanedTitle = await aiTriageService.cleanupTitle(ticket, context._settings);
|
||||
|
||||
if (!cleanedTitle || cleanedTitle === ticket.title) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
skipped: true,
|
||||
reason: 'No title cleanup needed or AI returned same title'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Update field changes
|
||||
if (!context.field_changes) {
|
||||
context.field_changes = {};
|
||||
}
|
||||
|
||||
context.field_changes['title'] = {
|
||||
before: ticket.title,
|
||||
after: cleanedTitle
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
method: 'ai',
|
||||
original_title: ticket.title,
|
||||
cleaned_title: cleanedTitle
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerWorkflowStepExecutor('ai_title', executeAiTitle);
|
||||
52
lib/services/workflow-steps/ai-troubleshooting.ts
Normal file
52
lib/services/workflow-steps/ai-troubleshooting.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* AI Troubleshooting Step — generates troubleshooting steps and creates a ticket note.
|
||||
* Config: {
|
||||
* template_purpose: 'troubleshooting_steps',
|
||||
* create_note: boolean // whether to create an Autotask ticket note
|
||||
* }
|
||||
* Condition: typically checks if ticket_type === 2 (Incident)
|
||||
*/
|
||||
|
||||
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
|
||||
import { aiTriageService } from '../ai-triage-service';
|
||||
import { AutotaskClient } from '../autotask-client';
|
||||
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
|
||||
|
||||
async function executeAiTroubleshooting(
|
||||
step: TicketWorkflowStep,
|
||||
context: WorkflowStepContext,
|
||||
_executionId: number
|
||||
): Promise<WorkflowStepResult> {
|
||||
const ticket = context.ticket;
|
||||
|
||||
// Generate troubleshooting steps
|
||||
const troubleshootingSteps = await aiTriageService.generateTroubleshootingSteps(
|
||||
ticket,
|
||||
context._settings
|
||||
);
|
||||
|
||||
if (!troubleshootingSteps) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
skipped: true,
|
||||
reason: 'No troubleshooting steps generated'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Note: Ticket note creation would go here
|
||||
// For now, just return the troubleshooting steps in the output
|
||||
// TODO: Implement createTicketNote in AutotaskClient if needed
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
method: 'ai',
|
||||
troubleshooting_steps: troubleshootingSteps,
|
||||
note_created: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerWorkflowStepExecutor('ai_troubleshooting', executeAiTroubleshooting);
|
||||
113
lib/services/workflow-steps/classify.ts
Normal file
113
lib/services/workflow-steps/classify.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Classify Step — keyword-based classification using classification_rules table.
|
||||
* Config: {
|
||||
* rule_type: 'branch_routing' | 'ticket_type' | 'issue_classification' | 'priority' | 'queue_routing',
|
||||
* result_field: 'branch' | 'ticket_type' | 'issue_type' | 'priority' | 'queue_id',
|
||||
* result_field_2?: 'sub_issue_type', // for issue_classification only
|
||||
* default_value?: any // fallback if no rules match
|
||||
* }
|
||||
*/
|
||||
|
||||
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
|
||||
import { roboticClassifier } from '../robotic-classifier';
|
||||
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
|
||||
|
||||
async function executeClassify(
|
||||
step: TicketWorkflowStep,
|
||||
context: WorkflowStepContext,
|
||||
_executionId: number
|
||||
): Promise<WorkflowStepResult> {
|
||||
const ruleType = step.config.rule_type;
|
||||
const resultField = step.config.result_field;
|
||||
const resultField2 = step.config.result_field_2;
|
||||
const defaultValue = step.config.default_value;
|
||||
|
||||
if (!ruleType || !resultField) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Missing required config: rule_type and result_field'
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure rules are loaded
|
||||
await roboticClassifier.loadRules();
|
||||
|
||||
// Build ticket context (may have values from previous steps)
|
||||
const ticket = {
|
||||
...context.ticket,
|
||||
...(context.field_changes || {})
|
||||
};
|
||||
|
||||
// Run classification for this rule type
|
||||
const result = await (roboticClassifier as any).classifyByType(ruleType, ticket);
|
||||
|
||||
if (!result && defaultValue !== undefined) {
|
||||
// No match, use default
|
||||
const output: any = {
|
||||
[resultField]: defaultValue,
|
||||
matched_rule: null,
|
||||
confidence: 'default',
|
||||
method: 'default'
|
||||
};
|
||||
|
||||
// Track field change
|
||||
if (!context.field_changes) {
|
||||
context.field_changes = {};
|
||||
}
|
||||
context.field_changes[resultField] = {
|
||||
before: (ticket as any)[resultField] || null,
|
||||
after: defaultValue
|
||||
};
|
||||
|
||||
return { success: true, output };
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
// No match and no default
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
[resultField]: null,
|
||||
matched_rule: null,
|
||||
confidence: 'none',
|
||||
method: 'no_match'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Matched a rule
|
||||
const output: any = {
|
||||
[resultField]: result.value,
|
||||
matched_rule: result.matched_rule_name,
|
||||
confidence: result.confidence,
|
||||
method: 'robotic'
|
||||
};
|
||||
|
||||
// Track field change for primary result
|
||||
if (!context.field_changes) {
|
||||
context.field_changes = {};
|
||||
}
|
||||
context.field_changes[resultField] = {
|
||||
before: (ticket as any)[resultField] || null,
|
||||
after: result.value
|
||||
};
|
||||
|
||||
// Handle secondary result (e.g., sub_issue_type)
|
||||
if (resultField2 && result.value_2 !== undefined) {
|
||||
output[resultField2] = result.value_2;
|
||||
context.field_changes[resultField2] = {
|
||||
before: (ticket as any)[resultField2] || null,
|
||||
after: result.value_2
|
||||
};
|
||||
}
|
||||
|
||||
// Store full classification result in context for later steps
|
||||
if (!context.classification) {
|
||||
context.classification = {};
|
||||
}
|
||||
context.classification[ruleType] = result;
|
||||
|
||||
return { success: true, output };
|
||||
}
|
||||
|
||||
registerWorkflowStepExecutor('classify', executeClassify);
|
||||
31
lib/services/workflow-steps/delay.ts
Normal file
31
lib/services/workflow-steps/delay.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Delay Step — wait N milliseconds before continuing.
|
||||
* Config: {
|
||||
* duration_ms: number | string // can be a number or a template like "{{settings.autotask_update_delay_ms}}"
|
||||
* }
|
||||
*/
|
||||
|
||||
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
|
||||
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
|
||||
|
||||
async function executeDelay(
|
||||
step: TicketWorkflowStep,
|
||||
_context: WorkflowStepContext,
|
||||
_executionId: number
|
||||
): Promise<WorkflowStepResult> {
|
||||
const durationMs = Number(step.config.duration_ms) || 0;
|
||||
|
||||
if (durationMs > 0) {
|
||||
console.log(`[WORKFLOW:delay] Waiting ${durationMs}ms`);
|
||||
await new Promise(resolve => setTimeout(resolve, durationMs));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
delayed_ms: durationMs
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerWorkflowStepExecutor('delay', executeDelay);
|
||||
12
lib/services/workflow-steps/index.ts
Normal file
12
lib/services/workflow-steps/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Workflow Step Executors — import all to register them with the engine.
|
||||
* This file must be imported once when the ticket workflow engine is used.
|
||||
*/
|
||||
|
||||
import './classify';
|
||||
import './validate';
|
||||
import './ai-classify';
|
||||
import './ai-title';
|
||||
import './ai-troubleshooting';
|
||||
import './delay';
|
||||
import './update-ticket';
|
||||
83
lib/services/workflow-steps/update-ticket.ts
Normal file
83
lib/services/workflow-steps/update-ticket.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Update Ticket Step — writes field_changes back to Autotask.
|
||||
* Config: {
|
||||
* use_field_changes: boolean // use context.field_changes
|
||||
* }
|
||||
*/
|
||||
|
||||
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
|
||||
import { AutotaskClient } from '../autotask-client';
|
||||
import { postgresClient } from '../postgres-client';
|
||||
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
|
||||
|
||||
async function executeUpdateTicket(
|
||||
step: TicketWorkflowStep,
|
||||
context: WorkflowStepContext,
|
||||
_executionId: number
|
||||
): Promise<WorkflowStepResult> {
|
||||
const fieldChanges = context.field_changes || {};
|
||||
const changeKeys = Object.keys(fieldChanges);
|
||||
|
||||
if (changeKeys.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
skipped: true,
|
||||
reason: 'No field changes to apply'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Build Autotask update payload
|
||||
const updatePayload: any = {};
|
||||
|
||||
for (const [field, change] of Object.entries(fieldChanges)) {
|
||||
updatePayload[field] = change.after;
|
||||
}
|
||||
|
||||
// Update in Autotask
|
||||
try {
|
||||
const autotaskClient = new AutotaskClient({
|
||||
apiUrl: process.env.AUTOTASK_API_URL || '',
|
||||
username: process.env.AUTOTASK_USERNAME || '',
|
||||
password: process.env.AUTOTASK_SECRET || '',
|
||||
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
});
|
||||
|
||||
await autotaskClient.updateTicket(context.ticket.id, updatePayload);
|
||||
|
||||
// Update local DB copy
|
||||
const setClause = Object.keys(fieldChanges)
|
||||
.map((field, idx) => `${field} = $${idx + 2}`)
|
||||
.join(', ');
|
||||
const values = [
|
||||
context.ticket.id,
|
||||
...Object.values(fieldChanges).map(c => c.after)
|
||||
];
|
||||
|
||||
if (setClause) {
|
||||
await postgresClient.query(
|
||||
`UPDATE tickets SET ${setClause}, updated_at = NOW() WHERE id = $1`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updated_fields: changeKeys,
|
||||
field_changes: fieldChanges,
|
||||
autotask_updated: true,
|
||||
local_db_updated: true
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UPDATE-TICKET] Failed to update Autotask:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
registerWorkflowStepExecutor('update_ticket', executeUpdateTicket);
|
||||
50
lib/services/workflow-steps/validate.ts
Normal file
50
lib/services/workflow-steps/validate.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Validate Step — validates classification results against DB picklists.
|
||||
* Config: {
|
||||
* required_fields?: string[] // optional list of fields that must be present
|
||||
* }
|
||||
*/
|
||||
|
||||
import { registerWorkflowStepExecutor } from '../ticket-workflow-engine';
|
||||
import { triageValidator } from '../triage-validator';
|
||||
import { TicketWorkflowStep, WorkflowStepContext, WorkflowStepResult } from '../../types/ticket-workflow';
|
||||
import { ClassificationResult } from '../../types/workflow';
|
||||
|
||||
async function executeValidate(
|
||||
step: TicketWorkflowStep,
|
||||
context: WorkflowStepContext,
|
||||
_executionId: number
|
||||
): Promise<WorkflowStepResult> {
|
||||
// Build ClassificationResult from context.classification
|
||||
const classification: ClassificationResult = {
|
||||
branch: context.classification?.branch_routing || null,
|
||||
ticket_type: context.classification?.ticket_type || null,
|
||||
issue_classification: context.classification?.issue_classification || null,
|
||||
priority: context.classification?.priority || null,
|
||||
queue: context.classification?.queue_routing || null,
|
||||
overall_confidence: 'medium',
|
||||
needs_ai: false,
|
||||
ai_reasons: []
|
||||
};
|
||||
|
||||
// Run validation
|
||||
const validationResult = await triageValidator.validate(classification);
|
||||
|
||||
// Store validation result in context for later steps
|
||||
context.validation = validationResult;
|
||||
|
||||
// Check for failed fields
|
||||
const validationFailedFields = validationResult.errors.map(e => e.field);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
is_valid: validationResult.is_valid,
|
||||
validation_errors: validationResult.errors,
|
||||
validation_failed_fields: validationFailedFields,
|
||||
total_errors: validationResult.errors.length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerWorkflowStepExecutor('validate', executeValidate);
|
||||
149
lib/services/zabbix-client.ts
Normal file
149
lib/services/zabbix-client.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import {
|
||||
ZabbixConfig,
|
||||
ZabbixHost,
|
||||
ZabbixHostGroup,
|
||||
ZabbixTemplate,
|
||||
ZabbixHostCreateParams,
|
||||
ZabbixHostUpdateParams,
|
||||
ZabbixRpcResponse,
|
||||
} from '@/lib/types/zabbix';
|
||||
|
||||
export type { ZabbixHostTag } from '@/lib/types/zabbix';
|
||||
|
||||
export class ZabbixClient {
|
||||
private config: ZabbixConfig;
|
||||
private rpcId = 0;
|
||||
|
||||
constructor(config: ZabbixConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a JSON-RPC 2.0 call to the Zabbix API.
|
||||
* Auth is stateless: Bearer token in Authorization header (Zabbix 6.0+ API token).
|
||||
*/
|
||||
private async rpc<T>(method: string, params: Record<string, any>): Promise<T> {
|
||||
const id = ++this.rpcId;
|
||||
const url = `${this.config.apiUrl.replace(/\/$/, '')}/api_jsonrpc.php`;
|
||||
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
params,
|
||||
id,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.config.apiToken}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Zabbix HTTP ${response.status}: ${text.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const data: ZabbixRpcResponse<T> = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(
|
||||
`Zabbix RPC error (${data.error.code}): ${data.error.message}` +
|
||||
(data.error.data ? ` — ${data.error.data}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
return data.result as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a host group by name. Returns the groupid.
|
||||
*/
|
||||
async ensureHostGroup(name: string): Promise<string> {
|
||||
const existing = await this.rpc<ZabbixHostGroup[]>('hostgroup.get', {
|
||||
output: ['groupid', 'name'],
|
||||
filter: { name: [name] },
|
||||
});
|
||||
|
||||
if (existing.length > 0) {
|
||||
return existing[0].groupid;
|
||||
}
|
||||
|
||||
const created = await this.rpc<{ groupids: string[] }>('hostgroup.create', {
|
||||
name,
|
||||
});
|
||||
|
||||
return created.groupids[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a template by exact or partial host name. Returns the first match or null.
|
||||
*/
|
||||
async findTemplate(name: string): Promise<ZabbixTemplate | null> {
|
||||
const results = await this.rpc<ZabbixTemplate[]>('template.get', {
|
||||
output: ['templateid', 'host', 'name'],
|
||||
search: { host: name },
|
||||
searchByAny: false,
|
||||
});
|
||||
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a host by its technical name (host field), with a fallback search by
|
||||
* display name. The fallback handles legacy hosts created before hostname
|
||||
* sanitization was introduced (their host == name == full display string).
|
||||
*/
|
||||
async findHostByName(hostname: string, displayName?: string): Promise<ZabbixHost | null> {
|
||||
const results = await this.rpc<ZabbixHost[]>('host.get', {
|
||||
output: ['hostid', 'host', 'name', 'status'],
|
||||
filter: { host: [hostname] },
|
||||
});
|
||||
if (results.length > 0) return results[0];
|
||||
|
||||
// Fallback: search by display name (catches pre-sanitization legacy hosts)
|
||||
if (displayName && displayName !== hostname) {
|
||||
const byName = await this.rpc<ZabbixHost[]>('host.get', {
|
||||
output: ['hostid', 'host', 'name', 'status'],
|
||||
filter: { name: [displayName] },
|
||||
});
|
||||
if (byName.length > 0) return byName[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a Zabbix host. Idempotent — looks up by host name first.
|
||||
* Returns the hostid and whether the host was created or updated.
|
||||
*/
|
||||
async upsertHost(params: ZabbixHostCreateParams): Promise<{
|
||||
action: 'created' | 'updated';
|
||||
hostid: string;
|
||||
}> {
|
||||
const existing = await this.findHostByName(params.host, params.name);
|
||||
|
||||
if (!existing) {
|
||||
const result = await this.rpc<{ hostids: string[] }>('host.create', params);
|
||||
return { action: 'created', hostid: result.hostids[0] };
|
||||
}
|
||||
|
||||
const updateParams: ZabbixHostUpdateParams = {
|
||||
hostid: existing.hostid,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
// Do not pass interfaces on update — Zabbix rejects changes when items
|
||||
// are already linked to the existing interface.
|
||||
groups: params.groups,
|
||||
templates: params.templates,
|
||||
macros: params.macros,
|
||||
tags: params.tags,
|
||||
};
|
||||
|
||||
await this.rpc<{ hostids: string[] }>('host.update', updateParams);
|
||||
return { action: 'updated', hostid: existing.hostid };
|
||||
}
|
||||
}
|
||||
171
lib/types/pipeline.ts
Normal file
171
lib/types/pipeline.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* Pipeline Engine Types
|
||||
*/
|
||||
|
||||
export type TriggerSource = 'datto_rmm' | 'autotask' | 'veeam' | 'manual';
|
||||
|
||||
export type StepType =
|
||||
| 'filter'
|
||||
| 'transform'
|
||||
| 'set_variable'
|
||||
| 'delay'
|
||||
| 'enrich_device'
|
||||
| 'enrich_company'
|
||||
| 'enrich_ticket'
|
||||
| 'create_ticket'
|
||||
| 'update_ticket'
|
||||
| 'create_note'
|
||||
| 'ai_analyze'
|
||||
| 'notify'
|
||||
| 'approval'
|
||||
| 'rmm_quick_job'
|
||||
| 'rmm_get_job_results';
|
||||
|
||||
export type ChannelType = 'teams' | 'telegram' | 'ntfy' | 'webhook';
|
||||
|
||||
export type PipelineStatus = 'pending' | 'running' | 'waiting' | 'completed' | 'failed' | 'skipped';
|
||||
export type StepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'waiting' | 'skipped';
|
||||
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'timeout';
|
||||
export type OnFailure = 'continue' | 'stop' | 'skip_to';
|
||||
|
||||
export interface TriggerCondition {
|
||||
field: string;
|
||||
operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'in' | 'not_in' | 'regex' | 'exists' | 'not_exists';
|
||||
value: any;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DB Row Types
|
||||
// ============================================================================
|
||||
|
||||
export interface NotificationChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
channel_type: ChannelType;
|
||||
config: Record<string, any>;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface WebhookPipeline {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
trigger_source: TriggerSource;
|
||||
trigger_conditions: TriggerCondition[];
|
||||
sort_order: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface PipelineStep {
|
||||
id: number;
|
||||
pipeline_id: number;
|
||||
step_order: number;
|
||||
step_type: StepType;
|
||||
name: string;
|
||||
config: Record<string, any>;
|
||||
on_failure: OnFailure;
|
||||
skip_to_step: number | null;
|
||||
is_active: boolean;
|
||||
timeout_ms: number | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface PipelineExecution {
|
||||
id: number;
|
||||
pipeline_id: number;
|
||||
trigger_source: string;
|
||||
trigger_payload: Record<string, any> | null;
|
||||
status: PipelineStatus;
|
||||
current_step: number | null;
|
||||
context: Record<string, any>;
|
||||
started_at: Date;
|
||||
completed_at: Date | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface PipelineExecutionStep {
|
||||
id: number;
|
||||
execution_id: number;
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
step_name: string | null;
|
||||
status: StepStatus;
|
||||
input_data: Record<string, any> | null;
|
||||
output_data: Record<string, any> | null;
|
||||
started_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalRequest {
|
||||
id: number;
|
||||
execution_id: number;
|
||||
step_order: number;
|
||||
channel_id: number | null;
|
||||
message: string;
|
||||
options: string[];
|
||||
status: ApprovalStatus;
|
||||
responded_by: string | null;
|
||||
responded_at: Date | null;
|
||||
response_data: Record<string, any> | null;
|
||||
expires_at: Date | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Types
|
||||
// ============================================================================
|
||||
|
||||
export interface PipelineContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface StepExecutorResult {
|
||||
success: boolean;
|
||||
output?: Record<string, any>;
|
||||
error?: string;
|
||||
waiting?: boolean; // true if step is paused (e.g., approval)
|
||||
}
|
||||
|
||||
export interface PipelineWithSteps extends WebhookPipeline {
|
||||
steps: PipelineStep[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Types
|
||||
// ============================================================================
|
||||
|
||||
export interface PipelineInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
trigger_source: TriggerSource;
|
||||
trigger_conditions?: TriggerCondition[];
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface PipelineStepInput {
|
||||
step_order: number;
|
||||
step_type: StepType;
|
||||
name: string;
|
||||
config?: Record<string, any>;
|
||||
on_failure?: OnFailure;
|
||||
skip_to_step?: number;
|
||||
is_active?: boolean;
|
||||
timeout_ms?: number;
|
||||
}
|
||||
|
||||
export interface NotificationChannelInput {
|
||||
name: string;
|
||||
channel_type: ChannelType;
|
||||
config: Record<string, any>;
|
||||
is_active?: boolean;
|
||||
}
|
||||
128
lib/types/ticket-workflow.ts
Normal file
128
lib/types/ticket-workflow.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* Ticket Workflow Engine Types
|
||||
* TypeScript definitions for the refactored table-driven workflow engine
|
||||
*/
|
||||
|
||||
import { TicketData, WorkflowSettings } from './workflow';
|
||||
|
||||
// ============================================================================
|
||||
// Database Row Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TicketWorkflow {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
trigger_event: string;
|
||||
trigger_conditions: TriggerCondition[];
|
||||
sort_order: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface TicketWorkflowStep {
|
||||
id: number;
|
||||
workflow_id: number;
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
name: string;
|
||||
config: Record<string, any>;
|
||||
on_failure: 'continue' | 'stop' | 'skip_to';
|
||||
skip_to_step: number | null;
|
||||
is_active: boolean;
|
||||
condition: StepCondition | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface TicketWorkflowExecution {
|
||||
id: number;
|
||||
workflow_id: number;
|
||||
ticket_id: number;
|
||||
ticket_number: string | null;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
classification_method: 'robotic' | 'ai' | 'hybrid' | null;
|
||||
branch: 'service_desk' | 'noc' | 'soc' | null;
|
||||
context: Record<string, any>;
|
||||
field_changes: Record<string, { before: any; after: any }> | null;
|
||||
started_at: Date;
|
||||
completed_at: Date | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface TicketWorkflowExecutionStep {
|
||||
id: number;
|
||||
execution_id: number;
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
step_name: string | null;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
input_data: Record<string, any> | null;
|
||||
output_data: Record<string, any> | null;
|
||||
started_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Workflow Execution Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TriggerCondition {
|
||||
field: string;
|
||||
operator: 'equals' | 'not_equals' | 'in' | 'not_in' | 'contains' | 'not_contains' | 'gt' | 'lt';
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface StepCondition {
|
||||
field: string;
|
||||
operator: 'equals' | 'not_equals' | 'in' | 'not_in' | 'contains' | 'not_contains' | 'gt' | 'lt' | 'is_null' | 'is_not_null';
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface WorkflowStepContext {
|
||||
// Original ticket data
|
||||
ticket: TicketData;
|
||||
|
||||
// Workflow settings (from workflow_settings table)
|
||||
_settings: WorkflowSettings;
|
||||
|
||||
// Accumulated classification results from classify steps
|
||||
classification?: Record<string, any>;
|
||||
|
||||
// Validation result from validate step
|
||||
validation?: {
|
||||
is_valid: boolean;
|
||||
errors: Array<{ field: string; message: string; value: any }>;
|
||||
};
|
||||
|
||||
// Field changes accumulated from all steps (will be written to Autotask)
|
||||
field_changes?: Record<string, { before: any; after: any }>;
|
||||
|
||||
// Any other data accumulated by steps
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface WorkflowStepResult {
|
||||
success: boolean;
|
||||
output?: Record<string, any>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TicketWorkflowWithSteps extends TicketWorkflow {
|
||||
steps: TicketWorkflowStep[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Step Executor Function Type
|
||||
// ============================================================================
|
||||
|
||||
export type WorkflowStepExecutorFn = (
|
||||
step: TicketWorkflowStep,
|
||||
context: WorkflowStepContext,
|
||||
executionId: number
|
||||
) => Promise<WorkflowStepResult>;
|
||||
|
|
@ -109,8 +109,10 @@ export interface AutotaskWebhookPayload {
|
|||
*/
|
||||
const ENTITY_TYPE_MAP: Record<string, WebhookEntityType> = {
|
||||
'Company': WebhookEntityType.COMPANIES,
|
||||
'Account': WebhookEntityType.COMPANIES, // Autotask legacy name
|
||||
'Contact': WebhookEntityType.CONTACTS,
|
||||
'ConfigurationItem': WebhookEntityType.CONFIGURATION_ITEMS,
|
||||
'InstalledProduct': WebhookEntityType.CONFIGURATION_ITEMS, // Autotask actual payload name
|
||||
'Ticket': WebhookEntityType.TICKETS,
|
||||
'TicketNote': WebhookEntityType.TICKET_NOTES,
|
||||
};
|
||||
|
|
|
|||
81
lib/types/zabbix.ts
Normal file
81
lib/types/zabbix.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Zabbix API Types
|
||||
|
||||
export interface ZabbixConfig {
|
||||
apiUrl: string;
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
export interface ZabbixHost {
|
||||
hostid: string;
|
||||
host: string;
|
||||
name: string;
|
||||
status: string;
|
||||
interfaces?: ZabbixHostInterface[];
|
||||
groups?: ZabbixHostGroup[];
|
||||
templates?: ZabbixTemplate[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ZabbixHostInterface {
|
||||
type: number; // 1 = agent, 2 = SNMP, 3 = IPMI, 4 = JMX
|
||||
main: number; // 1 = default
|
||||
useip: number; // 1 = use IP, 0 = use DNS
|
||||
ip: string;
|
||||
dns: string;
|
||||
port: string;
|
||||
}
|
||||
|
||||
export interface ZabbixHostGroup {
|
||||
groupid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ZabbixTemplate {
|
||||
templateid: string;
|
||||
host: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ZabbixHostMacro {
|
||||
macro: string; // e.g. "{$AUTOTASK_COMPANY_ID}"
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ZabbixHostTag {
|
||||
tag: string; // e.g. "client"
|
||||
value: string; // e.g. "TK Plastics"
|
||||
}
|
||||
|
||||
export interface ZabbixHostCreateParams {
|
||||
host: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
interfaces: ZabbixHostInterface[];
|
||||
groups: Array<{ groupid: string }>;
|
||||
templates?: Array<{ templateid: string }>;
|
||||
macros?: ZabbixHostMacro[];
|
||||
tags?: ZabbixHostTag[];
|
||||
}
|
||||
|
||||
export interface ZabbixHostUpdateParams {
|
||||
hostid: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
interfaces?: ZabbixHostInterface[];
|
||||
groups?: Array<{ groupid: string }>;
|
||||
templates?: Array<{ templateid: string }>;
|
||||
macros?: ZabbixHostMacro[];
|
||||
tags?: ZabbixHostTag[];
|
||||
}
|
||||
|
||||
export interface ZabbixRpcResponse<T> {
|
||||
jsonrpc: string;
|
||||
result?: T;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: string;
|
||||
};
|
||||
id: number;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue