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:
Lorentz Hinrichsen 2025-10-28 23:08:54 -04:00
parent f429f3af54
commit 3c3124d8c9
117 changed files with 8433 additions and 239 deletions

62
lib/hooks/use-api.ts Normal file
View 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();
}

View 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());
}
}

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

View 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());
}
}

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

View file

@ -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);

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

View 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 [];
}
}
}

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

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