- 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>
800 lines
29 KiB
TypeScript
800 lines
29 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState, use } 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 { Separator } from '@/components/ui/separator';
|
|
import {
|
|
ProviderToggle,
|
|
type AnalyzerProvider,
|
|
} from '@/components/analyzer/provider-toggle';
|
|
import { RmmScriptPicker } from '@/components/rmm/rmm-script-picker';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Sparkles,
|
|
Loader2,
|
|
ExternalLink,
|
|
AlertTriangle,
|
|
ArrowLeftRight,
|
|
CheckCircle2,
|
|
Undo2,
|
|
} from 'lucide-react';
|
|
import { useSession } from '@/lib/auth-client';
|
|
|
|
interface FieldRow {
|
|
id: string;
|
|
name: string;
|
|
kind: string | null;
|
|
hint: string | null;
|
|
required: boolean;
|
|
}
|
|
|
|
interface AssetDetail {
|
|
asset: {
|
|
id: string;
|
|
name: string | null;
|
|
organizationId: string | null;
|
|
organizationName: string | null;
|
|
flexibleAssetTypeId: string;
|
|
flexibleAssetTypeName: string | null;
|
|
autotaskCompanyId: string | null;
|
|
traits: Record<string, unknown>;
|
|
createdAt: string | null;
|
|
updatedAt: string | null;
|
|
};
|
|
fields: FieldRow[];
|
|
}
|
|
|
|
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 Contradiction {
|
|
description: string;
|
|
evidence: string;
|
|
}
|
|
|
|
interface AuditRow {
|
|
id: string;
|
|
generated_at: string;
|
|
provider: 'anthropic' | 'openrouter';
|
|
model_used: string | null;
|
|
ticket_count: number;
|
|
field_gaps: FieldGap[];
|
|
notes_promotions: NotePromotion[];
|
|
contradictions: Contradiction[];
|
|
overall_score: number | null;
|
|
estimated_cost_usd: number | null;
|
|
}
|
|
|
|
interface WriteRow {
|
|
id: string;
|
|
audit_id: string | null;
|
|
field_name: string;
|
|
before_value: unknown;
|
|
after_value: unknown;
|
|
performed_by_user_id: string | null;
|
|
performed_at: string;
|
|
status: 'pending' | 'committed' | 'failed' | 'reverted';
|
|
error_message: string | null;
|
|
}
|
|
|
|
interface XrefRow {
|
|
id: string;
|
|
ticketNumber: string;
|
|
analysisId: string | null;
|
|
relationship: 'referenced' | 'updated' | 'should_have_referenced';
|
|
source: string;
|
|
details: { write_id?: string; field_name?: string; relevance_reason?: string } | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
const CONFIDENCE_TONE: Record<FieldGap['confidence'], string> = {
|
|
high: 'border-red-500 bg-red-500/10 text-red-700 dark:text-red-300',
|
|
medium: 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300',
|
|
low: 'border-blue-500 bg-blue-500/10 text-blue-700 dark:text-blue-300',
|
|
};
|
|
|
|
function fieldNameToTraitKey(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
|
|
function formatTraitValue(v: unknown): string {
|
|
if (v === null || v === undefined) return '';
|
|
if (typeof v === 'string') return v;
|
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
if (Array.isArray(v)) return v.length === 0 ? '' : JSON.stringify(v);
|
|
if (typeof v === 'object') {
|
|
const obj = v as { values?: unknown[] };
|
|
if (Array.isArray(obj.values)) {
|
|
return obj.values
|
|
.map((it) => {
|
|
const o = it as { name?: string; 'first-name'?: string; 'last-name'?: string };
|
|
if (o.name) return o.name;
|
|
if (o['first-name'] || o['last-name'])
|
|
return [o['first-name'], o['last-name']].filter(Boolean).join(' ');
|
|
return JSON.stringify(it);
|
|
})
|
|
.join(', ');
|
|
}
|
|
return JSON.stringify(v).slice(0, 200);
|
|
}
|
|
return String(v);
|
|
}
|
|
|
|
function isPopulated(v: unknown): boolean {
|
|
if (v === null || v === undefined) return false;
|
|
if (typeof v === 'string') return v.trim().length > 0;
|
|
if (Array.isArray(v)) return v.length > 0;
|
|
if (typeof v === 'object') {
|
|
const obj = v as { values?: unknown[] };
|
|
if (Array.isArray(obj.values)) return obj.values.length > 0;
|
|
return Object.keys(v).length > 0;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export default function ApplicationAuditPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>;
|
|
}) {
|
|
const { id } = use(params);
|
|
const { data: session } = useSession();
|
|
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
|
|
const canWrite = role === 'admin' || role === 'super-admin';
|
|
|
|
const [detail, setDetail] = useState<AssetDetail | null>(null);
|
|
const [audit, setAudit] = useState<AuditRow | null>(null);
|
|
const [history, setHistory] = useState<AuditRow[]>([]);
|
|
const [writes, setWrites] = useState<WriteRow[]>([]);
|
|
const [xrefs, setXrefs] = useState<XrefRow[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [provider, setProvider] = useState<AnalyzerProvider>('anthropic');
|
|
const [running, setRunning] = useState(false);
|
|
const [busyKey, setBusyKey] = useState<string | null>(null);
|
|
|
|
async function loadAll(): Promise<void> {
|
|
try {
|
|
const [d, a, w, x] = await Promise.all([
|
|
fetch(`/api/analyzer/itglue/applications/${id}`).then((r) => r.json()),
|
|
fetch(`/api/analyzer/itglue/applications/${id}/audit?history=1`).then(
|
|
(r) => r.json()
|
|
),
|
|
fetch(`/api/analyzer/itglue/applications/${id}/writes`).then((r) =>
|
|
r.json()
|
|
),
|
|
fetch(`/api/analyzer/itglue/applications/${id}/xrefs`).then((r) =>
|
|
r.json()
|
|
),
|
|
]);
|
|
if (d.error) throw new Error(d.error);
|
|
setDetail(d as AssetDetail);
|
|
setAudit(a.audit ?? null);
|
|
setHistory(a.history ?? []);
|
|
setWrites(w.writes ?? []);
|
|
setXrefs(x.xrefs ?? []);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadAll();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [id]);
|
|
|
|
async function runAudit(): Promise<void> {
|
|
setRunning(true);
|
|
try {
|
|
const res = await fetch(`/api/analyzer/itglue/applications/${id}/audit`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ provider }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.message || data.error || 'Audit failed');
|
|
setAudit(data.audit);
|
|
// Refresh history.
|
|
void loadAll();
|
|
toast.success('Audit complete');
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Audit failed');
|
|
} finally {
|
|
setRunning(false);
|
|
}
|
|
}
|
|
|
|
async function applyGap(
|
|
gap: FieldGap | NotePromotion,
|
|
kind: 'field_gap' | 'note_promotion'
|
|
): Promise<void> {
|
|
if (!canWrite) return;
|
|
if (!audit) 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 = `${kind}:${fieldName}`;
|
|
setBusyKey(key);
|
|
try {
|
|
const res = await fetch(`/api/analyzer/itglue/applications/${id}/apply`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
auditId: audit.id,
|
|
fieldName,
|
|
suggestedValue: suggested,
|
|
sourceEvidence: evidence,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(data.message || data.error || 'Apply failed');
|
|
}
|
|
toast.success(`Applied: ${fieldName}`);
|
|
void loadAll();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Apply failed');
|
|
} finally {
|
|
setBusyKey(null);
|
|
}
|
|
}
|
|
|
|
async function revertWrite(writeId: string): Promise<void> {
|
|
if (!canWrite) return;
|
|
setBusyKey(`revert:${writeId}`);
|
|
try {
|
|
const res = await fetch(
|
|
`/api/analyzer/itglue/applications/${id}/revert/${writeId}`,
|
|
{ method: 'POST' }
|
|
);
|
|
const data = await res.json();
|
|
if (!res.ok)
|
|
throw new Error(data.message || data.error || 'Revert failed');
|
|
toast.success('Reverted');
|
|
void loadAll();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Revert failed');
|
|
} finally {
|
|
setBusyKey(null);
|
|
}
|
|
}
|
|
|
|
const orderedFields = useMemo(() => {
|
|
if (!detail) return [];
|
|
return detail.fields.map((f) => {
|
|
const traitKey = fieldNameToTraitKey(f.name);
|
|
const value = detail.asset.traits[traitKey];
|
|
return { ...f, traitKey, value, populated: isPopulated(value) };
|
|
});
|
|
}, [detail]);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="container mx-auto px-6 py-6 max-w-4xl">
|
|
<Alert variant="destructive">
|
|
<AlertTitle>Couldn’t load this asset</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
);
|
|
}
|
|
if (!detail) {
|
|
return (
|
|
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-4">
|
|
<Skeleton className="h-32 w-full" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const a = detail.asset;
|
|
const filledCount = orderedFields.filter((f) => f.populated).length;
|
|
const totalCount = orderedFields.length;
|
|
|
|
return (
|
|
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
|
{/* Header */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
<div className="space-y-1 min-w-0">
|
|
<p className="text-sm text-muted-foreground">
|
|
<Link
|
|
href="/analyzer/itglue/applications"
|
|
className="hover:underline"
|
|
>
|
|
Applications
|
|
</Link>{' '}
|
|
· {a.organizationName ?? 'Unknown org'}
|
|
</p>
|
|
<CardTitle className="text-2xl truncate">{a.name ?? a.id}</CardTitle>
|
|
<p className="text-xs text-muted-foreground">
|
|
{filledCount}/{totalCount} fields populated
|
|
{audit?.overall_score !== null && audit?.overall_score !== undefined ? (
|
|
<>
|
|
{' · '}
|
|
<Badge
|
|
variant={
|
|
(audit.overall_score ?? 0) > 0.8
|
|
? 'default'
|
|
: (audit.overall_score ?? 0) > 0.5
|
|
? 'secondary'
|
|
: 'destructive'
|
|
}
|
|
>
|
|
Audit score {Math.round((audit.overall_score ?? 0) * 100)}%
|
|
</Badge>
|
|
</>
|
|
) : null}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
|
|
<Button onClick={runAudit} disabled={running}>
|
|
{running ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Auditing…
|
|
</>
|
|
) : (
|
|
<>
|
|
<Sparkles className="w-4 h-4 mr-2" />
|
|
{audit ? 'Re-audit' : 'Run audit'}
|
|
</>
|
|
)}
|
|
</Button>
|
|
{a.autotaskCompanyId && (
|
|
<RmmScriptPicker
|
|
filter="site_anchor"
|
|
companyId={a.autotaskCompanyId}
|
|
onComplete={() => loadAll()}
|
|
/>
|
|
)}
|
|
<Button asChild variant="outline" size="sm">
|
|
<a
|
|
href={`https://wulf.itglue.com/${a.organizationId}/assets/${a.flexibleAssetTypeId}/records/${a.id}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Open in IT Glue
|
|
<ExternalLink className="w-3.5 h-3.5 ml-1.5" />
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
{/* Audit findings */}
|
|
{audit && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">
|
|
Audit findings
|
|
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
|
{new Date(audit.generated_at).toLocaleString()}
|
|
{' · '}
|
|
{audit.provider === 'openrouter' ? 'DeepSeek' : 'Claude'}
|
|
{audit.estimated_cost_usd !== null
|
|
? ` · $${audit.estimated_cost_usd.toFixed(4)}`
|
|
: ''}
|
|
{' · '}
|
|
{audit.ticket_count} ticket{audit.ticket_count === 1 ? '' : 's'}
|
|
</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Field gaps */}
|
|
<section>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
|
Field gaps ({audit.field_gaps.length})
|
|
</h3>
|
|
{audit.field_gaps.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No field gaps detected.</p>
|
|
) : (
|
|
<ul className="space-y-3">
|
|
{audit.field_gaps.map((g) => {
|
|
const key = `field_gap:${g.field_name}`;
|
|
const busy = busyKey === key;
|
|
return (
|
|
<li
|
|
key={key}
|
|
className={`border-l-4 rounded p-3 ${CONFIDENCE_TONE[g.confidence]}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-foreground">{g.field_name}</p>
|
|
<p className="text-sm mt-1">{g.why_missing_matters}</p>
|
|
{g.suggested_value !== null && (
|
|
<p className="text-sm mt-2">
|
|
<span className="font-medium">Suggested: </span>
|
|
<span className="font-mono">{g.suggested_value}</span>
|
|
</p>
|
|
)}
|
|
{g.evidence_ticket_numbers.length > 0 && (
|
|
<p className="text-xs mt-2 text-muted-foreground">
|
|
Evidence:{' '}
|
|
{g.evidence_ticket_numbers.map((tn, i) => (
|
|
<span key={tn}>
|
|
{i > 0 && ', '}
|
|
<Link
|
|
href={`/analyzer/ticket/${tn}`}
|
|
className="font-mono hover:underline"
|
|
>
|
|
{tn}
|
|
</Link>
|
|
</span>
|
|
))}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 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={() => applyGap(g, 'field_gap')}
|
|
title={
|
|
!canWrite
|
|
? 'Requires admin'
|
|
: g.suggested_value === null
|
|
? 'No concrete suggestion'
|
|
: 'Apply to IT Glue'
|
|
}
|
|
>
|
|
{busy ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
|
|
)}
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{/* Notes promotions */}
|
|
{audit.notes_promotions.length > 0 && (
|
|
<section>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
|
Promote from Notes ({audit.notes_promotions.length})
|
|
</h3>
|
|
<ul className="space-y-3">
|
|
{audit.notes_promotions.map((p, i) => {
|
|
const key = `note_promotion:${p.target_field}:${i}`;
|
|
const busy = busyKey === `note_promotion:${p.target_field}`;
|
|
return (
|
|
<li
|
|
key={key}
|
|
className="border-l-4 border-primary/40 bg-primary/5 rounded p-3"
|
|
>
|
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-mono italic text-muted-foreground">
|
|
“{p.quoted_note_text}”
|
|
</p>
|
|
<p className="text-sm mt-2">
|
|
Belongs in{' '}
|
|
<span className="font-medium">{p.target_field}</span>
|
|
:{' '}
|
|
<span className="font-mono">{p.suggested_value}</span>
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Badge variant="outline" className="text-[10px] uppercase">
|
|
{p.confidence}
|
|
</Badge>
|
|
<Button
|
|
size="sm"
|
|
disabled={!canWrite || busy}
|
|
onClick={() => applyGap(p, 'note_promotion')}
|
|
title={!canWrite ? 'Requires admin' : 'Apply to IT Glue'}
|
|
>
|
|
{busy ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
|
|
)}
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
{/* Contradictions */}
|
|
{audit.contradictions.length > 0 && (
|
|
<section>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
|
Contradictions ({audit.contradictions.length})
|
|
</h3>
|
|
<ul className="space-y-2">
|
|
{audit.contradictions.map((c, i) => (
|
|
<li
|
|
key={i}
|
|
className="flex items-start gap-2 text-sm p-3 rounded bg-muted/40"
|
|
>
|
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-600" />
|
|
<div>
|
|
<p>{c.description}</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{c.evidence}
|
|
</p>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!audit && (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
No audit yet. Click <strong>Run audit</strong> to analyze this asset.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Current fields */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Current fields</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<dl className="divide-y">
|
|
{orderedFields.map((f) => (
|
|
<div key={f.id} className="py-2 grid grid-cols-3 gap-3 text-sm">
|
|
<dt
|
|
className={`font-medium ${f.populated ? '' : 'text-muted-foreground'}`}
|
|
>
|
|
{f.name}
|
|
{f.required && <span className="text-red-500 ml-1">*</span>}
|
|
</dt>
|
|
<dd className="col-span-2 break-words">
|
|
{f.populated ? (
|
|
formatTraitValue(f.value)
|
|
) : (
|
|
<span className="text-muted-foreground italic">empty</span>
|
|
)}
|
|
{f.hint && !f.populated && (
|
|
<p className="text-xs text-muted-foreground mt-0.5">{f.hint}</p>
|
|
)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Tickets that touched this asset */}
|
|
{(xrefs.filter((x) => x.relationship === 'referenced').length > 0 ||
|
|
xrefs.filter((x) => x.relationship === 'updated').length > 0) && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Tickets that touched this asset</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{xrefs.filter((x) => x.relationship === 'referenced').length > 0 && (
|
|
<section>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
|
Referenced by ({xrefs.filter((x) => x.relationship === 'referenced').length})
|
|
</h3>
|
|
<ul className="space-y-1 text-sm">
|
|
{xrefs
|
|
.filter((x) => x.relationship === 'referenced')
|
|
.map((x) => (
|
|
<li key={x.id}>
|
|
<Link
|
|
href={`/analyzer/ticket/${x.ticketNumber}`}
|
|
className="font-mono hover:underline"
|
|
>
|
|
{x.ticketNumber}
|
|
</Link>
|
|
{x.details?.relevance_reason && (
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
— {x.details.relevance_reason}
|
|
</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
{xrefs.filter((x) => x.relationship === 'updated').length > 0 && (
|
|
<section>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
|
Updated by ({xrefs.filter((x) => x.relationship === 'updated').length})
|
|
</h3>
|
|
<ul className="space-y-1 text-sm">
|
|
{xrefs
|
|
.filter((x) => x.relationship === 'updated')
|
|
.map((x) => (
|
|
<li key={x.id}>
|
|
<Link
|
|
href={`/analyzer/ticket/${x.ticketNumber}`}
|
|
className="font-mono hover:underline"
|
|
>
|
|
{x.ticketNumber}
|
|
</Link>
|
|
{x.details?.field_name && (
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
— set <span className="font-mono">{x.details.field_name}</span>
|
|
</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Write history */}
|
|
{writes.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<ArrowLeftRight className="w-4 h-4" />
|
|
Write history ({writes.length})
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ul className="divide-y">
|
|
{writes.map((w) => (
|
|
<li key={w.id} className="py-3 flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm">
|
|
<span className="font-medium">{w.field_name}</span>
|
|
<Badge
|
|
variant={
|
|
w.status === 'committed'
|
|
? 'default'
|
|
: w.status === 'reverted'
|
|
? 'secondary'
|
|
: w.status === 'failed'
|
|
? 'destructive'
|
|
: 'outline'
|
|
}
|
|
className="ml-2 text-[10px]"
|
|
>
|
|
{w.status}
|
|
</Badge>
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{new Date(w.performed_at).toLocaleString()}
|
|
</p>
|
|
<p className="text-xs mt-1 break-words">
|
|
<span className="text-muted-foreground">Before: </span>
|
|
<span className="font-mono">
|
|
{w.before_value === null || w.before_value === undefined
|
|
? '(empty)'
|
|
: JSON.stringify(w.before_value).slice(0, 200)}
|
|
</span>
|
|
</p>
|
|
<p className="text-xs mt-0.5 break-words">
|
|
<span className="text-muted-foreground">After: </span>
|
|
<span className="font-mono">
|
|
{JSON.stringify(w.after_value).slice(0, 200)}
|
|
</span>
|
|
</p>
|
|
{w.error_message && (
|
|
<p className="text-xs mt-1 text-destructive">
|
|
Error: {w.error_message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{w.status === 'committed' && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={!canWrite || busyKey === `revert:${w.id}`}
|
|
onClick={() => revertWrite(w.id)}
|
|
title={!canWrite ? 'Requires admin' : 'Revert this write'}
|
|
>
|
|
{busyKey === `revert:${w.id}` ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<Undo2 className="w-3.5 h-3.5 mr-1" />
|
|
)}
|
|
Revert
|
|
</Button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Audit history */}
|
|
{history.length > 1 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Audit history</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ul className="divide-y">
|
|
{history.map((h) => (
|
|
<li
|
|
key={h.id}
|
|
className="py-2 flex items-center justify-between text-sm"
|
|
>
|
|
<span className="text-muted-foreground">
|
|
{new Date(h.generated_at).toLocaleString()}
|
|
{' · '}
|
|
{h.provider === 'openrouter' ? 'DeepSeek' : 'Claude'}
|
|
</span>
|
|
<span>
|
|
Score{' '}
|
|
<Badge variant="outline">
|
|
{h.overall_score !== null
|
|
? Math.round(h.overall_score * 100) + '%'
|
|
: 'n/a'}
|
|
</Badge>
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
<Separator />
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|