- 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>
264 lines
7.1 KiB
TypeScript
264 lines
7.1 KiB
TypeScript
/**
|
|
* Match an analyzer_analyses row to candidate IT Glue assets to audit.
|
|
*
|
|
* Inputs from the analysis fingerprint:
|
|
* - applications_involved → match against itg_flexible_assets.name (Application type)
|
|
* - device_classes / vendors_involved + ticket-mentioned device names →
|
|
* match against itg_configurations.name + hostname
|
|
*
|
|
* Same-client only (looked up via companies → itg_organizations name match).
|
|
* Score: exact match (3) > word-boundary match (2) > substring (1).
|
|
*/
|
|
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
|
|
const APPLICATION_TYPE_ID = 3790;
|
|
const PER_KIND_LIMIT = 5;
|
|
|
|
export interface MatchedFlexibleAsset {
|
|
id: string;
|
|
name: string | null;
|
|
type_name: string | null;
|
|
score: number;
|
|
matched_term: string;
|
|
}
|
|
|
|
export interface MatchedConfiguration {
|
|
id: string;
|
|
name: string;
|
|
hostname: string | null;
|
|
type_name: string | null;
|
|
score: number;
|
|
matched_term: string;
|
|
}
|
|
|
|
export interface AssetMatchResult {
|
|
ticketNumber: string;
|
|
organizationId: string | null;
|
|
organizationName: string | null;
|
|
flexibleAssets: MatchedFlexibleAsset[];
|
|
configurations: MatchedConfiguration[];
|
|
}
|
|
|
|
interface AnalysisRow {
|
|
ticket_number: string;
|
|
fingerprint: Record<string, unknown> | null;
|
|
company_id: string | null;
|
|
itglue_org_id: string | null;
|
|
itglue_org_name: string | null;
|
|
}
|
|
|
|
function normalizeTerm(s: string): string {
|
|
return s.toLowerCase().trim();
|
|
}
|
|
|
|
function scoreMatch(haystack: string | null, needle: string): number {
|
|
if (!haystack) return 0;
|
|
const h = haystack.toLowerCase();
|
|
const n = needle.toLowerCase();
|
|
if (h === n) return 3;
|
|
// Word boundary: surrounded by non-alphanum or start/end.
|
|
const re = new RegExp(`(^|[^a-z0-9])${n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`);
|
|
if (re.test(h)) return 2;
|
|
if (h.includes(n)) return 1;
|
|
return 0;
|
|
}
|
|
|
|
function dedupeByKey<T extends { id: string }>(items: T[]): T[] {
|
|
const seen = new Map<string, T>();
|
|
for (const item of items) {
|
|
const existing = seen.get(item.id);
|
|
if (
|
|
!existing ||
|
|
('score' in item &&
|
|
'score' in existing &&
|
|
(item as unknown as { score: number }).score >
|
|
(existing as unknown as { score: number }).score)
|
|
) {
|
|
seen.set(item.id, item);
|
|
}
|
|
}
|
|
return Array.from(seen.values());
|
|
}
|
|
|
|
/**
|
|
* Pull the analysis + ticket + IT Glue org mapping in one round-trip.
|
|
* Returns null if the analysis isn't complete or the company can't be
|
|
* mapped to an IT Glue organization.
|
|
*/
|
|
async function loadAnalysisContext(analysisId: string): Promise<AnalysisRow | null> {
|
|
const res = await postgresClient.query<AnalysisRow>(
|
|
`SELECT aa.ticket_number,
|
|
aa.aggregate_fingerprint AS fingerprint,
|
|
t.company_id::text AS company_id,
|
|
o.id::text AS itglue_org_id,
|
|
o.name AS itglue_org_name
|
|
FROM analyzer_analyses aa
|
|
JOIN tickets t ON t.ticket_number = aa.ticket_number
|
|
LEFT JOIN companies c ON c.id = t.company_id
|
|
LEFT JOIN itg_organizations o ON LOWER(o.name) = LOWER(c.company_name)
|
|
WHERE aa.id = $1
|
|
AND aa.status = 'complete'
|
|
LIMIT 1`,
|
|
[analysisId]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
return res.rows[0];
|
|
}
|
|
|
|
interface FlexAssetCandidate {
|
|
id: string;
|
|
name: string | null;
|
|
type_name: string | null;
|
|
}
|
|
|
|
interface ConfigCandidate {
|
|
id: string;
|
|
name: string;
|
|
hostname: string | null;
|
|
type_name: string | null;
|
|
}
|
|
|
|
async function loadFlexAssetCandidates(orgId: string): Promise<FlexAssetCandidate[]> {
|
|
const res = await postgresClient.query<FlexAssetCandidate>(
|
|
`SELECT id::text AS id,
|
|
name,
|
|
flexible_asset_type_name AS type_name
|
|
FROM itg_flexible_assets
|
|
WHERE organization_id = $1
|
|
AND flexible_asset_type_id = $2
|
|
AND COALESCE(archived, false) = false`,
|
|
[orgId, APPLICATION_TYPE_ID]
|
|
);
|
|
return res.rows;
|
|
}
|
|
|
|
async function loadConfigurationCandidates(
|
|
orgId: string
|
|
): Promise<ConfigCandidate[]> {
|
|
const res = await postgresClient.query<ConfigCandidate>(
|
|
`SELECT id::text AS id,
|
|
name, hostname,
|
|
configuration_type_name AS type_name
|
|
FROM itg_configurations
|
|
WHERE organization_id = $1`,
|
|
[orgId]
|
|
);
|
|
return res.rows;
|
|
}
|
|
|
|
function fingerprintTerms(fp: Record<string, unknown> | null): string[] {
|
|
if (!fp) return [];
|
|
const out = new Set<string>();
|
|
for (const key of [
|
|
'applications_involved',
|
|
'device_classes',
|
|
'vendors_involved',
|
|
]) {
|
|
const arr = fp[key];
|
|
if (Array.isArray(arr)) {
|
|
for (const v of arr) {
|
|
if (typeof v === 'string' && v.trim().length > 0) {
|
|
out.add(normalizeTerm(v));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return Array.from(out);
|
|
}
|
|
|
|
export async function matchAssetsForAnalysis(
|
|
analysisId: string
|
|
): Promise<AssetMatchResult | null> {
|
|
const ctx = await loadAnalysisContext(analysisId);
|
|
if (!ctx) return null;
|
|
if (!ctx.itglue_org_id) {
|
|
return {
|
|
ticketNumber: ctx.ticket_number,
|
|
organizationId: null,
|
|
organizationName: null,
|
|
flexibleAssets: [],
|
|
configurations: [],
|
|
};
|
|
}
|
|
|
|
const terms = fingerprintTerms(ctx.fingerprint);
|
|
if (terms.length === 0) {
|
|
return {
|
|
ticketNumber: ctx.ticket_number,
|
|
organizationId: ctx.itglue_org_id,
|
|
organizationName: ctx.itglue_org_name,
|
|
flexibleAssets: [],
|
|
configurations: [],
|
|
};
|
|
}
|
|
|
|
const [flexCandidates, configCandidates] = await Promise.all([
|
|
loadFlexAssetCandidates(ctx.itglue_org_id),
|
|
loadConfigurationCandidates(ctx.itglue_org_id),
|
|
]);
|
|
|
|
const flexHits: MatchedFlexibleAsset[] = [];
|
|
for (const c of flexCandidates) {
|
|
let bestScore = 0;
|
|
let bestTerm = '';
|
|
for (const term of terms) {
|
|
const s = scoreMatch(c.name, term);
|
|
if (s > bestScore) {
|
|
bestScore = s;
|
|
bestTerm = term;
|
|
}
|
|
}
|
|
if (bestScore > 0) {
|
|
flexHits.push({
|
|
id: c.id,
|
|
name: c.name,
|
|
type_name: c.type_name,
|
|
score: bestScore,
|
|
matched_term: bestTerm,
|
|
});
|
|
}
|
|
}
|
|
|
|
const configHits: MatchedConfiguration[] = [];
|
|
for (const c of configCandidates) {
|
|
let bestScore = 0;
|
|
let bestTerm = '';
|
|
for (const term of terms) {
|
|
const sName = scoreMatch(c.name, term);
|
|
const sHost = scoreMatch(c.hostname, term);
|
|
const s = Math.max(sName, sHost);
|
|
if (s > bestScore) {
|
|
bestScore = s;
|
|
bestTerm = term;
|
|
}
|
|
}
|
|
if (bestScore > 0) {
|
|
configHits.push({
|
|
id: c.id,
|
|
name: c.name,
|
|
hostname: c.hostname,
|
|
type_name: c.type_name,
|
|
score: bestScore,
|
|
matched_term: bestTerm,
|
|
});
|
|
}
|
|
}
|
|
|
|
const sortedFlex = dedupeByKey(flexHits)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, PER_KIND_LIMIT);
|
|
const sortedConfig = dedupeByKey(configHits)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, PER_KIND_LIMIT);
|
|
|
|
return {
|
|
ticketNumber: ctx.ticket_number,
|
|
organizationId: ctx.itglue_org_id,
|
|
organizationName: ctx.itglue_org_name,
|
|
flexibleAssets: sortedFlex,
|
|
configurations: sortedConfig,
|
|
};
|
|
}
|
|
|
|
export const _MATCHER_INTERNALS = { scoreMatch, fingerprintTerms };
|