wulf-pulse/lib/services/datto-rmm-client.ts
lorentz b98c67482a feat: QuickBooks Online integration
- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts)
- Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts)
- Add QBO types (lib/types/qbo.ts)
- Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect
- Add /admin/qbo status and sync management page
- Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment)
- Add QBO nav link under Admin
- Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all
- Add CashFlow report type alongside P&L and BalanceSheet
- Add NoReportData check to skip empty report months
- Add intuit_tid capture in error messages
- Add redirect: follow for cluster routing
- Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables

Also includes earlier work:
- Ping flap suppression pipeline step
- Ticket digest reports with LLM analysis
- Zabbix WAN monitor and gap analysis
- Kiosk is_deleted filter fixes
- Datto RMM ping target enrichment
- Entity sync soft-delete detection
2026-03-17 07:39:55 -04:00

578 lines
16 KiB
TypeScript

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<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 (with pagination support)
*/
async getAllDevices(): Promise<DattoRMMDevice[]> {
const allDevices: DattoRMMDevice[] = [];
let page = 1;
const pageSize = 250; // Datto RMM default page size
while (true) {
const response = await this.makeApiCall<any>(
`/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<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)
* @deprecated Use getDevicesByCompanyId with site mappings instead
*/
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 devices for multiple sites (for multi-site support)
*/
async getDevicesForSites(siteUids: string[]): Promise<DattoRMMDevice[]> {
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<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 [];
}
}
/**
* Generic paginated fetch — follows nextPageUrl until exhausted
*/
private async fetchAllPages<T>(
endpoint: string,
dataKey: string,
pageSize = 250
): Promise<T[]> {
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<DattoRMMSite[]> {
return this.fetchAllPages<DattoRMMSite>('/account/sites', 'sites');
}
/**
* Get all open alerts (paginated)
*/
async getAllOpenAlerts(): Promise<DattoRMMAlert[]> {
return this.fetchAllPages<DattoRMMAlert>('/account/alerts/open', 'alerts');
}
/**
* Get all resolved alerts (paginated, recent only)
*/
async getRecentResolvedAlerts(maxPages = 4): Promise<DattoRMMAlert[]> {
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<any[]> {
const allComponents: any[] = [];
let page = 1;
const max = 100;
while (true) {
const response = await this.makeApiCall<any>(
`/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<any> {
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<any> {
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<string | null> {
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;
}
}