- 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>
719 lines
26 KiB
TypeScript
719 lines
26 KiB
TypeScript
/**
|
|
* Data builder for the IT Glue asset audit pipeline.
|
|
*
|
|
* Pulls every input the LLM needs to evaluate one IT Glue record (Application
|
|
* flexible asset OR Configuration) against ticket history and IT Glue's own
|
|
* field schema:
|
|
*
|
|
* 1. The asset's current contents (redacted).
|
|
* 2. The field schema with hints (IT Glue's per-field documentation, or
|
|
* curated hints for Configurations which don't expose a *_fields table).
|
|
* 3. Peer exemplars from the same client — well-filled assets of the same
|
|
* type, redacted.
|
|
* 4. Best-in-class exemplars across all clients of the same type.
|
|
* 5. Per-field fill-rate stats (per-client + global).
|
|
* 6. Recent ticket fingerprints whose applications/devices overlap the
|
|
* asset, with summaries.
|
|
*
|
|
* Phase 4.1 additions:
|
|
* - assetType dispatch: 'flexible_asset' | 'configuration'
|
|
* - ticketScopeAnalysisId: when set, ticket evidence is the single source
|
|
* analysis only, so the LLM looks for what *this ticket* taught us.
|
|
*/
|
|
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
import { redact } from '../itglue-redact';
|
|
import {
|
|
listLatestEvidenceForAsset,
|
|
listLatestEvidenceForCompany,
|
|
} from '@/lib/services/rmm/persistence';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
|
|
export type AuditAssetType = 'flexible_asset' | 'configuration';
|
|
|
|
/**
|
|
* Generic asset shape the prompt consumes. For flexible assets, `traits` is
|
|
* the IT Glue traits blob. For configurations, `traits` is a synthesized
|
|
* map of editable columns → current values, so the prompt stays
|
|
* type-agnostic.
|
|
*/
|
|
export interface AuditAssetRow {
|
|
id: string;
|
|
organization_id: string | null;
|
|
organization_name: string | null;
|
|
type_id: string | null;
|
|
type_name: string | null;
|
|
name: string | null;
|
|
traits: Record<string, unknown>;
|
|
}
|
|
|
|
export interface AuditFieldRow {
|
|
id: string;
|
|
name: string;
|
|
kind: string | null;
|
|
hint: string | null;
|
|
required: boolean;
|
|
}
|
|
|
|
export interface FillRateRow {
|
|
field_name: string;
|
|
fill_rate: number; // 0..1
|
|
}
|
|
|
|
export interface TicketEvidenceRow {
|
|
ticket_number: string;
|
|
triggered_at: string;
|
|
summary: string | null;
|
|
fingerprint: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Phase 4.2: live evidence captured by an Overshell execution. The audit
|
|
* pipeline injects this as a 7th LLM context arm.
|
|
*/
|
|
export interface RmmEvidenceRow {
|
|
execution_id: string;
|
|
script_id: string;
|
|
target_type: 'site_anchor' | 'asset_self';
|
|
target_hostname: string | null;
|
|
captured_at: string;
|
|
parsed: unknown;
|
|
}
|
|
|
|
export interface AssetAuditContext {
|
|
asset_type: AuditAssetType;
|
|
asset: AuditAssetRow;
|
|
type_id: number | null;
|
|
type_name: string | null;
|
|
fields: AuditFieldRow[];
|
|
peer_same_client: AuditAssetRow[];
|
|
peer_global: AuditAssetRow[];
|
|
fill_rate_client: FillRateRow[];
|
|
fill_rate_global: FillRateRow[];
|
|
ticket_evidence: TicketEvidenceRow[];
|
|
/** Set when the audit was launched from a single ticket's analysis. */
|
|
ticket_scope: { analysis_id: string; ticket_number: string } | null;
|
|
rmm_evidence: RmmEvidenceRow[];
|
|
}
|
|
|
|
// ─── Tunables ─────────────────────────────────────────────────────────────
|
|
|
|
const PEER_SAME_CLIENT_LIMIT = 5;
|
|
const PEER_GLOBAL_LIMIT = 3;
|
|
const TICKET_EVIDENCE_LIMIT = 20;
|
|
|
|
// ─── Errors ───────────────────────────────────────────────────────────────
|
|
|
|
export class AssetAuditNotFoundError extends Error {
|
|
constructor(assetType: AuditAssetType, assetId: string | number) {
|
|
super(`${assetType} ${assetId} not found in mirror`);
|
|
this.name = 'AssetAuditNotFoundError';
|
|
}
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
function fillCount(traits: Record<string, unknown> | null | undefined): number {
|
|
if (!traits) return 0;
|
|
let n = 0;
|
|
for (const k of Object.keys(traits)) {
|
|
const v = traits[k];
|
|
if (v === null || v === undefined) continue;
|
|
if (typeof v === 'string' && v.trim().length === 0) continue;
|
|
if (Array.isArray(v) && v.length === 0) continue;
|
|
n += 1;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// ─── Flexible-asset loaders ───────────────────────────────────────────────
|
|
|
|
interface RawFlexAsset {
|
|
id: string;
|
|
organization_id: string | null;
|
|
organization_name: string | null;
|
|
flexible_asset_type_id: string;
|
|
flexible_asset_type_name: string | null;
|
|
name: string | null;
|
|
traits: Record<string, unknown> | null;
|
|
}
|
|
|
|
function flexAssetToAudit(r: RawFlexAsset): AuditAssetRow {
|
|
return {
|
|
id: r.id,
|
|
organization_id: r.organization_id,
|
|
organization_name: r.organization_name,
|
|
type_id: r.flexible_asset_type_id,
|
|
type_name: r.flexible_asset_type_name,
|
|
name: r.name,
|
|
traits: r.traits ?? {},
|
|
};
|
|
}
|
|
|
|
async function loadFlexAsset(assetId: string | number): Promise<AuditAssetRow & { _raw: RawFlexAsset }> {
|
|
const res = await postgresClient.query<RawFlexAsset>(
|
|
`SELECT id::text AS id,
|
|
organization_id::text AS organization_id,
|
|
organization_name,
|
|
flexible_asset_type_id::text AS flexible_asset_type_id,
|
|
flexible_asset_type_name,
|
|
name,
|
|
traits
|
|
FROM itg_flexible_assets
|
|
WHERE id = $1
|
|
AND COALESCE(archived, false) = false
|
|
LIMIT 1`,
|
|
[assetId]
|
|
);
|
|
if (res.rowCount === 0) throw new AssetAuditNotFoundError('flexible_asset', assetId);
|
|
const r = res.rows[0];
|
|
return { ...flexAssetToAudit(r), _raw: r };
|
|
}
|
|
|
|
async function loadFlexFields(typeId: string): Promise<AuditFieldRow[]> {
|
|
const res = await postgresClient.query<AuditFieldRow>(
|
|
`SELECT id::text AS id, name, kind, hint, required
|
|
FROM itg_flexible_asset_fields
|
|
WHERE flexible_asset_type_id = $1
|
|
ORDER BY id`,
|
|
[typeId]
|
|
);
|
|
return res.rows;
|
|
}
|
|
|
|
async function loadFlexPeerSameClient(
|
|
orgId: string | null,
|
|
typeId: string,
|
|
excludeId: string,
|
|
limit: number
|
|
): Promise<AuditAssetRow[]> {
|
|
if (!orgId) return [];
|
|
const res = await postgresClient.query<RawFlexAsset>(
|
|
`SELECT id::text AS id,
|
|
organization_id::text AS organization_id,
|
|
organization_name,
|
|
flexible_asset_type_id::text AS flexible_asset_type_id,
|
|
flexible_asset_type_name,
|
|
name,
|
|
traits
|
|
FROM itg_flexible_assets
|
|
WHERE organization_id = $1
|
|
AND flexible_asset_type_id = $2
|
|
AND id <> $3
|
|
AND COALESCE(archived, false) = false`,
|
|
[orgId, typeId, excludeId]
|
|
);
|
|
const rows = res.rows.map(flexAssetToAudit);
|
|
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
|
return rows.slice(0, limit);
|
|
}
|
|
|
|
async function loadFlexPeerGlobal(
|
|
typeId: string,
|
|
excludeId: string,
|
|
excludeOrgId: string | null,
|
|
limit: number
|
|
): Promise<AuditAssetRow[]> {
|
|
const res = await postgresClient.query<RawFlexAsset>(
|
|
`SELECT id::text AS id,
|
|
organization_id::text AS organization_id,
|
|
organization_name,
|
|
flexible_asset_type_id::text AS flexible_asset_type_id,
|
|
flexible_asset_type_name,
|
|
name,
|
|
traits
|
|
FROM itg_flexible_assets
|
|
WHERE flexible_asset_type_id = $1
|
|
AND id <> $2
|
|
AND ($3::text IS NULL OR organization_id::text <> $3)
|
|
AND COALESCE(archived, false) = false
|
|
ORDER BY synced_at DESC
|
|
LIMIT 200`,
|
|
[typeId, excludeId, excludeOrgId]
|
|
);
|
|
const rows = res.rows.map(flexAssetToAudit);
|
|
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
|
return rows.slice(0, limit);
|
|
}
|
|
|
|
function flexFieldKey(name: string): string {
|
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
async function loadFlexFillRates(
|
|
typeId: string,
|
|
orgIdFilter: string | null,
|
|
fields: AuditFieldRow[]
|
|
): Promise<FillRateRow[]> {
|
|
const params: unknown[] = [typeId];
|
|
let where = `flexible_asset_type_id = $1 AND COALESCE(archived, false) = false`;
|
|
if (orgIdFilter) {
|
|
params.push(orgIdFilter);
|
|
where += ` AND organization_id = $${params.length}`;
|
|
}
|
|
const res = await postgresClient.query<{ traits: Record<string, unknown> | null }>(
|
|
`SELECT traits FROM itg_flexible_assets WHERE ${where}`,
|
|
params
|
|
);
|
|
const total = res.rowCount ?? 0;
|
|
if (total === 0) return fields.map((f) => ({ field_name: f.name, fill_rate: 0 }));
|
|
return fields.map((f) => {
|
|
const key = flexFieldKey(f.name);
|
|
let filled = 0;
|
|
for (const row of res.rows) {
|
|
const v = row.traits?.[key];
|
|
if (v === null || v === undefined) continue;
|
|
if (typeof v === 'string' && v.trim().length === 0) continue;
|
|
if (Array.isArray(v) && v.length === 0) continue;
|
|
if (
|
|
typeof v === 'object' &&
|
|
v !== null &&
|
|
'values' in (v as Record<string, unknown>) &&
|
|
Array.isArray((v as { values: unknown[] }).values) &&
|
|
(v as { values: unknown[] }).values.length === 0
|
|
) {
|
|
continue;
|
|
}
|
|
filled += 1;
|
|
}
|
|
return {
|
|
field_name: f.name,
|
|
fill_rate: Math.round((filled / total) * 100) / 100,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ─── Configuration loaders ────────────────────────────────────────────────
|
|
|
|
interface RawConfiguration {
|
|
id: string;
|
|
organization_id: string | null;
|
|
organization_name: string | null;
|
|
configuration_type_id: string | null;
|
|
configuration_type_name: string | null;
|
|
configuration_status_id: string | null;
|
|
configuration_status_name: string | null;
|
|
manufacturer_id: string | null;
|
|
manufacturer_name: string | null;
|
|
model_id: string | null;
|
|
model_name: string | null;
|
|
operating_system_id: string | null;
|
|
operating_system_name: string | null;
|
|
contact_id: string | null;
|
|
location_id: string | null;
|
|
name: string;
|
|
hostname: string | null;
|
|
primary_ip: string | null;
|
|
mac_address: string | null;
|
|
serial_number: string | null;
|
|
asset_tag: string | null;
|
|
position: string | null;
|
|
notes: string | null;
|
|
operating_system_notes: string | null;
|
|
}
|
|
|
|
const CONFIGURATION_FIELDS: AuditFieldRow[] = [
|
|
{ id: '1', name: 'name', kind: 'Text', hint: 'Display name. Match the hostname or a stable label techs recognize.', required: true },
|
|
{ id: '2', name: 'hostname', kind: 'Text', hint: 'FQDN or NetBIOS name as it appears on the network. Should match what shows up in DNS and on the asset itself.', required: false },
|
|
{ id: '3', name: 'primary_ip', kind: 'Text', hint: 'Primary IP address. Static where possible; capture even if DHCP-assigned for current state.', required: false },
|
|
{ id: '4', name: 'mac_address', kind: 'Text', hint: 'Primary network adapter MAC.', required: false },
|
|
{ id: '5', name: 'serial_number', kind: 'Text', hint: 'Hardware serial / VM UUID — needed for vendor warranty calls.', required: false },
|
|
{ id: '6', name: 'asset_tag', kind: 'Text', hint: 'Physical or logical asset tag if the customer uses one.', required: false },
|
|
{ id: '7', name: 'position', kind: 'Text', hint: 'Rack U position, room location, or VM cluster placement.', required: false },
|
|
{ id: '8', name: 'configuration_type_name', kind: 'Select', hint: 'Server, Workstation, Printer, Firewall, Switch, etc. Drives downstream filtering and audit prompts.', required: false },
|
|
{ id: '9', name: 'configuration_status_name', kind: 'Select', hint: 'Active, Inactive, Decommissioned, Spare. Stale Active records hide retired infra.', required: false },
|
|
{ id: '10', name: 'manufacturer_name', kind: 'Tag', hint: 'Hardware/VM platform vendor (Dell, HPE, VMware, Hyper-V).', required: false },
|
|
{ id: '11', name: 'model_name', kind: 'Tag', hint: 'Model identifier (PowerEdge R650, Surface Laptop 5).', required: false },
|
|
{ id: '12', name: 'operating_system_name', kind: 'Tag', hint: 'OS + version (Windows Server 2019, Ubuntu 22.04). Stale OS = stale patch picture.', required: false },
|
|
{ id: '13', name: 'operating_system_notes', kind: 'Textbox', hint: 'OS-specific gotchas: hotfix levels, schedule windows, named services and what they do, important roles installed (DC, DHCP, DNS, RDS).', required: false },
|
|
{ id: '14', name: 'notes', kind: 'Textbox', hint: 'Free-text general notes. Keep architectural details (integration paths, data flows) here only if no structured field fits.', required: false },
|
|
{ id: '15', name: 'contact_id', kind: 'Tag', hint: 'Primary user / responsible contact (workstation = end user; server = champion).', required: false },
|
|
{ id: '16', name: 'location_id', kind: 'Tag', hint: 'IT Glue location/site this configuration lives at.', required: false },
|
|
];
|
|
|
|
function configToAudit(r: RawConfiguration): AuditAssetRow {
|
|
// Synthesize a traits-style map for prompt consistency.
|
|
const traits: Record<string, unknown> = {
|
|
name: r.name ?? null,
|
|
hostname: r.hostname ?? null,
|
|
primary_ip: r.primary_ip ?? null,
|
|
mac_address: r.mac_address ?? null,
|
|
serial_number: r.serial_number ?? null,
|
|
asset_tag: r.asset_tag ?? null,
|
|
position: r.position ?? null,
|
|
configuration_type_name: r.configuration_type_name ?? null,
|
|
configuration_status_name: r.configuration_status_name ?? null,
|
|
manufacturer_name: r.manufacturer_name ?? null,
|
|
model_name: r.model_name ?? null,
|
|
operating_system_name: r.operating_system_name ?? null,
|
|
operating_system_notes: r.operating_system_notes ?? null,
|
|
notes: r.notes ?? null,
|
|
contact_id: r.contact_id ?? null,
|
|
location_id: r.location_id ?? null,
|
|
};
|
|
return {
|
|
id: r.id,
|
|
organization_id: r.organization_id,
|
|
organization_name: r.organization_name,
|
|
type_id: r.configuration_type_id,
|
|
type_name: r.configuration_type_name,
|
|
name: r.name,
|
|
traits,
|
|
};
|
|
}
|
|
|
|
const CONFIG_SELECT = `
|
|
id::text AS id,
|
|
organization_id::text AS organization_id,
|
|
organization_name,
|
|
configuration_type_id::text AS configuration_type_id,
|
|
configuration_type_name,
|
|
configuration_status_id::text AS configuration_status_id,
|
|
configuration_status_name,
|
|
manufacturer_id::text AS manufacturer_id,
|
|
manufacturer_name,
|
|
model_id::text AS model_id,
|
|
model_name,
|
|
operating_system_id::text AS operating_system_id,
|
|
operating_system_name,
|
|
contact_id::text AS contact_id,
|
|
location_id::text AS location_id,
|
|
name, hostname, primary_ip, mac_address, serial_number, asset_tag,
|
|
position, notes, operating_system_notes
|
|
`;
|
|
|
|
async function loadConfiguration(assetId: string | number): Promise<AuditAssetRow & { type_id_str: string | null }> {
|
|
const res = await postgresClient.query<RawConfiguration>(
|
|
`SELECT ${CONFIG_SELECT} FROM itg_configurations WHERE id = $1 LIMIT 1`,
|
|
[assetId]
|
|
);
|
|
if (res.rowCount === 0) throw new AssetAuditNotFoundError('configuration', assetId);
|
|
return { ...configToAudit(res.rows[0]), type_id_str: res.rows[0].configuration_type_id };
|
|
}
|
|
|
|
async function loadConfigPeerSameClient(
|
|
orgId: string | null,
|
|
typeId: string | null,
|
|
excludeId: string,
|
|
limit: number
|
|
): Promise<AuditAssetRow[]> {
|
|
if (!orgId || !typeId) return [];
|
|
const res = await postgresClient.query<RawConfiguration>(
|
|
`SELECT ${CONFIG_SELECT}
|
|
FROM itg_configurations
|
|
WHERE organization_id = $1
|
|
AND configuration_type_id = $2
|
|
AND id <> $3`,
|
|
[orgId, typeId, excludeId]
|
|
);
|
|
const rows = res.rows.map(configToAudit);
|
|
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
|
return rows.slice(0, limit);
|
|
}
|
|
|
|
async function loadConfigPeerGlobal(
|
|
typeId: string | null,
|
|
excludeId: string,
|
|
excludeOrgId: string | null,
|
|
limit: number
|
|
): Promise<AuditAssetRow[]> {
|
|
if (!typeId) return [];
|
|
const res = await postgresClient.query<RawConfiguration>(
|
|
`SELECT ${CONFIG_SELECT}
|
|
FROM itg_configurations
|
|
WHERE configuration_type_id = $1
|
|
AND id <> $2
|
|
AND ($3::text IS NULL OR organization_id::text <> $3)
|
|
LIMIT 200`,
|
|
[typeId, excludeId, excludeOrgId]
|
|
);
|
|
const rows = res.rows.map(configToAudit);
|
|
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
|
return rows.slice(0, limit);
|
|
}
|
|
|
|
async function loadConfigFillRates(
|
|
typeId: string | null,
|
|
orgIdFilter: string | null
|
|
): Promise<FillRateRow[]> {
|
|
if (!typeId) {
|
|
return CONFIGURATION_FIELDS.map((f) => ({ field_name: f.name, fill_rate: 0 }));
|
|
}
|
|
const params: unknown[] = [typeId];
|
|
let where = `configuration_type_id = $1`;
|
|
if (orgIdFilter) {
|
|
params.push(orgIdFilter);
|
|
where += ` AND organization_id = $${params.length}`;
|
|
}
|
|
const res = await postgresClient.query<RawConfiguration>(
|
|
`SELECT ${CONFIG_SELECT} FROM itg_configurations WHERE ${where}`,
|
|
params
|
|
);
|
|
const total = res.rowCount ?? 0;
|
|
if (total === 0) {
|
|
return CONFIGURATION_FIELDS.map((f) => ({ field_name: f.name, fill_rate: 0 }));
|
|
}
|
|
return CONFIGURATION_FIELDS.map((f) => {
|
|
let filled = 0;
|
|
for (const row of res.rows) {
|
|
const v = (row as unknown as Record<string, unknown>)[f.name];
|
|
if (v === null || v === undefined) continue;
|
|
if (typeof v === 'string' && v.trim().length === 0) continue;
|
|
filled += 1;
|
|
}
|
|
return {
|
|
field_name: f.name,
|
|
fill_rate: Math.round((filled / total) * 100) / 100,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ─── Ticket evidence (shared) ────────────────────────────────────────────
|
|
|
|
async function loadTicketEvidence(
|
|
orgId: string | null,
|
|
assetName: string | null,
|
|
limit: number
|
|
): Promise<TicketEvidenceRow[]> {
|
|
if (!orgId || !assetName) return [];
|
|
const compRes = await postgresClient.query<{ id: string }>(
|
|
`SELECT c.id::text AS id
|
|
FROM companies c
|
|
JOIN itg_organizations o ON LOWER(c.company_name) = LOWER(o.name)
|
|
WHERE o.id = $1
|
|
LIMIT 1`,
|
|
[orgId]
|
|
);
|
|
if (compRes.rowCount === 0) return [];
|
|
const companyId = compRes.rows[0].id;
|
|
const lowerName = assetName.toLowerCase();
|
|
const res = await postgresClient.query<{
|
|
ticket_number: string;
|
|
triggered_at: Date;
|
|
summary: string | null;
|
|
fingerprint: Record<string, unknown> | null;
|
|
}>(
|
|
`SELECT aa.ticket_number, aa.triggered_at, aa.summary,
|
|
aa.aggregate_fingerprint AS fingerprint
|
|
FROM analyzer_analyses aa
|
|
JOIN tickets t ON t.ticket_number = aa.ticket_number
|
|
WHERE t.company_id = $1
|
|
AND aa.status = 'complete'
|
|
AND aa.aggregate_fingerprint IS NOT NULL
|
|
AND (
|
|
LOWER(COALESCE(aa.summary, '')) LIKE '%' || $2 || '%'
|
|
OR aa.aggregate_fingerprint::text ILIKE '%' || $2 || '%'
|
|
)
|
|
ORDER BY aa.triggered_at DESC
|
|
LIMIT $3`,
|
|
[companyId, lowerName, limit]
|
|
);
|
|
return res.rows.map((r) => ({
|
|
ticket_number: r.ticket_number,
|
|
triggered_at: r.triggered_at.toISOString(),
|
|
summary: r.summary,
|
|
fingerprint: r.fingerprint ?? {},
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Ticket-scoped evidence — returns just the one analysis the audit was
|
|
* launched from. Used when the user clicks "Check IT Glue documentation"
|
|
* on a specific analysis page; the audit looks for what *this ticket*
|
|
* taught us, not all-time history.
|
|
*/
|
|
/**
|
|
* Phase 4.2: pull the latest successful Overshell evidence for the audit's
|
|
* client + asset. Site-anchored scripts: latest per script_id within 7
|
|
* days. Asset-self scripts: latest per script_id, no time limit
|
|
* (asset-self facts are durable until something changes).
|
|
*/
|
|
async function loadRmmEvidence(
|
|
itglueOrgId: string | null,
|
|
assetType: AuditAssetType,
|
|
assetId: string
|
|
): Promise<RmmEvidenceRow[]> {
|
|
if (!itglueOrgId) return [];
|
|
// Map IT Glue org → Autotask company (same join the ticket-evidence loader uses).
|
|
const compRes = await postgresClient.query<{ id: string }>(
|
|
`SELECT c.id::text AS id
|
|
FROM companies c
|
|
JOIN itg_organizations o ON LOWER(c.company_name) = LOWER(o.name)
|
|
WHERE o.id = $1
|
|
LIMIT 1`,
|
|
[itglueOrgId]
|
|
);
|
|
if (compRes.rowCount === 0) return [];
|
|
const companyId = compRes.rows[0].id;
|
|
|
|
const [siteAnchored, assetSelf] = await Promise.all([
|
|
listLatestEvidenceForCompany(companyId, 7),
|
|
listLatestEvidenceForAsset(assetType, assetId),
|
|
]);
|
|
|
|
// Combine: site-anchored first (general context), then asset-self.
|
|
const out: RmmEvidenceRow[] = [];
|
|
for (const e of siteAnchored) {
|
|
if (e.targetType !== 'site_anchor') continue;
|
|
out.push({
|
|
execution_id: e.id,
|
|
script_id: e.scriptId,
|
|
target_type: e.targetType,
|
|
target_hostname: e.targetHostname,
|
|
captured_at: e.completedAt ?? e.queuedAt,
|
|
parsed: redact(e.parsedEvidence ?? null),
|
|
});
|
|
}
|
|
for (const e of assetSelf) {
|
|
out.push({
|
|
execution_id: e.id,
|
|
script_id: e.scriptId,
|
|
target_type: e.targetType,
|
|
target_hostname: e.targetHostname,
|
|
captured_at: e.completedAt ?? e.queuedAt,
|
|
parsed: redact(e.parsedEvidence ?? null),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function loadSingleTicketEvidence(
|
|
analysisId: string
|
|
): Promise<TicketEvidenceRow[]> {
|
|
const res = await postgresClient.query<{
|
|
ticket_number: string;
|
|
triggered_at: Date;
|
|
summary: string | null;
|
|
fingerprint: Record<string, unknown> | null;
|
|
}>(
|
|
`SELECT ticket_number, triggered_at, summary,
|
|
aggregate_fingerprint AS fingerprint
|
|
FROM analyzer_analyses
|
|
WHERE id = $1 AND status = 'complete'
|
|
LIMIT 1`,
|
|
[analysisId]
|
|
);
|
|
if (res.rowCount === 0) return [];
|
|
const r = res.rows[0];
|
|
return [
|
|
{
|
|
ticket_number: r.ticket_number,
|
|
triggered_at: r.triggered_at.toISOString(),
|
|
summary: r.summary,
|
|
fingerprint: r.fingerprint ?? {},
|
|
},
|
|
];
|
|
}
|
|
|
|
// ─── Top-level builder ────────────────────────────────────────────────────
|
|
|
|
export interface BuildAssetAuditContextInput {
|
|
assetType: AuditAssetType;
|
|
assetId: string | number;
|
|
/** When set, ticket evidence is just this one analysis (ticket-first mode). */
|
|
ticketScopeAnalysisId?: string;
|
|
}
|
|
|
|
export async function buildAssetAuditContext(
|
|
input: BuildAssetAuditContextInput
|
|
): Promise<AssetAuditContext> {
|
|
const { assetType, assetId, ticketScopeAnalysisId } = input;
|
|
|
|
if (assetType === 'flexible_asset') {
|
|
const asset = await loadFlexAsset(assetId);
|
|
const typeId = asset.type_id ?? '';
|
|
const fields = await loadFlexFields(typeId);
|
|
|
|
const [peerSameClient, peerGlobal, fillRateClient, fillRateGlobal, rmmEvidence] =
|
|
await Promise.all([
|
|
loadFlexPeerSameClient(asset.organization_id, typeId, asset.id, PEER_SAME_CLIENT_LIMIT),
|
|
loadFlexPeerGlobal(typeId, asset.id, asset.organization_id, PEER_GLOBAL_LIMIT),
|
|
loadFlexFillRates(typeId, asset.organization_id, fields),
|
|
loadFlexFillRates(typeId, null, fields),
|
|
loadRmmEvidence(asset.organization_id, 'flexible_asset', asset.id),
|
|
]);
|
|
|
|
let ticketEvidence: TicketEvidenceRow[];
|
|
let ticketScope: AssetAuditContext['ticket_scope'] = null;
|
|
if (ticketScopeAnalysisId) {
|
|
ticketEvidence = await loadSingleTicketEvidence(ticketScopeAnalysisId);
|
|
if (ticketEvidence.length > 0) {
|
|
ticketScope = {
|
|
analysis_id: ticketScopeAnalysisId,
|
|
ticket_number: ticketEvidence[0].ticket_number,
|
|
};
|
|
}
|
|
} else {
|
|
ticketEvidence = await loadTicketEvidence(asset.organization_id, asset.name, TICKET_EVIDENCE_LIMIT);
|
|
}
|
|
|
|
return {
|
|
asset_type: 'flexible_asset',
|
|
asset: { ...asset, traits: redact(asset.traits) },
|
|
type_id: Number(typeId) || null,
|
|
type_name: asset.type_name,
|
|
fields,
|
|
peer_same_client: peerSameClient.map((p) => ({ ...p, traits: redact(p.traits) })),
|
|
peer_global: peerGlobal.map((p) => ({ ...p, traits: redact(p.traits) })),
|
|
fill_rate_client: fillRateClient,
|
|
fill_rate_global: fillRateGlobal,
|
|
ticket_evidence: ticketEvidence,
|
|
ticket_scope: ticketScope,
|
|
rmm_evidence: rmmEvidence,
|
|
};
|
|
}
|
|
|
|
// Configurations
|
|
const config = await loadConfiguration(assetId);
|
|
const typeId = config.type_id_str;
|
|
|
|
const [peerSameClient, peerGlobal, fillRateClient, fillRateGlobal, rmmEvidence] =
|
|
await Promise.all([
|
|
loadConfigPeerSameClient(config.organization_id, typeId, config.id, PEER_SAME_CLIENT_LIMIT),
|
|
loadConfigPeerGlobal(typeId, config.id, config.organization_id, PEER_GLOBAL_LIMIT),
|
|
loadConfigFillRates(typeId, config.organization_id),
|
|
loadConfigFillRates(typeId, null),
|
|
loadRmmEvidence(config.organization_id, 'configuration', config.id),
|
|
]);
|
|
|
|
let ticketEvidence: TicketEvidenceRow[];
|
|
let ticketScope: AssetAuditContext['ticket_scope'] = null;
|
|
if (ticketScopeAnalysisId) {
|
|
ticketEvidence = await loadSingleTicketEvidence(ticketScopeAnalysisId);
|
|
if (ticketEvidence.length > 0) {
|
|
ticketScope = {
|
|
analysis_id: ticketScopeAnalysisId,
|
|
ticket_number: ticketEvidence[0].ticket_number,
|
|
};
|
|
}
|
|
} else {
|
|
// For configurations, also try matching on hostname for richer evidence.
|
|
const matchTerm = config.name ?? (config.traits.hostname as string | null) ?? null;
|
|
ticketEvidence = await loadTicketEvidence(config.organization_id, matchTerm, TICKET_EVIDENCE_LIMIT);
|
|
}
|
|
|
|
return {
|
|
asset_type: 'configuration',
|
|
asset: { ...config, traits: redact(config.traits) },
|
|
type_id: typeId !== null ? Number(typeId) : null,
|
|
type_name: config.type_name,
|
|
fields: CONFIGURATION_FIELDS,
|
|
peer_same_client: peerSameClient.map((p) => ({ ...p, traits: redact(p.traits) })),
|
|
peer_global: peerGlobal.map((p) => ({ ...p, traits: redact(p.traits) })),
|
|
fill_rate_client: fillRateClient,
|
|
fill_rate_global: fillRateGlobal,
|
|
ticket_evidence: ticketEvidence,
|
|
ticket_scope: ticketScope,
|
|
rmm_evidence: rmmEvidence,
|
|
};
|
|
}
|
|
|
|
// Exported for tests + observability.
|
|
export const _ASSET_AUDIT_INTERNALS = {
|
|
PEER_SAME_CLIENT_LIMIT,
|
|
PEER_GLOBAL_LIMIT,
|
|
TICKET_EVIDENCE_LIMIT,
|
|
CONFIGURATION_FIELDS,
|
|
fillCount,
|
|
};
|