wulf-pulse/lib/services/auvik-client.ts
root 6eee14f8af Add comprehensive admin features and multi-system integration
- 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
2025-11-19 14:18:16 -05:00

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;
}
}
}