- Renamed project from PSA-Utils to Pulse - Moved all app files from autotask-app/ to root - Updated package.json name to 'pulse' - Updated Docker container names to pulse-app and pulse-redis - Updated Docker network name to pulse-network
558 lines
16 KiB
TypeScript
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());
|
|
}
|
|
}
|