- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
6 KiB
TypeScript
192 lines
6 KiB
TypeScript
/**
|
|
* Resolve which Datto RMM device an RMM Overshell script should run against.
|
|
*
|
|
* For site-anchored scripts: pick the Wulf Nurse Production endpoint
|
|
* (hostname matching `LLLCCCWNPNN` — 6 letters + WNP + 2 digits) for the
|
|
* client. Prefer online devices and lowest numeric suffix (WNP01 over
|
|
* WNP02 etc.) so we hit the canonical endpoint deterministically.
|
|
*
|
|
* For asset-self: caller supplies the device_uid; we look up hostname /
|
|
* online state for telemetry only.
|
|
*/
|
|
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
|
|
export interface ResolvedTarget {
|
|
device_uid: string;
|
|
hostname: string | null;
|
|
online: boolean;
|
|
}
|
|
|
|
/**
|
|
* Wulf Nurse Production endpoint pattern.
|
|
* LLL = location code (3 letters)
|
|
* CCC = client code (3 letters)
|
|
* WNP = literal
|
|
* NN = numeric suffix
|
|
* e.g. YNGHYNWNP01 → Youngstown / Hynes / WNP / 01
|
|
*/
|
|
const WNP_REGEX = /^[A-Z]{3}[A-Z]{3}WNP\d{2}$/i;
|
|
|
|
interface SiteRow {
|
|
id: number;
|
|
name: string;
|
|
device_count: number;
|
|
}
|
|
|
|
interface DeviceRow {
|
|
uid: string;
|
|
hostname: string | null;
|
|
online: boolean;
|
|
site_id: number;
|
|
site_device_count: number;
|
|
}
|
|
|
|
/**
|
|
* Find every Datto RMM site that maps to the given Autotask company.
|
|
*
|
|
* Two paths because the autotask_company_id FK on datto_rmm_sites isn't
|
|
* always populated — many sites only carry the autotask_company_name. We
|
|
* match on either the FK or a case-insensitive name match against
|
|
* companies.company_name.
|
|
*
|
|
* Multi-site clients (e.g. Hynes Industries: Youngstown + Painesville +
|
|
* Kokomo) return multiple rows; the caller combines WNPs across all of
|
|
* them.
|
|
*/
|
|
async function findDattoSitesForCompany(
|
|
companyId: string | number
|
|
): Promise<SiteRow[]> {
|
|
const res = await postgresClient.query<SiteRow>(
|
|
`SELECT s.id, s.name, COALESCE(s.number_of_devices, 0) AS device_count
|
|
FROM datto_rmm_sites s
|
|
LEFT JOIN companies c ON c.id = $1::bigint
|
|
WHERE s.autotask_company_id = $1::bigint
|
|
OR (
|
|
c.company_name IS NOT NULL
|
|
AND s.autotask_company_name IS NOT NULL
|
|
AND LOWER(s.autotask_company_name) = LOWER(c.company_name)
|
|
)
|
|
ORDER BY device_count DESC, s.name`,
|
|
[companyId]
|
|
);
|
|
return res.rows;
|
|
}
|
|
|
|
async function findWnpDevicesForSites(siteIds: number[]): Promise<DeviceRow[]> {
|
|
if (siteIds.length === 0) return [];
|
|
const res = await postgresClient.query<DeviceRow>(
|
|
`SELECT d.uid, d.hostname, d.online, d.site_id,
|
|
COALESCE(s.number_of_devices, 0) AS site_device_count
|
|
FROM datto_rmm_devices d
|
|
JOIN datto_rmm_sites s ON s.id = d.site_id
|
|
WHERE d.site_id = ANY($1::int[])
|
|
AND d.hostname ~* '^[A-Z]{3}[A-Z]{3}WNP[0-9]{2}$'`,
|
|
[siteIds]
|
|
);
|
|
return res.rows.filter((r) => r.hostname && WNP_REGEX.test(r.hostname));
|
|
}
|
|
|
|
export async function resolveSiteAnchorTarget(
|
|
companyId: string | number
|
|
): Promise<ResolvedTarget | null> {
|
|
const sites = await findDattoSitesForCompany(companyId);
|
|
if (sites.length === 0) return null;
|
|
const devices = await findWnpDevicesForSites(sites.map((s) => s.id));
|
|
if (devices.length === 0) return null;
|
|
// Pick: online > offline; site with more devices (proxy for "main
|
|
// location"); lowest numeric suffix (WNP01 over WNP02).
|
|
devices.sort((a, b) => {
|
|
if (a.online !== b.online) return a.online ? -1 : 1;
|
|
if (a.site_device_count !== b.site_device_count) {
|
|
return b.site_device_count - a.site_device_count;
|
|
}
|
|
const suffixA = parseInt((a.hostname ?? '').slice(-2), 10);
|
|
const suffixB = parseInt((b.hostname ?? '').slice(-2), 10);
|
|
if (Number.isNaN(suffixA) || Number.isNaN(suffixB)) return 0;
|
|
return suffixA - suffixB;
|
|
});
|
|
const pick = devices[0];
|
|
return {
|
|
device_uid: pick.uid,
|
|
hostname: pick.hostname,
|
|
online: pick.online,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return ALL matching WNPs across every Datto site for the company, with
|
|
* their site context. The site-discovery page can show this so the user
|
|
* sees every available endpoint — Hynes has Youngstown, Kokomo,
|
|
* Painesville WNPs and they're all valid targets for some scripts.
|
|
*/
|
|
export async function listSiteAnchorTargets(
|
|
companyId: string | number
|
|
): Promise<
|
|
Array<
|
|
ResolvedTarget & { site_id: number; site_name: string; site_device_count: number }
|
|
>
|
|
> {
|
|
const sites = await findDattoSitesForCompany(companyId);
|
|
if (sites.length === 0) return [];
|
|
const devices = await findWnpDevicesForSites(sites.map((s) => s.id));
|
|
const siteNameById = new Map(sites.map((s) => [s.id, s.name]));
|
|
return devices
|
|
.sort((a, b) => {
|
|
if (a.online !== b.online) return a.online ? -1 : 1;
|
|
return b.site_device_count - a.site_device_count;
|
|
})
|
|
.map((d) => ({
|
|
device_uid: d.uid,
|
|
hostname: d.hostname,
|
|
online: d.online,
|
|
site_id: d.site_id,
|
|
site_name: siteNameById.get(d.site_id) ?? '',
|
|
site_device_count: d.site_device_count,
|
|
}));
|
|
}
|
|
|
|
export async function resolveAssetSelfTarget(
|
|
deviceUid: string
|
|
): Promise<ResolvedTarget> {
|
|
const res = await postgresClient.query<DeviceRow>(
|
|
`SELECT uid, hostname, online
|
|
FROM datto_rmm_devices
|
|
WHERE uid = $1
|
|
LIMIT 1`,
|
|
[deviceUid]
|
|
);
|
|
if (res.rowCount === 0) {
|
|
return { device_uid: deviceUid, hostname: null, online: false };
|
|
}
|
|
return {
|
|
device_uid: res.rows[0].uid,
|
|
hostname: res.rows[0].hostname,
|
|
online: res.rows[0].online,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve a Datto device by hostname (case-insensitive). Used when the
|
|
* caller wants to target the audited Configuration's hostname rather than
|
|
* a known device_uid.
|
|
*/
|
|
export async function resolveDeviceByHostname(
|
|
hostname: string
|
|
): Promise<ResolvedTarget | null> {
|
|
const res = await postgresClient.query<DeviceRow>(
|
|
`SELECT uid, hostname, online
|
|
FROM datto_rmm_devices
|
|
WHERE LOWER(hostname) = LOWER($1)
|
|
LIMIT 1`,
|
|
[hostname]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
return {
|
|
device_uid: res.rows[0].uid,
|
|
hostname: res.rows[0].hostname,
|
|
online: res.rows[0].online,
|
|
};
|
|
}
|
|
|
|
export const _TARGET_RESOLVER_INTERNALS = { WNP_REGEX };
|