wulf-pulse/autotask-app/lib/services/addigy-client.ts
Lorentz Hinrichsen f429f3af54 Add Addigy API integration and Docker deployment with Redis caching
- Implemented complete Addigy API v2 client with authentication via x-api-key
- Added device and policy endpoints with automatic org ID resolution
- Created field mapping from snake_case to Title Case for UI compatibility
- Handles nested 'facts' response structure from Addigy devices API
- Added comprehensive API documentation in ADDIGY_API_GUIDE.md

- Multi-stage Dockerfile with optimized production build
- Custom ports: App on 3100, Redis on 6380 (avoids conflicts)
- Docker Compose orchestration with health checks
- Standalone Next.js output for smaller container images
- Non-root user execution for security

- Implemented Redis caching layer for API responses
- 5-minute TTL with graceful fallback if Redis unavailable
- Cache key structure: service:entity:filter1:filter2
- Applied to Addigy devices endpoint with cache hit/miss logging

- Fixed TypeScript strict mode errors for production builds
- Added null safety checks with optional chaining throughout API routes
- Wrapped useSearchParams in Suspense boundary for Next.js 15+ compatibility
- Fixed type assertions for dynamic API responses
- Corrected Set<string> type mismatches in device comparison logic

- Created DOCKER_README.md with complete deployment guide
- Updated ADDIGY_API_GUIDE.md with real-world API patterns
- Documented response structures, field mappings, and troubleshooting

- Next.js 16.0.0 with Turbopack
- Redis 7 with AOF persistence
- Podman/Docker compatible
- TypeScript strict mode compliant
2025-10-28 22:49:08 -04:00

558 lines
16 KiB
TypeScript

