- 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>
368 lines
12 KiB
TypeScript
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’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>
|
|
);
|
|
}
|