import { DattoRMMConfig, DattoRMMDevice, DattoRMMSite, DattoRMMAlert, 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 { // 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( endpoint: string, options: RequestInit = {} ): Promise { 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 { const response = await this.makeApiCall( '/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 { 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 (with pagination support) */ async getAllDevices(): Promise { const allDevices: DattoRMMDevice[] = []; let page = 1; const pageSize = 250; // Datto RMM default page size while (true) { const response = await this.makeApiCall( `/account/devices?page=${page}&pageSize=${pageSize}`, { method: 'GET' } ); const devices = response.devices || []; allDevices.push(...devices); console.log(`Fetched page ${page}: ${devices.length} devices (total: ${allDevices.length})`); // If we got less than pageSize devices, we've reached the end if (devices.length < pageSize) { break; } page++; } console.log(`Total RMM devices fetched: ${allDevices.length}`); return allDevices; } /** * Get devices for a specific site */ async getDevicesBySite(siteUid: string): Promise { const response = await this.makeApiCall( `/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) * @deprecated Use getDevicesByCompanyId with site mappings instead */ async getDevicesByCompanyName(companyName: string): Promise { // 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 devices for multiple sites (for multi-site support) */ async getDevicesForSites(siteUids: string[]): Promise { if (siteUids.length === 0) { return []; } const allDevices: DattoRMMDevice[] = []; // Fetch devices from all sites in parallel const promises = siteUids.map(uid => this.getDevicesBySite(uid)); const results = await Promise.allSettled(promises); for (const result of results) { if (result.status === 'fulfilled') { allDevices.push(...result.value); } else { console.error('Failed to fetch devices for a site:', result.reason); } } return allDevices; } /** * Get device by ID */ async getDeviceById(deviceId: string): Promise { 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 { 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 { try { const response = await this.makeApiCall( `/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 { 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 { try { const response = await this.makeApiCall>( `/devices/${deviceId}/alerts`, { method: 'GET' } ); return response.items || []; } catch (error) { console.error(`Failed to get alerts for device ${deviceId}:`, error); return []; } } /** * Generic paginated fetch — follows nextPageUrl until exhausted */ private async fetchAllPages( endpoint: string, dataKey: string, pageSize = 250 ): Promise { const allItems: T[] = []; let url: string | null = `https://concord-api.centrastage.net/api/v2${endpoint}?pageSize=${pageSize}`; while (url) { const token = await this.getAccessToken(); const resp: Response = await fetch(url, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json', }, }); if (!resp.ok) { const errText = await resp.text(); throw new Error(`Datto RMM API ${resp.status}: ${errText.substring(0, 200)}`); } const body: any = await resp.json(); const items = body[dataKey] || []; allItems.push(...items); const nextUrl: string | null = body.pageDetails?.nextPageUrl ?? null; console.log(`[DATTO-RMM] ${dataKey}: fetched ${allItems.length} (page had ${items.length})`); url = nextUrl; } return allItems; } /** * Get all sites (paginated) */ async getAllSites(): Promise { return this.fetchAllPages('/account/sites', 'sites'); } /** * Get all open alerts (paginated) */ async getAllOpenAlerts(): Promise { return this.fetchAllPages('/account/alerts/open', 'alerts'); } /** * Get all resolved alerts (paginated, recent only) */ async getRecentResolvedAlerts(maxPages = 4): Promise { const allAlerts: DattoRMMAlert[] = []; let url: string | null = 'https://concord-api.centrastage.net/api/v2/account/alerts/resolved?pageSize=250'; let pages = 0; while (url && pages < maxPages) { const token = await this.getAccessToken(); const resp: Response = await fetch(url, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json', }, }); if (!resp.ok) break; const body: any = await resp.json(); const alerts = body.alerts || []; allAlerts.push(...alerts); url = body.pageDetails?.nextPageUrl ?? null; pages++; console.log(`[DATTO-RMM] resolved alerts: fetched ${allAlerts.length} (page ${pages})`); } return allAlerts; } /** * Get all available automation components (scripts/tasks) for quick jobs. * GET /api/v2/account/components */ async getComponents(): Promise { const allComponents: any[] = []; let page = 1; const max = 100; while (true) { const response = await this.makeApiCall( `/account/components?page=${page}&max=${max}`, { method: 'GET' } ); const components = response.components || []; allComponents.push(...components); if (components.length < max) break; page++; } return allComponents; } /** * Run a quick job on a device. * PUT /api/v2/device/{deviceUid}/quickjob */ async runQuickJob( deviceUid: string, payload: { jobName: string; jobComponent: { componentUid: string; variables?: Array<{ name: string; value: string }>; }; } ): Promise { const token = await this.getAccessToken(); const url = `https://concord-api.centrastage.net/api/v2/device/${deviceUid}/quickjob`; const resp = await fetch(url, { method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify(payload), }); if (!resp.ok) { const errText = await resp.text(); throw new Error(`Datto RMM quick job failed (${resp.status}): ${errText.substring(0, 200)}`); } const text = await resp.text(); return text ? JSON.parse(text) : {}; } /** * Get job results for a device. * GET /api/v2/job/{jobUid}/results/device/{deviceUid} */ async getJobResults(jobUid: string, deviceUid: string): Promise { const token = await this.getAccessToken(); const url = `https://concord-api.centrastage.net/api/v2/job/${jobUid}/results/${deviceUid}`; const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json', }, }); if (!resp.ok) { const errText = await resp.text(); throw new Error(`Datto RMM job results failed (${resp.status}): ${errText.substring(0, 200)}`); } return resp.json(); } /** * For a PING alert, fetch the ping target (instanceName) from alertContext. * Returns null if not found or API call fails. */ async getPingAlertTarget(alertUid: string): Promise { const token = await this.getAccessToken(); const url = `${this.config.apiUrl}/api/v2/alert/${alertUid}`; const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, }); if (!resp.ok) return null; const data = await resp.json(); const ctx = data?.alertContext; if (ctx?.['@class'] === 'ping_ctx' && ctx?.instanceName) { return ctx.instanceName as string; } return null; } }