import {
AddigyConfig,
AddigyDevice,
AddigyPolicy,
AddigyOrganization,
AddigyDeviceWithApps,
AddigyApplication,
AddigyAlert,
AddigyMaintenanceItem,
AddigyMonitoringItem,
AddigyCustomFact,
AddigySoftwareItem,
AddigyVariable,
AddigyApiResponse,
AddigyApiError,
QueryParams,
} from '@/lib/types/addigy';
export class AddigyClient {
private config: AddigyConfig;
private rateLimiter: RateLimiter;
constructor(config: AddigyConfig) {
this.config = config;
// Addigy rate limit: 1000 requests per 10 seconds = 100 requests per second
this.rateLimiter = new RateLimiter(100);
}
private getAuthHeaders(): Record<string, string> {
return {
'x-api-key': this.config.apiToken,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
}
private async makeApiCall<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
await this.rateLimiter.throttle();
const url = `${this.config.apiUrl}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers: {
...this.getAuthHeaders(),
...options.headers,
},
});
const responseText = await response.text();
if (!response.ok) {
console.error(`Addigy API error: ${response.status} - ${responseText}`);
let errorMessage = `API Error: ${response.statusText}`;
try {
const errorData: AddigyApiError = JSON.parse(responseText);
errorMessage = errorData.message || errorData.error || errorMessage;
} catch (parseError) {
errorMessage = responseText || errorMessage;
}
throw new Error(errorMessage);
}
try {
return JSON.parse(responseText);
} catch (parseError) {
console.error('Failed to parse API response:', parseError);
throw new Error('Invalid JSON response from Addigy API');
}
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}
private buildQueryString(params: QueryParams): string {
const queryParts: string[] = [];
if (params.page !== undefined) {
queryParts.push(`page=${params.page}`);
}
if (params.limit !== undefined) {
queryParts.push(`limit=${params.limit}`);
}
if (params.filters && params.filters.length > 0) {
const filtersJson = JSON.stringify({ filters: params.filters });
queryParts.push(`filters=${encodeURIComponent(filtersJson)}`);
}
return queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
}
// Device Methods
async getAllDevices(params: QueryParams = {}): Promise<AddigyDevice[]> {
// Get organization ID - either from config or fetch from policies
let orgId = this.config.organizationId;
if (!orgId) {
// Fetch first policy to get orgid
const policies = await this.getAllPolicies({ limit: 1 });
if (policies.length > 0) {
orgId = policies[0].orgid;
console.log('Using orgid from first policy:', orgId);
} else {
throw new Error('No organization ID configured and no policies found');
}
}
// Addigy v2 /o/{orgid}/devices requires POST with JSON body
const body: any = {
page: params.page || 1,
per_page: params.limit || 500,
};
// Add filters if provided
if (params.filters && params.filters.length > 0) {
body.query = {
filters: params.filters,
};
}
console.log('Addigy devices request body:', JSON.stringify(body));
console.log('Fetching devices from:', `${this.config.apiUrl}/o/${orgId}/devices`);
const response = await this.makeApiCall<{ items?: any[]; data?: any[]; records?: any[] }>(
`/o/${orgId}/devices`,
{
method: 'POST',
body: JSON.stringify(body),
}
);
console.log('Addigy response keys:', Object.keys(response));
console.log('Addigy raw items count:', response.items?.length || 0);
// Transform Addigy's nested facts structure to flat device objects
const rawItems = response.items || response.data || response.records || [];
// Field name mapping from Addigy to UI-expected format
const fieldMapping: Record<string, string> = {
'device_name': 'Device Name',
'device_model_name': 'Device Model Name',
'serial_number': 'Serial Number',
'mac_os_x_version': 'MAC OS X Version',
'ios_version': 'iOS Version',
'current_user': 'Current User',
'free_disk_percentage': 'Free Disk Percentage',
'battery_percentage': 'Battery Percentage',
'firewall_enabled': 'Firewall Enabled',
'filevault_enabled': 'FileVault Enabled',
'agent_version': 'Agent Version',
};
const devices: AddigyDevice[] = rawItems.map((item: any) => {
const device: any = {};
// Extract values from nested facts structure
if (item.facts) {
for (const [key, factObj] of Object.entries(item.facts)) {
const fact = factObj as any;
if (fact && typeof fact === 'object' && 'value' in fact) {
// Use mapped field name if available, otherwise use original
const mappedKey = fieldMapping[key] || key;
device[mappedKey] = fact.value;
}
}
}
return device as AddigyDevice;
});
console.log('Transformed devices count:', devices.length);
if (devices.length > 0) {
console.log('First device keys:', Object.keys(devices[0]).slice(0, 10));
}
return devices;
}
async getDeviceById(deviceId: string): Promise<AddigyDevice | null> {
try {
const response = await this.makeApiCall<AddigyApiResponse<AddigyDevice>>(
`/devices/${deviceId}`,
{ method: 'GET' }
);
return response.data || null;
} catch (error) {
console.error(`Failed to get device ${deviceId}:`, error);
return null;
}
}
async getDevicesByPolicy(policyId: string): Promise<AddigyDevice[]> {
return this.getAllDevices({
filters: [
{
audit_field: 'policy_id',
type: 'string',
operation: 'equals',
value: policyId,
},
],
});
}
async getOnlineDevices(): Promise<AddigyDevice[]> {
return this.getAllDevices({
filters: [
{
audit_field: 'online',
type: 'boolean',
operation: 'equals',
value: true,
},
],
});
}
async getDeviceApplications(deviceId: string): Promise<AddigyApplication[]> {
try {
const response = await this.makeApiCall<{ items?: AddigyApplication[]; data?: AddigyApplication[] }>(
`/devices/${deviceId}/applications`,
{ method: 'GET' }
);
return response.items || response.data || [];
} catch (error) {
console.error(`Failed to get applications for device ${deviceId}:`, error);
return [];
}
}
// Policy Methods
async getAllPolicies(params: QueryParams = {}): Promise<AddigyPolicy[]> {
const body: any = {
page: params.page || 1,
per_page: params.limit || 100,
};
console.log('Fetching policies from:', `${this.config.apiUrl}/oa/policies/query`);
// Addigy policies endpoint returns array directly, not wrapped in items/data
const response = await this.makeApiCall<AddigyPolicy[]>(
`/oa/policies/query`,
{
method: 'POST',
body: JSON.stringify(body),
}
);
console.log('Policies response type:', Array.isArray(response) ? 'array' : typeof response);
console.log('Policies count:', Array.isArray(response) ? response.length : 0);
// Response is already an array
return Array.isArray(response) ? response : [];
}
async getPolicyById(policyId: string): Promise<AddigyPolicy | null> {
try {
const response = await this.makeApiCall<AddigyApiResponse<AddigyPolicy>>(
`/policies/${policyId}`,
{ method: 'GET' }
);
return response.data || null;
} catch (error) {
console.error(`Failed to get policy ${policyId}:`, error);
return null;
}
}
async createPolicy(policy: Partial<AddigyPolicy>): Promise<AddigyPolicy> {
const response = await this.makeApiCall<AddigyApiResponse<AddigyPolicy>>(
'/policies',
{
method: 'POST',
body: JSON.stringify(policy),
}
);
if (!response.data) {
throw new Error('Failed to create policy');
}
return response.data;
}
async updatePolicy(
policyId: string,
updates: Partial<AddigyPolicy>
): Promise<AddigyPolicy> {
const response = await this.makeApiCall<AddigyApiResponse<AddigyPolicy>>(
`/policies/${policyId}`,
{
method: 'PATCH',
body: JSON.stringify(updates),
}
);
if (!response.data) {
throw new Error('Failed to update policy');
}
return response.data;
}
// Organization Methods
async getOrganizations(params: QueryParams = {}): Promise<AddigyOrganization[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigyOrganization[]; data?: AddigyOrganization[] }>(
`/organizations${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
async getOrganizationById(orgId: string): Promise<AddigyOrganization | null> {
try {
const response = await this.makeApiCall<AddigyApiResponse<AddigyOrganization>>(
`/organizations/${orgId}`,
{ method: 'GET' }
);
return response.data || null;
} catch (error) {
console.error(`Failed to get organization ${orgId}:`, error);
return null;
}
}
// Alert Methods
async getAlerts(params: QueryParams = {}): Promise<AddigyAlert[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigyAlert[]; data?: AddigyAlert[] }>(
`/alerts${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
async getActiveAlerts(): Promise<AddigyAlert[]> {
return this.getAlerts({
filters: [
{
audit_field: 'resolved',
type: 'boolean',
operation: 'equals',
value: false,
},
],
});
}
async resolveAlert(alertId: string): Promise<void> {
await this.makeApiCall(`/alerts/${alertId}/resolve`, {
method: 'POST',
});
}
// Maintenance Methods
async getMaintenanceItems(params: QueryParams = {}): Promise<AddigyMaintenanceItem[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigyMaintenanceItem[]; data?: AddigyMaintenanceItem[] }>(
`/maintenance${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
// Monitoring Methods
async getMonitoringItems(params: QueryParams = {}): Promise<AddigyMonitoringItem[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigyMonitoringItem[]; data?: AddigyMonitoringItem[] }>(
`/monitoring${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
// Custom Facts / Variables
async getCustomFacts(deviceId: string): Promise<AddigyCustomFact[]> {
try {
const response = await this.makeApiCall<{ items?: AddigyCustomFact[]; data?: AddigyCustomFact[] }>(
`/devices/${deviceId}/facts`,
{ method: 'GET' }
);
return response.items || response.data || [];
} catch (error) {
console.error(`Failed to get custom facts for device ${deviceId}:`, error);
return [];
}
}
async createCustomFact(
deviceId: string,
fact: Partial<AddigyCustomFact>
): Promise<AddigyCustomFact> {
const response = await this.makeApiCall<AddigyApiResponse<AddigyCustomFact>>(
`/devices/${deviceId}/facts`,
{
method: 'POST',
body: JSON.stringify(fact),
}
);
if (!response.data) {
throw new Error('Failed to create custom fact');
}
return response.data;
}
async getVariables(params: QueryParams = {}): Promise<AddigyVariable[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigyVariable[]; data?: AddigyVariable[] }>(
`/variables${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
// Software Methods
async getSoftwareItems(params: QueryParams = {}): Promise<AddigySoftwareItem[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigySoftwareItem[]; data?: AddigySoftwareItem[] }>(
`/software${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
async getSoftwareVersions(softwareIdentifier: string): Promise<string[]> {
try {
const response = await this.makeApiCall<AddigyApiResponse<{ versions: string[] }>>(
`/software/${softwareIdentifier}/versions`,
{ method: 'GET' }
);
return response.data?.versions || [];
} catch (error) {
console.error(`Failed to get versions for software ${softwareIdentifier}:`, error);
return [];
}
}
// Device Actions
async assignDeviceToPolicy(deviceId: string, policyId: string): Promise<void> {
await this.makeApiCall(`/devices/${deviceId}/policy`, {
method: 'PATCH',
body: JSON.stringify({ policy_id: policyId }),
});
}
async removeDevice(deviceId: string): Promise<void> {
await this.makeApiCall(`/devices/${deviceId}`, {
method: 'DELETE',
});
}
// Utility Methods
async testConnection(): Promise<boolean> {
try {
await this.getAllPolicies({ limit: 1 });
return true;
} catch (error) {
console.error('Addigy connection test failed:', error);
return false;
}
}
// Paginated query to handle large datasets
async getAllDevicesPaginated(pageSize: number = 100): Promise<AddigyDevice[]> {
const allDevices: AddigyDevice[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
console.log(`Fetching devices page ${page} (max ${pageSize} records)...`);
const body = {
page,
per_page: pageSize,
};
const response = await this.makeApiCall<{ items?: AddigyDevice[]; data?: AddigyDevice[]; pages?: number }>(
`/devices`,
{
method: 'POST',
body: JSON.stringify(body),
}
);
const devices = response.items || response.data || [];
allDevices.push(...devices);
console.log(`Fetched ${devices.length} devices, total so far: ${allDevices.length}`);
// Check if we have more pages
hasMore = devices.length === pageSize && (!response.pages || page < response.pages);
page++;
// Safety limit to prevent infinite loops
if (page > 100) {
console.warn('Reached page limit (100) for devices');
break;
}
}
console.log(`Total devices fetched: ${allDevices.length}`);
return allDevices;
}
}
// Rate Limiter class
class RateLimiter {
private maxRequestsPerSecond: number;
private requestTimes: number[];
constructor(maxRequestsPerSecond = 100) {
this.maxRequestsPerSecond = maxRequestsPerSecond;
this.requestTimes = [];
}
async throttle(): Promise<void> {
const now = Date.now();
const oneSecondAgo = now - 1000;
// Remove old request times
this.requestTimes = this.requestTimes.filter((t) => t > oneSecondAgo);
// If at limit, wait
if (this.requestTimes.length >= this.maxRequestsPerSecond) {
const oldestRequest = this.requestTimes[0];
const waitTime = 1000 - (now - oldestRequest);
if (waitTime > 0) {
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
}
this.requestTimes.push(Date.now());
}
}