- 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>
249 lines
7.7 KiB
TypeScript
249 lines
7.7 KiB
TypeScript
/**
|
|
* Device-link reconciler — links unlinked device_external_ids rows to a
|
|
* configuration_item. Cascading match strategies, highest confidence first.
|
|
* Conflicts (multiple matches) are logged for admin review, not auto-merged.
|
|
*
|
|
* Wire into sync-scheduler.ts as an hourly cron when ready. Not wired yet —
|
|
* reviewer should approve the match strategies + conflict policy first.
|
|
*/
|
|
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
|
|
type LinkConfidence = 'canonical' | 'exact_uid' | 'exact_serial' | 'hostname_in_company' | 'mac' | 'manual';
|
|
|
|
interface UnlinkedRow {
|
|
id: number;
|
|
source: string;
|
|
source_id: string;
|
|
hostname: string | null;
|
|
serial: string | null;
|
|
mac: string | null;
|
|
company_id: number | null;
|
|
}
|
|
|
|
interface MatchCandidate {
|
|
configuration_item_id: number;
|
|
link_confidence: LinkConfidence;
|
|
}
|
|
|
|
export interface ReconcileResult {
|
|
scanned: number;
|
|
linked: number;
|
|
conflicts: number;
|
|
unmatched: number;
|
|
byConfidence: Record<LinkConfidence, number>;
|
|
}
|
|
|
|
const LINK_CONFIDENCE_RANK: Record<LinkConfidence, number> = {
|
|
canonical: 100,
|
|
exact_uid: 90,
|
|
exact_serial: 80,
|
|
mac: 70,
|
|
hostname_in_company: 60,
|
|
manual: 50,
|
|
};
|
|
|
|
// Common BIOS/inventory placeholder serials that shouldn't be matched on —
|
|
// hundreds of unrelated CIs share these and any link based on them is noise.
|
|
const PLACEHOLDER_SERIALS = new Set([
|
|
'', '0', '1', 'n/a', 'na', 'none', 'null', 'unknown', 'not listed',
|
|
'not specified', 'not applicable', 'default string', 'to be filled by o.e.m.',
|
|
'system serial number', 'chassis serial number',
|
|
'0000000000', '00000000', 'ffffffffffff',
|
|
'00000000-0000-0000-0000-000000000000',
|
|
]);
|
|
|
|
function isPlaceholderSerial(serial: string): boolean {
|
|
const s = serial.trim().toLowerCase();
|
|
if (s.length < 4) return true;
|
|
if (PLACEHOLDER_SERIALS.has(s)) return true;
|
|
// Strings that are all the same character (e.g. "00000000", "FFFFFFFF").
|
|
if (/^(.)\1+$/.test(s)) return true;
|
|
return false;
|
|
}
|
|
|
|
async function findBySerial(serial: string): Promise<MatchCandidate[]> {
|
|
if (isPlaceholderSerial(serial)) return [];
|
|
const res = await postgresClient.query<{ id: string }>(
|
|
`SELECT id::text FROM configuration_items
|
|
WHERE serial_number IS NOT NULL
|
|
AND serial_number = $1
|
|
AND (is_deleted IS NULL OR is_deleted = false)`,
|
|
[serial]
|
|
);
|
|
return res.rows.map((r) => ({
|
|
configuration_item_id: Number(r.id),
|
|
link_confidence: 'exact_serial' as const,
|
|
}));
|
|
}
|
|
|
|
async function findByMac(mac: string): Promise<MatchCandidate[]> {
|
|
const res = await postgresClient.query<{ id: string }>(
|
|
`SELECT id::text FROM configuration_items
|
|
WHERE rmm_device_audit_mac_address IS NOT NULL
|
|
AND LOWER(rmm_device_audit_mac_address) = LOWER($1)
|
|
AND (is_deleted IS NULL OR is_deleted = false)`,
|
|
[mac]
|
|
);
|
|
return res.rows.map((r) => ({
|
|
configuration_item_id: Number(r.id),
|
|
link_confidence: 'mac' as const,
|
|
}));
|
|
}
|
|
|
|
async function findByHostnameInCompany(
|
|
hostname: string,
|
|
companyId: number | null
|
|
): Promise<MatchCandidate[]> {
|
|
if (!companyId) return [];
|
|
const res = await postgresClient.query<{ id: string }>(
|
|
`SELECT id::text FROM configuration_items
|
|
WHERE company_id = $1
|
|
AND reference_title IS NOT NULL
|
|
AND LOWER(reference_title) = LOWER($2)
|
|
AND (is_deleted IS NULL OR is_deleted = false)`,
|
|
[companyId, hostname]
|
|
);
|
|
return res.rows.map((r) => ({
|
|
configuration_item_id: Number(r.id),
|
|
link_confidence: 'hostname_in_company' as const,
|
|
}));
|
|
}
|
|
|
|
async function applyLink(
|
|
rowId: number,
|
|
configurationItemId: number,
|
|
confidence: LinkConfidence
|
|
): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE device_external_ids
|
|
SET configuration_item_id = $2,
|
|
link_confidence = $3,
|
|
linked_at = NOW()
|
|
WHERE id = $1
|
|
AND configuration_item_id IS NULL`,
|
|
[rowId, configurationItemId, confidence]
|
|
);
|
|
|
|
// Propagate the new link into endpoint_audits / device_observations that
|
|
// were anchored only on the tool-side ID (e.g. an IT Glue config) at the
|
|
// time they were written. Without this they'd stay "unanchored" in the UI.
|
|
const linked = await postgresClient.query<{
|
|
source: string;
|
|
source_id: string;
|
|
}>(
|
|
`SELECT source, source_id FROM device_external_ids WHERE id = $1`,
|
|
[rowId]
|
|
);
|
|
const link = linked.rows[0];
|
|
if (!link) return;
|
|
|
|
if (link.source === 'itglue') {
|
|
await postgresClient.query(
|
|
`UPDATE endpoint_audits
|
|
SET configuration_item_id = $1
|
|
WHERE configuration_item_id IS NULL
|
|
AND itglue_configuration_id::text = $2`,
|
|
[configurationItemId, link.source_id]
|
|
);
|
|
}
|
|
}
|
|
|
|
async function recordConflict(
|
|
rowId: number,
|
|
candidates: MatchCandidate[]
|
|
): Promise<void> {
|
|
// Order candidates highest-confidence-first so the admin UI sees the best
|
|
// match at the top.
|
|
const ordered = [...candidates].sort(
|
|
(a, b) => LINK_CONFIDENCE_RANK[b.link_confidence] - LINK_CONFIDENCE_RANK[a.link_confidence]
|
|
);
|
|
const ciIds = ordered.map((c) => c.configuration_item_id);
|
|
const confidences = ordered.map((c) => c.link_confidence);
|
|
await postgresClient.query(
|
|
`INSERT INTO device_link_review (device_external_id, candidate_ci_ids, match_confidences)
|
|
VALUES ($1, $2::bigint[], $3::text[])
|
|
ON CONFLICT (device_external_id) WHERE resolved_at IS NULL
|
|
DO UPDATE SET candidate_ci_ids = EXCLUDED.candidate_ci_ids,
|
|
match_confidences = EXCLUDED.match_confidences,
|
|
detected_at = NOW()`,
|
|
[rowId, ciIds, confidences]
|
|
);
|
|
}
|
|
|
|
function pickBestCandidate(candidates: MatchCandidate[]): MatchCandidate | null {
|
|
if (candidates.length === 0) return null;
|
|
const ids = new Set(candidates.map((c) => c.configuration_item_id));
|
|
if (ids.size > 1) return null; // ambiguous — admin review
|
|
return candidates.reduce((best, c) =>
|
|
LINK_CONFIDENCE_RANK[c.link_confidence] > LINK_CONFIDENCE_RANK[best.link_confidence] ? c : best
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Run one reconciliation pass over unlinked rows. Idempotent — safe to run
|
|
* repeatedly. Caller should schedule via sync-scheduler.
|
|
*/
|
|
export async function reconcileUnlinkedDevices(opts?: {
|
|
limit?: number;
|
|
dryRun?: boolean;
|
|
}): Promise<ReconcileResult> {
|
|
const limit = opts?.limit ?? 500;
|
|
const dryRun = opts?.dryRun ?? false;
|
|
const result: ReconcileResult = {
|
|
scanned: 0,
|
|
linked: 0,
|
|
conflicts: 0,
|
|
unmatched: 0,
|
|
byConfidence: {
|
|
canonical: 0,
|
|
exact_uid: 0,
|
|
exact_serial: 0,
|
|
mac: 0,
|
|
hostname_in_company: 0,
|
|
manual: 0,
|
|
},
|
|
};
|
|
|
|
const unlinked = await postgresClient.query<UnlinkedRow>(
|
|
`SELECT id, source, source_id, hostname, serial, mac, company_id
|
|
FROM device_external_ids
|
|
WHERE configuration_item_id IS NULL
|
|
ORDER BY last_seen_at DESC NULLS LAST
|
|
LIMIT $1`,
|
|
[limit]
|
|
);
|
|
|
|
for (const row of unlinked.rows) {
|
|
result.scanned += 1;
|
|
const candidates: MatchCandidate[] = [];
|
|
if (row.serial) candidates.push(...(await findBySerial(row.serial)));
|
|
if (row.mac) candidates.push(...(await findByMac(row.mac)));
|
|
if (row.hostname) candidates.push(...(await findByHostnameInCompany(row.hostname, row.company_id)));
|
|
|
|
if (candidates.length === 0) {
|
|
result.unmatched += 1;
|
|
continue;
|
|
}
|
|
const ids = new Set(candidates.map((c) => c.configuration_item_id));
|
|
if (ids.size > 1) {
|
|
result.conflicts += 1;
|
|
if (!dryRun) {
|
|
await recordConflict(row.id, candidates);
|
|
}
|
|
continue;
|
|
}
|
|
const best = pickBestCandidate(candidates);
|
|
if (!best) {
|
|
result.unmatched += 1;
|
|
continue;
|
|
}
|
|
if (!dryRun) {
|
|
await applyLink(row.id, best.configuration_item_id, best.link_confidence);
|
|
}
|
|
result.linked += 1;
|
|
result.byConfidence[best.link_confidence] += 1;
|
|
}
|
|
|
|
return result;
|
|
}
|