wulf-pulse/components/analyzer/itglue-suggestions-panel.tsx
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

493 lines
17 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
ProviderToggle,
type AnalyzerProvider,
} from '@/components/analyzer/provider-toggle';
import {
Sparkles,
Loader2,
ExternalLink,
CheckCircle2,
Server,
Layers,
Database,
} from 'lucide-react';
import { useSession } from '@/lib/auth-client';
import { toast } from 'sonner';
interface FieldGap {
field_name: string;
why_missing_matters: string;
suggested_value: string | null;
evidence_ticket_numbers: string[];
confidence: 'high' | 'medium' | 'low';
}
interface NotePromotion {
quoted_note_text: string;
target_field: string;
suggested_value: string;
confidence: 'high' | 'medium' | 'low';
}
interface AuditRow {
id: string;
generated_at: string;
provider: 'anthropic' | 'openrouter';
ticket_count: number;
field_gaps: FieldGap[];
notes_promotions: NotePromotion[];
contradictions: { description: string; evidence: string }[];
overall_score: number | null;
estimated_cost_usd: number | null;
}
interface MatchedAsset {
id: string;
name: string | null;
hostname?: string | null;
type_name: string | null;
score: number;
matched_term: string;
latestAudit: AuditRow | null;
}
interface SuggestionsResponse {
ticketNumber: string;
organizationId: string | null;
organizationName: string | null;
flexibleAssets: MatchedAsset[];
configurations: MatchedAsset[];
}
const CONFIDENCE_TONE: Record<FieldGap['confidence'], string> = {
high: 'border-red-500 bg-red-500/10',
medium: 'border-amber-500 bg-amber-500/10',
low: 'border-blue-500 bg-blue-500/10',
};
interface ItglueSuggestionsPanelProps {
analysisId: string;
}
export function ItglueSuggestionsPanel({ analysisId }: ItglueSuggestionsPanelProps) {
const { data: session } = useSession();
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
const canWrite = role === 'admin' || role === 'super-admin';
const [data, setData] = useState<SuggestionsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [provider, setProvider] = useState<AnalyzerProvider>('anthropic');
const [auditing, setAuditing] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
async function loadSuggestions(): Promise<void> {
setLoading(true);
setLoadError(null);
try {
const res = await fetch(
`/api/analyzer/analyses/${analysisId}/itglue-suggestions`
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(err.error ?? `Request failed: ${res.status}`);
}
const d = (await res.json()) as SuggestionsResponse;
setData(d);
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}
async function runAudit(
assetType: 'flexible_asset' | 'configuration',
assetId: string
): Promise<void> {
const key = `${assetType}:${assetId}`;
setAuditing(key);
try {
const res = await fetch(
`/api/analyzer/analyses/${analysisId}/itglue-suggestions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assetType, assetId, provider }),
}
);
const d = await res.json();
if (!res.ok) throw new Error(d.message || d.error || 'Audit failed');
toast.success('Audit complete');
// Refresh the suggestions to pick up the new audit row.
await loadSuggestions();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Audit failed');
} finally {
setAuditing(null);
}
}
async function applyGap(
assetType: 'flexible_asset' | 'configuration',
assetId: string,
auditId: string,
gap: FieldGap | NotePromotion,
kind: 'field_gap' | 'note_promotion'
): Promise<void> {
if (!canWrite) return;
const fieldName =
kind === 'field_gap' ? (gap as FieldGap).field_name : (gap as NotePromotion).target_field;
const suggested =
kind === 'field_gap'
? (gap as FieldGap).suggested_value
: (gap as NotePromotion).suggested_value;
if (suggested === null || suggested === undefined || suggested === '') {
toast.error('No suggested value to apply');
return;
}
const evidence =
kind === 'field_gap'
? {
ticket_numbers: (gap as FieldGap).evidence_ticket_numbers,
gap_description: (gap as FieldGap).why_missing_matters,
}
: {
ticket_numbers: [],
gap_description: `Promoted from Notes: "${(gap as NotePromotion).quoted_note_text}"`,
};
const key = `${assetType}:${assetId}:${kind}:${fieldName}`;
setBusyKey(key);
try {
const path =
assetType === 'flexible_asset'
? `/api/analyzer/itglue/applications/${assetId}/apply`
: `/api/analyzer/itglue/configurations/${assetId}/apply`;
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
auditId,
fieldName,
suggestedValue: suggested,
sourceEvidence: evidence,
}),
});
const d = await res.json();
if (!res.ok) throw new Error(d.message || d.error || 'Apply failed');
toast.success(`Applied: ${fieldName}`);
await loadSuggestions();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Apply failed');
} finally {
setBusyKey(null);
}
}
const totalMatches = useMemo(() => {
if (!data) return 0;
return data.flexibleAssets.length + data.configurations.length;
}, [data]);
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Database className="w-5 h-5" />
<CardTitle className="text-base">IT Glue documentation</CardTitle>
</div>
<div className="flex items-center gap-2 flex-wrap">
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
<Button onClick={loadSuggestions} disabled={loading} size="sm">
{loading ? (
<>
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
Checking
</>
) : data ? (
<>
<Sparkles className="w-3.5 h-3.5 mr-1.5" />
Re-check
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5 mr-1.5" />
Check IT Glue documentation
</>
)}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{loadError && (
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t load suggestions</AlertTitle>
<AlertDescription>{loadError}</AlertDescription>
</Alert>
)}
{!data && !loading && !loadError && (
<p className="text-sm text-muted-foreground">
Click <strong>Check IT Glue documentation</strong> to find IT Glue
records this ticket touched and surface what should be documented.
</p>
)}
{data && (
<>
<p className="text-sm text-muted-foreground">
Matched <strong>{totalMatches}</strong> IT Glue record
{totalMatches === 1 ? '' : 's'} for{' '}
<strong>{data.organizationName ?? 'this client'}</strong>.
{totalMatches === 0 &&
' (No matches — ticket fingerprint did not mention any IT Glue assets we could find.)'}
</p>
{data.flexibleAssets.length > 0 && (
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1">
<Layers className="w-3.5 h-3.5" /> Applications ({data.flexibleAssets.length})
</h3>
{data.flexibleAssets.map((m) =>
renderAssetMatch(
m,
'flexible_asset',
auditing === `flexible_asset:${m.id}`,
busyKey,
canWrite,
() => runAudit('flexible_asset', m.id),
(g, k, auditId) => applyGap('flexible_asset', m.id, auditId, g, k)
)
)}
</section>
)}
{data.configurations.length > 0 && (
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1">
<Server className="w-3.5 h-3.5" /> Configurations ({data.configurations.length})
</h3>
{data.configurations.map((m) =>
renderAssetMatch(
m,
'configuration',
auditing === `configuration:${m.id}`,
busyKey,
canWrite,
() => runAudit('configuration', m.id),
(g, k, auditId) => applyGap('configuration', m.id, auditId, g, k)
)
)}
</section>
)}
</>
)}
</CardContent>
</Card>
);
}
function renderAssetMatch(
m: MatchedAsset,
assetType: 'flexible_asset' | 'configuration',
auditing: boolean,
busyKey: string | null,
canWrite: boolean,
onRunAudit: () => void,
onApply: (
gap: FieldGap | NotePromotion,
kind: 'field_gap' | 'note_promotion',
auditId: string
) => void
) {
const detailHref =
assetType === 'flexible_asset'
? `/analyzer/itglue/applications/${m.id}`
: `/analyzer/itglue/configurations/${m.id}`;
const audit = m.latestAudit;
return (
<div key={`${assetType}:${m.id}`} className="rounded-md border p-3 space-y-3">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<Link href={detailHref} className="font-medium hover:underline">
{m.name ?? m.id}
</Link>
<p className="text-xs text-muted-foreground mt-0.5">
{m.type_name ?? '—'}
{m.hostname && ` · ${m.hostname}`}
{' · '}match: <span className="font-mono">{m.matched_term}</span>
{audit?.overall_score !== null && audit?.overall_score !== undefined && (
<>
{' · '}score{' '}
<Badge
variant={
(audit.overall_score ?? 0) > 0.8
? 'default'
: (audit.overall_score ?? 0) > 0.5
? 'secondary'
: 'destructive'
}
className="text-[10px]"
>
{Math.round((audit.overall_score ?? 0) * 100)}%
</Badge>
</>
)}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button asChild variant="outline" size="sm">
<Link href={detailHref}>
Open
<ExternalLink className="w-3 h-3 ml-1" />
</Link>
</Button>
<Button onClick={onRunAudit} disabled={auditing} size="sm">
{auditing ? (
<>
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
Auditing
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5 mr-1.5" />
{audit ? 'Re-audit for this ticket' : 'Audit for this ticket'}
</>
)}
</Button>
</div>
</div>
{audit && (
<div className="space-y-2">
{audit.field_gaps.length === 0 && audit.notes_promotions.length === 0 && (
<p className="text-xs text-muted-foreground">
No new gaps surfaced from this ticket. Existing record looks
sufficient for what was learned.
</p>
)}
{audit.field_gaps.map((g) => {
const k = `${assetType}:${m.id}:field_gap:${g.field_name}`;
const busy = busyKey === k;
return (
<div
key={k}
className={`border-l-4 rounded p-2 ${CONFIDENCE_TONE[g.confidence]}`}
>
<div className="flex items-start justify-between gap-2 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{g.field_name}</p>
<p className="text-xs mt-0.5">{g.why_missing_matters}</p>
{g.suggested_value !== null && (
<p className="text-xs mt-1">
<span className="font-medium">Suggested: </span>
<span className="font-mono break-all">
{g.suggested_value}
</span>
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Badge variant="outline" className="text-[10px] uppercase">
{g.confidence}
</Badge>
<Button
size="sm"
disabled={
!canWrite ||
g.suggested_value === null ||
g.suggested_value === '' ||
busy
}
onClick={() => onApply(g, 'field_gap', audit.id)}
title={
!canWrite
? 'Requires admin'
: g.suggested_value === null
? 'No concrete suggestion'
: 'Apply to IT Glue'
}
>
{busy ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<CheckCircle2 className="w-3 h-3 mr-1" />
)}
Apply
</Button>
</div>
</div>
</div>
);
})}
{audit.notes_promotions.map((p, i) => {
const k = `${assetType}:${m.id}:note_promotion:${p.target_field}:${i}`;
const busy = busyKey === `${assetType}:${m.id}:note_promotion:${p.target_field}`;
return (
<div
key={k}
className="border-l-4 border-primary/40 bg-primary/5 rounded p-2"
>
<div className="flex items-start justify-between gap-2 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-xs font-mono italic text-muted-foreground break-words">
&ldquo;{p.quoted_note_text}&rdquo;
</p>
<p className="text-xs mt-1">
<span className="font-medium">{p.target_field}</span>:{' '}
<span className="font-mono break-all">{p.suggested_value}</span>
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<Badge variant="outline" className="text-[10px] uppercase">
{p.confidence}
</Badge>
<Button
size="sm"
disabled={!canWrite || busy}
onClick={() => onApply(p, 'note_promotion', audit.id)}
title={!canWrite ? 'Requires admin' : 'Apply to IT Glue'}
>
{busy ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<CheckCircle2 className="w-3 h-3 mr-1" />
)}
Apply
</Button>
</div>
</div>
</div>
);
})}
{audit.contradictions.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1">
{audit.contradictions.map((c, i) => (
<p key={i}>
{c.description}
<span className="ml-1"> {c.evidence}</span>
</p>
))}
</div>
)}
</div>
)}
</div>
);
}
interface MatchedAssetExt extends MatchedAsset {
hostname?: string | null;
}
void ({} as MatchedAssetExt);