wulf-pulse/lib/services/analyzer/asset-audit/data-builder.ts
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- 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>
2026-05-03 07:13:18 -04:00

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,
};