Restructure: rename to Pulse and move app to root
- 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
This commit is contained in:
parent
f429f3af54
commit
3c3124d8c9
117 changed files with 8433 additions and 239 deletions
62
lib/hooks/use-api.ts
Normal file
62
lib/hooks/use-api.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface UseApiOptions {
|
||||
autoFetch?: boolean;
|
||||
}
|
||||
|
||||
interface UseApiResult<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useApi<T>(
|
||||
url: string,
|
||||
options: UseApiOptions = { autoFetch: true }
|
||||
): UseApiResult<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to fetch data');
|
||||
}
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.autoFetch) {
|
||||
fetchData();
|
||||
}
|
||||
}, [fetchData, options.autoFetch]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
|
||||
export async function apiCall<T>(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'API call failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
558
lib/services/addigy-client.ts
Normal file
558
lib/services/addigy-client.ts
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
28
lib/services/addigy-factory.ts
Normal file
28
lib/services/addigy-factory.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { AddigyClient } from './addigy-client';
|
||||
import { AddigyConfig } from '@/lib/types/addigy';
|
||||
|
||||
let cachedAddigyClient: AddigyClient | null = null;
|
||||
|
||||
export function getAddigyClient(): AddigyClient {
|
||||
if (cachedAddigyClient) {
|
||||
return cachedAddigyClient;
|
||||
}
|
||||
|
||||
const config: AddigyConfig = {
|
||||
apiUrl: process.env.ADDIGY_API_URL || 'https://api.addigy.com/api/v2',
|
||||
apiToken: process.env.ADDIGY_API_TOKEN || '',
|
||||
organizationId: process.env.ADDIGY_ORG_ID,
|
||||
};
|
||||
|
||||
// Validate required config
|
||||
if (!config.apiToken) {
|
||||
throw new Error('ADDIGY_API_TOKEN environment variable is required');
|
||||
}
|
||||
|
||||
cachedAddigyClient = new AddigyClient(config);
|
||||
return cachedAddigyClient;
|
||||
}
|
||||
|
||||
export function clearAddigyClientCache(): void {
|
||||
cachedAddigyClient = null;
|
||||
}
|
||||
433
lib/services/autotask-client.ts
Normal file
433
lib/services/autotask-client.ts
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
import {
|
||||
AutotaskConfig,
|
||||
AutotaskHeaders,
|
||||
QueryParams,
|
||||
ApiResponse,
|
||||
ApiError,
|
||||
Resource,
|
||||
Ticket,
|
||||
Task,
|
||||
Company,
|
||||
ConfigurationItem,
|
||||
Attachment,
|
||||
EntityField,
|
||||
PicklistValue,
|
||||
} from '@/lib/types/autotask';
|
||||
|
||||
export class AutotaskClient {
|
||||
private config: AutotaskConfig;
|
||||
private rateLimiter: RateLimiter;
|
||||
|
||||
constructor(config: AutotaskConfig) {
|
||||
this.config = config;
|
||||
this.rateLimiter = new RateLimiter(10); // 10 requests per second
|
||||
}
|
||||
|
||||
private getAuthHeaders(impersonationResourceId?: number): Record<string, string> {
|
||||
// Using the direct header authentication method (not Basic auth)
|
||||
const headers: Record<string, string> = {
|
||||
'Username': this.config.username,
|
||||
'Secret': this.config.password,
|
||||
'APIIntegrationcode': this.config.apiIntegrationCode,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
if (impersonationResourceId) {
|
||||
headers.ImpersonationResourceID = impersonationResourceId.toString();
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async makeApiCall<T>(
|
||||
url: string,
|
||||
options: RequestInit
|
||||
): Promise<T> {
|
||||
await this.rateLimiter.throttle();
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Autotask API error: ${response.status} - ${responseText}`);
|
||||
|
||||
let errorMessage = `API Error: ${response.statusText}`;
|
||||
try {
|
||||
const errorData: ApiError = JSON.parse(responseText);
|
||||
if (errorData.errors && errorData.errors.length > 0) {
|
||||
errorMessage = errorData.errors.map((e) => e.message).join(', ');
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
}
|
||||
} 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 Autotask API');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API call failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildQueryString(params: QueryParams): string {
|
||||
if (!params.filter || params.filter.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const query = { filter: params.filter };
|
||||
return `?search=${encodeURIComponent(JSON.stringify(query))}`;
|
||||
}
|
||||
|
||||
async queryEntity<T>(
|
||||
entityName: string,
|
||||
params: QueryParams = {}
|
||||
): Promise<T[]> {
|
||||
const queryString = this.buildQueryString(params);
|
||||
const url = `${this.config.apiUrl}/${entityName}/query${queryString}`;
|
||||
|
||||
const response = await this.makeApiCall<ApiResponse<T>>(url, {
|
||||
method: 'GET',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
return response.items || [];
|
||||
}
|
||||
|
||||
// Paginated query to handle large datasets
|
||||
async queryEntityPaginated<T>(
|
||||
entityName: string,
|
||||
params: QueryParams = {},
|
||||
pageSize: number = 500
|
||||
): Promise<T[]> {
|
||||
const allItems: T[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const paginatedParams = {
|
||||
...params,
|
||||
MaxRecords: pageSize,
|
||||
// Autotask uses MaxRecords for page size
|
||||
};
|
||||
|
||||
console.log(`Fetching ${entityName} page ${page} (max ${pageSize} records)...`);
|
||||
const queryString = this.buildQueryString(paginatedParams);
|
||||
const url = `${this.config.apiUrl}/${entityName}/query${queryString}`;
|
||||
|
||||
const response = await this.makeApiCall<ApiResponse<T>>(url, {
|
||||
method: 'GET',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
const items = response.items || [];
|
||||
allItems.push(...items);
|
||||
|
||||
console.log(`Fetched ${items.length} ${entityName}, total so far: ${allItems.length}`);
|
||||
|
||||
// If we got fewer items than pageSize, we've reached the end
|
||||
hasMore = items.length === pageSize;
|
||||
page++;
|
||||
|
||||
// Safety limit to prevent infinite loops
|
||||
if (page > 100) {
|
||||
console.warn(`Reached page limit (100) for ${entityName}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Total ${entityName} fetched: ${allItems.length}`);
|
||||
return allItems;
|
||||
}
|
||||
|
||||
async getEntityById<T>(entityName: string, id: number): Promise<T | null> {
|
||||
const url = `${this.config.apiUrl}/${entityName}/${id}`;
|
||||
|
||||
const response = await this.makeApiCall<ApiResponse<T>>(url, {
|
||||
method: 'GET',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
return response.item || null;
|
||||
}
|
||||
|
||||
async createEntity<T>(entityName: string, data: Partial<T>): Promise<T> {
|
||||
const url = `${this.config.apiUrl}/${entityName}`;
|
||||
|
||||
const response = await this.makeApiCall<ApiResponse<T>>(url, {
|
||||
method: 'POST',
|
||||
headers: this.getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.item) {
|
||||
throw new Error('Failed to create entity');
|
||||
}
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
async updateEntity<T>(
|
||||
entityName: string,
|
||||
id: number,
|
||||
data: Partial<T>
|
||||
): Promise<T> {
|
||||
const url = `${this.config.apiUrl}/${entityName}/${id}`;
|
||||
|
||||
const response = await this.makeApiCall<ApiResponse<T>>(url, {
|
||||
method: 'PATCH',
|
||||
headers: this.getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.item) {
|
||||
throw new Error('Failed to update entity');
|
||||
}
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
// Resource-specific methods
|
||||
async getResourceByEmail(email: string): Promise<Resource | null> {
|
||||
const resources = await this.queryEntity<Resource>('Resources', {
|
||||
filter: [{ op: 'eq', field: 'email', value: email }],
|
||||
});
|
||||
|
||||
return resources.length > 0 ? resources[0] : null;
|
||||
}
|
||||
|
||||
async getAllResources(): Promise<Resource[]> {
|
||||
return this.queryEntity<Resource>('Resources', {
|
||||
filter: [{ op: 'eq', field: 'isActive', value: true }],
|
||||
});
|
||||
}
|
||||
|
||||
// Ticket-specific methods
|
||||
async getOpenTicketsByResource(resourceId: number): Promise<Ticket[]> {
|
||||
return this.queryEntity<Ticket>('Tickets', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'assignedResourceID', value: resourceId },
|
||||
{ op: 'noteq', field: 'status', value: 5 }, // Exclude completed
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async getTicketsByCompany(companyId: number): Promise<Ticket[]> {
|
||||
return this.queryEntity<Ticket>('Tickets', {
|
||||
filter: [{ op: 'eq', field: 'companyID', value: companyId }],
|
||||
});
|
||||
}
|
||||
|
||||
async createTicket(ticket: Partial<Ticket>): Promise<Ticket> {
|
||||
return this.createEntity<Ticket>('Tickets', ticket);
|
||||
}
|
||||
|
||||
async updateTicket(id: number, updates: Partial<Ticket>): Promise<Ticket> {
|
||||
return this.updateEntity<Ticket>('Tickets', id, updates);
|
||||
}
|
||||
|
||||
// Task-specific methods
|
||||
async getTasksByResource(resourceId: number): Promise<Task[]> {
|
||||
return this.queryEntity<Task>('Tasks', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'assignedResourceID', value: resourceId },
|
||||
{ op: 'noteq', field: 'status', value: 5 }, // Exclude completed
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async getTasksByProject(projectId: number): Promise<Task[]> {
|
||||
return this.queryEntity<Task>('Tasks', {
|
||||
filter: [{ op: 'eq', field: 'projectID', value: projectId }],
|
||||
});
|
||||
}
|
||||
|
||||
async createTask(task: Partial<Task>): Promise<Task> {
|
||||
return this.createEntity<Task>('Tasks', task);
|
||||
}
|
||||
|
||||
async updateTask(id: number, updates: Partial<Task>): Promise<Task> {
|
||||
return this.updateEntity<Task>('Tasks', id, updates);
|
||||
}
|
||||
|
||||
// Company-specific methods
|
||||
async getAllCompanies(): Promise<Company[]> {
|
||||
const companies = await this.queryEntity<Company>('Companies', {
|
||||
filter: [{ op: 'eq', field: 'isActive', value: true }],
|
||||
});
|
||||
|
||||
return companies.sort((a, b) =>
|
||||
a.companyName.localeCompare(b.companyName)
|
||||
);
|
||||
}
|
||||
|
||||
async getCompanyById(id: number): Promise<Company | null> {
|
||||
return this.getEntityById<Company>('Companies', id);
|
||||
}
|
||||
|
||||
// Configuration Item methods
|
||||
async getConfigurationItemsByCompany(companyId: number): Promise<ConfigurationItem[]> {
|
||||
return this.queryEntity<ConfigurationItem>('ConfigurationItems', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'companyID', value: companyId },
|
||||
{ op: 'eq', field: 'isActive', value: true }
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async getAllConfigurationItems(): Promise<ConfigurationItem[]> {
|
||||
return this.queryEntity<ConfigurationItem>('ConfigurationItems', {
|
||||
filter: [{ op: 'eq', field: 'isActive', value: true }],
|
||||
});
|
||||
}
|
||||
|
||||
async getConfigurationItemById(id: number): Promise<ConfigurationItem | null> {
|
||||
// Use query instead of direct GET as ConfigurationItems might not support direct ID access
|
||||
const items = await this.queryEntity<ConfigurationItem>('ConfigurationItems', {
|
||||
filter: [{ op: 'eq', field: 'id', value: id }],
|
||||
});
|
||||
|
||||
return items.length > 0 ? items[0] : null;
|
||||
}
|
||||
|
||||
async createConfigurationItem(item: Partial<ConfigurationItem>): Promise<ConfigurationItem> {
|
||||
return this.createEntity<ConfigurationItem>('ConfigurationItems', item);
|
||||
}
|
||||
|
||||
async updateConfigurationItem(id: number, updates: Partial<ConfigurationItem>): Promise<ConfigurationItem> {
|
||||
return this.updateEntity<ConfigurationItem>('ConfigurationItems', id, updates);
|
||||
}
|
||||
|
||||
// Picklist methods
|
||||
async getPicklistValues(
|
||||
entityName: string,
|
||||
fieldName: string
|
||||
): Promise<Record<string | number, string>> {
|
||||
const url = `${this.config.apiUrl}/${entityName}/entityInformation/fields`;
|
||||
|
||||
const response = await this.makeApiCall<{ fields: EntityField[] }>(url, {
|
||||
method: 'GET',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
const field = response.fields.find((f) => f.name === fieldName);
|
||||
|
||||
if (field && field.picklistValues) {
|
||||
const picklistMap: Record<string | number, string> = {};
|
||||
field.picklistValues.forEach((item) => {
|
||||
picklistMap[item.value] = item.label;
|
||||
});
|
||||
return picklistMap;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async getTicketStatusPicklist(): Promise<Record<number, string>> {
|
||||
return this.getPicklistValues('Tickets', 'status');
|
||||
}
|
||||
|
||||
async getTicketPriorityPicklist(): Promise<Record<number, string>> {
|
||||
return this.getPicklistValues('Tickets', 'priority');
|
||||
}
|
||||
|
||||
async getTaskStatusPicklist(): Promise<Record<number, string>> {
|
||||
return this.getPicklistValues('Tasks', 'status');
|
||||
}
|
||||
|
||||
// Attachment methods
|
||||
async uploadAttachment(
|
||||
entityName: string,
|
||||
entityId: number,
|
||||
fileBuffer: Buffer,
|
||||
fileName: string,
|
||||
impersonatorEmail?: string
|
||||
): Promise<Attachment> {
|
||||
let impersonationResourceId: number | undefined;
|
||||
if (impersonatorEmail) {
|
||||
const resource = await this.getResourceByEmail(impersonatorEmail);
|
||||
if (resource) {
|
||||
impersonationResourceId = resource.id;
|
||||
}
|
||||
}
|
||||
|
||||
const base64Data = fileBuffer.toString('base64');
|
||||
|
||||
const payload: Partial<Attachment> = {
|
||||
id: 0,
|
||||
attachmentType: 'FILE_ATTACHMENT',
|
||||
fullPath: fileName,
|
||||
title: fileName,
|
||||
publish: 1, // All Autotask Users
|
||||
data: base64Data,
|
||||
};
|
||||
|
||||
const url = `${this.config.apiUrl}/${entityName}/${entityId}/Attachments`;
|
||||
|
||||
const response = await this.makeApiCall<ApiResponse<Attachment>>(url, {
|
||||
method: 'POST',
|
||||
headers: this.getAuthHeaders(impersonationResourceId),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.item) {
|
||||
throw new Error('Failed to upload attachment');
|
||||
}
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
async getAttachments(
|
||||
entityName: string,
|
||||
entityId: number
|
||||
): Promise<Attachment[]> {
|
||||
const url = `${this.config.apiUrl}/${entityName}/${entityId}/Attachments`;
|
||||
|
||||
const response = await this.makeApiCall<ApiResponse<Attachment>>(url, {
|
||||
method: 'GET',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
return response.items || [];
|
||||
}
|
||||
}
|
||||
|
||||
// Rate Limiter class
|
||||
class RateLimiter {
|
||||
private maxRequestsPerSecond: number;
|
||||
private requestTimes: number[];
|
||||
|
||||
constructor(maxRequestsPerSecond = 10) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
26
lib/services/autotask-factory.ts
Normal file
26
lib/services/autotask-factory.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { AutotaskClient } from './autotask-client';
|
||||
import { AutotaskConfig } from '@/lib/types/autotask';
|
||||
|
||||
let clientInstance: AutotaskClient | null = null;
|
||||
|
||||
export function getAutotaskClient(): AutotaskClient {
|
||||
if (!clientInstance) {
|
||||
const config: AutotaskConfig = {
|
||||
apiUrl: process.env.AUTOTASK_API_URL || '',
|
||||
username: process.env.AUTOTASK_USERNAME || '',
|
||||
password: process.env.AUTOTASK_SECRET || '',
|
||||
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
};
|
||||
|
||||
// Validate configuration
|
||||
if (!config.apiUrl || !config.username || !config.password || !config.apiIntegrationCode) {
|
||||
throw new Error(
|
||||
'Missing Autotask API configuration. Please check your environment variables.'
|
||||
);
|
||||
}
|
||||
|
||||
clientInstance = new AutotaskClient(config);
|
||||
}
|
||||
|
||||
return clientInstance;
|
||||
}
|
||||
|
|
@ -54,9 +54,8 @@ class SimpleCache {
|
|||
// Global cache instance
|
||||
export const apiCache = new SimpleCache();
|
||||
|
||||
// Run cleanup every 5 minutes
|
||||
// Run cleanup every 5 minutes (server-side only)
|
||||
if (typeof window === 'undefined') {
|
||||
// Server-side only
|
||||
setInterval(() => {
|
||||
apiCache.cleanup();
|
||||
}, 5 * 60 * 1000);
|
||||
|
|
|
|||
133
lib/services/datto-rmm-client-simple.ts
Normal file
133
lib/services/datto-rmm-client-simple.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import {
|
||||
DattoRMMConfig,
|
||||
DattoRMMDevice,
|
||||
DattoRMMSite,
|
||||
DattoRMMApiResponse,
|
||||
DattoRMMError,
|
||||
} from '@/lib/types/datto-rmm';
|
||||
|
||||
export class DattoRMMClientSimple {
|
||||
private config: DattoRMMConfig;
|
||||
|
||||
constructor(config: DattoRMMConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API request using Basic authentication directly
|
||||
*/
|
||||
private async makeApiCall<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// Create Basic auth header using API key and secret
|
||||
const credentials = Buffer.from(`${this.config.apiKey}:${this.config.apiSecret}`).toString('base64');
|
||||
|
||||
// Use the base URL directly with v2 API
|
||||
const url = `https://concord-api.centrastage.net/api/v2${endpoint}`;
|
||||
|
||||
console.log(`Making API call to: ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `Basic ${credentials}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorMessage = `API Error: ${response.status}`;
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText) as DattoRMMError;
|
||||
errorMessage = `${errorData.error}: ${errorData.message}`;
|
||||
} catch {
|
||||
errorMessage += ` - ${errorText}`;
|
||||
}
|
||||
|
||||
console.error('API call failed:', errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!responseText) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(responseText) as T;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse response:', responseText);
|
||||
throw new Error('Invalid JSON response from API');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sites
|
||||
*/
|
||||
async getSites(): Promise<DattoRMMSite[]> {
|
||||
const response = await this.makeApiCall<DattoRMMApiResponse<DattoRMMSite>>(
|
||||
'/sites',
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
return response.items || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get site by name (for matching with Autotask company)
|
||||
*/
|
||||
async getSiteByName(name: string): Promise<DattoRMMSite | null> {
|
||||
const sites = await this.getSites();
|
||||
const site = sites.find(s =>
|
||||
s.name.toLowerCase() === name.toLowerCase() ||
|
||||
s.name.toLowerCase().includes(name.toLowerCase())
|
||||
);
|
||||
|
||||
return site || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all devices
|
||||
*/
|
||||
async getAllDevices(): Promise<DattoRMMDevice[]> {
|
||||
const response = await this.makeApiCall<DattoRMMApiResponse<DattoRMMDevice>>(
|
||||
'/devices',
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
return response.items || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get devices for a specific site
|
||||
*/
|
||||
async getDevicesBySite(siteId: string): Promise<DattoRMMDevice[]> {
|
||||
const response = await this.makeApiCall<DattoRMMApiResponse<DattoRMMDevice>>(
|
||||
`/sites/${siteId}/devices`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
return response.items || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get devices by site name (matches with company name)
|
||||
*/
|
||||
async getDevicesByCompanyName(companyName: string): Promise<DattoRMMDevice[]> {
|
||||
// First, find the site that matches the company name
|
||||
const site = await this.getSiteByName(companyName);
|
||||
|
||||
if (!site) {
|
||||
console.warn(`No RMM site found for company: ${companyName}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Then get devices for that site
|
||||
return this.getDevicesBySite(site.id);
|
||||
}
|
||||
}
|
||||
340
lib/services/datto-rmm-client.ts
Normal file
340
lib/services/datto-rmm-client.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import {
|
||||
DattoRMMConfig,
|
||||
DattoRMMDevice,
|
||||
DattoRMMSite,
|
||||
DattoRMMApiResponse,
|
||||
DattoRMMError,
|
||||
} from '@/lib/types/datto-rmm';
|
||||
|
||||
export class DattoRMMClient {
|
||||
private config: DattoRMMConfig;
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: Date | null = null;
|
||||
|
||||
constructor(config: DattoRMMConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth2 access token
|
||||
* Datto RMM API v2 uses OAuth2 with password grant type
|
||||
* The API Access Key is the username and API Secret Key is the password
|
||||
*/
|
||||
private async getAccessToken(): Promise<string> {
|
||||
// Check if we have a valid token
|
||||
if (this.accessToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
// OAuth2 token endpoint
|
||||
const authUrl = 'https://concord-api.centrastage.net/auth/oauth/token';
|
||||
|
||||
// According to Datto RMM API v2 docs:
|
||||
// - Basic auth with client_id: "public-client" and client_secret: "public"
|
||||
// - grant_type: "password"
|
||||
// - username: Your API Access Key
|
||||
// - password: Your API Secret Key
|
||||
const clientCredentials = Buffer.from('public-client:public').toString('base64');
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('grant_type', 'password');
|
||||
formData.append('username', this.config.apiKey);
|
||||
formData.append('password', this.config.apiSecret);
|
||||
|
||||
try {
|
||||
console.log('Authenticating with Datto RMM API v2...');
|
||||
const response = await fetch(authUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${clientCredentials}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('Datto RMM auth failed:', response.status, error);
|
||||
throw new Error(`Authentication failed: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse auth response. Response was:', responseText.substring(0, 500));
|
||||
throw new Error('Invalid response from auth server - expected JSON but got HTML/text');
|
||||
}
|
||||
|
||||
this.accessToken = data.access_token;
|
||||
|
||||
// Set token expiry (usually 1 hour, but we'll refresh after 50 minutes to be safe)
|
||||
this.tokenExpiry = new Date(Date.now() + 50 * 60 * 1000);
|
||||
|
||||
console.log('Successfully authenticated with Datto RMM');
|
||||
return this.accessToken as string;
|
||||
} catch (error) {
|
||||
console.error('Failed to get Datto RMM access token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated API request
|
||||
*/
|
||||
private async makeApiCall<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// Use the base URL directly with v2 API
|
||||
const url = `https://concord-api.centrastage.net/api/v2${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`API call failed: ${response.status} ${response.statusText}`);
|
||||
console.error('Error response:', errorText);
|
||||
|
||||
let errorMessage = `API Error: ${response.status}`;
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText) as DattoRMMError;
|
||||
errorMessage = `${errorData.error}: ${errorData.message}`;
|
||||
} catch {
|
||||
errorMessage += ` - ${errorText.substring(0, 200)}`;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!responseText) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(responseText) as T;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse response:', responseText);
|
||||
throw new Error('Invalid JSON response from API');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sites
|
||||
*/
|
||||
async getSites(): Promise<DattoRMMSite[]> {
|
||||
const response = await this.makeApiCall<any>(
|
||||
'/account/sites',
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
// The API returns sites in a 'sites' field
|
||||
return response.sites || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get site by name (for matching with Autotask company)
|
||||
*/
|
||||
async getSiteByName(name: string): Promise<DattoRMMSite | null> {
|
||||
const sites = await this.getSites();
|
||||
const site = sites.find(s =>
|
||||
s.name.toLowerCase() === name.toLowerCase() ||
|
||||
s.name.toLowerCase().includes(name.toLowerCase())
|
||||
);
|
||||
|
||||
return site || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all devices
|
||||
*/
|
||||
async getAllDevices(): Promise<DattoRMMDevice[]> {
|
||||
const response = await this.makeApiCall<any>(
|
||||
'/account/devices',
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
// The API returns devices in a 'devices' field
|
||||
return response.devices || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get devices for a specific site
|
||||
*/
|
||||
async getDevicesBySite(siteUid: string): Promise<DattoRMMDevice[]> {
|
||||
const response = await this.makeApiCall<any>(
|
||||
`/site/${siteUid}/devices`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
// The API returns devices in a 'devices' field
|
||||
return response.devices || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get devices by site name (matches with company name)
|
||||
*/
|
||||
async getDevicesByCompanyName(companyName: string): Promise<DattoRMMDevice[]> {
|
||||
// First, find the site that matches the company name
|
||||
const site = await this.getSiteByName(companyName);
|
||||
|
||||
if (!site) {
|
||||
console.warn(`No RMM site found for company: ${companyName}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Then get devices for that site using the UID
|
||||
return this.getDevicesBySite(site.uid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device by ID
|
||||
*/
|
||||
async getDeviceById(deviceId: string): Promise<DattoRMMDevice | null> {
|
||||
try {
|
||||
const response = await this.makeApiCall<{ item: DattoRMMDevice }>(
|
||||
`/devices/${deviceId}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
return response.item || null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get device ${deviceId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search devices by various criteria
|
||||
*/
|
||||
async searchDevices(criteria: {
|
||||
hostname?: string;
|
||||
serialNumber?: string;
|
||||
ipAddress?: string;
|
||||
macAddress?: string;
|
||||
}): Promise<DattoRMMDevice[]> {
|
||||
const allDevices = await this.getAllDevices();
|
||||
|
||||
return allDevices.filter(device => {
|
||||
if (criteria.hostname &&
|
||||
!device.hostname.toLowerCase().includes(criteria.hostname.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (criteria.serialNumber &&
|
||||
device.serialNumber !== criteria.serialNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (criteria.ipAddress &&
|
||||
device.intIpAddress !== criteria.ipAddress &&
|
||||
device.extIpAddress !== criteria.ipAddress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (criteria.macAddress &&
|
||||
!device.macAddresses?.some(mac =>
|
||||
mac.toLowerCase() === criteria.macAddress!.toLowerCase()
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device audit data with detailed hardware information
|
||||
*/
|
||||
async getDeviceAudit(deviceId: string | number): Promise<any> {
|
||||
try {
|
||||
const response = await this.makeApiCall<any>(
|
||||
`/device/${deviceId}/auditdata`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get audit for device ${deviceId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device with audit data
|
||||
*/
|
||||
async getDeviceWithAudit(deviceId: string | number): Promise<DattoRMMDevice | null> {
|
||||
try {
|
||||
// Get basic device info
|
||||
const device = await this.getDeviceById(deviceId.toString());
|
||||
if (!device) return null;
|
||||
|
||||
// Try to get audit data for more details
|
||||
const audit = await this.getDeviceAudit(deviceId);
|
||||
if (audit) {
|
||||
// Merge audit data into device
|
||||
if (audit.bios) {
|
||||
device.manufacturer = audit.bios.manufacturer || device.manufacturer;
|
||||
device.model = audit.bios.model || device.model;
|
||||
device.serialNumber = audit.bios.serialNumber || device.serialNumber;
|
||||
}
|
||||
|
||||
if (audit.system) {
|
||||
device.manufacturer = audit.system.manufacturer || device.manufacturer;
|
||||
device.model = audit.system.model || device.model;
|
||||
}
|
||||
|
||||
if (audit.processors && audit.processors.length > 0) {
|
||||
device.cpuName = audit.processors[0].name;
|
||||
device.cpuCores = audit.processors[0].cores;
|
||||
}
|
||||
|
||||
if (audit.memory) {
|
||||
device.memory = audit.memory.totalPhysicalMemory;
|
||||
}
|
||||
|
||||
if (audit.disks && audit.disks.length > 0) {
|
||||
// Sum up all disk sizes
|
||||
device.diskSize = audit.disks.reduce((total: number, disk: any) =>
|
||||
total + (disk.size || 0), 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return device;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get device with audit ${deviceId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device alerts
|
||||
*/
|
||||
async getDeviceAlerts(deviceId: string): Promise<any[]> {
|
||||
try {
|
||||
const response = await this.makeApiCall<DattoRMMApiResponse<any>>(
|
||||
`/devices/${deviceId}/alerts`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
return response.items || [];
|
||||
} catch (error) {
|
||||
console.error(`Failed to get alerts for device ${deviceId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
25
lib/services/datto-rmm-factory.ts
Normal file
25
lib/services/datto-rmm-factory.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { DattoRMMClient } from './datto-rmm-client';
|
||||
import { DattoRMMConfig } from '@/lib/types/datto-rmm';
|
||||
|
||||
let clientInstance: DattoRMMClient | null = null;
|
||||
|
||||
export function getDattoRMMClient(): DattoRMMClient {
|
||||
if (!clientInstance) {
|
||||
const config: DattoRMMConfig = {
|
||||
apiUrl: process.env.DATTO_RMM_API_URL || '',
|
||||
apiKey: process.env.DATTO_RMM_API_KEY || '',
|
||||
apiSecret: process.env.DATTO_RMM_API_SECRET || '',
|
||||
};
|
||||
|
||||
// Validate configuration
|
||||
if (!config.apiUrl || !config.apiKey || !config.apiSecret) {
|
||||
throw new Error(
|
||||
'Missing Datto RMM API configuration. Please check your environment variables.'
|
||||
);
|
||||
}
|
||||
|
||||
clientInstance = new DattoRMMClient(config);
|
||||
}
|
||||
|
||||
return clientInstance;
|
||||
}
|
||||
99
lib/services/redis-client.ts
Normal file
99
lib/services/redis-client.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import Redis from 'ioredis';
|
||||
|
||||
let redisClient: Redis | null = null;
|
||||
|
||||
export function getRedisClient(): Redis | null {
|
||||
if (!process.env.REDIS_URL) {
|
||||
console.log('Redis URL not configured, caching disabled');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!redisClient) {
|
||||
try {
|
||||
redisClient = new Redis(process.env.REDIS_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
reconnectOnError: (err) => {
|
||||
const targetError = 'READONLY';
|
||||
if (err.message.includes(targetError)) {
|
||||
// Only reconnect when the error contains "READONLY"
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => {
|
||||
console.error('Redis Client Error:', err);
|
||||
});
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
console.log('Redis Client Connected');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Redis client:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
export async function getCachedData<T>(key: string): Promise<T | null> {
|
||||
const client = getRedisClient();
|
||||
if (!client) return null;
|
||||
|
||||
try {
|
||||
const data = await client.get(key);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting cached data for key ${key}:`, error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function setCachedData<T>(
|
||||
key: string,
|
||||
data: T,
|
||||
ttlSeconds: number = 300 // Default 5 minutes
|
||||
): Promise<void> {
|
||||
const client = getRedisClient();
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.set(key, JSON.stringify(data), 'EX', ttlSeconds);
|
||||
} catch (error) {
|
||||
console.error(`Error setting cached data for key ${key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCachedData(pattern: string): Promise<void> {
|
||||
const client = getRedisClient();
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const keys = await client.keys(pattern);
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error deleting cached data for pattern ${pattern}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushCache(): Promise<void> {
|
||||
const client = getRedisClient();
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.flushdb();
|
||||
console.log('Cache flushed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error flushing cache:', error);
|
||||
}
|
||||
}
|
||||
213
lib/types/addigy.ts
Normal file
213
lib/types/addigy.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
// Addigy API Types
|
||||
|
||||
export interface AddigyConfig {
|
||||
apiUrl: string;
|
||||
apiToken: string;
|
||||
organizationId?: string; // Parent organization ID if needed
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface FilterOperation {
|
||||
audit_field: string;
|
||||
type: 'string' | 'list' | 'number' | 'boolean' | 'date';
|
||||
operation: 'equals' | 'contains' | 'greater_than' | 'less_than' | 'in' | 'not_in';
|
||||
value: string | number | boolean | string[] | number[];
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
filters?: FilterOperation[];
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AddigyDevice {
|
||||
agentid: string;
|
||||
'Device Name': string;
|
||||
'Device Model Name': string;
|
||||
'MAC OS X Version'?: string;
|
||||
'iOS Version'?: string;
|
||||
'Processor Type'?: string;
|
||||
'Processor Speed (GHz)'?: number;
|
||||
'Total Disk Space (GB)'?: number;
|
||||
'Free Disk Space (GB)'?: number;
|
||||
'Free Disk Percentage'?: number;
|
||||
'Battery Percentage'?: number;
|
||||
'Battery Charging'?: boolean;
|
||||
'Battery Capacity Loss Percentage'?: number;
|
||||
'Current User'?: string;
|
||||
'Serial Number'?: string;
|
||||
'Displays Serial Number'?: string[];
|
||||
'Agent Version': string;
|
||||
policy_id: string;
|
||||
online: boolean;
|
||||
'Firewall Enabled'?: boolean;
|
||||
'FileVault Enabled'?: boolean;
|
||||
'Remote Login Enabled'?: boolean;
|
||||
'XCode Installed'?: boolean;
|
||||
'SMART Failing'?: boolean;
|
||||
'Has Wireless'?: boolean;
|
||||
Timezone?: string;
|
||||
'Warranty Expiration Date'?: string;
|
||||
'Warranty Days Left'?: number;
|
||||
'TeamViewer Client Id'?: string;
|
||||
'Crashplan Days Since Last Backup'?: number;
|
||||
'Last Check In'?: string;
|
||||
// Additional fields from device audits
|
||||
[key: string]: string | number | boolean | string[] | undefined;
|
||||
}
|
||||
|
||||
export interface AddigyPolicy {
|
||||
policyId: string;
|
||||
orgid: string;
|
||||
name: string;
|
||||
parent?: string | null;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
download_path?: string;
|
||||
agent_path?: string;
|
||||
last_deployed?: string;
|
||||
creation_time?: number;
|
||||
agent_version?: string;
|
||||
ignore_updates?: boolean;
|
||||
instructions?: any[];
|
||||
vnc_settings?: any;
|
||||
splashtop_settings?: any;
|
||||
ssh_settings?: any;
|
||||
system_updates_settings?: any;
|
||||
collector_settings?: any;
|
||||
prebuilt_app_settings?: any;
|
||||
}
|
||||
|
||||
export interface AddigyOrganization {
|
||||
orgid: string;
|
||||
name: string;
|
||||
parent_org_id?: string;
|
||||
domain?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface AddigyApplication {
|
||||
name: string;
|
||||
version: string;
|
||||
path: string;
|
||||
bundle_id?: string;
|
||||
installed_date?: string;
|
||||
}
|
||||
|
||||
export interface AddigyDeviceWithApps {
|
||||
agentid: string;
|
||||
'Device Name': string;
|
||||
'Device Model Name': string;
|
||||
'MAC OS X Version'?: string;
|
||||
'iOS Version'?: string;
|
||||
'Agent Version': string;
|
||||
policy_id: string;
|
||||
online: boolean;
|
||||
'Serial Number'?: string;
|
||||
'Current User'?: string;
|
||||
'Free Disk Percentage'?: number;
|
||||
'Battery Percentage'?: number;
|
||||
'Firewall Enabled'?: boolean;
|
||||
'FileVault Enabled'?: boolean;
|
||||
installed_applications?: AddigyApplication[];
|
||||
[key: string]: string | number | boolean | string[] | AddigyApplication[] | undefined;
|
||||
}
|
||||
|
||||
export interface AddigyAlert {
|
||||
id: string;
|
||||
device_id: string;
|
||||
alert_type: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
message: string;
|
||||
created_at: string;
|
||||
resolved: boolean;
|
||||
resolved_at?: string;
|
||||
}
|
||||
|
||||
export interface AddigyMaintenanceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
policy_id?: string;
|
||||
enabled: boolean;
|
||||
schedule?: string;
|
||||
last_run?: string;
|
||||
next_run?: string;
|
||||
}
|
||||
|
||||
export interface AddigyMonitoringItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
policy_id?: string;
|
||||
enabled: boolean;
|
||||
condition: string;
|
||||
alert_level: 'critical' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
export interface AddigyCustomFact {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string | number | boolean;
|
||||
device_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AddigySoftwareItem {
|
||||
identifier: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
versions?: string[];
|
||||
category?: string;
|
||||
description?: string;
|
||||
install_type?: string;
|
||||
}
|
||||
|
||||
export interface AddigyVariable {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
policy_id?: string;
|
||||
device_id?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AddigyApiResponse<T> {
|
||||
data?: T;
|
||||
items?: T[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
pages?: number;
|
||||
}
|
||||
|
||||
export interface AddigyApiError {
|
||||
error: string;
|
||||
message: string;
|
||||
status_code?: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Enums for common values
|
||||
export enum AddigyDeviceStatus {
|
||||
Online = 'online',
|
||||
Offline = 'offline',
|
||||
}
|
||||
|
||||
export enum AddigyAlertSeverity {
|
||||
Critical = 'critical',
|
||||
Warning = 'warning',
|
||||
Info = 'info',
|
||||
}
|
||||
|
||||
export enum AddigyDeviceType {
|
||||
Mac = 'mac',
|
||||
iPhone = 'iphone',
|
||||
iPad = 'ipad',
|
||||
AppleTV = 'appletv',
|
||||
}
|
||||
262
lib/types/autotask.ts
Normal file
262
lib/types/autotask.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
// Autotask API Types
|
||||
|
||||
export interface AutotaskConfig {
|
||||
apiUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
apiIntegrationCode: string;
|
||||
}
|
||||
|
||||
export type AutotaskHeaders = {
|
||||
Authorization: string;
|
||||
ApiIntegrationcode: string;
|
||||
'Content-Type': string;
|
||||
Accept: string;
|
||||
ImpersonationResourceId?: string;
|
||||
}
|
||||
|
||||
export interface QueryFilter {
|
||||
op: 'eq' | 'noteq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'beginsWith' | 'endsWith';
|
||||
field: string;
|
||||
value: string | number | boolean;
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
filter?: QueryFilter[];
|
||||
maxRecords?: number;
|
||||
includeFields?: string[];
|
||||
excludeFields?: string[];
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
userName?: string;
|
||||
isActive?: boolean;
|
||||
title?: string;
|
||||
mobilePhone?: string;
|
||||
officePhone?: string;
|
||||
officeExtension?: string;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: number;
|
||||
ticketNumber: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: number;
|
||||
priority: number;
|
||||
assignedResourceID?: number;
|
||||
companyID: number;
|
||||
companyLocationID?: number;
|
||||
contactID?: number;
|
||||
createDate: string;
|
||||
dueDateTime?: string;
|
||||
lastActivityDate?: string;
|
||||
completedDate?: string;
|
||||
queueID?: number;
|
||||
issueType?: number;
|
||||
subIssueType?: number;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: number;
|
||||
priority: number;
|
||||
assignedResourceID?: number;
|
||||
projectID?: number;
|
||||
phaseID?: number;
|
||||
createDateTime: string;
|
||||
startDateTime?: string;
|
||||
endDateTime?: string;
|
||||
completedDateTime?: string;
|
||||
percentComplete?: number;
|
||||
estimatedHours?: number;
|
||||
actualHours?: number;
|
||||
}
|
||||
|
||||
export interface Company {
|
||||
id: number;
|
||||
companyName: string;
|
||||
companyNumber?: string;
|
||||
isActive: boolean;
|
||||
phone?: string;
|
||||
alternatePhone1?: string;
|
||||
alternatePhone2?: string;
|
||||
fax?: string;
|
||||
webSiteURL?: string;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
companyType?: number;
|
||||
territoryID?: number;
|
||||
marketSegmentID?: number;
|
||||
competitorID?: number;
|
||||
}
|
||||
|
||||
export interface ConfigurationItem {
|
||||
id: number;
|
||||
companyID: number;
|
||||
companyLocationID?: number;
|
||||
contactID?: number;
|
||||
contractID?: number;
|
||||
contractServiceID?: number;
|
||||
createDate: string;
|
||||
createdByPersonType?: number;
|
||||
createdByResourceID?: number;
|
||||
dattoAvailableKilobytes?: number;
|
||||
dattoDeviceMemoryMegabytes?: number;
|
||||
dattoHostname?: string;
|
||||
dattoInternalIP?: string;
|
||||
dattoKernelVersionID?: number;
|
||||
dattoLastCheckInDateTime?: string;
|
||||
dattoNumberOfCPUs?: number;
|
||||
dattoNumberOfLogicalProcessors?: number;
|
||||
dattoOSVersionID?: number;
|
||||
dattoProtectedKilobytes?: number;
|
||||
dattoRemoteIP?: string;
|
||||
dattoSerialNumber?: string;
|
||||
dattoUDF?: string;
|
||||
dattoUsedKilobytes?: number;
|
||||
dattoZfsPoolZpoolVersionID?: number;
|
||||
deviceNetworkingID?: string;
|
||||
dnsServer1?: string;
|
||||
dnsServer2?: string;
|
||||
installDate?: string;
|
||||
installedByContactID?: number;
|
||||
installedByID?: number;
|
||||
installedProductCategoryID?: number;
|
||||
isActive: boolean;
|
||||
lastActivityPersonResourceID?: number;
|
||||
lastActivityPersonType?: number;
|
||||
lastModifiedTime?: string;
|
||||
location?: string;
|
||||
macAddress?: string;
|
||||
modelNumber?: string;
|
||||
notes?: string;
|
||||
numberOfUsers?: number;
|
||||
parentConfigurationItemID?: number;
|
||||
productID?: number;
|
||||
referenceNumber?: string;
|
||||
referenceTitle: string;
|
||||
rmmDeviceAuditAntivirusStatusID?: number;
|
||||
rmmDeviceAuditArchitectureID?: number;
|
||||
rmmDeviceAuditBackupStatusID?: number;
|
||||
rmmDeviceAuditDescription?: string;
|
||||
rmmDeviceAuditDeviceTypeID?: number;
|
||||
rmmDeviceAuditDisplayAdaptorID?: number;
|
||||
rmmDeviceAuditDomainID?: number;
|
||||
rmmDeviceAuditHostname?: string;
|
||||
rmmDeviceAuditIPAddress?: string;
|
||||
rmmDeviceAuditLastUser?: string;
|
||||
rmmDeviceAuditMacAddress?: string;
|
||||
rmmDeviceAuditManufacturerID?: number;
|
||||
rmmDeviceAuditMemoryBytes?: number;
|
||||
rmmDeviceAuditMissingPatchCount?: number;
|
||||
rmmDeviceAuditMobileNetworkOperatorID?: number;
|
||||
rmmDeviceAuditMobileNumber?: string;
|
||||
rmmDeviceAuditModelID?: number;
|
||||
rmmDeviceAuditMotherboardID?: number;
|
||||
rmmDeviceAuditOperatingSystemID?: number;
|
||||
rmmDeviceAuditPatchStatusID?: number;
|
||||
rmmDeviceAuditProcessorID?: number;
|
||||
rmmDeviceAuditServicePackID?: number;
|
||||
rmmDeviceAuditSNMPContact?: string;
|
||||
rmmDeviceAuditSNMPLocation?: string;
|
||||
rmmDeviceAuditSNMPName?: string;
|
||||
rmmDeviceAuditSoftwareStatusID?: number;
|
||||
rmmDeviceAuditStorageBytes?: number;
|
||||
rmmDeviceID?: string;
|
||||
rmmDeviceUID?: string;
|
||||
rmmOpenAlertCount?: number;
|
||||
serialNumber?: string;
|
||||
serviceBundleID?: number;
|
||||
serviceID?: number;
|
||||
serviceLevelAgreementID?: number;
|
||||
setupFee?: number;
|
||||
sourceProductID?: number;
|
||||
type?: number;
|
||||
vendorID?: number;
|
||||
vendorName?: string;
|
||||
warrantyExpirationDate?: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number;
|
||||
attachmentType: 'FILE_ATTACHMENT' | 'FILE_LINK' | 'URL' | 'NOTE';
|
||||
fullPath: string;
|
||||
title: string;
|
||||
publish: 1 | 2; // 1 = All Autotask Users, 2 = Internal Users Only
|
||||
data?: string; // Base64 encoded file data
|
||||
contentType?: string;
|
||||
createDate?: string;
|
||||
creatorResourceID?: number;
|
||||
}
|
||||
|
||||
export interface PicklistValue {
|
||||
value: number | string;
|
||||
label: string;
|
||||
isDefaultValue?: boolean;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
isSystem?: boolean;
|
||||
}
|
||||
|
||||
export interface EntityField {
|
||||
name: string;
|
||||
dataType: string;
|
||||
length?: number;
|
||||
isRequired?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isQueryable?: boolean;
|
||||
isReference?: boolean;
|
||||
referenceEntityType?: string;
|
||||
picklistValues?: PicklistValue[];
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
item?: T;
|
||||
items?: T[];
|
||||
pageDetails?: {
|
||||
count: number;
|
||||
requestCount: number;
|
||||
prevPageUrl?: string;
|
||||
nextPageUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
errors?: Array<{
|
||||
message: string;
|
||||
field?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export enum TicketStatus {
|
||||
New = 1,
|
||||
InProgress = 8,
|
||||
Waiting = 9,
|
||||
Complete = 5,
|
||||
}
|
||||
|
||||
export enum TaskStatus {
|
||||
New = 1,
|
||||
InProgress = 11,
|
||||
Waiting = 12,
|
||||
Complete = 5,
|
||||
}
|
||||
|
||||
export enum Priority {
|
||||
Critical = 1,
|
||||
High = 2,
|
||||
Medium = 3,
|
||||
Low = 4,
|
||||
}
|
||||
105
lib/types/datto-rmm.ts
Normal file
105
lib/types/datto-rmm.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Datto RMM API Types
|
||||
|
||||
export interface DattoRMMConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
}
|
||||
|
||||
export interface DattoRMMDevice {
|
||||
id: number;
|
||||
uid: string;
|
||||
siteId: number;
|
||||
siteUid: string;
|
||||
siteName: string;
|
||||
deviceType: {
|
||||
category: string;
|
||||
type: string;
|
||||
};
|
||||
hostname: string;
|
||||
description: string;
|
||||
intIpAddress: string;
|
||||
extIpAddress: string;
|
||||
macAddresses?: string[];
|
||||
domain: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
lastSeen: number; // timestamp in milliseconds
|
||||
lastLoggedInUser?: string;
|
||||
lastReboot?: number;
|
||||
lastAuditDate?: number;
|
||||
creationDate?: number;
|
||||
online: boolean;
|
||||
suspended: boolean;
|
||||
deleted: boolean;
|
||||
rebootRequired?: boolean;
|
||||
a64Bit?: boolean;
|
||||
operatingSystem: string;
|
||||
cagVersion?: string;
|
||||
displayVersion?: string;
|
||||
memory?: number; // in MB
|
||||
cpuCores?: number;
|
||||
cpuName?: string;
|
||||
diskSize?: number; // in GB
|
||||
antivirus?: {
|
||||
antivirusProduct: string;
|
||||
antivirusStatus: string;
|
||||
};
|
||||
patchManagement?: {
|
||||
patchStatus: string;
|
||||
patchesApprovedPending: number;
|
||||
patchesNotApproved: number;
|
||||
patchesInstalled: number;
|
||||
};
|
||||
softwareStatus?: string;
|
||||
portalUrl?: string;
|
||||
webRemoteUrl?: string;
|
||||
warrantyDate?: string | null;
|
||||
snmpEnabled?: boolean;
|
||||
deviceClass?: string;
|
||||
udf?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface DattoRMMSite {
|
||||
id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
notes: string;
|
||||
onDemand: boolean;
|
||||
proxySettings?: {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
};
|
||||
devices?: DattoRMMDevice[];
|
||||
}
|
||||
|
||||
export interface DattoRMMApiResponse<T> {
|
||||
items?: T[];
|
||||
item?: T;
|
||||
pageDetails?: {
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DattoRMMError {
|
||||
error: string;
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface DeviceComparison {
|
||||
autotaskDevice?: any; // ConfigurationItem from Autotask
|
||||
rmmDevice?: DattoRMMDevice;
|
||||
status: 'matched' | 'autotask-only' | 'rmm-only' | 'mismatch';
|
||||
discrepancies?: {
|
||||
field: string;
|
||||
autotaskValue: any;
|
||||
rmmValue: any;
|
||||
}[];
|
||||
}
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
174
lib/utils/device-lookup.ts
Normal file
174
lib/utils/device-lookup.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* Device information lookup utilities
|
||||
* Provides manufacturer and model information based on serial number patterns
|
||||
*/
|
||||
|
||||
interface DeviceInfo {
|
||||
manufacturer: string;
|
||||
modelFamily?: string;
|
||||
estimatedModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up device information based on serial number
|
||||
*/
|
||||
export function lookupDeviceBySerial(serialNumber: string | undefined): DeviceInfo | null {
|
||||
if (!serialNumber) return null;
|
||||
|
||||
const serial = serialNumber.toUpperCase().trim();
|
||||
|
||||
// Dell serial numbers (7 characters, alphanumeric)
|
||||
if (/^[A-Z0-9]{7}$/.test(serial)) {
|
||||
return {
|
||||
manufacturer: 'Dell Inc.',
|
||||
modelFamily: 'Dell',
|
||||
estimatedModel: identifyDellModel(serial)
|
||||
};
|
||||
}
|
||||
|
||||
// HP serial numbers (often start with specific patterns)
|
||||
if (/^(CNU|2UA|CND|CNF|MXL|SGH|USE|5CD|5CG)[A-Z0-9]+/.test(serial)) {
|
||||
return {
|
||||
manufacturer: 'HP',
|
||||
modelFamily: 'HP',
|
||||
estimatedModel: identifyHPModel(serial)
|
||||
};
|
||||
}
|
||||
|
||||
// Lenovo serial numbers (often start with specific patterns)
|
||||
if (/^(MJ|PF|MP|R9|S4|PC|PB)[A-Z0-9]+/.test(serial)) {
|
||||
return {
|
||||
manufacturer: 'Lenovo',
|
||||
modelFamily: 'ThinkPad/ThinkCentre',
|
||||
estimatedModel: identifyLenovoModel(serial)
|
||||
};
|
||||
}
|
||||
|
||||
// Apple serial numbers (12 characters)
|
||||
if (/^[A-Z0-9]{12}$/.test(serial)) {
|
||||
const yearCode = serial.substring(3, 4);
|
||||
if (['P', 'Q', 'R', 'S', 'T', 'V', 'W', 'X', 'Y', 'Z'].includes(yearCode)) {
|
||||
return {
|
||||
manufacturer: 'Apple Inc.',
|
||||
modelFamily: 'Mac',
|
||||
estimatedModel: identifyAppleModel(serial)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Microsoft Surface (often starts with specific patterns)
|
||||
if (/^[0-9]{12}$/.test(serial) || serial.startsWith('00')) {
|
||||
return {
|
||||
manufacturer: 'Microsoft',
|
||||
modelFamily: 'Surface',
|
||||
estimatedModel: 'Surface Device'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify Dell model based on service tag patterns
|
||||
*/
|
||||
function identifyDellModel(serial: string): string {
|
||||
// Dell service tags can indicate model families
|
||||
// This is a simplified example - real implementation would need more patterns
|
||||
const firstChar = serial[0];
|
||||
|
||||
if (['H', 'J', 'K'].includes(firstChar)) {
|
||||
return 'OptiPlex Desktop';
|
||||
} else if (['F', 'G'].includes(firstChar)) {
|
||||
return 'Latitude Laptop';
|
||||
} else if (['D', 'C'].includes(firstChar)) {
|
||||
return 'Precision Workstation';
|
||||
}
|
||||
|
||||
return 'Dell Computer';
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify HP model based on serial patterns
|
||||
*/
|
||||
function identifyHPModel(serial: string): string {
|
||||
const prefix = serial.substring(0, 3);
|
||||
|
||||
switch (prefix) {
|
||||
case 'CNU':
|
||||
case 'CND':
|
||||
return 'HP ProBook/EliteBook';
|
||||
case '2UA':
|
||||
case '5CD':
|
||||
case '5CG':
|
||||
return 'HP Desktop/Workstation';
|
||||
case 'MXL':
|
||||
return 'HP ProDesk';
|
||||
case 'SGH':
|
||||
return 'HP Server';
|
||||
default:
|
||||
return 'HP Computer';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify Lenovo model based on serial patterns
|
||||
*/
|
||||
function identifyLenovoModel(serial: string): string {
|
||||
const prefix = serial.substring(0, 2);
|
||||
|
||||
switch (prefix) {
|
||||
case 'MJ':
|
||||
return 'ThinkCentre Desktop';
|
||||
case 'PF':
|
||||
case 'PC':
|
||||
case 'PB':
|
||||
return 'ThinkPad Laptop';
|
||||
case 'MP':
|
||||
return 'ThinkStation';
|
||||
case 'R9':
|
||||
case 'S4':
|
||||
return 'IdeaPad/Yoga';
|
||||
default:
|
||||
return 'Lenovo Computer';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify Apple model based on serial number
|
||||
*/
|
||||
function identifyAppleModel(serial: string): string {
|
||||
// Apple serial numbers encode model info in positions 4-5
|
||||
const modelCode = serial.substring(4, 6);
|
||||
|
||||
// This is a simplified mapping - real implementation would need comprehensive list
|
||||
if (modelCode.startsWith('M')) {
|
||||
return 'MacBook Pro';
|
||||
} else if (modelCode.startsWith('F')) {
|
||||
return 'MacBook Air';
|
||||
} else if (modelCode.startsWith('G')) {
|
||||
return 'iMac';
|
||||
} else if (modelCode.startsWith('J')) {
|
||||
return 'Mac mini';
|
||||
} else if (modelCode.startsWith('P')) {
|
||||
return 'Mac Studio/Pro';
|
||||
}
|
||||
|
||||
return 'Mac Computer';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format device information for display
|
||||
*/
|
||||
export function formatDeviceInfo(info: DeviceInfo | null): string {
|
||||
if (!info) return 'Unknown Device';
|
||||
|
||||
if (info.estimatedModel) {
|
||||
return `${info.manufacturer} - ${info.estimatedModel}`;
|
||||
}
|
||||
|
||||
if (info.modelFamily) {
|
||||
return `${info.manufacturer} ${info.modelFamily}`;
|
||||
}
|
||||
|
||||
return info.manufacturer;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue