- Add admin dashboard with sync controls and data browser - Implement RMM, Auvik, and Addigy organization mappings - Add chunked ticket sync with progress tracking - Implement entity sync service with rate limiting - Add analytics engine and performance optimizer - Create data browser for all PSA entities - Add navigation components and UI improvements - Implement background processing and sync services - Add comprehensive documentation and migration scripts - Update configuration items with multi-system support - Enhance contact management and purchase history - Add issue type assignment and LLM analyzer - Improve error handling and logging utilities
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
import {
|
|
AuvikClientConfig,
|
|
AuvikDevice,
|
|
AuvikDeviceResponse,
|
|
AuvikTenant,
|
|
AuvikTenantResponse,
|
|
} from '../types/auvik';
|
|
|
|
export class AuvikClient {
|
|
private config: AuvikClientConfig;
|
|
private requestCount: number = 0;
|
|
private requestTimestamps: number[] = [];
|
|
private readonly RATE_LIMIT = 1000; // requests per hour
|
|
private readonly RATE_LIMIT_WINDOW = 3600000; // 1 hour in milliseconds
|
|
|
|
constructor(config: AuvikClientConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Get Basic Authentication headers
|
|
*/
|
|
private getAuthHeaders(): HeadersInit {
|
|
const credentials = Buffer.from(
|
|
`${this.config.apiUser}:${this.config.apiKey}`
|
|
).toString('base64');
|
|
|
|
return {
|
|
Authorization: `Basic ${credentials}`,
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check and enforce rate limiting
|
|
*/
|
|
private checkRateLimit(): void {
|
|
const now = Date.now();
|
|
// Remove timestamps older than 1 hour
|
|
this.requestTimestamps = this.requestTimestamps.filter(
|
|
(timestamp) => now - timestamp < this.RATE_LIMIT_WINDOW
|
|
);
|
|
|
|
if (this.requestTimestamps.length >= this.RATE_LIMIT) {
|
|
console.warn(
|
|
`Auvik API rate limit approaching: ${this.requestTimestamps.length}/${this.RATE_LIMIT} requests in the last hour`
|
|
);
|
|
}
|
|
|
|
this.requestTimestamps.push(now);
|
|
this.requestCount++;
|
|
}
|
|
|
|
/**
|
|
* Make a generic API call with error handling
|
|
*/
|
|
private async makeApiCall<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|
this.checkRateLimit();
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
...this.getAuthHeaders(),
|
|
...options.headers,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(
|
|
`Auvik API error: ${response.status} ${response.statusText}`,
|
|
errorText
|
|
);
|
|
throw new Error(
|
|
`Auvik API request failed: ${response.status} ${response.statusText}`
|
|
);
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Auvik API call failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all tenants
|
|
*/
|
|
async getTenants(): Promise<AuvikTenant[]> {
|
|
try {
|
|
const url = `${this.config.apiUrl}/v1/tenants`;
|
|
console.log('Fetching Auvik tenants from:', url);
|
|
|
|
const response = await this.makeApiCall<AuvikTenantResponse>(url);
|
|
|
|
const tenants = response.data.map((item) => ({
|
|
id: item.id,
|
|
domainPrefix: item.attributes.domainPrefix,
|
|
tenantType: item.attributes.tenantType as 'multiClient' | 'client',
|
|
parentId: item.relationships?.parent?.data?.id,
|
|
}));
|
|
|
|
console.log(`Fetched ${tenants.length} Auvik tenants`);
|
|
return tenants;
|
|
} catch (error) {
|
|
console.error('Failed to fetch Auvik tenants:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all devices (requires tenant filtering)
|
|
*/
|
|
async getAllDevices(): Promise<AuvikDevice[]> {
|
|
try {
|
|
// Fetch all tenants first
|
|
const tenants = await this.getTenants();
|
|
if (tenants.length === 0) {
|
|
console.warn('No Auvik tenants found');
|
|
return [];
|
|
}
|
|
|
|
// Fetch devices for all tenants
|
|
const allDevices: AuvikDevice[] = [];
|
|
for (const tenant of tenants) {
|
|
const devices = await this.getDevicesByTenant(tenant.id);
|
|
allDevices.push(...devices);
|
|
}
|
|
|
|
console.log(`Fetched total of ${allDevices.length} Auvik devices across all tenants`);
|
|
return allDevices;
|
|
} catch (error) {
|
|
console.error('Failed to fetch all Auvik devices:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get devices filtered by tenant ID
|
|
*/
|
|
async getDevicesByTenant(tenantId: string): Promise<AuvikDevice[]> {
|
|
try {
|
|
const allDevices: AuvikDevice[] = [];
|
|
let nextUrl: string | null = `${this.config.apiUrl}/v1/inventory/device/info?tenants=${tenantId}&page[first]=100`;
|
|
|
|
console.log(`Fetching Auvik devices for tenant ${tenantId}`);
|
|
|
|
// Paginate through all results
|
|
while (nextUrl) {
|
|
const response: AuvikDeviceResponse = await this.makeApiCall<AuvikDeviceResponse>(nextUrl);
|
|
|
|
const devices = response.data.map((item) => this.transformDevice(item, tenantId));
|
|
allDevices.push(...devices);
|
|
|
|
// Check if there's a next page
|
|
nextUrl = response.links?.next || null;
|
|
|
|
if (nextUrl) {
|
|
console.log(`Fetching next page for tenant ${tenantId} (${allDevices.length} devices so far)`);
|
|
}
|
|
}
|
|
|
|
console.log(`Fetched total of ${allDevices.length} devices for tenant ${tenantId}`);
|
|
return allDevices;
|
|
} catch (error) {
|
|
console.error(`Failed to fetch devices for tenant ${tenantId}:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transform Auvik API device response to AuvikDevice interface
|
|
*/
|
|
private transformDevice(item: AuvikDeviceResponse['data'][0], tenantId: string): AuvikDevice {
|
|
return {
|
|
id: item.id,
|
|
deviceName: item.attributes.deviceName,
|
|
serialNumber: item.attributes.serialNumber,
|
|
macAddresses: [], // MAC addresses would need to be fetched from device details
|
|
ipAddresses: item.attributes.ipAddresses || [],
|
|
deviceType: item.attributes.deviceType,
|
|
manufacturer: item.attributes.vendorName,
|
|
model: item.attributes.makeModel,
|
|
makeModel: item.attributes.makeModel,
|
|
vendorName: item.attributes.vendorName,
|
|
firmwareVersion: item.attributes.firmwareVersion,
|
|
softwareVersion: item.attributes.softwareVersion,
|
|
onlineStatus: this.normalizeOnlineStatus(item.attributes.onlineStatus),
|
|
lastSeenTime: item.attributes.lastSeenTime,
|
|
uptime: undefined, // Would need to be calculated from lastSeenTime
|
|
tenantId: tenantId,
|
|
tenantName: item.relationships?.tenant?.data?.attributes?.domainPrefix,
|
|
description: item.attributes.description,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize online status to expected values
|
|
*/
|
|
private normalizeOnlineStatus(status: string): 'online' | 'offline' | 'unknown' {
|
|
const normalized = status.toLowerCase();
|
|
if (normalized === 'online') return 'online';
|
|
if (normalized === 'offline') return 'offline';
|
|
return 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Find tenant by company ID using database mapping
|
|
*/
|
|
async findTenantByCompanyId(companyId: number): Promise<AuvikTenant | null> {
|
|
try {
|
|
// Query database directly for mapping
|
|
const { Pool } = require('pg');
|
|
const pool = new Pool({
|
|
host: process.env.POSTGRES_HOST,
|
|
port: parseInt(process.env.POSTGRES_PORT || '5432'),
|
|
database: process.env.POSTGRES_DB,
|
|
user: process.env.POSTGRES_USER,
|
|
password: process.env.POSTGRES_PASSWORD,
|
|
});
|
|
|
|
const result = await pool.query(
|
|
'SELECT auvik_tenant_id FROM auvik_tenant_mappings WHERE autotask_company_id = $1',
|
|
[companyId]
|
|
);
|
|
|
|
await pool.end();
|
|
|
|
if (result.rows.length > 0) {
|
|
const auvikTenantId = result.rows[0].auvik_tenant_id;
|
|
const tenants = await this.getTenants();
|
|
const tenant = tenants.find(t => t.id === auvikTenantId);
|
|
|
|
if (tenant) {
|
|
console.log(`Found tenant via mapping: ${tenant.domainPrefix} for company ID: ${companyId}`);
|
|
return tenant;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Failed to find tenant by company ID:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize company name for matching by removing common suffixes and special characters
|
|
*/
|
|
private normalizeCompanyName(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.trim()
|
|
// Remove common legal suffixes
|
|
.replace(/,?\s*(inc\.?|llc\.?|ltd\.?|corp\.?|corporation|company|co\.?|limited|l\.?l\.?c\.?|incorporated)$/i, '')
|
|
// Remove commas and other punctuation
|
|
.replace(/[,\.]/g, '')
|
|
// Replace multiple spaces with single space
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Find tenant by name (case-insensitive, fuzzy match)
|
|
* This is a fallback when no mapping exists
|
|
*/
|
|
async findTenantByName(companyName: string): Promise<AuvikTenant | null> {
|
|
try {
|
|
const tenants = await this.getTenants();
|
|
const normalizedCompanyName = this.normalizeCompanyName(companyName);
|
|
|
|
console.log(`Searching for Auvik tenant matching company: "${companyName}"`);
|
|
console.log(`Normalized company name: "${normalizedCompanyName}"`);
|
|
console.log(`Available tenants: ${tenants.map(t => t.domainPrefix).join(', ')}`);
|
|
|
|
// Try exact match first (normalized)
|
|
let match = tenants.find(
|
|
(t) => this.normalizeCompanyName(t.domainPrefix) === normalizedCompanyName
|
|
);
|
|
|
|
if (match) {
|
|
console.log(`Found exact tenant match: ${match.domainPrefix} for company: ${companyName}`);
|
|
return match;
|
|
}
|
|
|
|
// Try exact match on original (case-insensitive)
|
|
match = tenants.find(
|
|
(t) => t.domainPrefix.toLowerCase() === companyName.toLowerCase().trim()
|
|
);
|
|
|
|
if (match) {
|
|
console.log(`Found exact tenant match (original): ${match.domainPrefix} for company: ${companyName}`);
|
|
return match;
|
|
}
|
|
|
|
// Try fuzzy match (contains) with normalized names
|
|
// Find all potential matches and pick the best one (longest match)
|
|
const potentialMatches = tenants.filter((t) => {
|
|
const normalizedTenant = this.normalizeCompanyName(t.domainPrefix);
|
|
return normalizedTenant.includes(normalizedCompanyName) ||
|
|
normalizedCompanyName.includes(normalizedTenant);
|
|
});
|
|
|
|
if (potentialMatches.length > 0) {
|
|
// Sort by length of normalized tenant name (descending) to prefer more specific matches
|
|
match = potentialMatches.sort((a, b) => {
|
|
const aNorm = this.normalizeCompanyName(a.domainPrefix);
|
|
const bNorm = this.normalizeCompanyName(b.domainPrefix);
|
|
return bNorm.length - aNorm.length;
|
|
})[0];
|
|
|
|
console.log(`Found fuzzy tenant match: ${match.domainPrefix} for company: ${companyName}`);
|
|
if (potentialMatches.length > 1) {
|
|
console.log(`Other potential matches: ${potentialMatches.slice(1).map(t => t.domainPrefix).join(', ')}`);
|
|
}
|
|
return match;
|
|
}
|
|
|
|
console.log(`No tenant match found for company: ${companyName}`);
|
|
console.log(`Tried to match "${normalizedCompanyName}" against: ${tenants.map(t => this.normalizeCompanyName(t.domainPrefix)).join(', ')}`);
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Failed to find tenant by name:', error);
|
|
return null;
|
|
}
|
|
}
|
|
}
|