- 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>
493 lines
17 KiB
TypeScript
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’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">
|
|
“{p.quoted_note_text}”
|
|
</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);
|