- 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>
133 lines
4.3 KiB
TypeScript
133 lines
4.3 KiB
TypeScript
/**
|
|
* GET /api/admin/device-link-conflicts
|
|
* Returns unresolved device-link reviews with candidate CI details, paged.
|
|
* Query params:
|
|
* limit (default 50, max 200)
|
|
* offset (default 0)
|
|
* source (filter: 'datto_rmm' | 'itglue' | ...)
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { requirePermission } from '@/lib/auth-utils';
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
|
|
interface ReviewRow {
|
|
id: string;
|
|
detected_at: string;
|
|
xref_id: string;
|
|
source: string;
|
|
source_id: string;
|
|
hostname: string | null;
|
|
serial: string | null;
|
|
mac: string | null;
|
|
xref_company_id: string | null;
|
|
xref_company_name: string | null;
|
|
last_seen_at: string | null;
|
|
candidate_ci_ids: string[];
|
|
match_confidences: string[];
|
|
}
|
|
|
|
interface CiRow {
|
|
id: string;
|
|
reference_title: string | null;
|
|
serial_number: string | null;
|
|
rmm_device_audit_mac_address: string | null;
|
|
company_id: string | null;
|
|
company_name: string | null;
|
|
is_deleted: boolean;
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const { error } = await requirePermission('admin', 'access');
|
|
if (error) return error;
|
|
|
|
const url = request.nextUrl;
|
|
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10) || 50, 200);
|
|
const offset = Math.max(parseInt(url.searchParams.get('offset') ?? '0', 10) || 0, 0);
|
|
const source = url.searchParams.get('source');
|
|
|
|
const params: unknown[] = [limit, offset];
|
|
let sourceFilter = '';
|
|
if (source) {
|
|
params.push(source);
|
|
sourceFilter = `AND dx.source = $${params.length}`;
|
|
}
|
|
|
|
const reviews = await postgresClient.query<ReviewRow>(
|
|
`SELECT r.id::text, r.detected_at::text, r.candidate_ci_ids::text[],
|
|
r.match_confidences,
|
|
dx.id::text AS xref_id, dx.source, dx.source_id,
|
|
dx.hostname, dx.serial, dx.mac,
|
|
dx.company_id::text AS xref_company_id,
|
|
c.company_name AS xref_company_name,
|
|
dx.last_seen_at::text
|
|
FROM device_link_review r
|
|
JOIN device_external_ids dx ON dx.id = r.device_external_id
|
|
LEFT JOIN companies c ON c.id = dx.company_id
|
|
WHERE r.resolved_at IS NULL
|
|
${sourceFilter}
|
|
ORDER BY r.detected_at DESC
|
|
LIMIT $1 OFFSET $2`,
|
|
params
|
|
);
|
|
|
|
// Bulk-fetch all candidate CI details in one query.
|
|
const allCiIds = new Set<string>();
|
|
for (const r of reviews.rows) {
|
|
for (const id of r.candidate_ci_ids ?? []) allCiIds.add(String(id));
|
|
}
|
|
const ciDetails = new Map<string, CiRow>();
|
|
if (allCiIds.size > 0) {
|
|
const ciRes = await postgresClient.query<CiRow>(
|
|
`SELECT ci.id::text, ci.reference_title, ci.serial_number,
|
|
ci.rmm_device_audit_mac_address,
|
|
ci.company_id::text AS company_id,
|
|
c.company_name, COALESCE(ci.is_deleted, false) AS is_deleted
|
|
FROM configuration_items ci
|
|
LEFT JOIN companies c ON c.id = ci.company_id
|
|
WHERE ci.id = ANY($1::bigint[])`,
|
|
[Array.from(allCiIds)]
|
|
);
|
|
for (const ci of ciRes.rows) ciDetails.set(ci.id, ci);
|
|
}
|
|
|
|
const totalRes = await postgresClient.query<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count
|
|
FROM device_link_review r
|
|
JOIN device_external_ids dx ON dx.id = r.device_external_id
|
|
WHERE r.resolved_at IS NULL ${sourceFilter}`,
|
|
source ? [source] : []
|
|
);
|
|
const total = parseInt(totalRes.rows[0]?.count ?? '0', 10);
|
|
|
|
const items = reviews.rows.map((r) => ({
|
|
id: r.id,
|
|
detectedAt: r.detected_at,
|
|
xref: {
|
|
id: r.xref_id,
|
|
source: r.source,
|
|
sourceId: r.source_id,
|
|
hostname: r.hostname,
|
|
serial: r.serial,
|
|
mac: r.mac,
|
|
companyId: r.xref_company_id,
|
|
companyName: r.xref_company_name,
|
|
lastSeenAt: r.last_seen_at,
|
|
},
|
|
candidates: (r.candidate_ci_ids ?? []).map((ciId, i) => {
|
|
const ci = ciDetails.get(String(ciId));
|
|
return {
|
|
ciId: String(ciId),
|
|
confidence: r.match_confidences?.[i] ?? null,
|
|
hostname: ci?.reference_title ?? null,
|
|
serial: ci?.serial_number ?? null,
|
|
mac: ci?.rmm_device_audit_mac_address ?? null,
|
|
companyId: ci?.company_id ?? null,
|
|
companyName: ci?.company_name ?? null,
|
|
isDeleted: ci?.is_deleted ?? false,
|
|
};
|
|
}),
|
|
}));
|
|
|
|
return NextResponse.json({ items, total, limit, offset });
|
|
}
|