wulf-pulse/components/analyzer/related-tickets-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

368 lines
12 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { toast } from 'sonner';
import { Sparkles, Loader2, Network } from 'lucide-react';
import type {
AggregateReportStatus,
DiscoveredLinks,
TicketRef,
} from '@/lib/types/analyzer';
interface RelatedTicketsPanelProps {
ticketNumber: string;
/** LLM provider for the bundle run. Defaults to 'anthropic'. */
provider?: 'anthropic' | 'openrouter';
}
type Phase =
| 'idle'
| 'starting'
| 'pending_analyses'
| 'pending'
| 'running'
| 'complete'
| 'failed';
const REPORT_POLL_TIMEOUT_MS = 10 * 60 * 1000;
export function RelatedTicketsPanel({
ticketNumber,
provider = 'anthropic',
}: RelatedTicketsPanelProps) {
const router = useRouter();
const [links, setLinks] = useState<DiscoveredLinks | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [includeSuggested, setIncludeSuggested] = useState(false);
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
const [phase, setPhase] = useState<Phase>('idle');
const [statusLabel, setStatusLabel] = useState<string>('');
// Initial cheap fetch.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/links`
);
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const data = (await res.json()) as DiscoveredLinks;
if (cancelled) return;
setLinks(data);
// Pre-check all explicit refs.
setSelected(new Set(data.explicit.map((r) => r.ticket_number)));
} catch (err) {
if (!cancelled)
setLoadError(err instanceof Error ? err.message : 'Unknown error');
}
})();
return () => {
cancelled = true;
};
}, [ticketNumber]);
async function loadSuggestions(): Promise<void> {
if (!links) return;
setSuggestionsLoading(true);
try {
const res = await fetch(
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/links`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ includeSuggested: true }),
}
);
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const data = (await res.json()) as DiscoveredLinks;
setLinks(data);
} catch (err) {
toast.error(
err instanceof Error
? `Suggestion failed: ${err.message}`
: 'Suggestion failed'
);
setIncludeSuggested(false);
} finally {
setSuggestionsLoading(false);
}
}
function toggleRef(ref: TicketRef): void {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(ref.ticket_number)) next.delete(ref.ticket_number);
else next.add(ref.ticket_number);
return next;
});
}
async function pollReport(reportId: string): Promise<void> {
const start = Date.now();
while (Date.now() - start < REPORT_POLL_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, 3000));
const res = await fetch(`/api/analyzer/aggregate-reports/${reportId}`);
if (!res.ok) throw new Error(`Report poll failed: ${res.status}`);
const data = (await res.json()) as {
report: { status: AggregateReportStatus; errorMessage: string | null };
};
const status = data.report.status;
setPhase(status as Phase);
setStatusLabel(
status === 'pending_analyses'
? 'Analyzing linked tickets…'
: status === 'pending' || status === 'running'
? 'Building bundle report…'
: status === 'complete'
? 'Done'
: status === 'failed'
? 'Failed'
: ''
);
if (status === 'complete') {
router.push(`/analyzer/reports/${reportId}`);
return;
}
if (status === 'failed') {
throw new Error(data.report.errorMessage ?? 'Bundle report failed');
}
}
throw new Error('Bundle report timed out after 10 minutes');
}
async function submit(opts: { confirmedCost?: boolean } = {}): Promise<void> {
if (selected.size === 0) {
toast.error('Select at least one linked ticket');
return;
}
setPhase('starting');
setStatusLabel('Queueing analyses…');
try {
const res = await fetch(
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/analyze-bundle`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
linkedTicketNumbers: Array.from(selected),
includeItglueContext: true,
confirmedCost: opts.confirmedCost ?? false,
provider,
}),
}
);
if (res.status === 400) {
const data = (await res.json().catch(() => ({}))) as {
requiresConfirmation?: boolean;
message?: string;
estimatedCost?: number;
};
if (data.requiresConfirmation) {
const ok = window.confirm(
`${data.message ?? 'Confirmation required'}.\n\nEstimated cost: $${data.estimatedCost?.toFixed(2) ?? '?'}\n\nProceed?`
);
if (ok) {
await submit({ confirmedCost: true });
return;
}
setPhase('idle');
setStatusLabel('');
return;
}
throw new Error(data.message ?? 'Bundle request rejected');
}
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const data = (await res.json()) as {
aggregateReportId: string;
status: AggregateReportStatus;
};
setPhase(data.status as Phase);
setStatusLabel(
data.status === 'pending_analyses'
? 'Analyzing linked tickets…'
: 'Building bundle report…'
);
await pollReport(data.aggregateReportId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Bundle failed');
setPhase('idle');
setStatusLabel('');
}
}
const allRefs = useMemo<TicketRef[]>(
() => (links ? [...links.explicit, ...links.suggested] : []),
[links]
);
const isRunning = phase !== 'idle' && phase !== 'failed' && phase !== 'complete';
const selectedCount = selected.size;
const hasContent = links && (links.explicit.length > 0 || links.isProblemTicket);
if (loadError) {
return (
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t check for related tickets</AlertTitle>
<AlertDescription>{loadError}</AlertDescription>
</Alert>
);
}
if (links === null) {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
);
}
if (!hasContent) {
// Nothing to show — render nothing, the regular AnalyzeButton on the
// parent page is sufficient.
return null;
}
return (
<Card className={links.isProblemTicket ? 'border-primary' : ''}>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Network className="w-5 h-5" />
<CardTitle className="text-base">
Related tickets detected ({allRefs.length})
</CardTitle>
{links.isProblemTicket && (
<Badge variant="secondary">Problem ticket</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Switch
id="include-suggested"
checked={includeSuggested}
disabled={isRunning || suggestionsLoading}
onCheckedChange={(v) => {
const next = Boolean(v);
setIncludeSuggested(next);
if (next && links.suggested.length === 0) {
void loadSuggestions();
}
}}
/>
<Label htmlFor="include-suggested" className="text-xs">
{suggestionsLoading ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Asking AI
</span>
) : (
'AI-suggest more'
)}
</Label>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{links.isProblemTicket
? 'This looks like a problem/master ticket. Bundling will analyze every linked ticket and produce a cross-ticket report.'
: 'This ticket references other tickets. Bundle them to get a cross-ticket analysis.'}
</p>
<ul className="divide-y">
{allRefs.map((ref) => (
<li
key={ref.ticket_number + ':' + ref.source}
className="py-2 flex items-start gap-3"
>
<Checkbox
id={'rt-' + ref.ticket_number}
checked={selected.has(ref.ticket_number)}
onCheckedChange={() => toggleRef(ref)}
disabled={isRunning}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm">{ref.ticket_number}</span>
{ref.confidence === 'high' && (
<Badge variant="default" className="text-[10px] py-0">
explicit
</Badge>
)}
{ref.source === 'llm_suggested' && (
<Badge variant="outline" className="text-[10px] py-0">
AI-suggested
</Badge>
)}
{ref.status_label && (
<Badge variant="secondary" className="text-[10px] py-0">
{ref.status_label}
</Badge>
)}
</div>
{ref.title && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{ref.title}
</p>
)}
{ref.reason && (
<p className="text-xs text-muted-foreground italic mt-0.5">
{ref.reason}
</p>
)}
</div>
</li>
))}
</ul>
<div className="flex items-center gap-2 flex-wrap pt-2">
<Button
onClick={() => void submit()}
disabled={isRunning || selectedCount === 0}
variant={links.isProblemTicket ? 'default' : 'secondary'}
>
{isRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{statusLabel || 'Working…'}
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
Analyze with {selectedCount} linked ticket
{selectedCount === 1 ? '' : 's'}
</>
)}
</Button>
<span className="text-xs text-muted-foreground">
({selectedCount + 1} total master + linked)
</span>
</div>
</CardContent>
</Card>
);
}