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>
This commit is contained in:
parent
378e68ad8a
commit
1112a06afe
132 changed files with 21352 additions and 743 deletions
253
app/admin/device-link-conflicts/page.tsx
Normal file
253
app/admin/device-link-conflicts/page.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Candidate {
|
||||
ciId: string;
|
||||
confidence: string | null;
|
||||
hostname: string | null;
|
||||
serial: string | null;
|
||||
mac: string | null;
|
||||
companyId: string | null;
|
||||
companyName: string | null;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
|
||||
interface Review {
|
||||
id: string;
|
||||
detectedAt: string;
|
||||
xref: {
|
||||
id: string;
|
||||
source: string;
|
||||
sourceId: string;
|
||||
hostname: string | null;
|
||||
serial: string | null;
|
||||
mac: string | null;
|
||||
companyId: string | null;
|
||||
companyName: string | null;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
candidates: Candidate[];
|
||||
}
|
||||
|
||||
const SOURCES = ['all', 'datto_rmm', 'itglue', 's1', 'veeam'] as const;
|
||||
type SourceFilter = (typeof SOURCES)[number];
|
||||
|
||||
function confidenceColor(c: string | null): 'default' | 'secondary' | 'outline' {
|
||||
if (c === 'exact_serial') return 'default';
|
||||
if (c === 'mac') return 'default';
|
||||
if (c === 'hostname_in_company') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
export default function DeviceLinkConflictsPage() {
|
||||
const [items, setItems] = useState<Review[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<SourceFilter>('all');
|
||||
const [resolving, setResolving] = useState<string | null>(null);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
setError(null);
|
||||
setItems(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '100' });
|
||||
if (source !== 'all') params.set('source', source);
|
||||
const res = await fetch(`/api/admin/device-link-conflicts?${params}`);
|
||||
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 { items: Review[]; total: number };
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [source]);
|
||||
|
||||
async function resolve(reviewId: string, ciId: string): Promise<void> {
|
||||
setResolving(`${reviewId}:${ciId}`);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/device-link-conflicts/${reviewId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ciId }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
toast.success(`Linked to CI ${ciId}`);
|
||||
setItems((prev) => prev?.filter((r) => r.id !== reviewId) ?? null);
|
||||
setTotal((t) => Math.max(0, t - 1));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Resolve failed');
|
||||
} finally {
|
||||
setResolving(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-6xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-amber-500" />
|
||||
Device-link conflicts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cases where the reconciler found two or more configuration_items
|
||||
matching one external device record. Pick the right CI to break the
|
||||
tie. Skipped rows stay unlinked until resolved.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">Source:</span>
|
||||
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOURCES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{total} unresolved {total === 1 ? 'conflict' : 'conflicts'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{items === null && !error && (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items !== null && items.length === 0 && !error && (
|
||||
<Alert>
|
||||
<CheckCircle2 className="size-4" />
|
||||
<AlertTitle>No conflicts</AlertTitle>
|
||||
<AlertDescription>
|
||||
Nothing waiting for review on this filter.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{items?.map((r) => (
|
||||
<Card key={r.id} className="border-amber-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-mono">
|
||||
{r.xref.source}:{r.xref.sourceId}
|
||||
</div>
|
||||
<div className="text-base font-medium">
|
||||
{r.xref.hostname ?? '(no hostname)'}
|
||||
{r.xref.companyName && (
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
@ {r.xref.companyName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{r.candidates.length} candidates
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-x-3">
|
||||
{r.xref.serial && <span>serial: {r.xref.serial}</span>}
|
||||
{r.xref.mac && <span>mac: {r.xref.mac}</span>}
|
||||
{r.xref.lastSeenAt && (
|
||||
<span>last seen: {new Date(r.xref.lastSeenAt).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{r.candidates.map((c) => {
|
||||
const isResolving = resolving === `${r.id}:${c.ciId}`;
|
||||
return (
|
||||
<div
|
||||
key={c.ciId}
|
||||
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
||||
>
|
||||
<div className="space-y-0.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium truncate">
|
||||
{c.hostname ?? '(no hostname)'}
|
||||
</span>
|
||||
{c.isDeleted && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
deleted
|
||||
</Badge>
|
||||
)}
|
||||
{c.confidence && (
|
||||
<Badge
|
||||
variant={confidenceColor(c.confidence)}
|
||||
className="text-xs"
|
||||
>
|
||||
{c.confidence}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-x-3">
|
||||
<span className="font-mono">CI {c.ciId}</span>
|
||||
{c.serial && <span>serial: {c.serial}</span>}
|
||||
{c.companyName && <span>@ {c.companyName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isResolving || c.isDeleted}
|
||||
onClick={() => void resolve(r.id, c.ciId)}
|
||||
>
|
||||
{isResolving ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin mr-1" />
|
||||
Linking…
|
||||
</>
|
||||
) : (
|
||||
'Link to this CI'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
app/admin/itglue-writes/page.tsx
Normal file
168
app/admin/itglue-writes/page.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
interface WriteRow {
|
||||
id: string;
|
||||
audit_id: string | null;
|
||||
asset_type: 'flexible_asset';
|
||||
asset_id: string;
|
||||
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;
|
||||
}
|
||||
|
||||
const STATUSES: Array<WriteRow['status'] | 'all'> = [
|
||||
'all',
|
||||
'committed',
|
||||
'reverted',
|
||||
'failed',
|
||||
'pending',
|
||||
];
|
||||
|
||||
function statusVariant(
|
||||
s: WriteRow['status']
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (s) {
|
||||
case 'committed':
|
||||
return 'default';
|
||||
case 'reverted':
|
||||
return 'secondary';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ItglueWritesPage() {
|
||||
const [rows, setRows] = useState<WriteRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<WriteRow['status'] | 'all'>('all');
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const url =
|
||||
statusFilter === 'all'
|
||||
? '/api/analyzer/itglue/writes'
|
||||
: `/api/analyzer/itglue/writes?status=${statusFilter}`;
|
||||
const res = await fetch(url);
|
||||
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 { writes: WriteRow[] };
|
||||
setRows(data.writes);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statusFilter]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<CardTitle>IT Glue write log</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Every PATCH to IT Glue from Pulse, with before/after diffs and
|
||||
revert history.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{STATUSES.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant={statusFilter === s ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter(s)}
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Couldn’t load writes</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{rows === null && !error ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : rows && rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No writes recorded yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{(rows ?? []).map((w) => (
|
||||
<li key={w.id} className="py-3">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm">
|
||||
<Link
|
||||
href={`/analyzer/itglue/applications/${w.asset_id}`}
|
||||
className="font-mono hover:underline"
|
||||
>
|
||||
{w.asset_id}
|
||||
</Link>
|
||||
{' · '}
|
||||
<span className="font-medium">{w.field_name}</span>
|
||||
</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>
|
||||
<Badge variant={statusVariant(w.status)}>{w.status}</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
321
app/admin/page.tsx
Normal file
321
app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
RefreshCw,
|
||||
Network,
|
||||
Globe,
|
||||
Smartphone,
|
||||
Radio,
|
||||
AlertTriangle,
|
||||
Workflow,
|
||||
GitBranch,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Bell,
|
||||
Sun,
|
||||
BarChart3,
|
||||
ScrollText,
|
||||
Database,
|
||||
SlidersHorizontal,
|
||||
Activity,
|
||||
Tv,
|
||||
Users,
|
||||
ShieldCheck,
|
||||
Settings as SettingsIcon,
|
||||
Shield,
|
||||
DollarSign,
|
||||
CalendarClock,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AdminCounts {
|
||||
linkConflicts: number;
|
||||
schedules: { enabled: number; total: number };
|
||||
unmappedAuvik: number;
|
||||
unmappedRmm: number;
|
||||
unmappedAddigy: number;
|
||||
}
|
||||
|
||||
interface NavTile {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
description?: string;
|
||||
badge?: { label: string; tone: 'warn' | 'info' | 'muted' };
|
||||
}
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
tiles: NavTile[];
|
||||
}
|
||||
|
||||
function tone(badge?: NavTile['badge']) {
|
||||
if (!badge) return null;
|
||||
const variant: 'destructive' | 'secondary' | 'outline' =
|
||||
badge.tone === 'warn' ? 'destructive' : badge.tone === 'info' ? 'secondary' : 'outline';
|
||||
return (
|
||||
<Badge variant={variant} className="text-[10px] font-mono ml-2 shrink-0">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminIndexPage() {
|
||||
const [counts, setCounts] = useState<AdminCounts | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const [overviewRes, mappingsRes] = await Promise.all([
|
||||
fetch('/api/dashboard/overview'),
|
||||
fetch('/api/dashboard/stats'),
|
||||
]);
|
||||
const overview = overviewRes.ok ? await overviewRes.json() : null;
|
||||
const mappings = mappingsRes.ok ? await mappingsRes.json() : null;
|
||||
setCounts({
|
||||
linkConflicts: overview?.attention?.linkConflicts ?? 0,
|
||||
schedules: overview?.attention?.schedules ?? { enabled: 0, total: 0 },
|
||||
unmappedAuvik: mappings?.mappings?.auvik?.unmapped ?? 0,
|
||||
unmappedRmm: mappings?.mappings?.rmm?.unmapped ?? 0,
|
||||
unmappedAddigy: 0,
|
||||
});
|
||||
} catch {
|
||||
// Counts are decorative — fail quiet.
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: 'Sync',
|
||||
tiles: [
|
||||
{
|
||||
title: 'Integrations & Sync',
|
||||
href: '/admin/sync',
|
||||
icon: RefreshCw,
|
||||
description: 'Overview of sync status across all integrations',
|
||||
badge: counts
|
||||
? {
|
||||
label: `${counts.schedules.enabled}/${counts.schedules.total} schedules on`,
|
||||
tone: 'muted',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{ title: 'Autotask', href: '/admin/sync/autotask', icon: RefreshCw },
|
||||
{ title: 'Datto RMM', href: '/admin/sync/datto-rmm', icon: Globe },
|
||||
{ title: 'IT Glue', href: '/admin/sync/itglue', icon: Shield },
|
||||
{ title: 'SentinelOne', href: '/admin/sync/sentinelone', icon: Shield },
|
||||
{ title: 'Veeam', href: '/admin/sync/veeam', icon: Activity },
|
||||
{ title: 'Auvik', href: '/admin/sync/auvik', icon: Network },
|
||||
{ title: 'Addigy', href: '/admin/sync/addigy', icon: Smartphone },
|
||||
{ title: 'Mimecast', href: '/admin/sync/mimecast', icon: Shield },
|
||||
{ title: 'Duo', href: '/admin/sync/duo', icon: Shield },
|
||||
{ title: 'QuickBooks Online', href: '/admin/qbo', icon: DollarSign },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Mappings',
|
||||
tiles: [
|
||||
{
|
||||
title: 'Device-Link Conflicts',
|
||||
href: '/admin/device-link-conflicts',
|
||||
icon: AlertTriangle,
|
||||
description: 'Resolve cases where one external device matches multiple Autotask CIs',
|
||||
badge:
|
||||
counts && counts.linkConflicts > 0
|
||||
? { label: counts.linkConflicts.toLocaleString(), tone: 'warn' }
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: 'NMS Mapping (Auvik)',
|
||||
href: '/auvik-mappings',
|
||||
icon: Network,
|
||||
description: 'Map Auvik tenants to companies',
|
||||
badge:
|
||||
counts && counts.unmappedAuvik > 0
|
||||
? { label: `${counts.unmappedAuvik} unmapped`, tone: 'info' }
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: 'RMM Mapping (Datto)',
|
||||
href: '/rmm-mappings',
|
||||
icon: Globe,
|
||||
description: 'Map RMM sites to companies',
|
||||
badge:
|
||||
counts && counts.unmappedRmm > 0
|
||||
? { label: `${counts.unmappedRmm} unmapped`, tone: 'info' }
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: 'Apple RMM (Addigy)',
|
||||
href: '/addigy-mappings',
|
||||
icon: Smartphone,
|
||||
description: 'Map Addigy devices to companies',
|
||||
},
|
||||
{
|
||||
title: 'SentinelOne Mappings',
|
||||
href: '/sentinelone/mappings',
|
||||
icon: Shield,
|
||||
description: 'Map S1 sites to companies (gap blocks reconciler)',
|
||||
},
|
||||
{
|
||||
title: 'Zabbix WAN Monitor',
|
||||
href: '/admin/zabbix-wan',
|
||||
icon: Radio,
|
||||
description: 'Sync RMM site WAN IPs to Zabbix with Autotask routing',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Workflow',
|
||||
tiles: [
|
||||
{
|
||||
title: 'Ticket Workflows',
|
||||
href: '/admin/workflow',
|
||||
icon: Workflow,
|
||||
description: 'Automated ticket triage and classification',
|
||||
},
|
||||
{
|
||||
title: 'Classification Rules',
|
||||
href: '/admin/workflow/classification-rules',
|
||||
icon: GitBranch,
|
||||
description: 'Keyword-based classification rules',
|
||||
},
|
||||
{
|
||||
title: 'AI Templates',
|
||||
href: '/admin/workflow/ai-templates',
|
||||
icon: Sparkles,
|
||||
description: 'AI prompt templates for enhancement',
|
||||
},
|
||||
{
|
||||
title: 'Webhook Pipelines',
|
||||
href: '/admin/workflow/pipelines',
|
||||
icon: Zap,
|
||||
description: 'Automated webhook processing workflows',
|
||||
},
|
||||
{
|
||||
title: 'Notification Channels',
|
||||
href: '/admin/workflow/channels',
|
||||
icon: Bell,
|
||||
description: 'Teams, Telegram, and webhook notifications',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Reports',
|
||||
tiles: [
|
||||
{
|
||||
title: 'Morning NOC Summary',
|
||||
href: '/admin/morning-summary',
|
||||
icon: Sun,
|
||||
description: 'Daily Zabbix overnight summary posted to Teams',
|
||||
},
|
||||
{
|
||||
title: 'Ticket Digest Reports',
|
||||
href: '/admin/ticket-digest',
|
||||
icon: BarChart3,
|
||||
description: 'LLM-analyzed ticket reports — daily/weekly/monthly',
|
||||
},
|
||||
{
|
||||
title: 'IT Glue Writes',
|
||||
href: '/admin/itglue-writes',
|
||||
icon: ScrollText,
|
||||
description: 'History of audit-driven IT Glue field writes',
|
||||
},
|
||||
{
|
||||
title: 'Audit Log',
|
||||
href: '/admin/audit-log',
|
||||
icon: ScrollText,
|
||||
description: 'System audit trail',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tools & Data',
|
||||
tiles: [
|
||||
{
|
||||
title: 'RMM Overshell',
|
||||
href: '/admin/rmm-overshell',
|
||||
icon: Database,
|
||||
description: 'Datto RMM PowerShell discovery — settings, executions, evidence pipeline',
|
||||
},
|
||||
{
|
||||
title: 'Data Browser',
|
||||
href: '/admin/data-browser',
|
||||
icon: Database,
|
||||
description: 'Browse and query system data',
|
||||
},
|
||||
{
|
||||
title: 'Display Settings',
|
||||
href: '/admin/display-settings',
|
||||
icon: SlidersHorizontal,
|
||||
description: 'Configure company filters for Kiosk and Mobile dashboards',
|
||||
},
|
||||
{
|
||||
title: 'Kiosk Settings',
|
||||
href: '/kiosk/settings',
|
||||
icon: Tv,
|
||||
description: 'Configure executive dashboard for TV display',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Access',
|
||||
tiles: [
|
||||
{ title: 'Users', href: '/admin/users', icon: Users },
|
||||
{ title: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
||||
{ title: 'Settings', href: '/admin/settings', icon: SettingsIcon },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-7xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Admin</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Sync, mappings, workflows, reporting, and tooling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{sections.map((section) => (
|
||||
<Card key={section.title}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{section.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{section.tiles.map((tile) => (
|
||||
<Link
|
||||
key={tile.href}
|
||||
href={tile.href}
|
||||
className="flex items-start gap-3 rounded-md p-2 -mx-2 hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<tile.icon className="size-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm font-medium truncate">{tile.title}</span>
|
||||
{tone(tile.badge)}
|
||||
</div>
|
||||
{tile.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tile.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
app/admin/rmm-overshell/page.tsx
Normal file
272
app/admin/rmm-overshell/page.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { Loader2, RefreshCw, Terminal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Settings {
|
||||
overshellComponentUid: string | null;
|
||||
overshellComponentName: string | null;
|
||||
overshellVariableName: string;
|
||||
discoveredAt: string | null;
|
||||
logliftComponentUid: string | null;
|
||||
logliftComponentName: string | null;
|
||||
logliftDiscoveredAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ExecRow {
|
||||
id: string;
|
||||
scriptId: string;
|
||||
jobName: string;
|
||||
targetHostname: string | null;
|
||||
status: 'queued' | 'running' | 'complete' | 'failed' | 'timeout';
|
||||
exitCode: number | null;
|
||||
errorMessage: string | null;
|
||||
performedByUserId: string | null;
|
||||
queuedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export default function RmmOvershellAdminPage() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [counts, setCounts] = useState<{ total: string; running: string; failed_24h: string } | null>(null);
|
||||
const [executions, setExecutions] = useState<ExecRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [discoveringLoglift, setDiscoveringLoglift] = useState(false);
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [s, e] = await Promise.all([
|
||||
fetch('/api/admin/rmm/settings').then((r) => r.json()),
|
||||
fetch('/api/rmm/executions?limit=50').then((r) => r.json()),
|
||||
]);
|
||||
if (s.error) throw new Error(s.error);
|
||||
setSettings(s.settings);
|
||||
setCounts(s.counts);
|
||||
setExecutions(e.executions ?? []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
async function discover() {
|
||||
setDiscovering(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/rmm/settings/discover', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Discovery failed');
|
||||
toast.success(`Found component: ${data.discovered?.name ?? 'unknown'}`);
|
||||
void loadAll();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Discovery failed');
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverLoglift() {
|
||||
setDiscoveringLoglift(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/rmm/settings/discover-loglift', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Discovery failed');
|
||||
toast.success(`Found LogLift component: ${data.discovered?.name ?? 'unknown'}`);
|
||||
void loadAll();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Discovery failed');
|
||||
} finally {
|
||||
setDiscoveringLoglift(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5" />
|
||||
RMM Overshell
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Datto RMM PowerShell evidence pipeline. Pulse dispatches scripts via
|
||||
the configured Overshell component; the worker polls for results
|
||||
and the audit pipeline pulls them in as live evidence.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load settings</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{settings === null && !error ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : settings ? (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">Overshell component</p>
|
||||
{settings.overshellComponentUid ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{settings.overshellComponentName}
|
||||
</p>
|
||||
<p className="text-[10px] font-mono text-muted-foreground">
|
||||
{settings.overshellComponentUid}
|
||||
</p>
|
||||
{settings.discoveredAt && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
discovered {new Date(settings.discoveredAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-amber-600 mt-0.5">
|
||||
No component cached. Click Discover to scan Datto RMM.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Variable name</p>
|
||||
<p className="text-xs font-mono text-muted-foreground mt-0.5">
|
||||
{settings.overshellVariableName}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Adjust if your component uses a different variable.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Activity (24h)</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{counts?.total ?? '0'} total · {counts?.running ?? '0'} running ·
|
||||
<span className="text-destructive">
|
||||
{' '}
|
||||
{counts?.failed_24h ?? '0'} failed
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button onClick={discover} disabled={discovering}>
|
||||
{discovering ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Discovering…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Re-discover Overshell
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="col-span-2 border-t pt-4">
|
||||
<p className="font-medium">LogLift component</p>
|
||||
{settings.logliftComponentUid ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{settings.logliftComponentName}
|
||||
</p>
|
||||
<p className="text-[10px] font-mono text-muted-foreground">
|
||||
{settings.logliftComponentUid}
|
||||
</p>
|
||||
{settings.logliftDiscoveredAt && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
discovered{' '}
|
||||
{new Date(settings.logliftDiscoveredAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-amber-600 mt-0.5">
|
||||
No LogLift component cached. Click below to scan Datto RMM
|
||||
for one named “loglift” or “eventlog”.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={discoverLoglift}
|
||||
disabled={discoveringLoglift}
|
||||
>
|
||||
{discoveringLoglift ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Discovering…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Re-discover LogLift
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Recent executions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{executions === null ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : executions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No executions yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{executions.map((e) => (
|
||||
<li key={e.id} className="py-2 grid grid-cols-12 gap-2 text-sm">
|
||||
<span className="col-span-3 font-mono truncate">{e.scriptId}</span>
|
||||
<span className="col-span-3 truncate">{e.targetHostname ?? '—'}</span>
|
||||
<span className="col-span-2">
|
||||
<Badge
|
||||
variant={
|
||||
e.status === 'complete'
|
||||
? 'default'
|
||||
: e.status === 'failed' || e.status === 'timeout'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{e.status}
|
||||
{e.exitCode !== null ? ` · exit ${e.exitCode}` : ''}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="col-span-3 text-xs text-muted-foreground truncate">
|
||||
{new Date(e.queuedAt).toLocaleString()}
|
||||
</span>
|
||||
<span className="col-span-1 text-right">
|
||||
{e.errorMessage && (
|
||||
<span className="text-xs text-destructive truncate">!</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useEffect, useState, use } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AnalysisView } from '@/components/analyzer/analysis-view';
|
||||
import { ItglueSuggestionsPanel } from '@/components/analyzer/itglue-suggestions-panel';
|
||||
import type { PersistedAnalysis } from '@/lib/types/analyzer';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
|
|
@ -50,7 +51,12 @@ export default function AnalysisDetailPage({
|
|||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
)}
|
||||
{analysis && <AnalysisView analysis={analysis} />}
|
||||
{analysis && (
|
||||
<div className="space-y-6">
|
||||
<AnalysisView analysis={analysis} />
|
||||
<ItglueSuggestionsPanel analysisId={analysis.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
800
app/analyzer/itglue/applications/[id]/page.tsx
Normal file
800
app/analyzer/itglue/applications/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,800 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
155
app/analyzer/itglue/applications/page.tsx
Normal file
155
app/analyzer/itglue/applications/page.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface ApplicationRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
organizationId: string | null;
|
||||
organizationName: string | null;
|
||||
traitCount: number;
|
||||
latestAudit: {
|
||||
id: string;
|
||||
generatedAt: string | null;
|
||||
overallScore: number | null;
|
||||
provider: 'anthropic' | 'openrouter' | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function scoreBadgeVariant(
|
||||
score: number | null
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (score === null) return 'outline';
|
||||
if (score > 0.8) return 'default';
|
||||
if (score > 0.5) return 'secondary';
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
export default function ApplicationsListPage() {
|
||||
const [rows, setRows] = useState<ApplicationRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/analyzer/itglue/applications');
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
const data = (await res.json()) as { applications: ApplicationRow[] };
|
||||
if (!cancelled) setRows(data.applications);
|
||||
} catch (err) {
|
||||
if (!cancelled)
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visible = rows
|
||||
? rows.filter((r) => {
|
||||
if (!filter.trim()) return true;
|
||||
const q = filter.toLowerCase();
|
||||
return (
|
||||
(r.name ?? '').toLowerCase().includes(q) ||
|
||||
(r.organizationName ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
|
||||
const auditedCount = rows
|
||||
? rows.filter((r) => r.latestAudit !== null).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<CardTitle>IT Glue applications audit</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{rows === null
|
||||
? 'Loading…'
|
||||
: `${rows.length} application records · ${auditedCount} audited`}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Filter by name or client…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load applications</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{rows === null && !error ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{visible.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="py-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/analyzer/itglue/applications/${r.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{r.name ?? r.id}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{r.organizationName ?? '—'}
|
||||
{' · '}
|
||||
{r.traitCount} field{r.traitCount === 1 ? '' : 's'} populated
|
||||
{r.latestAudit && (
|
||||
<>
|
||||
{' · '}
|
||||
last audited{' '}
|
||||
{r.latestAudit.generatedAt
|
||||
? new Date(r.latestAudit.generatedAt).toLocaleDateString()
|
||||
: 'unknown'}
|
||||
{' '}
|
||||
(
|
||||
{r.latestAudit.provider === 'openrouter'
|
||||
? 'DeepSeek'
|
||||
: 'Claude'}
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={scoreBadgeVariant(r.latestAudit?.overallScore ?? null)}>
|
||||
{r.latestAudit?.overallScore !== null &&
|
||||
r.latestAudit?.overallScore !== undefined
|
||||
? Math.round(r.latestAudit.overallScore * 100) + '%'
|
||||
: 'No audit'}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
762
app/analyzer/itglue/configurations/[id]/page.tsx
Normal file
762
app/analyzer/itglue/configurations/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,762 @@
|
|||
'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,
|
||||
Server,
|
||||
} 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;
|
||||
hostname: string | null;
|
||||
organizationId: string | null;
|
||||
organizationName: string | null;
|
||||
typeId: string | null;
|
||||
typeName: string | null;
|
||||
statusName: string | null;
|
||||
operatingSystemName: string | null;
|
||||
dattoDeviceUid: 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 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') return Object.keys(v).length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatValue(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);
|
||||
return JSON.stringify(v).slice(0, 200);
|
||||
}
|
||||
|
||||
export default function ConfigurationAuditPage({
|
||||
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/configurations/${id}`).then((r) => r.json()),
|
||||
fetch(`/api/analyzer/itglue/configurations/${id}/audit?history=1`).then(
|
||||
(r) => r.json()
|
||||
),
|
||||
fetch(`/api/analyzer/itglue/configurations/${id}/writes`).then((r) =>
|
||||
r.json()
|
||||
),
|
||||
fetch(`/api/analyzer/itglue/configurations/${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/configurations/${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);
|
||||
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 || !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/configurations/${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/configurations/${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 value = detail.asset.traits[f.name];
|
||||
return { ...f, 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 configuration</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;
|
||||
const referencedXrefs = xrefs.filter((x) => x.relationship === 'referenced');
|
||||
const updatedXrefs = xrefs.filter((x) => x.relationship === 'updated');
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
||||
<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/configurations"
|
||||
className="hover:underline"
|
||||
>
|
||||
Configurations
|
||||
</Link>{' '}
|
||||
· {a.organizationName ?? 'Unknown org'}
|
||||
{a.typeName && ` · ${a.typeName}`}
|
||||
</p>
|
||||
<CardTitle className="text-2xl truncate flex items-center gap-2">
|
||||
<Server className="w-6 h-6 text-muted-foreground" />
|
||||
{a.name}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{filledCount}/{totalCount} fields populated
|
||||
{a.statusName && ` · ${a.statusName}`}
|
||||
{a.operatingSystemName && ` · ${a.operatingSystemName}`}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</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.dattoDeviceUid && (
|
||||
<RmmScriptPicker
|
||||
filter="asset_self"
|
||||
deviceUid={a.dattoDeviceUid}
|
||||
hostname={a.hostname}
|
||||
companyId={a.autotaskCompanyId ?? undefined}
|
||||
assetType="configuration"
|
||||
assetId={a.id}
|
||||
onComplete={() => loadAll()}
|
||||
/>
|
||||
)}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
href={`https://wulf.itglue.com/${a.organizationId}/configurations/${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 && (
|
||||
<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">
|
||||
<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 break-all">{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>
|
||||
|
||||
{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 busy = busyKey === `note_promotion:${p.target_field}`;
|
||||
return (
|
||||
<li
|
||||
key={`np-${i}`}
|
||||
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 break-words">
|
||||
“{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 break-all">{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>
|
||||
)}
|
||||
|
||||
{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 configuration.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<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 ? (
|
||||
formatValue(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>
|
||||
|
||||
{(referencedXrefs.length > 0 || updatedXrefs.length > 0) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Tickets that touched this configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{referencedXrefs.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Referenced by ({referencedXrefs.length})
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{referencedXrefs.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>
|
||||
)}
|
||||
{updatedXrefs.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Updated by ({updatedXrefs.length})
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{updatedXrefs.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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
156
app/analyzer/itglue/configurations/page.tsx
Normal file
156
app/analyzer/itglue/configurations/page.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface ConfigurationRow {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
typeName: string | null;
|
||||
statusName: string | null;
|
||||
organizationId: string | null;
|
||||
organizationName: string | null;
|
||||
latestAudit: {
|
||||
id: string;
|
||||
generatedAt: string | null;
|
||||
overallScore: number | null;
|
||||
provider: 'anthropic' | 'openrouter' | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function scoreBadgeVariant(
|
||||
score: number | null
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (score === null) return 'outline';
|
||||
if (score > 0.8) return 'default';
|
||||
if (score > 0.5) return 'secondary';
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
export default function ConfigurationsListPage() {
|
||||
const [rows, setRows] = useState<ConfigurationRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/analyzer/itglue/configurations');
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
const data = (await res.json()) as { configurations: ConfigurationRow[] };
|
||||
if (!cancelled) setRows(data.configurations);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visible = rows
|
||||
? rows.filter((r) => {
|
||||
if (!filter.trim()) return true;
|
||||
const q = filter.toLowerCase();
|
||||
return (
|
||||
(r.name ?? '').toLowerCase().includes(q) ||
|
||||
(r.hostname ?? '').toLowerCase().includes(q) ||
|
||||
(r.organizationName ?? '').toLowerCase().includes(q) ||
|
||||
(r.typeName ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
|
||||
const auditedCount = rows ? rows.filter((r) => r.latestAudit !== null).length : 0;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<CardTitle>IT Glue configurations audit</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{rows === null
|
||||
? 'Loading…'
|
||||
: `${rows.length} configurations · ${auditedCount} audited`}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Filter by name, hostname, type, or client…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load configurations</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{rows === null && !error ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{visible.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="py-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/analyzer/itglue/configurations/${r.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{r.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{r.organizationName ?? '—'}
|
||||
{r.typeName && ` · ${r.typeName}`}
|
||||
{r.statusName && ` · ${r.statusName}`}
|
||||
{r.hostname && ` · ${r.hostname}`}
|
||||
{r.latestAudit && (
|
||||
<>
|
||||
{' · '}last audited{' '}
|
||||
{r.latestAudit.generatedAt
|
||||
? new Date(r.latestAudit.generatedAt).toLocaleDateString()
|
||||
: 'unknown'}
|
||||
{' '}
|
||||
(
|
||||
{r.latestAudit.provider === 'openrouter'
|
||||
? 'DeepSeek'
|
||||
: 'Claude'}
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={scoreBadgeVariant(r.latestAudit?.overallScore ?? null)}>
|
||||
{r.latestAudit?.overallScore !== null &&
|
||||
r.latestAudit?.overallScore !== undefined
|
||||
? Math.round(r.latestAudit.overallScore * 100) + '%'
|
||||
: 'No audit'}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
app/analyzer/itglue/sites/[companyId]/page.tsx
Normal file
234
app/analyzer/itglue/sites/[companyId]/page.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, use } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { RmmScriptPicker } from '@/components/rmm/rmm-script-picker';
|
||||
import { Server } from 'lucide-react';
|
||||
|
||||
interface SiteInfo {
|
||||
companyId: string;
|
||||
companyName: string | null;
|
||||
itglueOrgId: string | null;
|
||||
itglueOrgName: string | null;
|
||||
dattoSiteId: number | null;
|
||||
wnpHostname: string | null;
|
||||
wnpDeviceUid: string | null;
|
||||
wnpOnline: boolean | null;
|
||||
sites: Array<{
|
||||
device_uid: string;
|
||||
hostname: string | null;
|
||||
online: boolean;
|
||||
site_id: number;
|
||||
site_name: string;
|
||||
site_device_count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ExecRow {
|
||||
id: string;
|
||||
scriptId: string;
|
||||
jobName: string;
|
||||
targetHostname: string | null;
|
||||
status: 'queued' | 'running' | 'complete' | 'failed' | 'timeout';
|
||||
exitCode: number | null;
|
||||
parsedEvidence: unknown;
|
||||
queuedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export default function SiteDiscoveryPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ companyId: string }>;
|
||||
}) {
|
||||
const { companyId } = use(params);
|
||||
const [info, setInfo] = useState<SiteInfo | null>(null);
|
||||
const [executions, setExecutions] = useState<ExecRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadAll(): Promise<void> {
|
||||
try {
|
||||
const [s, e] = await Promise.all([
|
||||
fetch(`/api/analyzer/itglue/sites/${companyId}`).then((r) => r.json()),
|
||||
fetch(`/api/rmm/executions?companyId=${companyId}&limit=50`).then((r) =>
|
||||
r.json()
|
||||
),
|
||||
]);
|
||||
if (s.error) throw new Error(s.error);
|
||||
setInfo(s.site);
|
||||
setExecutions(e.executions ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [companyId]);
|
||||
|
||||
const recent = useMemo(() => executions ?? [], [executions]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load this site</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">Site discovery</p>
|
||||
<CardTitle className="text-2xl truncate">
|
||||
{info.companyName ?? `Company ${info.companyId}`}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{info.wnpHostname ? (
|
||||
<>
|
||||
<Server className="inline w-3 h-3 mr-1" />
|
||||
Primary target:{' '}
|
||||
<span className="font-mono">{info.wnpHostname}</span>
|
||||
{info.wnpOnline === false && (
|
||||
<Badge variant="destructive" className="ml-2 text-[10px]">
|
||||
offline
|
||||
</Badge>
|
||||
)}
|
||||
{info.sites.length > 1 && (
|
||||
<span className="ml-2">
|
||||
({info.sites.length} WNP endpoints across this client’s sites)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-amber-600">
|
||||
No Wulf Nurse Production endpoint registered for this client.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<RmmScriptPicker
|
||||
filter="site_anchor"
|
||||
companyId={info.companyId}
|
||||
onComplete={() => loadAll()}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Site-anchored discovery scripts run from the Wulf Nurse Production
|
||||
endpoint and use native PowerShell + AD/DHCP/DNS cmdlets to gather
|
||||
facts about the environment. Output is captured and made available
|
||||
to subsequent IT Glue audits as live evidence.
|
||||
</p>
|
||||
{info.sites.length > 1 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">
|
||||
All sites for this client
|
||||
</p>
|
||||
<ul className="text-xs space-y-1">
|
||||
{info.sites.map((s) => (
|
||||
<li
|
||||
key={s.device_uid}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span>
|
||||
<span className="font-mono">{s.hostname}</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{s.site_name} · {s.site_device_count} devices
|
||||
</span>
|
||||
{s.hostname === info.wnpHostname && (
|
||||
<Badge variant="secondary" className="ml-2 text-[10px]">
|
||||
primary
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
{!s.online && (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
offline
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
v1 dispatches site-anchored scripts to the primary target only.
|
||||
A future version will let you pick a specific site here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Recent runs ({recent.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recent.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No runs yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{recent.map((e) => (
|
||||
<li key={e.id} className="py-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{e.scriptId}{' '}
|
||||
<Badge
|
||||
variant={
|
||||
e.status === 'complete'
|
||||
? 'default'
|
||||
: e.status === 'failed' || e.status === 'timeout'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="ml-1 text-[10px]"
|
||||
>
|
||||
{e.status}
|
||||
</Badge>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{e.targetHostname ?? '—'} ·{' '}
|
||||
{new Date(e.queuedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{e.parsedEvidence !== null && e.parsedEvidence !== undefined && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-muted-foreground cursor-pointer">
|
||||
Show parsed evidence
|
||||
</summary>
|
||||
<pre className="text-[11px] bg-muted/50 rounded p-2 max-h-72 overflow-auto whitespace-pre-wrap mt-1">
|
||||
{JSON.stringify(e.parsedEvidence, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,12 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { AnalyzeButton } from '@/components/analyzer/analyze-button';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { RelatedTicketsPanel } from '@/components/analyzer/related-tickets-panel';
|
||||
import {
|
||||
ProviderToggle,
|
||||
type AnalyzerProvider,
|
||||
} from '@/components/analyzer/provider-toggle';
|
||||
import { Sparkles, Zap } from 'lucide-react';
|
||||
import type { PersistedAnalysis } from '@/lib/types/analyzer';
|
||||
|
||||
export default function TicketAnalyzerPage({
|
||||
|
|
@ -18,6 +23,7 @@ export default function TicketAnalyzerPage({
|
|||
const { ticketNumber } = use(params);
|
||||
const [analyses, setAnalyses] = useState<PersistedAnalysis[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [provider, setProvider] = useState<AnalyzerProvider>('anthropic');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -52,14 +58,18 @@ export default function TicketAnalyzerPage({
|
|||
<p className="text-sm text-muted-foreground">Ticket</p>
|
||||
<CardTitle className="font-mono">{ticketNumber}</CardTitle>
|
||||
</div>
|
||||
<AnalyzeButton ticketNumber={ticketNumber} />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
|
||||
<AnalyzeButton ticketNumber={ticketNumber} provider={provider} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click <strong>Analyze</strong> to run the AI pipeline. If a current
|
||||
analysis already exists, you’ll be navigated straight to it.
|
||||
Otherwise the run takes ~10–60 seconds.
|
||||
Click <strong>Analyze</strong> to run the AI pipeline using the
|
||||
selected provider. Each provider keeps its own analysis history,
|
||||
so you can compare Claude and DeepSeek output side-by-side. A run
|
||||
with the same content hash on the same provider returns instantly.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -71,6 +81,9 @@ export default function TicketAnalyzerPage({
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<RelatedTicketsPanel ticketNumber={ticketNumber} provider={provider} />
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Analysis history</CardTitle>
|
||||
|
|
@ -87,40 +100,64 @@ export default function TicketAnalyzerPage({
|
|||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{(analyses ?? []).map((a) => (
|
||||
<li key={a.id} className="py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/analyzer/analysis/${a.id}`}
|
||||
className="font-medium hover:underline flex items-center gap-2"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Version {a.analysisVersion}
|
||||
{latest?.id === a.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
latest
|
||||
{(analyses ?? []).map((a) => {
|
||||
const isOpenRouter = a.provider === 'openrouter';
|
||||
const tierLabel = isOpenRouter
|
||||
? a.opusUsed
|
||||
? 'V4 Flash → V4 Pro → R1'
|
||||
: a.sonnetUsed
|
||||
? 'V4 Flash → V4 Pro'
|
||||
: 'V4 Flash'
|
||||
: a.opusUsed
|
||||
? 'Haiku → Sonnet → Opus'
|
||||
: a.sonnetUsed
|
||||
? 'Haiku → Sonnet'
|
||||
: 'Haiku';
|
||||
const ProviderIcon = isOpenRouter ? Zap : Sparkles;
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className="py-3 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/analyzer/analysis/${a.id}`}
|
||||
className="font-medium hover:underline flex items-center gap-2 flex-wrap"
|
||||
>
|
||||
<ProviderIcon className="w-4 h-4" />
|
||||
Version {a.analysisVersion}
|
||||
<Badge
|
||||
variant={isOpenRouter ? 'default' : 'secondary'}
|
||||
className="text-[10px] py-0"
|
||||
>
|
||||
{isOpenRouter ? 'DeepSeek' : 'Claude'}
|
||||
</Badge>
|
||||
)}
|
||||
{a.needsHumanReview && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Needs review
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(a.triggeredAt).toLocaleString()}
|
||||
{' · '}
|
||||
{a.opusUsed ? 'Haiku → Sonnet → Opus' : a.sonnetUsed ? 'Haiku → Sonnet' : 'Haiku'}
|
||||
{' · '}${a.estimatedCostUsd.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
{a.confidenceScore !== null && (
|
||||
<Badge variant="outline">
|
||||
{Math.round(a.confidenceScore * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{latest?.id === a.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
latest
|
||||
</Badge>
|
||||
)}
|
||||
{a.needsHumanReview && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Needs review
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(a.triggeredAt).toLocaleString()}
|
||||
{' · '}
|
||||
{tierLabel}
|
||||
{' · '}${a.estimatedCostUsd.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
{a.confidenceScore !== null && (
|
||||
<Badge variant="outline">
|
||||
{Math.round(a.confidenceScore * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
98
app/api/admin/device-link-conflicts/[id]/resolve/route.ts
Normal file
98
app/api/admin/device-link-conflicts/[id]/resolve/route.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* POST /api/admin/device-link-conflicts/[id]/resolve
|
||||
* Body: { ciId: string, note?: string }
|
||||
*
|
||||
* Resolves a conflict by manually picking a configuration_item to link the
|
||||
* underlying device_external_ids row to. Sets link_confidence='manual' and
|
||||
* marks the review row resolved.
|
||||
*
|
||||
* Validates that ciId is in candidate_ci_ids — admins can't pick an arbitrary
|
||||
* CI here. (For an arbitrary-CI override, separate flow.)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const ResolveBody = z.object({
|
||||
ciId: z.string().regex(/^\d+$/, 'ciId must be numeric'),
|
||||
note: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
if (!/^[0-9a-f-]{36}$/i.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid review id' }, { status: 400 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
const parsed = ResolveBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid payload', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const ciIdNum = Number(parsed.data.ciId);
|
||||
|
||||
return postgresClient.transaction(async (tx) => {
|
||||
const reviewRes = await tx.query<{
|
||||
device_external_id: string;
|
||||
candidate_ci_ids: string[];
|
||||
resolved_at: string | null;
|
||||
}>(
|
||||
`SELECT device_external_id::text, candidate_ci_ids::text[], resolved_at::text
|
||||
FROM device_link_review
|
||||
WHERE id = $1
|
||||
FOR UPDATE`,
|
||||
[id]
|
||||
);
|
||||
if (reviewRes.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Review not found' }, { status: 404 });
|
||||
}
|
||||
const review = reviewRes.rows[0];
|
||||
if (review.resolved_at) {
|
||||
return NextResponse.json({ error: 'Already resolved' }, { status: 409 });
|
||||
}
|
||||
if (!review.candidate_ci_ids.map(String).includes(String(ciIdNum))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ciId must be one of the conflict candidates' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await tx.query(
|
||||
`UPDATE device_external_ids
|
||||
SET configuration_item_id = $2,
|
||||
link_confidence = 'manual',
|
||||
linked_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[review.device_external_id, ciIdNum]
|
||||
);
|
||||
|
||||
await tx.query(
|
||||
`UPDATE device_link_review
|
||||
SET resolved_at = NOW(),
|
||||
resolved_by_user_id = $2,
|
||||
resolved_to_ci_id = $3,
|
||||
resolution_note = $4
|
||||
WHERE id = $1`,
|
||||
[id, session?.user?.id ?? null, ciIdNum, parsed.data.note ?? null]
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, resolvedToCiId: String(ciIdNum) });
|
||||
});
|
||||
}
|
||||
133
app/api/admin/device-link-conflicts/route.ts
Normal file
133
app/api/admin/device-link-conflicts/route.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* GET /api/admin/device-link-conflicts
|
||||
* Returns unresolved device-link reviews with candidate CI details, paged.
|
||||
* Query params:
|
||||
* limit (default 50, max 200)
|
||||
* offset (default 0)
|
||||
* source (filter: 'datto_rmm' | 'itglue' | ...)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
interface ReviewRow {
|
||||
id: string;
|
||||
detected_at: string;
|
||||
xref_id: string;
|
||||
source: string;
|
||||
source_id: string;
|
||||
hostname: string | null;
|
||||
serial: string | null;
|
||||
mac: string | null;
|
||||
xref_company_id: string | null;
|
||||
xref_company_name: string | null;
|
||||
last_seen_at: string | null;
|
||||
candidate_ci_ids: string[];
|
||||
match_confidences: string[];
|
||||
}
|
||||
|
||||
interface CiRow {
|
||||
id: string;
|
||||
reference_title: string | null;
|
||||
serial_number: string | null;
|
||||
rmm_device_audit_mac_address: string | null;
|
||||
company_id: string | null;
|
||||
company_name: string | null;
|
||||
is_deleted: boolean;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
|
||||
const url = request.nextUrl;
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10) || 50, 200);
|
||||
const offset = Math.max(parseInt(url.searchParams.get('offset') ?? '0', 10) || 0, 0);
|
||||
const source = url.searchParams.get('source');
|
||||
|
||||
const params: unknown[] = [limit, offset];
|
||||
let sourceFilter = '';
|
||||
if (source) {
|
||||
params.push(source);
|
||||
sourceFilter = `AND dx.source = $${params.length}`;
|
||||
}
|
||||
|
||||
const reviews = await postgresClient.query<ReviewRow>(
|
||||
`SELECT r.id::text, r.detected_at::text, r.candidate_ci_ids::text[],
|
||||
r.match_confidences,
|
||||
dx.id::text AS xref_id, dx.source, dx.source_id,
|
||||
dx.hostname, dx.serial, dx.mac,
|
||||
dx.company_id::text AS xref_company_id,
|
||||
c.company_name AS xref_company_name,
|
||||
dx.last_seen_at::text
|
||||
FROM device_link_review r
|
||||
JOIN device_external_ids dx ON dx.id = r.device_external_id
|
||||
LEFT JOIN companies c ON c.id = dx.company_id
|
||||
WHERE r.resolved_at IS NULL
|
||||
${sourceFilter}
|
||||
ORDER BY r.detected_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
params
|
||||
);
|
||||
|
||||
// Bulk-fetch all candidate CI details in one query.
|
||||
const allCiIds = new Set<string>();
|
||||
for (const r of reviews.rows) {
|
||||
for (const id of r.candidate_ci_ids ?? []) allCiIds.add(String(id));
|
||||
}
|
||||
const ciDetails = new Map<string, CiRow>();
|
||||
if (allCiIds.size > 0) {
|
||||
const ciRes = await postgresClient.query<CiRow>(
|
||||
`SELECT ci.id::text, ci.reference_title, ci.serial_number,
|
||||
ci.rmm_device_audit_mac_address,
|
||||
ci.company_id::text AS company_id,
|
||||
c.company_name, COALESCE(ci.is_deleted, false) AS is_deleted
|
||||
FROM configuration_items ci
|
||||
LEFT JOIN companies c ON c.id = ci.company_id
|
||||
WHERE ci.id = ANY($1::bigint[])`,
|
||||
[Array.from(allCiIds)]
|
||||
);
|
||||
for (const ci of ciRes.rows) ciDetails.set(ci.id, ci);
|
||||
}
|
||||
|
||||
const totalRes = await postgresClient.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM device_link_review r
|
||||
JOIN device_external_ids dx ON dx.id = r.device_external_id
|
||||
WHERE r.resolved_at IS NULL ${sourceFilter}`,
|
||||
source ? [source] : []
|
||||
);
|
||||
const total = parseInt(totalRes.rows[0]?.count ?? '0', 10);
|
||||
|
||||
const items = reviews.rows.map((r) => ({
|
||||
id: r.id,
|
||||
detectedAt: r.detected_at,
|
||||
xref: {
|
||||
id: r.xref_id,
|
||||
source: r.source,
|
||||
sourceId: r.source_id,
|
||||
hostname: r.hostname,
|
||||
serial: r.serial,
|
||||
mac: r.mac,
|
||||
companyId: r.xref_company_id,
|
||||
companyName: r.xref_company_name,
|
||||
lastSeenAt: r.last_seen_at,
|
||||
},
|
||||
candidates: (r.candidate_ci_ids ?? []).map((ciId, i) => {
|
||||
const ci = ciDetails.get(String(ciId));
|
||||
return {
|
||||
ciId: String(ciId),
|
||||
confidence: r.match_confidences?.[i] ?? null,
|
||||
hostname: ci?.reference_title ?? null,
|
||||
serial: ci?.serial_number ?? null,
|
||||
mac: ci?.rmm_device_audit_mac_address ?? null,
|
||||
companyId: ci?.company_id ?? null,
|
||||
companyName: ci?.company_name ?? null,
|
||||
isDeleted: ci?.is_deleted ?? false,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ items, total, limit, offset });
|
||||
}
|
||||
41
app/api/admin/rmm/settings/discover-loglift/route.ts
Normal file
41
app/api/admin/rmm/settings/discover-loglift/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* POST /api/admin/rmm/settings/discover-loglift
|
||||
*
|
||||
* Force a re-scan of Datto RMM components, find the LogLift / event-log
|
||||
* collector component, and update rmm_settings. Returns the discovered
|
||||
* { uid, name } or 404 if nothing matches the discovery pattern.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import { discoverLogliftComponent } from '@/lib/services/rmm/settings';
|
||||
|
||||
export async function POST(_request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
try {
|
||||
const result = await discoverLogliftComponent();
|
||||
if (!result.discovered) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'No matching component found',
|
||||
message:
|
||||
'Datto RMM did not return any component whose name matches /loglift|eventlog/i. Register the LogLift collector component and retry.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
settings: result.settings,
|
||||
discovered: result.discovered,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Discovery failed',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
app/api/admin/rmm/settings/discover/route.ts
Normal file
38
app/api/admin/rmm/settings/discover/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* POST /api/admin/rmm/settings/discover
|
||||
*
|
||||
* Force a re-scan of Datto RMM components, find the Overshell, and update
|
||||
* rmm_settings. Returns the discovered { uid, name } or 404 if nothing
|
||||
* matches the discovery pattern.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import { discoverOvershellComponent } from '@/lib/services/rmm/settings';
|
||||
|
||||
export async function POST(_request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
try {
|
||||
const result = await discoverOvershellComponent();
|
||||
if (!result.discovered) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'No matching component found',
|
||||
message:
|
||||
'Datto RMM did not return any component whose name matches /overshell/i. Register a component (Account → ComStore → Run Command or similar) and retry.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ settings: result.settings, discovered: result.discovered });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Discovery failed',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
61
app/api/admin/rmm/settings/route.ts
Normal file
61
app/api/admin/rmm/settings/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* GET /api/admin/rmm/settings
|
||||
* Returns the cached Overshell config + recent execution counts.
|
||||
*
|
||||
* PATCH /api/admin/rmm/settings
|
||||
* Body: { overshellVariableName: string }
|
||||
* Updates the variable name the Overshell component expects.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import {
|
||||
getRmmSettings,
|
||||
updateOvershellVariableName,
|
||||
} from '@/lib/services/rmm/settings';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const PatchBody = z.object({
|
||||
overshellVariableName: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
interface CountsRow {
|
||||
total: string;
|
||||
running: string;
|
||||
failed_24h: string;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
const settings = await getRmmSettings();
|
||||
const counts = await postgresClient.query<CountsRow>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*)::text FROM rmm_executions) AS total,
|
||||
(SELECT COUNT(*)::text FROM rmm_executions WHERE status = 'running') AS running,
|
||||
(SELECT COUNT(*)::text FROM rmm_executions
|
||||
WHERE status = 'failed' AND queued_at >= NOW() - INTERVAL '24 hours') AS failed_24h`
|
||||
);
|
||||
return NextResponse.json({
|
||||
settings,
|
||||
counts: counts.rows[0] ?? { total: '0', running: '0', failed_24h: '0' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = PatchBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const settings = await updateOvershellVariableName(
|
||||
parsed.data.overshellVariableName
|
||||
);
|
||||
return NextResponse.json({ settings });
|
||||
}
|
||||
144
app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts
Normal file
144
app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* GET /api/analyzer/analyses/:id/itglue-suggestions
|
||||
* Returns matched IT Glue assets (flexible_asset + configuration) for the
|
||||
* analysis, plus any existing ticket-scoped audits keyed by (assetType, assetId).
|
||||
*
|
||||
* POST /api/analyzer/analyses/:id/itglue-suggestions
|
||||
* Body: { assetType: 'flexible_asset' | 'configuration', assetId, provider? }
|
||||
* Runs a ticket-scoped audit (evidence = just this analysis). Returns the
|
||||
* audit row.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { matchAssetsForAnalysis } from '@/lib/services/analyzer/asset-audit/asset-matcher';
|
||||
import { runAssetAudit } from '@/lib/services/analyzer/asset-audit/runner';
|
||||
import {
|
||||
getAssetAuditById,
|
||||
getLatestTicketScopedAudit,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import {
|
||||
evaluateCost,
|
||||
recordCostAuditDecision,
|
||||
} from '@/lib/services/analyzer/cost-guard';
|
||||
import { ProviderEnum } from '@/lib/types/analyzer';
|
||||
|
||||
const PER_AUDIT_COST_USD: Record<'anthropic' | 'openrouter', number> = {
|
||||
anthropic: 0.05,
|
||||
openrouter: 0.005,
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
const matched = await matchAssetsForAnalysis(id);
|
||||
if (!matched) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Analysis not found or not yet complete' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// For each match, look up an existing ticket-scoped audit if any.
|
||||
const flexWithAudits = await Promise.all(
|
||||
matched.flexibleAssets.map(async (m) => ({
|
||||
...m,
|
||||
latestAudit: await getLatestTicketScopedAudit(id, 'flexible_asset', m.id),
|
||||
}))
|
||||
);
|
||||
const configWithAudits = await Promise.all(
|
||||
matched.configurations.map(async (m) => ({
|
||||
...m,
|
||||
latestAudit: await getLatestTicketScopedAudit(id, 'configuration', m.id),
|
||||
}))
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
ticketNumber: matched.ticketNumber,
|
||||
organizationId: matched.organizationId,
|
||||
organizationName: matched.organizationName,
|
||||
flexibleAssets: flexWithAudits,
|
||||
configurations: configWithAudits,
|
||||
});
|
||||
}
|
||||
|
||||
const PostBody = z.object({
|
||||
assetType: z.enum(['flexible_asset', 'configuration']),
|
||||
assetId: z.union([z.string(), z.number()]),
|
||||
provider: ProviderEnum.optional().default('anthropic'),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id: analysisId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = PostBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { assetType, assetId, provider } = parsed.data;
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
|
||||
const evaluation = await evaluateCost({
|
||||
userId,
|
||||
estimatedCost: PER_AUDIT_COST_USD[provider],
|
||||
confirmedCost: false,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId,
|
||||
action: 'itglue_audit',
|
||||
evaluation,
|
||||
context: {
|
||||
mode: 'ticket_scoped',
|
||||
analysisId,
|
||||
assetType,
|
||||
assetId,
|
||||
provider,
|
||||
},
|
||||
});
|
||||
if (evaluation.decision === 'blocked') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Daily cost limit reached',
|
||||
message: evaluation.decisionReason,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await runAssetAudit({
|
||||
assetType,
|
||||
assetId,
|
||||
generatedByUserId: userId,
|
||||
provider,
|
||||
ticketScopeAnalysisId: analysisId,
|
||||
});
|
||||
|
||||
if (result.status === 'failed') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Audit failed',
|
||||
message: result.errorMessage,
|
||||
auditId: result.auditId,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const audit = await getAssetAuditById(result.auditId);
|
||||
return NextResponse.json({ audit });
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
createShare,
|
||||
getAnalysisById,
|
||||
} from '@/lib/services/analyzer/persistence';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { sendAnalysisShareEmail } from '@/lib/services/email';
|
||||
|
||||
function getAllowedDomains(): string[] {
|
||||
|
|
@ -92,6 +93,32 @@ export async function POST(
|
|||
note,
|
||||
});
|
||||
|
||||
// Fetch the ticket title for the email subtitle. Best-effort: a missing
|
||||
// ticket (analyzer mirror skew) shouldn't fail the share.
|
||||
let ticketTitle: string | null = null;
|
||||
try {
|
||||
const titleRes = await postgresClient.query<{ title: string | null }>(
|
||||
`SELECT title FROM tickets
|
||||
WHERE ticket_number = $1
|
||||
AND COALESCE(is_deleted, false) = false
|
||||
LIMIT 1`,
|
||||
[analysis.ticketNumber]
|
||||
);
|
||||
if (titleRes.rowCount && titleRes.rowCount > 0) {
|
||||
ticketTitle = titleRes.rows[0].title;
|
||||
}
|
||||
} catch {
|
||||
// Title is decorative — proceed without it.
|
||||
}
|
||||
|
||||
const modelTier: 'haiku' | 'sonnet' | 'opus' | null = analysis.opusUsed
|
||||
? 'opus'
|
||||
: analysis.sonnetUsed
|
||||
? 'sonnet'
|
||||
: analysis.haikuUsed
|
||||
? 'haiku'
|
||||
: null;
|
||||
|
||||
const analysisUrl = `${getAppBaseUrl().replace(/\/$/, '')}/analyzer/analysis/${id}`;
|
||||
let emailSent = true;
|
||||
let emailError: string | null = null;
|
||||
|
|
@ -101,9 +128,16 @@ export async function POST(
|
|||
senderName: sessionUser.name || sessionUser.email,
|
||||
senderEmail: sessionUser.email,
|
||||
ticketNumber: analysis.ticketNumber,
|
||||
ticketTitle,
|
||||
analysisVersion: analysis.analysisVersion,
|
||||
summary: analysis.summary,
|
||||
nextStep: analysis.nextStep,
|
||||
nextStepRationale: analysis.nextStepRationale,
|
||||
whatWasDone: analysis.whatWasDone,
|
||||
whatShouldHaveBeenDone: analysis.whatShouldHaveBeenDone,
|
||||
gaps: analysis.gaps,
|
||||
confidenceScore: analysis.confidenceScore,
|
||||
modelTier,
|
||||
analysisUrl,
|
||||
note,
|
||||
});
|
||||
|
|
|
|||
201
app/api/analyzer/itglue/applications/[id]/apply/route.ts
Normal file
201
app/api/analyzer/itglue/applications/[id]/apply/route.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* POST /api/analyzer/itglue/applications/:id/apply
|
||||
*
|
||||
* Body: ApplyAssetSuggestionRequest = { auditId, fieldName, suggestedValue, sourceEvidence? }
|
||||
*
|
||||
* Flow:
|
||||
* 1. Verify auth + itglue.write permission.
|
||||
* 2. Read the asset's current traits (the source of truth for before_value).
|
||||
* 3. Insert pending row in itglue_writes capturing before/after.
|
||||
* 4. Call IT Glue PATCH /flexible_assets/:id (merged trait map).
|
||||
* 5. On success: mark write committed, refresh the local mirror row, write
|
||||
* a generic audit_log entry.
|
||||
* 6. On failure: mark write failed, return 502.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { ApplyAssetSuggestionRequest } from '@/lib/types/analyzer';
|
||||
import {
|
||||
createPendingWrite,
|
||||
fieldNameToTraitKey,
|
||||
getAssetAuditById,
|
||||
markWriteCommitted,
|
||||
markWriteFailed,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import { insertUpdatedXref } from '@/lib/services/analyzer/asset-audit/xrefs';
|
||||
import { getITGlueClient } from '@/lib/services/itglue-client';
|
||||
import { getITGlueSyncService } from '@/lib/services/itglue-sync-service';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
organization_name: string | null;
|
||||
flexible_asset_type_id: string;
|
||||
traits: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function loadAssetRow(assetId: string): Promise<AssetRow | null> {
|
||||
const res = await postgresClient.query<AssetRow>(
|
||||
`SELECT id::text AS id,
|
||||
organization_name,
|
||||
flexible_asset_type_id::text AS flexible_asset_type_id,
|
||||
traits
|
||||
FROM itg_flexible_assets
|
||||
WHERE id = $1
|
||||
LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
return res.rowCount === 0 ? null : res.rows[0];
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requirePermission('itglue', 'write');
|
||||
if (error) return error;
|
||||
|
||||
const { id: assetId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = ApplyAssetSuggestionRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { auditId, fieldName, suggestedValue, sourceEvidence } = parsed.data;
|
||||
|
||||
// Sanity-check: the audit must exist and reference this asset.
|
||||
const auditRow = await getAssetAuditById(auditId);
|
||||
if (!auditRow) {
|
||||
return NextResponse.json({ error: 'Audit not found' }, { status: 404 });
|
||||
}
|
||||
if (auditRow.asset_id !== assetId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audit does not reference this asset' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Refuse to write to credential-shaped fields. Belt for the prompt's
|
||||
// braces; the prompt already tells the LLM not to suggest these, but
|
||||
// re-block here so a malicious-looking payload can't sneak through.
|
||||
const lowerField = fieldName.toLowerCase();
|
||||
if (
|
||||
/(password|secret|key|token|credential)/.test(lowerField)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Refusing to write to credential-shaped field' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read current traits.
|
||||
const asset = await loadAssetRow(assetId);
|
||||
if (!asset) {
|
||||
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
|
||||
}
|
||||
const traitKey = fieldNameToTraitKey(fieldName);
|
||||
const beforeValue = asset.traits[traitKey] ?? null;
|
||||
const userId =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.id ?? null;
|
||||
const userEmail =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.email ??
|
||||
undefined;
|
||||
|
||||
// Insert pending row first so we never write to IT Glue without an audit
|
||||
// row in flight.
|
||||
const writeRow = await createPendingWrite({
|
||||
audit_id: auditId,
|
||||
asset_type: 'flexible_asset',
|
||||
asset_id: assetId,
|
||||
field_name: fieldName,
|
||||
before_value: beforeValue,
|
||||
after_value: suggestedValue,
|
||||
performed_by_user_id: userId,
|
||||
source_evidence: sourceEvidence ?? null,
|
||||
triggered_by_ticket_number: auditRow.triggered_by_ticket_number ?? null,
|
||||
});
|
||||
|
||||
// Build merged trait map. IT Glue replaces the trait set on PATCH, so we
|
||||
// must include unchanged traits.
|
||||
const merged: Record<string, unknown> = {
|
||||
...asset.traits,
|
||||
[traitKey]: suggestedValue,
|
||||
};
|
||||
|
||||
try {
|
||||
const client = getITGlueClient();
|
||||
const updated = await client.updateFlexibleAsset(assetId, merged);
|
||||
await markWriteCommitted(writeRow.id, updated);
|
||||
|
||||
// Best-effort: refresh the mirror so subsequent reads see the new value
|
||||
// without waiting for the next fullSync.
|
||||
try {
|
||||
await getITGlueSyncService().refreshFlexibleAssetById(assetId);
|
||||
} catch (refreshErr) {
|
||||
console.warn(
|
||||
`[itglue-apply] mirror refresh failed for asset ${assetId}:`,
|
||||
refreshErr instanceof Error ? refreshErr.message : refreshErr
|
||||
);
|
||||
}
|
||||
|
||||
// Generic admin-visible audit log row.
|
||||
await audit.log({
|
||||
userId: userId ?? undefined,
|
||||
userEmail,
|
||||
action: 'itglue.write',
|
||||
resource: 'flexible_asset',
|
||||
resourceId: assetId,
|
||||
details: {
|
||||
write_id: writeRow.id,
|
||||
audit_id: auditId,
|
||||
field_name: fieldName,
|
||||
trait_key: traitKey,
|
||||
before: beforeValue,
|
||||
after: suggestedValue,
|
||||
},
|
||||
});
|
||||
|
||||
// Phase 4.1: cross-reference row when this write was triggered by a
|
||||
// ticket-scoped audit. Best-effort.
|
||||
if (auditRow.triggered_by_ticket_number) {
|
||||
try {
|
||||
await insertUpdatedXref({
|
||||
ticketNumber: auditRow.triggered_by_ticket_number,
|
||||
analysisId: auditRow.triggered_by_analysis_id ?? null,
|
||||
assetType: 'flexible_asset',
|
||||
assetId,
|
||||
writeId: writeRow.id,
|
||||
fieldName,
|
||||
});
|
||||
} catch (xrefErr) {
|
||||
console.warn(
|
||||
`[itglue-apply] xref insert failed for write ${writeRow.id}:`,
|
||||
xrefErr instanceof Error ? xrefErr.message : xrefErr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
writeId: writeRow.id,
|
||||
status: 'committed',
|
||||
asset: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markWriteFailed(writeRow.id, message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
writeId: writeRow.id,
|
||||
status: 'failed',
|
||||
error: 'IT Glue write failed',
|
||||
message,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
113
app/api/analyzer/itglue/applications/[id]/audit/route.ts
Normal file
113
app/api/analyzer/itglue/applications/[id]/audit/route.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/applications/:id/audit
|
||||
* Returns the latest audit row for the asset (or null).
|
||||
*
|
||||
* POST /api/analyzer/itglue/applications/:id/audit
|
||||
* Body: { provider?: 'anthropic' | 'openrouter' }
|
||||
* Runs a fresh audit. Cost-guarded the same way single-ticket runs are.
|
||||
* Returns the new audit row.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { RunAssetAuditRequest } from '@/lib/types/analyzer';
|
||||
import { runAssetAudit } from '@/lib/services/analyzer/asset-audit/runner';
|
||||
import {
|
||||
getAssetAuditById,
|
||||
getLatestAssetAudit,
|
||||
listAssetAudits,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import {
|
||||
evaluateCost,
|
||||
recordCostAuditDecision,
|
||||
} from '@/lib/services/analyzer/cost-guard';
|
||||
|
||||
const PER_AUDIT_COST_USD: Record<'anthropic' | 'openrouter', number> = {
|
||||
anthropic: 0.1,
|
||||
openrouter: 0.01,
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
const url = new URL(request.url);
|
||||
const includeHistory = url.searchParams.get('history') === '1';
|
||||
|
||||
const latest = await getLatestAssetAudit(id);
|
||||
if (!includeHistory) {
|
||||
return NextResponse.json({ audit: latest });
|
||||
}
|
||||
const history = await listAssetAudits(id, 'flexible_asset', 20);
|
||||
return NextResponse.json({ audit: latest, history });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = RunAssetAuditRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const provider = parsed.data.provider;
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
|
||||
const evaluation = await evaluateCost({
|
||||
userId,
|
||||
estimatedCost: PER_AUDIT_COST_USD[provider],
|
||||
confirmedCost: false,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId,
|
||||
action: 'itglue_audit',
|
||||
evaluation,
|
||||
context: { assetId: id, provider },
|
||||
});
|
||||
if (evaluation.decision === 'blocked') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Daily cost limit reached',
|
||||
message: evaluation.decisionReason,
|
||||
estimatedCost: evaluation.estimatedCost,
|
||||
dailySpendBefore: evaluation.dailySpendBefore,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
// Audits are cheap enough that requires_confirmation should never trip
|
||||
// the per-request threshold — but let it fall through anyway.
|
||||
|
||||
const result = await runAssetAudit({
|
||||
assetType: 'flexible_asset',
|
||||
assetId: id,
|
||||
generatedByUserId: userId,
|
||||
provider,
|
||||
});
|
||||
|
||||
if (result.status === 'failed') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Audit failed',
|
||||
message: result.errorMessage,
|
||||
auditId: result.auditId,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const audit = await getAssetAuditById(result.auditId);
|
||||
return NextResponse.json({ audit });
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* POST /api/analyzer/itglue/applications/:id/revert/:writeId
|
||||
*
|
||||
* Reverts a previously committed write by re-applying its before_value.
|
||||
* Implementation: insert a NEW itglue_writes row with reversed before/after,
|
||||
* call IT Glue PATCH with the original before_value, then mark the original
|
||||
* row status='reverted'. Audit log captures both rows.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
createPendingWrite,
|
||||
fieldNameToTraitKey,
|
||||
getWriteById,
|
||||
markWriteCommitted,
|
||||
markWriteFailed,
|
||||
markWriteReverted,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import { getITGlueClient } from '@/lib/services/itglue-client';
|
||||
import { getITGlueSyncService } from '@/lib/services/itglue-sync-service';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
traits: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; writeId: string }> }
|
||||
) {
|
||||
const { session, error } = await requirePermission('itglue', 'write');
|
||||
if (error) return error;
|
||||
|
||||
const { id: assetId, writeId } = await params;
|
||||
const userId =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.id ?? null;
|
||||
const userEmail =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.email ??
|
||||
undefined;
|
||||
|
||||
const original = await getWriteById(writeId);
|
||||
if (!original) {
|
||||
return NextResponse.json({ error: 'Write not found' }, { status: 404 });
|
||||
}
|
||||
if (original.asset_id !== assetId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write does not reference this asset' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (original.status === 'reverted') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write already reverted' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (original.status !== 'committed') {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot revert a write in status '${original.status}'` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read current asset traits to merge cleanly.
|
||||
const assetRes = await postgresClient.query<AssetRow>(
|
||||
`SELECT id::text AS id, traits FROM itg_flexible_assets WHERE id = $1 LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
if (assetRes.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
|
||||
}
|
||||
const traits = assetRes.rows[0].traits;
|
||||
const traitKey = fieldNameToTraitKey(original.field_name);
|
||||
|
||||
// Build the revert: swap original's before/after; the new "after" value is
|
||||
// the original's "before".
|
||||
const revertRow = await createPendingWrite({
|
||||
audit_id: original.audit_id,
|
||||
asset_type: 'flexible_asset',
|
||||
asset_id: assetId,
|
||||
field_name: original.field_name,
|
||||
before_value: original.after_value,
|
||||
after_value: original.before_value,
|
||||
performed_by_user_id: userId,
|
||||
source_evidence: { reverts_write_id: original.id },
|
||||
});
|
||||
|
||||
const merged: Record<string, unknown> = {
|
||||
...traits,
|
||||
[traitKey]: original.before_value,
|
||||
};
|
||||
|
||||
try {
|
||||
const client = getITGlueClient();
|
||||
const updated = await client.updateFlexibleAsset(assetId, merged);
|
||||
|
||||
await markWriteCommitted(revertRow.id, updated);
|
||||
await markWriteReverted(original.id);
|
||||
|
||||
try {
|
||||
await getITGlueSyncService().refreshFlexibleAssetById(assetId);
|
||||
} catch (refreshErr) {
|
||||
console.warn(
|
||||
`[itglue-revert] mirror refresh failed for asset ${assetId}:`,
|
||||
refreshErr instanceof Error ? refreshErr.message : refreshErr
|
||||
);
|
||||
}
|
||||
|
||||
await audit.log({
|
||||
userId: userId ?? undefined,
|
||||
userEmail,
|
||||
action: 'itglue.revert',
|
||||
resource: 'flexible_asset',
|
||||
resourceId: assetId,
|
||||
details: {
|
||||
revert_write_id: revertRow.id,
|
||||
original_write_id: original.id,
|
||||
field_name: original.field_name,
|
||||
before: original.after_value,
|
||||
after: original.before_value,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
writeId: revertRow.id,
|
||||
revertedWriteId: original.id,
|
||||
status: 'committed',
|
||||
asset: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markWriteFailed(revertRow.id, message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
writeId: revertRow.id,
|
||||
status: 'failed',
|
||||
error: 'IT Glue revert failed',
|
||||
message,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
88
app/api/analyzer/itglue/applications/[id]/route.ts
Normal file
88
app/api/analyzer/itglue/applications/[id]/route.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/applications/:id
|
||||
*
|
||||
* Returns the asset row + its type's field schema (with hints) so the
|
||||
* detail page can render fields in IT Glue's order with empty fields shown
|
||||
* muted. Read-only.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { redact } from '@/lib/services/analyzer/itglue-redact';
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
flexible_asset_type_id: string;
|
||||
flexible_asset_type_name: string | null;
|
||||
traits: Record<string, unknown>;
|
||||
created_at: Date | null;
|
||||
updated_at: Date | null;
|
||||
}
|
||||
|
||||
interface FieldRow {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string | null;
|
||||
hint: string | null;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const assetRes = await postgresClient.query<AssetRow & { autotask_company_id: string | null }>(
|
||||
`SELECT a.id::text AS id,
|
||||
a.name,
|
||||
a.organization_id::text AS organization_id,
|
||||
a.organization_name,
|
||||
a.flexible_asset_type_id::text AS flexible_asset_type_id,
|
||||
a.flexible_asset_type_name,
|
||||
a.traits,
|
||||
a.created_at,
|
||||
a.updated_at,
|
||||
comp.id::text AS autotask_company_id
|
||||
FROM itg_flexible_assets a
|
||||
LEFT JOIN companies comp ON LOWER(comp.company_name) = LOWER(a.organization_name)
|
||||
WHERE a.id = $1
|
||||
LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
if (assetRes.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
|
||||
}
|
||||
const a = assetRes.rows[0];
|
||||
|
||||
const fieldsRes = await postgresClient.query<FieldRow>(
|
||||
`SELECT id::text AS id, name, kind, hint, required
|
||||
FROM itg_flexible_asset_fields
|
||||
WHERE flexible_asset_type_id = $1
|
||||
ORDER BY id`,
|
||||
[a.flexible_asset_type_id]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
asset: {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
organizationId: a.organization_id,
|
||||
organizationName: a.organization_name,
|
||||
flexibleAssetTypeId: a.flexible_asset_type_id,
|
||||
flexibleAssetTypeName: a.flexible_asset_type_name,
|
||||
autotaskCompanyId: a.autotask_company_id,
|
||||
traits: redact(a.traits ?? {}),
|
||||
createdAt: a.created_at?.toISOString() ?? null,
|
||||
updatedAt: a.updated_at?.toISOString() ?? null,
|
||||
},
|
||||
fields: fieldsRes.rows,
|
||||
});
|
||||
}
|
||||
22
app/api/analyzer/itglue/applications/[id]/writes/route.ts
Normal file
22
app/api/analyzer/itglue/applications/[id]/writes/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/applications/:id/writes
|
||||
*
|
||||
* Returns the write history for a single asset (for the asset detail page).
|
||||
* Auth-only — admins use the same data plus the dedicated /admin/itglue-writes
|
||||
* page for cross-asset views.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listWritesForAsset } from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { id } = await params;
|
||||
const writes = await listWritesForAsset(id, 50);
|
||||
return NextResponse.json({ writes });
|
||||
}
|
||||
21
app/api/analyzer/itglue/applications/[id]/xrefs/route.ts
Normal file
21
app/api/analyzer/itglue/applications/[id]/xrefs/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/applications/:id/xrefs
|
||||
*
|
||||
* Returns the xref rows for this flexible asset — "tickets that referenced
|
||||
* me" + "tickets that updated me" — for the asset detail page.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listXrefsForAsset } from '@/lib/services/analyzer/asset-audit/xrefs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { id } = await params;
|
||||
const xrefs = await listXrefsForAsset('flexible_asset', id, 100);
|
||||
return NextResponse.json({ xrefs });
|
||||
}
|
||||
76
app/api/analyzer/itglue/applications/route.ts
Normal file
76
app/api/analyzer/itglue/applications/route.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/applications
|
||||
*
|
||||
* Returns all Application flexible-asset records, joined to their latest
|
||||
* audit (if any). Powers /analyzer/itglue/applications listing page.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const APPLICATION_TYPE_ID = 3790;
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string | null;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
trait_count: number;
|
||||
latest_audit_id: string | null;
|
||||
latest_audit_at: Date | null;
|
||||
latest_audit_score: number | null;
|
||||
latest_audit_provider: 'anthropic' | 'openrouter' | null;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const res = await postgresClient.query<Row>(
|
||||
`SELECT a.id::text AS id,
|
||||
a.name,
|
||||
a.organization_id::text AS organization_id,
|
||||
a.organization_name,
|
||||
(SELECT COUNT(*)::int
|
||||
FROM jsonb_object_keys(a.traits) k) AS trait_count,
|
||||
la.id::text AS latest_audit_id,
|
||||
la.generated_at AS latest_audit_at,
|
||||
la.overall_score::float8 AS latest_audit_score,
|
||||
la.provider AS latest_audit_provider
|
||||
FROM itg_flexible_assets a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, generated_at, overall_score, provider
|
||||
FROM itglue_asset_audits
|
||||
WHERE asset_type = 'flexible_asset'
|
||||
AND asset_id = a.id
|
||||
AND status = 'complete'
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1
|
||||
) la ON true
|
||||
WHERE a.flexible_asset_type_id = $1
|
||||
AND COALESCE(a.archived, false) = false
|
||||
ORDER BY
|
||||
la.overall_score ASC NULLS FIRST,
|
||||
a.organization_name,
|
||||
a.name`,
|
||||
[APPLICATION_TYPE_ID]
|
||||
);
|
||||
|
||||
const applications = res.rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
organizationId: r.organization_id,
|
||||
organizationName: r.organization_name,
|
||||
traitCount: r.trait_count,
|
||||
latestAudit: r.latest_audit_id
|
||||
? {
|
||||
id: r.latest_audit_id,
|
||||
generatedAt: r.latest_audit_at?.toISOString() ?? null,
|
||||
overallScore: r.latest_audit_score,
|
||||
provider: r.latest_audit_provider,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
return NextResponse.json({ applications });
|
||||
}
|
||||
215
app/api/analyzer/itglue/configurations/[id]/apply/route.ts
Normal file
215
app/api/analyzer/itglue/configurations/[id]/apply/route.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* POST /api/analyzer/itglue/configurations/:id/apply
|
||||
*
|
||||
* Same shape as the Applications apply route but PATCHes /configurations/:id
|
||||
* with flat attributes (no traits blob). All audit-trail layers identical.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { ApplyAssetSuggestionRequest } from '@/lib/types/analyzer';
|
||||
import {
|
||||
createPendingWrite,
|
||||
getAssetAuditById,
|
||||
markWriteCommitted,
|
||||
markWriteFailed,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import { insertUpdatedXref } from '@/lib/services/analyzer/asset-audit/xrefs';
|
||||
import { getITGlueClient } from '@/lib/services/itglue-client';
|
||||
import { getITGlueSyncService } from '@/lib/services/itglue-sync-service';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
|
||||
interface ConfigRow {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
primary_ip: string | null;
|
||||
mac_address: string | null;
|
||||
serial_number: string | null;
|
||||
asset_tag: string | null;
|
||||
position: string | null;
|
||||
notes: string | null;
|
||||
operating_system_notes: string | null;
|
||||
}
|
||||
|
||||
const CONFIG_EDITABLE_COLUMNS = [
|
||||
'name',
|
||||
'hostname',
|
||||
'primary_ip',
|
||||
'mac_address',
|
||||
'serial_number',
|
||||
'asset_tag',
|
||||
'position',
|
||||
'notes',
|
||||
'operating_system_notes',
|
||||
] as const;
|
||||
|
||||
const FIELD_TO_ITGLUE_ATTR: Record<string, string> = {
|
||||
name: 'name',
|
||||
hostname: 'hostname',
|
||||
primary_ip: 'primary-ip',
|
||||
mac_address: 'mac-address',
|
||||
serial_number: 'serial-number',
|
||||
asset_tag: 'asset-tag',
|
||||
position: 'position',
|
||||
notes: 'notes',
|
||||
operating_system_notes: 'operating-system-notes',
|
||||
};
|
||||
|
||||
async function loadConfigurationRow(assetId: string): Promise<ConfigRow | null> {
|
||||
const res = await postgresClient.query<ConfigRow>(
|
||||
`SELECT id::text AS id,
|
||||
name, hostname, primary_ip, mac_address, serial_number,
|
||||
asset_tag, position, notes, operating_system_notes
|
||||
FROM itg_configurations
|
||||
WHERE id = $1
|
||||
LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
return res.rowCount === 0 ? null : res.rows[0];
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requirePermission('itglue', 'write');
|
||||
if (error) return error;
|
||||
|
||||
const { id: assetId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = ApplyAssetSuggestionRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { auditId, fieldName, suggestedValue, sourceEvidence } = parsed.data;
|
||||
|
||||
const auditRow = await getAssetAuditById(auditId);
|
||||
if (!auditRow) {
|
||||
return NextResponse.json({ error: 'Audit not found' }, { status: 404 });
|
||||
}
|
||||
if (auditRow.asset_id !== assetId || auditRow.asset_type !== 'configuration') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audit does not reference this configuration' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const lowerField = fieldName.toLowerCase();
|
||||
if (/(password|secret|key|token|credential)/.test(lowerField)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Refusing to write to credential-shaped field' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Refuse to write fields the audit pipeline shouldn't be touching on a
|
||||
// Configuration. We only let it edit the columns flagged editable.
|
||||
if (!CONFIG_EDITABLE_COLUMNS.includes(fieldName as (typeof CONFIG_EDITABLE_COLUMNS)[number])) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Field '${fieldName}' is not editable via the audit pipeline`,
|
||||
editableFields: CONFIG_EDITABLE_COLUMNS,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await loadConfigurationRow(assetId);
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: 'Configuration not found' }, { status: 404 });
|
||||
}
|
||||
const beforeValue =
|
||||
(config as unknown as Record<string, unknown>)[fieldName] ?? null;
|
||||
const userId =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.id ?? null;
|
||||
const userEmail =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.email ??
|
||||
undefined;
|
||||
|
||||
const writeRow = await createPendingWrite({
|
||||
audit_id: auditId,
|
||||
asset_type: 'configuration',
|
||||
asset_id: assetId,
|
||||
field_name: fieldName,
|
||||
before_value: beforeValue,
|
||||
after_value: suggestedValue,
|
||||
performed_by_user_id: userId,
|
||||
source_evidence: sourceEvidence ?? null,
|
||||
triggered_by_ticket_number: auditRow.triggered_by_ticket_number ?? null,
|
||||
});
|
||||
|
||||
// IT Glue PATCH attribute — convert snake to dash form.
|
||||
const attrKey = FIELD_TO_ITGLUE_ATTR[fieldName];
|
||||
const attributes = { [attrKey]: suggestedValue };
|
||||
|
||||
try {
|
||||
const client = getITGlueClient();
|
||||
const updated = await client.updateConfiguration(assetId, attributes);
|
||||
await markWriteCommitted(writeRow.id, updated);
|
||||
|
||||
try {
|
||||
await getITGlueSyncService().refreshConfigurationById(assetId);
|
||||
} catch (refreshErr) {
|
||||
console.warn(
|
||||
`[itglue-config-apply] mirror refresh failed for ${assetId}:`,
|
||||
refreshErr instanceof Error ? refreshErr.message : refreshErr
|
||||
);
|
||||
}
|
||||
|
||||
await audit.log({
|
||||
userId: userId ?? undefined,
|
||||
userEmail,
|
||||
action: 'itglue.write',
|
||||
resource: 'configuration',
|
||||
resourceId: assetId,
|
||||
details: {
|
||||
write_id: writeRow.id,
|
||||
audit_id: auditId,
|
||||
field_name: fieldName,
|
||||
before: beforeValue,
|
||||
after: suggestedValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (auditRow.triggered_by_ticket_number) {
|
||||
try {
|
||||
await insertUpdatedXref({
|
||||
ticketNumber: auditRow.triggered_by_ticket_number,
|
||||
analysisId: auditRow.triggered_by_analysis_id ?? null,
|
||||
assetType: 'configuration',
|
||||
assetId,
|
||||
writeId: writeRow.id,
|
||||
fieldName,
|
||||
});
|
||||
} catch (xrefErr) {
|
||||
console.warn(
|
||||
`[itglue-config-apply] xref insert failed for write ${writeRow.id}:`,
|
||||
xrefErr instanceof Error ? xrefErr.message : xrefErr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
writeId: writeRow.id,
|
||||
status: 'committed',
|
||||
asset: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markWriteFailed(writeRow.id, message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
writeId: writeRow.id,
|
||||
status: 'failed',
|
||||
error: 'IT Glue write failed',
|
||||
message,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
104
app/api/analyzer/itglue/configurations/[id]/audit/route.ts
Normal file
104
app/api/analyzer/itglue/configurations/[id]/audit/route.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/configurations/:id/audit
|
||||
* POST /api/analyzer/itglue/configurations/:id/audit
|
||||
*
|
||||
* Mirrors the Applications endpoint but for Configurations.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { RunAssetAuditRequest } from '@/lib/types/analyzer';
|
||||
import { runAssetAudit } from '@/lib/services/analyzer/asset-audit/runner';
|
||||
import {
|
||||
getAssetAuditById,
|
||||
getLatestAssetAudit,
|
||||
listAssetAudits,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import {
|
||||
evaluateCost,
|
||||
recordCostAuditDecision,
|
||||
} from '@/lib/services/analyzer/cost-guard';
|
||||
|
||||
const PER_AUDIT_COST_USD: Record<'anthropic' | 'openrouter', number> = {
|
||||
anthropic: 0.1,
|
||||
openrouter: 0.01,
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
const url = new URL(request.url);
|
||||
const includeHistory = url.searchParams.get('history') === '1';
|
||||
|
||||
const latest = await getLatestAssetAudit(id, 'configuration');
|
||||
if (!includeHistory) return NextResponse.json({ audit: latest });
|
||||
const history = await listAssetAudits(id, 'configuration', 20);
|
||||
return NextResponse.json({ audit: latest, history });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = RunAssetAuditRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const provider = parsed.data.provider;
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
|
||||
const evaluation = await evaluateCost({
|
||||
userId,
|
||||
estimatedCost: PER_AUDIT_COST_USD[provider],
|
||||
confirmedCost: false,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId,
|
||||
action: 'itglue_audit',
|
||||
evaluation,
|
||||
context: { assetId: id, assetType: 'configuration', provider },
|
||||
});
|
||||
if (evaluation.decision === 'blocked') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Daily cost limit reached',
|
||||
message: evaluation.decisionReason,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await runAssetAudit({
|
||||
assetType: 'configuration',
|
||||
assetId: id,
|
||||
generatedByUserId: userId,
|
||||
provider,
|
||||
});
|
||||
|
||||
if (result.status === 'failed') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Audit failed',
|
||||
message: result.errorMessage,
|
||||
auditId: result.auditId,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const audit = await getAssetAuditById(result.auditId);
|
||||
return NextResponse.json({ audit });
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* POST /api/analyzer/itglue/configurations/:id/revert/:writeId
|
||||
*
|
||||
* Mirrors the Applications revert path but PATCHes /configurations/:id.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
createPendingWrite,
|
||||
getWriteById,
|
||||
markWriteCommitted,
|
||||
markWriteFailed,
|
||||
markWriteReverted,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import { getITGlueClient } from '@/lib/services/itglue-client';
|
||||
import { getITGlueSyncService } from '@/lib/services/itglue-sync-service';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
|
||||
const FIELD_TO_ITGLUE_ATTR: Record<string, string> = {
|
||||
name: 'name',
|
||||
hostname: 'hostname',
|
||||
primary_ip: 'primary-ip',
|
||||
mac_address: 'mac-address',
|
||||
serial_number: 'serial-number',
|
||||
asset_tag: 'asset-tag',
|
||||
position: 'position',
|
||||
notes: 'notes',
|
||||
operating_system_notes: 'operating-system-notes',
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; writeId: string }> }
|
||||
) {
|
||||
const { session, error } = await requirePermission('itglue', 'write');
|
||||
if (error) return error;
|
||||
|
||||
const { id: assetId, writeId } = await params;
|
||||
const userId =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.id ?? null;
|
||||
const userEmail =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.email ??
|
||||
undefined;
|
||||
|
||||
const original = await getWriteById(writeId);
|
||||
if (!original) return NextResponse.json({ error: 'Write not found' }, { status: 404 });
|
||||
if (original.asset_id !== assetId || original.asset_type !== 'configuration') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write does not reference this configuration' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (original.status === 'reverted') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write already reverted' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (original.status !== 'committed') {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot revert a write in status '${original.status}'` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const exists = await postgresClient.query(
|
||||
`SELECT 1 FROM itg_configurations WHERE id = $1 LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
if (exists.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Configuration not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const attrKey = FIELD_TO_ITGLUE_ATTR[original.field_name];
|
||||
if (!attrKey) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot map field '${original.field_name}' to IT Glue attribute` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const revertRow = await createPendingWrite({
|
||||
audit_id: original.audit_id,
|
||||
asset_type: 'configuration',
|
||||
asset_id: assetId,
|
||||
field_name: original.field_name,
|
||||
before_value: original.after_value,
|
||||
after_value: original.before_value,
|
||||
performed_by_user_id: userId,
|
||||
source_evidence: { reverts_write_id: original.id },
|
||||
});
|
||||
|
||||
try {
|
||||
const client = getITGlueClient();
|
||||
const updated = await client.updateConfiguration(assetId, {
|
||||
[attrKey]: original.before_value,
|
||||
});
|
||||
|
||||
await markWriteCommitted(revertRow.id, updated);
|
||||
await markWriteReverted(original.id);
|
||||
|
||||
try {
|
||||
await getITGlueSyncService().refreshConfigurationById(assetId);
|
||||
} catch (refreshErr) {
|
||||
console.warn(
|
||||
`[itglue-config-revert] mirror refresh failed for ${assetId}:`,
|
||||
refreshErr instanceof Error ? refreshErr.message : refreshErr
|
||||
);
|
||||
}
|
||||
|
||||
await audit.log({
|
||||
userId: userId ?? undefined,
|
||||
userEmail,
|
||||
action: 'itglue.revert',
|
||||
resource: 'configuration',
|
||||
resourceId: assetId,
|
||||
details: {
|
||||
revert_write_id: revertRow.id,
|
||||
original_write_id: original.id,
|
||||
field_name: original.field_name,
|
||||
before: original.after_value,
|
||||
after: original.before_value,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
writeId: revertRow.id,
|
||||
revertedWriteId: original.id,
|
||||
status: 'committed',
|
||||
asset: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markWriteFailed(revertRow.id, message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
writeId: revertRow.id,
|
||||
status: 'failed',
|
||||
error: 'IT Glue revert failed',
|
||||
message,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
135
app/api/analyzer/itglue/configurations/[id]/route.ts
Normal file
135
app/api/analyzer/itglue/configurations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/configurations/:id
|
||||
*
|
||||
* Returns one Configuration row + the curated field schema (with hints) so
|
||||
* the detail page renders fields in a stable order.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { _ASSET_AUDIT_INTERNALS } from '@/lib/services/analyzer/asset-audit/data-builder';
|
||||
|
||||
interface ConfigRow {
|
||||
id: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
configuration_type_id: string | null;
|
||||
configuration_type_name: string | null;
|
||||
configuration_status_id: string | null;
|
||||
configuration_status_name: string | null;
|
||||
manufacturer_name: string | null;
|
||||
model_name: string | null;
|
||||
operating_system_name: string | null;
|
||||
contact_id: string | null;
|
||||
location_id: string | null;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
primary_ip: string | null;
|
||||
mac_address: string | null;
|
||||
serial_number: string | null;
|
||||
asset_tag: string | null;
|
||||
position: string | null;
|
||||
notes: string | null;
|
||||
operating_system_notes: string | null;
|
||||
created_at: Date | null;
|
||||
updated_at: Date | null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const res = await postgresClient.query<ConfigRow & { rmm_id: string | null; autotask_company_id: string | null }>(
|
||||
`SELECT c.id::text AS id,
|
||||
c.organization_id::text AS organization_id,
|
||||
c.organization_name,
|
||||
c.configuration_type_id::text AS configuration_type_id,
|
||||
c.configuration_type_name,
|
||||
c.configuration_status_id::text AS configuration_status_id,
|
||||
c.configuration_status_name,
|
||||
c.manufacturer_name, c.model_name,
|
||||
c.operating_system_name,
|
||||
c.contact_id::text AS contact_id,
|
||||
c.location_id::text AS location_id,
|
||||
c.name, c.hostname, c.primary_ip, c.mac_address, c.serial_number, c.asset_tag,
|
||||
c.position, c.notes, c.operating_system_notes,
|
||||
c.created_at, c.updated_at,
|
||||
c.rmm_id,
|
||||
comp.id::text AS autotask_company_id
|
||||
FROM itg_configurations c
|
||||
LEFT JOIN companies comp ON LOWER(comp.company_name) = LOWER(c.organization_name)
|
||||
WHERE c.id = $1
|
||||
LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Configuration not found' }, { status: 404 });
|
||||
}
|
||||
const c = res.rows[0];
|
||||
|
||||
// If we have an rmm_id, look up the Datto device uid for the picker.
|
||||
let dattoDeviceUid: string | null = null;
|
||||
if (c.rmm_id) {
|
||||
const drmm = await postgresClient.query<{ uid: string }>(
|
||||
`SELECT uid FROM datto_rmm_devices WHERE id::text = $1 OR uid = $1 LIMIT 1`,
|
||||
[c.rmm_id]
|
||||
);
|
||||
dattoDeviceUid = drmm.rows[0]?.uid ?? null;
|
||||
}
|
||||
// Fallback: hostname-based device lookup.
|
||||
if (!dattoDeviceUid && c.hostname) {
|
||||
const drmm = await postgresClient.query<{ uid: string }>(
|
||||
`SELECT uid FROM datto_rmm_devices WHERE LOWER(hostname) = LOWER($1) LIMIT 1`,
|
||||
[c.hostname]
|
||||
);
|
||||
dattoDeviceUid = drmm.rows[0]?.uid ?? null;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
asset: {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
hostname: c.hostname,
|
||||
organizationId: c.organization_id,
|
||||
organizationName: c.organization_name,
|
||||
typeId: c.configuration_type_id,
|
||||
typeName: c.configuration_type_name,
|
||||
statusName: c.configuration_status_name,
|
||||
manufacturerName: c.manufacturer_name,
|
||||
modelName: c.model_name,
|
||||
operatingSystemName: c.operating_system_name,
|
||||
contactId: c.contact_id,
|
||||
locationId: c.location_id,
|
||||
dattoDeviceUid,
|
||||
autotaskCompanyId: c.autotask_company_id,
|
||||
// Synthesized "fields" map keyed by the audit field names.
|
||||
traits: {
|
||||
name: c.name,
|
||||
hostname: c.hostname,
|
||||
primary_ip: c.primary_ip,
|
||||
mac_address: c.mac_address,
|
||||
serial_number: c.serial_number,
|
||||
asset_tag: c.asset_tag,
|
||||
position: c.position,
|
||||
configuration_type_name: c.configuration_type_name,
|
||||
configuration_status_name: c.configuration_status_name,
|
||||
manufacturer_name: c.manufacturer_name,
|
||||
model_name: c.model_name,
|
||||
operating_system_name: c.operating_system_name,
|
||||
operating_system_notes: c.operating_system_notes,
|
||||
notes: c.notes,
|
||||
contact_id: c.contact_id,
|
||||
location_id: c.location_id,
|
||||
},
|
||||
createdAt: c.created_at?.toISOString() ?? null,
|
||||
updatedAt: c.updated_at?.toISOString() ?? null,
|
||||
},
|
||||
fields: _ASSET_AUDIT_INTERNALS.CONFIGURATION_FIELDS,
|
||||
});
|
||||
}
|
||||
24
app/api/analyzer/itglue/configurations/[id]/writes/route.ts
Normal file
24
app/api/analyzer/itglue/configurations/[id]/writes/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/configurations/:id/writes
|
||||
*
|
||||
* Per-configuration write history.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listWritesForAsset } from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { id } = await params;
|
||||
const writes = await listWritesForAsset(id, 50);
|
||||
// Filter to configuration writes — listWritesForAsset doesn't filter by
|
||||
// asset_type, but a given asset_id only ever exists under one type so this
|
||||
// is functionally equivalent. Defensive filter:
|
||||
const filtered = writes.filter((w) => w.asset_type === 'configuration');
|
||||
return NextResponse.json({ writes: filtered });
|
||||
}
|
||||
18
app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts
Normal file
18
app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/configurations/:id/xrefs
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listXrefsForAsset } from '@/lib/services/analyzer/asset-audit/xrefs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { id } = await params;
|
||||
const xrefs = await listXrefsForAsset('configuration', id, 100);
|
||||
return NextResponse.json({ xrefs });
|
||||
}
|
||||
76
app/api/analyzer/itglue/configurations/route.ts
Normal file
76
app/api/analyzer/itglue/configurations/route.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/configurations
|
||||
*
|
||||
* Returns Configuration records joined to their latest audit. Powers the
|
||||
* /analyzer/itglue/configurations listing page.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
configuration_type_name: string | null;
|
||||
configuration_status_name: string | null;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
latest_audit_id: string | null;
|
||||
latest_audit_at: Date | null;
|
||||
latest_audit_score: number | null;
|
||||
latest_audit_provider: 'anthropic' | 'openrouter' | null;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const res = await postgresClient.query<Row>(
|
||||
`SELECT c.id::text AS id,
|
||||
c.name,
|
||||
c.hostname,
|
||||
c.configuration_type_name,
|
||||
c.configuration_status_name,
|
||||
c.organization_id::text AS organization_id,
|
||||
c.organization_name,
|
||||
la.id::text AS latest_audit_id,
|
||||
la.generated_at AS latest_audit_at,
|
||||
la.overall_score::float8 AS latest_audit_score,
|
||||
la.provider AS latest_audit_provider
|
||||
FROM itg_configurations c
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, generated_at, overall_score, provider
|
||||
FROM itglue_asset_audits
|
||||
WHERE asset_type = 'configuration'
|
||||
AND asset_id = c.id
|
||||
AND status = 'complete'
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1
|
||||
) la ON true
|
||||
ORDER BY
|
||||
la.overall_score ASC NULLS FIRST,
|
||||
c.organization_name,
|
||||
c.name`
|
||||
);
|
||||
|
||||
const configurations = res.rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
hostname: r.hostname,
|
||||
typeName: r.configuration_type_name,
|
||||
statusName: r.configuration_status_name,
|
||||
organizationId: r.organization_id,
|
||||
organizationName: r.organization_name,
|
||||
latestAudit: r.latest_audit_id
|
||||
? {
|
||||
id: r.latest_audit_id,
|
||||
generatedAt: r.latest_audit_at?.toISOString() ?? null,
|
||||
overallScore: r.latest_audit_score,
|
||||
provider: r.latest_audit_provider,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
return NextResponse.json({ configurations });
|
||||
}
|
||||
67
app/api/analyzer/itglue/sites/[companyId]/route.ts
Normal file
67
app/api/analyzer/itglue/sites/[companyId]/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/sites/:companyId
|
||||
*
|
||||
* Returns the site-discovery summary for a client: company identity,
|
||||
* IT Glue org name, Datto site mapping, and the resolved Wulf Nurse
|
||||
* Production endpoint (hostname + uid + online state).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
listSiteAnchorTargets,
|
||||
resolveSiteAnchorTarget,
|
||||
} from '@/lib/services/rmm/target-resolver';
|
||||
|
||||
interface CompanyRow {
|
||||
id: string;
|
||||
company_name: string | null;
|
||||
itglue_org_id: string | null;
|
||||
itglue_org_name: string | null;
|
||||
datto_site_id: number | null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ companyId: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { companyId } = await params;
|
||||
const res = await postgresClient.query<CompanyRow>(
|
||||
`SELECT c.id::text AS id,
|
||||
c.company_name,
|
||||
o.id::text AS itglue_org_id,
|
||||
o.name AS itglue_org_name,
|
||||
ds.id AS datto_site_id
|
||||
FROM companies c
|
||||
LEFT JOIN itg_organizations o ON LOWER(o.name) = LOWER(c.company_name)
|
||||
LEFT JOIN datto_rmm_sites ds ON ds.autotask_company_id = c.id
|
||||
WHERE c.id = $1
|
||||
LIMIT 1`,
|
||||
[companyId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Company not found' }, { status: 404 });
|
||||
}
|
||||
const c = res.rows[0];
|
||||
const target = await resolveSiteAnchorTarget(companyId);
|
||||
const allTargets = await listSiteAnchorTargets(companyId);
|
||||
return NextResponse.json({
|
||||
site: {
|
||||
companyId: c.id,
|
||||
companyName: c.company_name,
|
||||
itglueOrgId: c.itglue_org_id,
|
||||
itglueOrgName: c.itglue_org_name,
|
||||
dattoSiteId: c.datto_site_id,
|
||||
// The chosen primary target — what the executor picks by default.
|
||||
wnpHostname: target?.hostname ?? null,
|
||||
wnpDeviceUid: target?.device_uid ?? null,
|
||||
wnpOnline: target?.online ?? null,
|
||||
// Every available WNP across all sites for the client.
|
||||
sites: allTargets,
|
||||
},
|
||||
});
|
||||
}
|
||||
39
app/api/analyzer/itglue/writes/route.ts
Normal file
39
app/api/analyzer/itglue/writes/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/writes
|
||||
*
|
||||
* Cross-asset write history — admin only. Powers /admin/itglue-writes.
|
||||
*
|
||||
* Query params: ?status=...&limit=...&offset=...
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import {
|
||||
listAllWrites,
|
||||
type WriteStatus,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
|
||||
const ALLOWED_STATUSES: ReadonlyArray<WriteStatus> = [
|
||||
'pending',
|
||||
'committed',
|
||||
'failed',
|
||||
'reverted',
|
||||
];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get('limit') ?? 100);
|
||||
const offset = Number(url.searchParams.get('offset') ?? 0);
|
||||
const statusParam = url.searchParams.get('status');
|
||||
const status = (
|
||||
statusParam && ALLOWED_STATUSES.includes(statusParam as WriteStatus)
|
||||
? (statusParam as WriteStatus)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const writes = await listAllWrites({ limit, offset, status });
|
||||
return NextResponse.json({ writes });
|
||||
}
|
||||
105
app/api/analyzer/share/recipients/route.ts
Normal file
105
app/api/analyzer/share/recipients/route.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* GET /api/analyzer/share/recipients
|
||||
*
|
||||
* Returns suggestions for the share modal:
|
||||
* - recent: the last few unique recipient emails the calling user has
|
||||
* shared with (from analyzer_shares).
|
||||
* - directory: the AD/Microsoft Graph user directory, filtered to
|
||||
* ALLOWED_SHARE_DOMAINS and active accounts only.
|
||||
*
|
||||
* The share endpoint itself still validates the domain server-side, so
|
||||
* the directory list is a UX nicety, not a security boundary.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const RECENT_LIMIT = 5;
|
||||
|
||||
function getAllowedDomains(): string[] {
|
||||
const raw = process.env.ALLOWED_SHARE_DOMAINS ?? '';
|
||||
return raw
|
||||
.split(',')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter((d) => d.length > 0);
|
||||
}
|
||||
|
||||
interface RecentRow {
|
||||
shared_with_email: string;
|
||||
last_shared_at: Date | string;
|
||||
}
|
||||
|
||||
interface DirectoryRow {
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
job_title: string | null;
|
||||
department: string | null;
|
||||
}
|
||||
|
||||
function toIso(d: Date | string): string {
|
||||
return d instanceof Date ? d.toISOString() : new Date(d).toISOString();
|
||||
}
|
||||
|
||||
function emailDomain(email: string): string {
|
||||
return email.split('@')[1]?.toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
const allowedDomains = getAllowedDomains();
|
||||
|
||||
if (allowedDomains.length === 0) {
|
||||
return NextResponse.json({
|
||||
recent: [],
|
||||
directory: [],
|
||||
allowedDomains: [],
|
||||
});
|
||||
}
|
||||
|
||||
const recentRows = userId
|
||||
? (
|
||||
await postgresClient.query<RecentRow>(
|
||||
`SELECT shared_with_email,
|
||||
MAX(shared_at) AS last_shared_at
|
||||
FROM analyzer_shares
|
||||
WHERE shared_by_user_id = $1
|
||||
GROUP BY shared_with_email
|
||||
ORDER BY MAX(shared_at) DESC
|
||||
LIMIT $2`,
|
||||
[userId, RECENT_LIMIT]
|
||||
)
|
||||
).rows
|
||||
: [];
|
||||
|
||||
const directoryRes = await postgresClient.query<DirectoryRow>(
|
||||
`SELECT email, display_name, job_title, department
|
||||
FROM graph_users
|
||||
WHERE account_enabled = true
|
||||
AND email IS NOT NULL
|
||||
AND lower(split_part(email, '@', 2)) = ANY($1::text[])
|
||||
ORDER BY COALESCE(display_name, email)`,
|
||||
[allowedDomains]
|
||||
);
|
||||
|
||||
// Drop recents whose domain is no longer allowed (defensive — share endpoint
|
||||
// would reject them anyway, but the picker shouldn't dangle).
|
||||
const recent = recentRows
|
||||
.filter((r) => allowedDomains.includes(emailDomain(r.shared_with_email)))
|
||||
.map((r) => ({
|
||||
email: r.shared_with_email,
|
||||
lastSharedAt: toIso(r.last_shared_at),
|
||||
}));
|
||||
|
||||
const directory = directoryRes.rows.map((d) => ({
|
||||
email: d.email,
|
||||
displayName: d.display_name,
|
||||
jobTitle: d.job_title,
|
||||
department: d.department,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ recent, directory, allowedDomains });
|
||||
}
|
||||
257
app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts
Normal file
257
app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* POST /api/analyzer/tickets/:ticketNumber/analyze-bundle
|
||||
*
|
||||
* Body: { linkedTicketNumbers: string[], includeItglueContext?, reportTitle?, confirmedCost? }
|
||||
*
|
||||
* Behavior:
|
||||
* 1. Validate the master + each linked ticket exists in the local mirror.
|
||||
* 2. For each ticket: idempotency-check via content hash; if a fresh
|
||||
* analysis exists, collect its id; otherwise queue an analyzer job.
|
||||
* 3. Cost-guard the *new* work only (already-complete analyses don't add
|
||||
* cost).
|
||||
* 4. Create an analyzer_aggregate_reports row populated with
|
||||
* expected_ticket_numbers + triggered_by_ticket_number. If everything
|
||||
* was already complete, transition straight to 'pending' and fire
|
||||
* runAggregateReport. Otherwise the row sits in 'pending_analyses'
|
||||
* until the worker chain-trigger flips it once all jobs land.
|
||||
*
|
||||
* Response:
|
||||
* { aggregateReportId, ticketCount, queuedJobIds, alreadyCompleteAnalysisIds, status }
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
loadTicketBundle,
|
||||
TicketNotFoundError,
|
||||
} from '@/lib/services/analyzer/data-access';
|
||||
import { preprocessTicket } from '@/lib/services/analyzer/preprocessor';
|
||||
import {
|
||||
findExistingAnalysisByContentHash,
|
||||
queueJob,
|
||||
} from '@/lib/services/analyzer/persistence';
|
||||
import {
|
||||
createAggregateReport,
|
||||
runAggregateReport,
|
||||
} from '@/lib/services/analyzer/aggregate-persistence';
|
||||
import {
|
||||
estimateAggregateReportCost,
|
||||
evaluateCost,
|
||||
recordCostAuditDecision,
|
||||
} from '@/lib/services/analyzer/cost-guard';
|
||||
import { BundleAnalyzeRequest } from '@/lib/types/analyzer';
|
||||
// Side-effect import: ensure the worker self-starts so queued jobs run.
|
||||
import '@/lib/services/analyzer/worker';
|
||||
|
||||
const MAX_BUNDLE_SIZE = 25;
|
||||
|
||||
/**
|
||||
* Pessimistic per-ticket cost. Anthropic path runs Sonnet (+ optional Opus);
|
||||
* OpenRouter path runs DeepSeek V4 Pro (+ optional R1) at ~7-10× lower rate.
|
||||
*/
|
||||
const PER_TICKET_COST_USD: Record<'anthropic' | 'openrouter', number> = {
|
||||
anthropic: 0.15,
|
||||
openrouter: 0.02,
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ ticketNumber: string }> }
|
||||
) {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { ticketNumber: masterTicketNumber } = await params;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = BundleAnalyzeRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const {
|
||||
linkedTicketNumbers,
|
||||
includeItglueContext,
|
||||
reportTitle,
|
||||
confirmedCost,
|
||||
provider,
|
||||
} = parsed.data;
|
||||
|
||||
// Build the unique ordered set: master first, then linked (deduped, master removed).
|
||||
const linkedSet = new Set(linkedTicketNumbers);
|
||||
linkedSet.delete(masterTicketNumber);
|
||||
const allTicketNumbers = [masterTicketNumber, ...Array.from(linkedSet)];
|
||||
|
||||
if (allTicketNumbers.length < 2) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Bundle requires at least one linked ticket besides the master. Use /analyze for single-ticket runs.',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (allTicketNumbers.length > MAX_BUNDLE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Bundle size ${allTicketNumbers.length} exceeds cap ${MAX_BUNDLE_SIZE}.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify every ticket exists locally in one query.
|
||||
const lookup = await postgresClient.query<{ ticket_number: string }>(
|
||||
`SELECT ticket_number FROM tickets
|
||||
WHERE ticket_number = ANY($1::text[])
|
||||
AND COALESCE(is_deleted, false) = false`,
|
||||
[allTicketNumbers]
|
||||
);
|
||||
const present = new Set(lookup.rows.map((r) => r.ticket_number));
|
||||
const missing = allTicketNumbers.filter((n) => !present.has(n));
|
||||
if (missing.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Some tickets not found in local mirror', missing },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
|
||||
// Per-ticket idempotency check + queue plan.
|
||||
const queuedJobIds: string[] = [];
|
||||
const alreadyCompleteAnalysisIds: string[] = [];
|
||||
const ticketsNeedingAnalysis: string[] = [];
|
||||
|
||||
for (const tn of allTicketNumbers) {
|
||||
let bundle;
|
||||
try {
|
||||
bundle = await loadTicketBundle(tn);
|
||||
} catch (err) {
|
||||
if (err instanceof TicketNotFoundError) {
|
||||
// Shouldn't happen — we just verified existence — but be defensive.
|
||||
return NextResponse.json(
|
||||
{ error: `Ticket ${tn} disappeared between checks` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
console.error(`[analyze-bundle] data-access error for ${tn}:`, err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load ticket', message: tn },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const pre = preprocessTicket(bundle);
|
||||
const existing = await findExistingAnalysisByContentHash(
|
||||
tn,
|
||||
pre.content_hash,
|
||||
provider
|
||||
);
|
||||
if (existing) {
|
||||
alreadyCompleteAnalysisIds.push(existing.id);
|
||||
} else {
|
||||
ticketsNeedingAnalysis.push(tn);
|
||||
}
|
||||
}
|
||||
|
||||
// Cost-guard: only the new per-ticket work + the aggregate-reduce step.
|
||||
const aggregateCost = estimateAggregateReportCost({
|
||||
ticketCount: allTicketNumbers.length,
|
||||
includeItglueContext,
|
||||
});
|
||||
const newPerTicketCost =
|
||||
ticketsNeedingAnalysis.length * PER_TICKET_COST_USD[provider];
|
||||
const estimatedCost =
|
||||
Math.round((newPerTicketCost + aggregateCost) * 10_000) / 10_000;
|
||||
|
||||
const evaluation = await evaluateCost({
|
||||
userId,
|
||||
estimatedCost,
|
||||
confirmedCost,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId,
|
||||
action: 'analyze_bundle',
|
||||
evaluation,
|
||||
context: {
|
||||
masterTicketNumber,
|
||||
ticketCount: allTicketNumbers.length,
|
||||
newPerTicketCount: ticketsNeedingAnalysis.length,
|
||||
includeItglueContext,
|
||||
},
|
||||
});
|
||||
if (evaluation.decision === 'blocked') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Daily cost limit reached',
|
||||
message: evaluation.decisionReason,
|
||||
estimatedCost: evaluation.estimatedCost,
|
||||
dailySpendBefore: evaluation.dailySpendBefore,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
if (evaluation.decision === 'requires_confirmation') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Confirmation required',
|
||||
message: evaluation.decisionReason,
|
||||
estimatedCost: evaluation.estimatedCost,
|
||||
dailySpendBefore: evaluation.dailySpendBefore,
|
||||
requiresConfirmation: true,
|
||||
retryWith: { confirmedCost: true },
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Queue jobs for the missing tickets.
|
||||
for (const tn of ticketsNeedingAnalysis) {
|
||||
const job = await queueJob({
|
||||
ticket_number: tn,
|
||||
queued_by_user_id: userId,
|
||||
provider,
|
||||
});
|
||||
queuedJobIds.push(job.id);
|
||||
}
|
||||
|
||||
// Create the aggregate report row. Bundle mode (expectedTicketNumbers set)
|
||||
// means status starts as 'pending_analyses' if any jobs were queued, or as
|
||||
// 'pending' if everything was already complete and we can run immediately.
|
||||
const allAlreadyComplete = ticketsNeedingAnalysis.length === 0;
|
||||
const created = await createAggregateReport({
|
||||
generatedByUserId: userId,
|
||||
filterCriteria: {
|
||||
mode: 'bundle',
|
||||
masterTicketNumber,
|
||||
linkedTicketNumbers: Array.from(linkedSet),
|
||||
provider,
|
||||
},
|
||||
analysisIds: alreadyCompleteAnalysisIds,
|
||||
ticketCount: allTicketNumbers.length,
|
||||
includeItglueContext,
|
||||
reportTitle: reportTitle ?? null,
|
||||
expectedTicketNumbers: allAlreadyComplete ? undefined : allTicketNumbers,
|
||||
triggeredByTicketNumber: masterTicketNumber,
|
||||
});
|
||||
|
||||
if (allAlreadyComplete) {
|
||||
void runAggregateReport(created.id).catch((err) => {
|
||||
console.error('[analyze-bundle] background runner threw:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
aggregateReportId: created.id,
|
||||
ticketCount: allTicketNumbers.length,
|
||||
queuedJobIds,
|
||||
alreadyCompleteAnalysisIds,
|
||||
status: allAlreadyComplete ? 'pending' : 'pending_analyses',
|
||||
estimatedCost: evaluation.estimatedCost,
|
||||
softWarn: evaluation.softWarn,
|
||||
});
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export async function POST(
|
|||
|
||||
const { ticketNumber } = await params;
|
||||
|
||||
let parsedBody: { force?: boolean };
|
||||
let parsedBody: { force?: boolean; provider?: 'anthropic' | 'openrouter' };
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const result = AnalyzeTicketRequest.safeParse(body);
|
||||
|
|
@ -51,6 +51,7 @@ export async function POST(
|
|||
} catch {
|
||||
parsedBody = {};
|
||||
}
|
||||
const provider = parsedBody.provider ?? 'anthropic';
|
||||
|
||||
// Verify the ticket exists and load its data for the idempotency check.
|
||||
let bundle;
|
||||
|
|
@ -74,12 +75,14 @@ export async function POST(
|
|||
}
|
||||
|
||||
// Idempotency short-circuit: when force=false, return the existing analysis
|
||||
// without queueing a job if the source data hasn't changed since.
|
||||
// without queueing a job if the source data hasn't changed since. Provider-
|
||||
// scoped so a Claude run never short-circuits a DeepSeek request.
|
||||
if (!parsedBody.force) {
|
||||
const pre = preprocessTicket(bundle);
|
||||
const existing = await findExistingAnalysisByContentHash(
|
||||
ticketNumber,
|
||||
pre.content_hash
|
||||
pre.content_hash,
|
||||
provider
|
||||
);
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
|
|
@ -96,6 +99,7 @@ export async function POST(
|
|||
const job = await queueJob({
|
||||
ticket_number: ticketNumber,
|
||||
queued_by_user_id: userId,
|
||||
provider,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* GET /api/analyzer/tickets/:ticketNumber/itglue-xrefs
|
||||
*
|
||||
* Returns the xrefs for one ticket — "every IT Glue asset this ticket
|
||||
* touched" — for use on the analysis page or future ticket views.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listXrefsForTicket } from '@/lib/services/analyzer/asset-audit/xrefs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ ticketNumber: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { ticketNumber } = await params;
|
||||
const xrefs = await listXrefsForTicket(ticketNumber, 100);
|
||||
return NextResponse.json({ xrefs });
|
||||
}
|
||||
97
app/api/analyzer/tickets/[ticketNumber]/links/route.ts
Normal file
97
app/api/analyzer/tickets/[ticketNumber]/links/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* GET /api/analyzer/tickets/:ticketNumber/links
|
||||
* Cheap explicit-only discovery (regex + RELATED TICKETS section + problem_ticket_id).
|
||||
* No LLM cost. Use this on page load to render the Related Tickets panel.
|
||||
*
|
||||
* POST /api/analyzer/tickets/:ticketNumber/links
|
||||
* Body: { includeSuggested?: boolean }
|
||||
* Runs the Haiku-suggested arm in addition to the explicit arm.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import {
|
||||
loadTicketBundle,
|
||||
TicketNotFoundError,
|
||||
} from '@/lib/services/analyzer/data-access';
|
||||
import {
|
||||
discoverLinks,
|
||||
discoverExplicitLinks,
|
||||
} from '@/lib/services/analyzer/link-discovery';
|
||||
import { SuggestLinksRequest } from '@/lib/types/analyzer';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ ticketNumber: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { ticketNumber } = await params;
|
||||
|
||||
let bundle;
|
||||
try {
|
||||
bundle = await loadTicketBundle(ticketNumber);
|
||||
} catch (err) {
|
||||
if (err instanceof TicketNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: `Ticket ${ticketNumber} not found in local mirror` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
console.error('[analyzer/links] data-access error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load ticket' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await discoverExplicitLinks(bundle);
|
||||
return NextResponse.json({
|
||||
explicit: result.explicit,
|
||||
suggested: [],
|
||||
isProblemTicket: result.isProblemTicket,
|
||||
problemTicketSignals: result.problemTicketSignals,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ ticketNumber: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { ticketNumber } = await params;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = SuggestLinksRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let bundle;
|
||||
try {
|
||||
bundle = await loadTicketBundle(ticketNumber);
|
||||
} catch (err) {
|
||||
if (err instanceof TicketNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: `Ticket ${ticketNumber} not found in local mirror` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
console.error('[analyzer/links] data-access error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load ticket' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await discoverLinks(bundle, {
|
||||
includeSuggested: parsed.data.includeSuggested,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
31
app/api/dashboard/integration-health/route.ts
Normal file
31
app/api/dashboard/integration-health/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* GET /api/dashboard/integration-health
|
||||
* Live auth check + JWT expiry decode for each configured integration.
|
||||
* Cached in-process for 5 minutes.
|
||||
*
|
||||
* ?refresh=1 forces re-check (admin only).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requirePermission } from '@/lib/auth-utils';
|
||||
import {
|
||||
checkIntegrationHealth,
|
||||
clearIntegrationHealthCache,
|
||||
summarize,
|
||||
} from '@/lib/services/integration-health';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const refresh = request.nextUrl.searchParams.get('refresh') === '1';
|
||||
|
||||
if (refresh) {
|
||||
const adminCheck = await requirePermission('admin', 'access');
|
||||
if (adminCheck.error) return adminCheck.error;
|
||||
clearIntegrationHealthCache();
|
||||
} else {
|
||||
const authCheck = await requireAuth();
|
||||
if (authCheck.error) return authCheck.error;
|
||||
}
|
||||
|
||||
const items = await checkIntegrationHealth({ skipCache: refresh });
|
||||
return NextResponse.json({ items, summary: summarize(items) });
|
||||
}
|
||||
168
app/api/dashboard/overview/route.ts
Normal file
168
app/api/dashboard/overview/route.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* GET /api/dashboard/overview
|
||||
* Single round-trip backing the new dashboard. All queries run in parallel.
|
||||
*
|
||||
* attention — counts that should pull a human's eyes
|
||||
* observations — recent device_observations (loglift et al.)
|
||||
* audits — recent endpoint_audits
|
||||
* syncHealth — per-schedule last_run / last_status from sync_schedules
|
||||
* stats — small footer: companies, CIs, xref linkage
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
type Counts = { count: string };
|
||||
|
||||
const [
|
||||
linkConflictsRes,
|
||||
itglueUnlinkedRes,
|
||||
s1UnmappedRes,
|
||||
schedulesRes,
|
||||
observationsRes,
|
||||
auditsRes,
|
||||
syncHealthRes,
|
||||
companiesRes,
|
||||
ciRes,
|
||||
xrefRes,
|
||||
] = await Promise.all([
|
||||
postgresClient.query<Counts>(
|
||||
`SELECT COUNT(*)::text AS count FROM device_link_review WHERE resolved_at IS NULL`
|
||||
),
|
||||
postgresClient.query<Counts>(
|
||||
`SELECT COUNT(*)::text AS count FROM device_external_ids WHERE source = 'itglue' AND configuration_item_id IS NULL`
|
||||
),
|
||||
postgresClient.query<Counts>(
|
||||
`SELECT COUNT(*)::text AS count FROM device_external_ids WHERE source = 's1' AND configuration_item_id IS NULL`
|
||||
),
|
||||
postgresClient.query<{ enabled: string; total: string }>(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE is_enabled)::text AS enabled,
|
||||
COUNT(*)::text AS total
|
||||
FROM sync_schedules`
|
||||
),
|
||||
postgresClient.query<{
|
||||
id: string;
|
||||
kind: string;
|
||||
source: string;
|
||||
collected_at: string;
|
||||
hostname: string | null;
|
||||
company_name: string | null;
|
||||
run_id: string | null;
|
||||
}>(
|
||||
`SELECT o.id::text,
|
||||
o.kind, o.source,
|
||||
o.collected_at::text,
|
||||
ci.reference_title AS hostname,
|
||||
c.company_name,
|
||||
o.run_id
|
||||
FROM device_observations o
|
||||
LEFT JOIN configuration_items ci ON ci.id = o.configuration_item_id
|
||||
LEFT JOIN companies c ON c.id = ci.company_id
|
||||
ORDER BY o.collected_at DESC
|
||||
LIMIT 10`
|
||||
),
|
||||
postgresClient.query<{
|
||||
id: string;
|
||||
generated_at: string;
|
||||
hostname: string | null;
|
||||
company_name: string | null;
|
||||
overall_score: string | null;
|
||||
field_gaps_count: string;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT a.id::text,
|
||||
a.generated_at::text,
|
||||
ci.reference_title AS hostname,
|
||||
c.company_name,
|
||||
a.overall_score::text,
|
||||
jsonb_array_length(COALESCE(a.field_gaps, '[]'::jsonb))::text AS field_gaps_count,
|
||||
a.status
|
||||
FROM endpoint_audits a
|
||||
LEFT JOIN configuration_items ci ON ci.id = a.configuration_item_id
|
||||
LEFT JOIN companies c ON c.id = ci.company_id
|
||||
ORDER BY a.generated_at DESC
|
||||
LIMIT 10`
|
||||
),
|
||||
postgresClient.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
sync_type: string;
|
||||
is_enabled: boolean;
|
||||
last_run: string | null;
|
||||
last_status: string | null;
|
||||
last_error: string | null;
|
||||
next_run: string | null;
|
||||
}>(
|
||||
`SELECT id, name, sync_type, is_enabled,
|
||||
last_run::text, last_status, last_error,
|
||||
next_run::text
|
||||
FROM sync_schedules
|
||||
ORDER BY name`
|
||||
),
|
||||
postgresClient.query<Counts>(
|
||||
`SELECT COUNT(*)::text AS count FROM companies WHERE company_type = 1 AND is_active = true`
|
||||
),
|
||||
postgresClient.query<Counts>(
|
||||
`SELECT COUNT(*)::text AS count FROM configuration_items WHERE is_deleted = false OR is_deleted IS NULL`
|
||||
),
|
||||
postgresClient.query<{ total: string; linked: string }>(
|
||||
`SELECT COUNT(*)::text AS total,
|
||||
COUNT(*) FILTER (WHERE configuration_item_id IS NOT NULL)::text AS linked
|
||||
FROM device_external_ids`
|
||||
),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
attention: {
|
||||
linkConflicts: parseInt(linkConflictsRes.rows[0]?.count ?? '0', 10),
|
||||
itglueUnlinked: parseInt(itglueUnlinkedRes.rows[0]?.count ?? '0', 10),
|
||||
s1Unmapped: parseInt(s1UnmappedRes.rows[0]?.count ?? '0', 10),
|
||||
schedules: {
|
||||
enabled: parseInt(schedulesRes.rows[0]?.enabled ?? '0', 10),
|
||||
total: parseInt(schedulesRes.rows[0]?.total ?? '0', 10),
|
||||
},
|
||||
},
|
||||
observations: observationsRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
source: r.source,
|
||||
collectedAt: r.collected_at,
|
||||
hostname: r.hostname,
|
||||
companyName: r.company_name,
|
||||
runId: r.run_id,
|
||||
})),
|
||||
audits: auditsRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
generatedAt: r.generated_at,
|
||||
hostname: r.hostname,
|
||||
companyName: r.company_name,
|
||||
overallScore: r.overall_score === null ? null : Number(r.overall_score),
|
||||
fieldGapsCount: parseInt(r.field_gaps_count, 10),
|
||||
status: r.status,
|
||||
})),
|
||||
syncHealth: syncHealthRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
syncType: r.sync_type,
|
||||
isEnabled: r.is_enabled,
|
||||
lastRun: r.last_run,
|
||||
lastStatus: r.last_status,
|
||||
lastError: r.last_error,
|
||||
nextRun: r.next_run,
|
||||
})),
|
||||
stats: {
|
||||
activeCompanies: parseInt(companiesRes.rows[0]?.count ?? '0', 10),
|
||||
configurationItems: parseInt(ciRes.rows[0]?.count ?? '0', 10),
|
||||
xref: {
|
||||
total: parseInt(xrefRes.rows[0]?.total ?? '0', 10),
|
||||
linked: parseInt(xrefRes.rows[0]?.linked ?? '0', 10),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
24
app/api/rmm/executions/[id]/route.ts
Normal file
24
app/api/rmm/executions/[id]/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* GET /api/rmm/executions/:id
|
||||
*
|
||||
* Returns one execution row including stdout / stderr / parsed_evidence.
|
||||
* Used by the live-stream UI to poll status.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { getExecutionById } from '@/lib/services/rmm/persistence';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { id } = await params;
|
||||
const execution = await getExecutionById(id);
|
||||
if (!execution) {
|
||||
return NextResponse.json({ error: 'Execution not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ execution });
|
||||
}
|
||||
118
app/api/rmm/executions/route.ts
Normal file
118
app/api/rmm/executions/route.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* GET /api/rmm/executions
|
||||
* List recent executions. Filters: companyId, scriptId, status,
|
||||
* assetType, assetId. requireAuth() — admin sees all by default.
|
||||
*
|
||||
* POST /api/rmm/executions
|
||||
* Body: { scriptId, target: { type: 'site_anchor', companyId } |
|
||||
* { type: 'asset_self', deviceUid, hostname?, companyId?, assetType?, assetId? },
|
||||
* triggeredByAuditId? }
|
||||
* Requires rmm.execute. Queues a fresh execution.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requireAuth, requirePermission } from '@/lib/auth-utils';
|
||||
import {
|
||||
listExecutions,
|
||||
type RmmExecutionStatus,
|
||||
} from '@/lib/services/rmm/persistence';
|
||||
import { queueExecution } from '@/lib/services/rmm/executor';
|
||||
// Side-effect import: starts the worker once per process.
|
||||
import '@/lib/services/rmm/worker';
|
||||
|
||||
const PostBody = z.object({
|
||||
scriptId: z.string().min(1),
|
||||
target: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('site_anchor'),
|
||||
companyId: z.union([z.string(), z.number()]),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('asset_self'),
|
||||
deviceUid: z.string().min(1),
|
||||
hostname: z.string().nullable().optional(),
|
||||
companyId: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
assetType: z.enum(['flexible_asset', 'configuration']).optional(),
|
||||
assetId: z.union([z.string(), z.number()]).optional(),
|
||||
}),
|
||||
]),
|
||||
triggeredByAuditId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
const ALLOWED_STATUSES: ReadonlyArray<RmmExecutionStatus> = [
|
||||
'queued',
|
||||
'running',
|
||||
'complete',
|
||||
'failed',
|
||||
'timeout',
|
||||
];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get('limit') ?? 100);
|
||||
const offset = Number(url.searchParams.get('offset') ?? 0);
|
||||
const companyIdParam = url.searchParams.get('companyId');
|
||||
const scriptIdParam = url.searchParams.get('scriptId');
|
||||
const statusParam = url.searchParams.get('status');
|
||||
const assetTypeParam = url.searchParams.get('assetType');
|
||||
const assetIdParam = url.searchParams.get('assetId');
|
||||
const status =
|
||||
statusParam && ALLOWED_STATUSES.includes(statusParam as RmmExecutionStatus)
|
||||
? (statusParam as RmmExecutionStatus)
|
||||
: undefined;
|
||||
const assetType =
|
||||
assetTypeParam === 'flexible_asset' || assetTypeParam === 'configuration'
|
||||
? assetTypeParam
|
||||
: undefined;
|
||||
|
||||
const executions = await listExecutions({
|
||||
limit,
|
||||
offset,
|
||||
companyId: companyIdParam ?? undefined,
|
||||
scriptId: scriptIdParam ?? undefined,
|
||||
status,
|
||||
assetType,
|
||||
assetId: assetIdParam ?? undefined,
|
||||
});
|
||||
return NextResponse.json({ executions });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { session, error } = await requirePermission('rmm', 'execute');
|
||||
if (error) return error;
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = PostBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await queueExecution({
|
||||
scriptId: parsed.data.scriptId,
|
||||
target: parsed.data.target,
|
||||
performedByUserId: userId,
|
||||
triggeredByAuditId: parsed.data.triggeredByAuditId ?? null,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Rate-limit failures + bad targets read as 400; everything else 500.
|
||||
const isClient =
|
||||
message.includes('rate limit') ||
|
||||
message.includes('No Wulf Nurse') ||
|
||||
message.includes('Unknown script') ||
|
||||
message.includes('expects target_type');
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not queue execution', message },
|
||||
{ status: isClient ? 400 : 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
99
app/api/rmm/loglift/upload/route.ts
Normal file
99
app/api/rmm/loglift/upload/route.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* LogLift evidence webhook.
|
||||
*
|
||||
* The Datto RMM collector component uploads gzipped event-log JSON to B2,
|
||||
* then POSTs the metadata here. Pulse downloads the gzip, slims it, persists
|
||||
* it as RMM evidence, and (when the hostname matches a single IT Glue
|
||||
* Configuration) fires an asset-first audit on the side.
|
||||
*
|
||||
* Auth: `x-openclaw-key` header — same shared secret the collector already
|
||||
* carries for OpenClaw API calls.
|
||||
*
|
||||
* Public route per `middleware.ts` (`/api/rmm/loglift` exclusion).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { validateOpenClawKey } from '@/lib/utils/openclaw-auth';
|
||||
import { OBJECT_KEY_REGEX } from '@/lib/services/b2/client';
|
||||
import { processLogliftWebhook } from '@/lib/services/rmm/loglift-receiver';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const WebhookSchema = z.object({
|
||||
runId: z.string().min(1).max(200),
|
||||
clientId: z.string().min(1).max(200),
|
||||
computerName: z.string().min(1).max(200),
|
||||
deviceUid: z.string().max(200).nullable().optional(),
|
||||
summary: z.object({
|
||||
totalEvents: z.number().int().nonnegative().nullable().optional(),
|
||||
criticalEvents: z.number().int().nonnegative().nullable().optional(),
|
||||
errorCount: z.number().int().nonnegative().nullable().optional(),
|
||||
warningCount: z.number().int().nonnegative().nullable().optional(),
|
||||
timeRange: z.string().max(200).nullable().optional(),
|
||||
}),
|
||||
objectKey: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.refine((s) => OBJECT_KEY_REGEX.test(s), {
|
||||
message: 'objectKey does not match the required eventlogs path shape',
|
||||
}),
|
||||
collectedAt: z.string().min(1).max(64),
|
||||
rmmContext: z
|
||||
.object({
|
||||
siteName: z.string().max(200).nullable().optional(),
|
||||
siteUid: z.string().max(200).nullable().optional(),
|
||||
accountUid: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
.partial()
|
||||
.nullable()
|
||||
.optional(),
|
||||
issueDescription: z.string().max(4000).nullable().optional(),
|
||||
ticketNumber: z.string().max(64).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const auth = validateOpenClawKey(req);
|
||||
if (auth) return auth;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = WebhookSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid payload',
|
||||
details: parsed.error.flatten(),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processLogliftWebhook(parsed.data);
|
||||
return NextResponse.json(
|
||||
{
|
||||
executionId: result.executionId,
|
||||
matched: result.matched,
|
||||
parsed: result.parsed,
|
||||
auditId: result.audit_id,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('[LOGLIFT-WEBHOOK]', message, err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process LogLift webhook', message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
25
app/api/rmm/scripts/route.ts
Normal file
25
app/api/rmm/scripts/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* GET /api/rmm/scripts
|
||||
*
|
||||
* Returns the script library catalog (id, name, description, target_type,
|
||||
* expected_runtime_seconds, version). Bodies are not returned — they live
|
||||
* in the repo and are only sent to Datto RMM, never to clients.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listScripts } from '@/lib/services/rmm/scripts';
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const scripts = listScripts().map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
target_type: s.target_type,
|
||||
expected_runtime_seconds: s.expected_runtime_seconds,
|
||||
version: s.version,
|
||||
}));
|
||||
return NextResponse.json({ scripts });
|
||||
}
|
||||
27
app/api/sync/schedules/reload/route.ts
Normal file
27
app/api/sync/schedules/reload/route.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* POST /api/sync/schedules/reload
|
||||
* Stops every running cron task and re-loads from the DB. Use after seeding
|
||||
* new schedule rows directly via SQL (the scheduler only seeds defaults on a
|
||||
* virgin table).
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import { syncScheduler } from '@/lib/services/sync-scheduler';
|
||||
|
||||
export async function POST() {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const result = await syncScheduler.reloadAllSchedules();
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('[SCHEDULE API] reload failed:', message);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reload schedules', details: message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,7 @@ import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
|||
import { AuvikDevice } from '@/lib/types/auvik';
|
||||
import { AddigyDevice } from '@/lib/types/addigy';
|
||||
import { useApi } from '@/lib/hooks/use-api';
|
||||
import { RmmDispatchDialog } from '@/components/rmm/rmm-dispatch-dialog';
|
||||
|
||||
interface DeviceComparison {
|
||||
autotaskDevice?: ConfigurationItem;
|
||||
|
|
@ -864,6 +865,7 @@ function ConfigurationItemsContent() {
|
|||
<TableHead>RMM</TableHead>
|
||||
<TableHead>NMS</TableHead>
|
||||
<TableHead>ARMM</TableHead>
|
||||
<TableHead className="w-32">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -889,7 +891,7 @@ function ConfigurationItemsContent() {
|
|||
setExpandedContacts(newExpanded);
|
||||
}}
|
||||
>
|
||||
<TableCell colSpan={11}>
|
||||
<TableCell colSpan={12}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
|
||||
<Users className="h-4 w-4" />
|
||||
|
|
@ -1024,6 +1026,17 @@ function ConfigurationItemsContent() {
|
|||
<XCircle className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-32" onClick={(e) => e.stopPropagation()}>
|
||||
{item.rmmDevice?.uid ? (
|
||||
<RmmDispatchDialog
|
||||
deviceUid={item.rmmDevice.uid}
|
||||
hostname={item.rmmDevice.hostname ?? null}
|
||||
companyId={selectedCompany ?? null}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
|
|
@ -1150,6 +1163,17 @@ function ConfigurationItemsContent() {
|
|||
<XCircle className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-32" onClick={(e) => e.stopPropagation()}>
|
||||
{item.rmmDevice?.uid ? (
|
||||
<RmmDispatchDialog
|
||||
deviceUid={item.rmmDevice.uid}
|
||||
hostname={item.rmmDevice.hostname ?? null}
|
||||
companyId={selectedCompany ?? null}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,386 +2,453 @@
|
|||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Server,
|
||||
Building2,
|
||||
Network,
|
||||
Globe,
|
||||
Smartphone,
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Database,
|
||||
Shield,
|
||||
CalendarClock,
|
||||
RefreshCw,
|
||||
ArrowRight,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Users,
|
||||
HardDrive,
|
||||
Wifi,
|
||||
FileText
|
||||
Clock,
|
||||
Activity,
|
||||
Sparkles,
|
||||
Plug,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DashboardStats {
|
||||
companies: {
|
||||
total: number;
|
||||
active: number;
|
||||
};
|
||||
configurationItems: {
|
||||
total: number;
|
||||
active: number;
|
||||
};
|
||||
mappings: {
|
||||
auvik: {
|
||||
mapped: number;
|
||||
unmapped: number;
|
||||
};
|
||||
rmm: {
|
||||
mapped: number;
|
||||
unmapped: number;
|
||||
};
|
||||
};
|
||||
quotes: {
|
||||
open: number;
|
||||
interface IntegrationHealthItem {
|
||||
key: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown';
|
||||
configured: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string | null;
|
||||
tokenExpiry?: {
|
||||
envVar: string;
|
||||
expiresAt: string;
|
||||
daysRemaining: number;
|
||||
subject?: string | null;
|
||||
} | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface IntegrationHealthResponse {
|
||||
items: IntegrationHealthItem[];
|
||||
summary: {
|
||||
total: number;
|
||||
ok: number;
|
||||
failed: number;
|
||||
notConfigured: number;
|
||||
expiringWithin14Days: number;
|
||||
expired: number;
|
||||
hasIssues: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface Overview {
|
||||
attention: {
|
||||
linkConflicts: number;
|
||||
itglueUnlinked: number;
|
||||
s1Unmapped: number;
|
||||
schedules: { enabled: number; total: number };
|
||||
};
|
||||
observations: Array<{
|
||||
id: string;
|
||||
kind: string;
|
||||
source: string;
|
||||
collectedAt: string;
|
||||
hostname: string | null;
|
||||
companyName: string | null;
|
||||
runId: string | null;
|
||||
}>;
|
||||
audits: Array<{
|
||||
id: string;
|
||||
generatedAt: string;
|
||||
hostname: string | null;
|
||||
companyName: string | null;
|
||||
overallScore: number | null;
|
||||
fieldGapsCount: number;
|
||||
status: string;
|
||||
}>;
|
||||
syncHealth: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
syncType: string;
|
||||
isEnabled: boolean;
|
||||
lastRun: string | null;
|
||||
lastStatus: string | null;
|
||||
lastError: string | null;
|
||||
nextRun: string | null;
|
||||
}>;
|
||||
stats: {
|
||||
activeCompanies: number;
|
||||
configurationItems: number;
|
||||
xref: { total: number; linked: number };
|
||||
};
|
||||
}
|
||||
|
||||
const STALE_HOURS = 24;
|
||||
|
||||
function relTime(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (ms < 0) return 'in the future';
|
||||
const min = Math.floor(ms / 60000);
|
||||
if (min < 1) return 'just now';
|
||||
if (min < 60) return `${min} min ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 48) return `${hr} h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return `${day} d ago`;
|
||||
}
|
||||
|
||||
function isStale(iso: string | null): boolean {
|
||||
if (!iso) return true;
|
||||
return Date.now() - new Date(iso).getTime() > STALE_HOURS * 3600_000;
|
||||
}
|
||||
|
||||
function syncStatusIcon(s: { lastStatus: string | null; lastRun: string | null; isEnabled: boolean }) {
|
||||
if (!s.isEnabled) return <span className="text-muted-foreground text-xs">off</span>;
|
||||
if (s.lastStatus === 'failed')
|
||||
return <XCircle className="size-4 text-destructive" aria-label="failed" />;
|
||||
if (isStale(s.lastRun))
|
||||
return <Clock className="size-4 text-amber-500" aria-label="stale" />;
|
||||
if (s.lastStatus === 'success')
|
||||
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />;
|
||||
return <Clock className="size-4 text-muted-foreground" aria-label="never run" />;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
companies: { total: 0, active: 0 },
|
||||
configurationItems: { total: 0, active: 0 },
|
||||
mappings: {
|
||||
auvik: { mapped: 0, unmapped: 0 },
|
||||
rmm: { mapped: 0, unmapped: 0 }
|
||||
},
|
||||
quotes: { open: 0, total: 0 }
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<Overview | null>(null);
|
||||
const [health, setHealth] = useState<IntegrationHealthResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const fetchStats = async () => {
|
||||
async function load(): Promise<void> {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch cached stats from database (fast, no external API calls)
|
||||
const statsRes = await fetch('/api/dashboard/stats');
|
||||
const statsData = await statsRes.json();
|
||||
|
||||
setStats({
|
||||
companies: statsData.companies || { total: 0, active: 0 },
|
||||
configurationItems: {
|
||||
total: 0,
|
||||
active: 0
|
||||
},
|
||||
mappings: statsData.mappings || {
|
||||
auvik: { mapped: 0, unmapped: 0 },
|
||||
rmm: { mapped: 0, unmapped: 0 }
|
||||
},
|
||||
quotes: statsData.quotes || { open: 0, total: 0 }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard stats:', error);
|
||||
const [overviewRes, healthRes] = await Promise.all([
|
||||
fetch('/api/dashboard/overview'),
|
||||
fetch('/api/dashboard/integration-health'),
|
||||
]);
|
||||
if (!overviewRes.ok) {
|
||||
const body = (await overviewRes.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${overviewRes.status}`);
|
||||
}
|
||||
setData((await overviewRes.json()) as Overview);
|
||||
if (healthRes.ok) {
|
||||
setHealth((await healthRes.json()) as IntegrationHealthResponse);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
title: 'Configuration Items',
|
||||
description: 'View and manage IT assets and devices',
|
||||
href: '/configuration-items',
|
||||
icon: Server,
|
||||
color: 'blue',
|
||||
stats: `${stats.configurationItems.active} active items`
|
||||
},
|
||||
{
|
||||
title: 'Kiosk Display',
|
||||
description: 'Configure and view executive dashboard for TV',
|
||||
href: '/kiosk/settings',
|
||||
icon: Activity,
|
||||
color: 'blue',
|
||||
stats: 'Settings & display'
|
||||
},
|
||||
{
|
||||
title: 'Sync Management',
|
||||
description: 'Synchronize data from external systems',
|
||||
href: '/admin/sync',
|
||||
icon: RefreshCw,
|
||||
color: 'green',
|
||||
stats: 'Run data synchronization'
|
||||
},
|
||||
{
|
||||
title: 'Data Browser',
|
||||
description: 'Browse and query system data',
|
||||
href: '/admin/data-browser',
|
||||
icon: Database,
|
||||
color: 'purple',
|
||||
stats: 'Explore database tables'
|
||||
}
|
||||
];
|
||||
|
||||
const mappingCards = [
|
||||
{
|
||||
title: 'NMS Mapping (Auvik)',
|
||||
description: 'Network Management System integration',
|
||||
href: '/auvik-mappings',
|
||||
icon: Network,
|
||||
color: 'blue',
|
||||
mapped: stats.mappings.auvik.mapped,
|
||||
unmapped: stats.mappings.auvik.unmapped,
|
||||
total: stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped
|
||||
},
|
||||
{
|
||||
title: 'RMM Mapping (Datto)',
|
||||
description: 'Remote Monitoring & Management',
|
||||
href: '/rmm-mappings',
|
||||
icon: Globe,
|
||||
color: 'purple',
|
||||
mapped: stats.mappings.rmm.mapped,
|
||||
unmapped: stats.mappings.rmm.unmapped,
|
||||
total: stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped
|
||||
},
|
||||
{
|
||||
title: 'Apple RMM (Addigy)',
|
||||
description: 'Apple device management',
|
||||
href: '/addigy-mappings',
|
||||
icon: Smartphone,
|
||||
color: 'orange',
|
||||
mapped: 0,
|
||||
unmapped: 0,
|
||||
total: 0,
|
||||
comingSoon: true
|
||||
}
|
||||
];
|
||||
|
||||
const getMappingProgress = (mapped: number, total: number) => {
|
||||
if (total === 0) return 0;
|
||||
return (mapped / total) * 100;
|
||||
};
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="container mx-auto px-6 py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Welcome to Pulse - Your PSA Management System
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={fetchStats} variant="outline" size="sm" disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<Button onClick={load} variant="outline" size="sm" disabled={loading}>
|
||||
<RefreshCw className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Companies</CardTitle>
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.companies.total}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.companies.active} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">NMS Coverage</CardTitle>
|
||||
<Wifi className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped > 0
|
||||
? Math.round(getMappingProgress(stats.mappings.auvik.mapped, stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped))
|
||||
: 0}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.mappings.auvik.mapped} of {stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped} tenants
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">RMM Coverage</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped > 0
|
||||
? Math.round(getMappingProgress(stats.mappings.rmm.mapped, stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped))
|
||||
: 0}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.mappings.rmm.mapped} of {stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped} sites
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Link href="/quotes">
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Open Quotes</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.quotes.open}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pending approval
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">System Status</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
Online
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All systems operational
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Quick Access</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{quickLinks.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<link.icon className={`h-8 w-8 text-${link.color}-600`} />
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="mt-4">{link.title}</CardTitle>
|
||||
<CardDescription>{link.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{link.stats}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
{/* NEEDS ATTENTION ----------------------------------------------------- */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
||||
Needs attention
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<AttentionCard
|
||||
icon={AlertTriangle}
|
||||
value={data?.attention.linkConflicts}
|
||||
label="Device-link conflicts"
|
||||
href="/admin/device-link-conflicts"
|
||||
tone={data && data.attention.linkConflicts > 0 ? 'warn' : 'ok'}
|
||||
/>
|
||||
<AttentionCard
|
||||
icon={Database}
|
||||
value={data?.attention.itglueUnlinked}
|
||||
label="IT Glue ↛ Autotask"
|
||||
sub="unlinked configurations"
|
||||
href="/admin/device-link-conflicts"
|
||||
tone="info"
|
||||
/>
|
||||
<AttentionCard
|
||||
icon={Shield}
|
||||
value={data?.attention.s1Unmapped}
|
||||
label="S1 unmapped"
|
||||
sub="missing site → company mapping"
|
||||
href="/sentinelone/mappings"
|
||||
tone="info"
|
||||
/>
|
||||
<AttentionCard
|
||||
icon={CalendarClock}
|
||||
value={data?.attention.schedules.enabled}
|
||||
label={`Schedules on / ${data?.attention.schedules.total ?? '—'}`}
|
||||
href="/admin/sync/autotask"
|
||||
tone="info"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mapping Status */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Integration Mappings</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{mappingCards.map((mapping) => (
|
||||
<Card key={mapping.href} className="relative">
|
||||
{mapping.comingSoon && (
|
||||
<Badge className="absolute top-4 right-4" variant="secondary">
|
||||
Coming Soon
|
||||
</Badge>
|
||||
)}
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<mapping.icon className={`h-8 w-8 text-${mapping.color}-600`} />
|
||||
{!mapping.comingSoon && mapping.unmapped > 0 && (
|
||||
<Badge variant="outline" className="bg-orange-50 border-orange-200 text-orange-700">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{mapping.unmapped} unmapped
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="mt-4">{mapping.title}</CardTitle>
|
||||
<CardDescription>{mapping.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!mapping.comingSoon ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Coverage</span>
|
||||
<span className="font-medium">
|
||||
{Math.round(getMappingProgress(mapping.mapped, mapping.total))}%
|
||||
</span>
|
||||
{/* RECENT OBSERVATIONS + AUDITS ---------------------------------------- */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Activity className="size-4" />
|
||||
Recent device observations
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data === null && !error ? (
|
||||
<RowSkeletons />
|
||||
) : data?.observations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No observations recorded yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{data?.observations.map((o) => (
|
||||
<div
|
||||
key={o.id}
|
||||
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{o.hostname ?? '(unanchored)'}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-mono">{o.kind}</span>
|
||||
{o.companyName && <span> · {o.companyName}</span>}
|
||||
</div>
|
||||
<Progress value={getMappingProgress(mapping.mapped, mapping.total)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||
{mapping.mapped} mapped
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3 text-gray-400" />
|
||||
{mapping.unmapped} unmapped
|
||||
</span>
|
||||
<div className="text-xs text-muted-foreground shrink-0 ml-3">
|
||||
{relTime(o.collectedAt)}
|
||||
</div>
|
||||
<Link href={mapping.href}>
|
||||
<Button className="w-full" variant="outline" size="sm">
|
||||
Manage Mappings
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Integration under development
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity - Placeholder */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Recent Activity</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2 w-2 bg-green-600 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Data sync completed</p>
|
||||
<p className="text-xs text-muted-foreground">Companies synchronized successfully - 5 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2 w-2 bg-blue-600 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">New RMM site mapped</p>
|
||||
<p className="text-xs text-muted-foreground">Site "Acme Corp - Dallas" mapped to Acme Corp - 2 hours ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2 w-2 bg-purple-600 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Configuration items updated</p>
|
||||
<p className="text-xs text-muted-foreground">247 devices synchronized from RMM - 1 day ago</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="size-4" />
|
||||
Recent audits
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data === null && !error ? (
|
||||
<RowSkeletons />
|
||||
) : data?.audits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No endpoint audits yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{data?.audits.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{a.hostname ?? '(unanchored)'}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
score {a.overallScore?.toFixed(2) ?? '—'} · {a.fieldGapsCount} gaps
|
||||
{a.companyName && <span> · {a.companyName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0 ml-3">
|
||||
{relTime(a.generatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* INTEGRATION HEALTH -------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Plug className="size-4" />
|
||||
Integration health
|
||||
{health?.summary.hasIssues && (
|
||||
<Badge variant="destructive" className="text-[10px]">issues</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!health ? (
|
||||
<RowSkeletons />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{health.items
|
||||
.slice()
|
||||
.sort((a, b) => statusOrder(a.status) - statusOrder(b.status))
|
||||
.map((i) => (
|
||||
<IntegrationRow key={i.key} item={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SYNC HEALTH --------------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Sync health</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data === null && !error ? (
|
||||
<RowSkeletons />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{data?.syncHealth.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between py-1.5 text-sm border-b last:border-0">
|
||||
<div className="min-w-0 flex-1 truncate">{s.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0 ml-3">
|
||||
<span>{relTime(s.lastRun)}</span>
|
||||
{syncStatusIcon(s)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* STATS FOOTER -------------------------------------------------------- */}
|
||||
{data && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.stats.activeCompanies} companies · {data.stats.configurationItems.toLocaleString()} CIs ·{' '}
|
||||
{data.stats.xref.total.toLocaleString()} xref rows (
|
||||
{data.stats.xref.total > 0
|
||||
? Math.round((data.stats.xref.linked / data.stats.xref.total) * 100)
|
||||
: 0}
|
||||
% linked)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttentionCard(props: {
|
||||
icon: React.ElementType;
|
||||
value: number | undefined;
|
||||
label: string;
|
||||
sub?: string;
|
||||
href: string;
|
||||
tone: 'ok' | 'warn' | 'info';
|
||||
}) {
|
||||
const { icon: Icon, value, label, sub, href, tone } = props;
|
||||
const valueColor =
|
||||
tone === 'warn' && value && value > 0
|
||||
? 'text-amber-600 dark:text-amber-500'
|
||||
: tone === 'ok'
|
||||
? 'text-foreground'
|
||||
: 'text-foreground';
|
||||
return (
|
||||
<Link href={href} className="block">
|
||||
<Card className="hover:shadow-md transition-shadow h-full">
|
||||
<CardContent className="pt-4 pb-3 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<ArrowRight className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className={`text-2xl font-semibold tabular-nums ${valueColor}`}>
|
||||
{value === undefined ? '—' : value.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm font-medium leading-tight">{label}</div>
|
||||
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function statusOrder(s: IntegrationHealthItem['status']): number {
|
||||
switch (s) {
|
||||
case 'auth_failed': return 0;
|
||||
case 'unreachable': return 1;
|
||||
case 'unknown': return 2;
|
||||
case 'ok': return 3;
|
||||
case 'not_configured': return 4;
|
||||
default: return 5;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(item: IntegrationHealthItem) {
|
||||
const expiringSoon =
|
||||
item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14;
|
||||
const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
|
||||
if (item.status === 'auth_failed' || item.status === 'unreachable')
|
||||
return <XCircle className="size-4 text-destructive" aria-label={item.status} />;
|
||||
if (expired)
|
||||
return <KeyRound className="size-4 text-destructive" aria-label="token expired" />;
|
||||
if (expiringSoon)
|
||||
return <KeyRound className="size-4 text-amber-500" aria-label="token expires soon" />;
|
||||
if (item.status === 'ok')
|
||||
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />;
|
||||
if (item.status === 'unknown')
|
||||
return <CheckCircle2 className="size-4 text-muted-foreground" aria-label="configured" />;
|
||||
return <span className="text-xs text-muted-foreground">off</span>;
|
||||
}
|
||||
|
||||
function IntegrationRow({ item }: { item: IntegrationHealthItem }) {
|
||||
const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
|
||||
const expiringSoon =
|
||||
item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14;
|
||||
const detail =
|
||||
item.status === 'auth_failed' || item.status === 'unreachable'
|
||||
? item.error?.slice(0, 80)
|
||||
: expired
|
||||
? `token expired ${Math.abs(item.tokenExpiry!.daysRemaining).toFixed(0)} d ago`
|
||||
: expiringSoon
|
||||
? `token expires in ${item.tokenExpiry!.daysRemaining.toFixed(0)} d`
|
||||
: item.latencyMs !== undefined
|
||||
? `${item.latencyMs} ms`
|
||||
: null;
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5 text-sm border-b last:border-0">
|
||||
<div className="min-w-0 flex-1 truncate">{item.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0 ml-3">
|
||||
{detail && <span className="truncate max-w-[20ch]">{detail}</span>}
|
||||
{statusBadge(item)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowSkeletons() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Switch } from '@/components/ui/switch';
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Clock, Play, Pause, Trash2, Plus, Calendar, AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Clock, Play, Pause, Trash2, Plus, Calendar, AlertCircle, CheckCircle2, XCircle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface ScheduleConfig {
|
||||
id: string;
|
||||
|
|
@ -37,6 +37,7 @@ interface ScheduleStatus {
|
|||
|
||||
export default function SyncScheduler() {
|
||||
const [schedules, setSchedules] = useState<ScheduleStatus[]>([]);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingSchedule, setEditingSchedule] = useState<ScheduleConfig | null>(null);
|
||||
|
|
@ -90,6 +91,20 @@ export default function SyncScheduler() {
|
|||
}
|
||||
};
|
||||
|
||||
const reloadSchedules = async () => {
|
||||
setReloading(true);
|
||||
try {
|
||||
const res = await fetch('/api/sync/schedules/reload', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
await fetchSchedules();
|
||||
} catch (err) {
|
||||
alert(`Reload failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSchedule = async (scheduleId: string, currentState: boolean) => {
|
||||
try {
|
||||
const response = await fetch(`/api/sync/schedules/${scheduleId}`, {
|
||||
|
|
@ -259,10 +274,16 @@ export default function SyncScheduler() {
|
|||
Manage automatic sync schedules
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Schedule
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={reloadSchedules} disabled={reloading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${reloading ? 'animate-spin' : ''}`} />
|
||||
{reloading ? 'Reloading…' : 'Reload from DB'}
|
||||
</Button>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { toast } from 'sonner';
|
||||
import { Sparkles, Loader2 } from 'lucide-react';
|
||||
import type { JobStatus } from '@/lib/types/analyzer';
|
||||
import type { AnalyzerProvider } from './provider-toggle';
|
||||
|
||||
interface AnalyzeButtonProps {
|
||||
ticketNumber: string;
|
||||
|
|
@ -13,6 +14,8 @@ interface AnalyzeButtonProps {
|
|||
force?: boolean;
|
||||
variant?: 'default' | 'outline' | 'secondary';
|
||||
label?: string;
|
||||
/** LLM provider (anthropic = Claude default; openrouter = DeepSeek). */
|
||||
provider?: AnalyzerProvider;
|
||||
}
|
||||
|
||||
const STAGE_LABEL: Record<JobStatus, string> = {
|
||||
|
|
@ -31,13 +34,16 @@ export function AnalyzeButton({
|
|||
force = false,
|
||||
variant = 'default',
|
||||
label = 'Analyze',
|
||||
provider = 'anthropic',
|
||||
}: AnalyzeButtonProps) {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<JobStatus | 'idle'>('idle');
|
||||
|
||||
async function pollJob(jobId: string) {
|
||||
const start = Date.now();
|
||||
const TIMEOUT_MS = 5 * 60 * 1000;
|
||||
// DeepSeek runs (especially V4 Pro deep analysis) take 4-6× longer than
|
||||
// Claude — observed ~5min on a typical ticket. 12min keeps headroom.
|
||||
const TIMEOUT_MS = 12 * 60 * 1000;
|
||||
while (Date.now() - start < TIMEOUT_MS) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const res = await fetch(`/api/analyzer/jobs/${jobId}`);
|
||||
|
|
@ -64,7 +70,7 @@ export function AnalyzeButton({
|
|||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ force }),
|
||||
body: JSON.stringify({ force, provider }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
493
components/analyzer/itglue-suggestions-panel.tsx
Normal file
493
components/analyzer/itglue-suggestions-panel.tsx
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
'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);
|
||||
75
components/analyzer/provider-toggle.tsx
Normal file
75
components/analyzer/provider-toggle.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
'use client';
|
||||
|
||||
import { Sparkles, Zap } from 'lucide-react';
|
||||
|
||||
export type AnalyzerProvider = 'anthropic' | 'openrouter';
|
||||
|
||||
interface ProviderToggleProps {
|
||||
value: AnalyzerProvider;
|
||||
onChange: (next: AnalyzerProvider) => void;
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const OPTIONS: Array<{
|
||||
value: AnalyzerProvider;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: typeof Sparkles;
|
||||
}> = [
|
||||
{
|
||||
value: 'anthropic',
|
||||
label: 'Claude',
|
||||
hint: 'Haiku → Sonnet → Opus',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
value: 'openrouter',
|
||||
label: 'DeepSeek',
|
||||
hint: 'V4 Flash → V4 Pro → R1',
|
||||
icon: Zap,
|
||||
},
|
||||
];
|
||||
|
||||
export function ProviderToggle({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
}: ProviderToggleProps) {
|
||||
const padding = size === 'sm' ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm';
|
||||
return (
|
||||
<div
|
||||
className="inline-flex rounded-md border bg-muted/40 p-0.5"
|
||||
role="radiogroup"
|
||||
aria-label="LLM provider"
|
||||
>
|
||||
{OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
const active = opt.value === value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(opt.value)}
|
||||
title={opt.hint}
|
||||
className={[
|
||||
padding,
|
||||
'rounded-sm flex items-center gap-1.5 transition-colors',
|
||||
active
|
||||
? 'bg-background shadow-sm font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className={size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
components/analyzer/related-tickets-panel.tsx
Normal file
368
components/analyzer/related-tickets-panel.tsx
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -14,20 +14,104 @@ import { Button } from '@/components/ui/button';
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import { Share2, Clock, Users, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ShareModalProps {
|
||||
analysisId: string;
|
||||
}
|
||||
|
||||
interface DirectoryEntry {
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
jobTitle: string | null;
|
||||
department: string | null;
|
||||
}
|
||||
|
||||
interface RecentEntry {
|
||||
email: string;
|
||||
lastSharedAt: string;
|
||||
}
|
||||
|
||||
interface RecipientsResponse {
|
||||
recent: RecentEntry[];
|
||||
directory: DirectoryEntry[];
|
||||
allowedDomains: string[];
|
||||
}
|
||||
|
||||
const MAX_DIRECTORY_VISIBLE = 12;
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const minute = 60_000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
if (ms < hour) return `${Math.max(1, Math.round(ms / minute))}m ago`;
|
||||
if (ms < day) return `${Math.round(ms / hour)}h ago`;
|
||||
return `${Math.round(ms / day)}d ago`;
|
||||
}
|
||||
|
||||
export function ShareModal({ analysisId }: ShareModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [recipientEmail, setRecipientEmail] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
const [recipients, setRecipients] = useState<RecipientsResponse | null>(null);
|
||||
const [recipientsError, setRecipientsError] = useState<string | null>(null);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fetch recipients lazily on first dialog open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (recipients !== null) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/analyzer/share/recipients');
|
||||
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 RecipientsResponse;
|
||||
if (!cancelled) setRecipients(data);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setRecipientsError(
|
||||
err instanceof Error ? err.message : 'Unknown error'
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, recipients]);
|
||||
|
||||
const filteredDirectory = useMemo(() => {
|
||||
if (!recipients) return [];
|
||||
const q = recipientEmail.trim().toLowerCase();
|
||||
if (!q) return recipients.directory.slice(0, MAX_DIRECTORY_VISIBLE);
|
||||
return recipients.directory
|
||||
.filter(
|
||||
(d) =>
|
||||
d.email.toLowerCase().includes(q) ||
|
||||
(d.displayName ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
.slice(0, MAX_DIRECTORY_VISIBLE);
|
||||
}, [recipients, recipientEmail]);
|
||||
|
||||
const hasRecent = (recipients?.recent.length ?? 0) > 0;
|
||||
|
||||
function pick(email: string): void {
|
||||
setRecipientEmail(email);
|
||||
setShowSuggestions(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
|
|
@ -66,8 +150,16 @@ export function ShareModal({ analysisId }: ShareModalProps) {
|
|||
}
|
||||
}
|
||||
|
||||
// Reset transient state when the dialog closes.
|
||||
function onOpenChange(next: boolean): void {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
|
|
@ -78,22 +170,107 @@ export function ShareModal({ analysisId }: ShareModalProps) {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Share this analysis</DialogTitle>
|
||||
<DialogDescription>
|
||||
Recipient must be on an allowed domain (set via
|
||||
ALLOWED_SHARE_DOMAINS).
|
||||
{recipients?.allowedDomains.length
|
||||
? `Allowed domains: ${recipients.allowedDomains.join(', ')}`
|
||||
: 'Recipient must be on an allowed domain.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recipient">Recipient email</Label>
|
||||
<Input
|
||||
id="recipient"
|
||||
type="email"
|
||||
required
|
||||
value={recipientEmail}
|
||||
onChange={(e) => setRecipientEmail(e.target.value)}
|
||||
placeholder="colleague@wulfconsulting.com"
|
||||
/>
|
||||
<Label htmlFor="recipient">Recipient</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="recipient"
|
||||
ref={inputRef}
|
||||
type="email"
|
||||
required
|
||||
autoComplete="off"
|
||||
value={recipientEmail}
|
||||
onChange={(e) => {
|
||||
setRecipientEmail(e.target.value);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onBlur={() => {
|
||||
// Delay so a click on a suggestion lands before we hide.
|
||||
setTimeout(() => setShowSuggestions(false), 150);
|
||||
}}
|
||||
placeholder="Search teammates or type an email…"
|
||||
/>
|
||||
|
||||
{showSuggestions && recipients !== null && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md max-h-72 overflow-auto">
|
||||
{hasRecent && recipientEmail.trim().length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> Recent
|
||||
</div>
|
||||
)}
|
||||
{hasRecent &&
|
||||
recipientEmail.trim().length === 0 &&
|
||||
recipients.recent.slice(0, 3).map((r) => (
|
||||
<button
|
||||
key={'recent-' + r.email}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
pick(r.email);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{r.email}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{timeAgo(r.lastSharedAt)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground flex items-center gap-1 border-t">
|
||||
<Users className="w-3 h-3" /> Directory
|
||||
</div>
|
||||
{filteredDirectory.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
No matching directory users.
|
||||
</div>
|
||||
) : (
|
||||
filteredDirectory.map((d) => (
|
||||
<button
|
||||
key={'dir-' + d.email}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-start justify-between gap-2"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
pick(d.email);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">
|
||||
{d.displayName ?? d.email}
|
||||
</div>
|
||||
{d.displayName && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{d.email}
|
||||
{d.jobTitle ? ` · ${d.jobTitle}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{recipientEmail === d.email && (
|
||||
<Check className="w-4 h-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{recipientsError && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Couldn’t load directory ({recipientsError}). Type any
|
||||
allowed-domain email to share.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">Note (optional)</Label>
|
||||
<Textarea
|
||||
|
|
@ -105,6 +282,7 @@ export function ShareModal({ analysisId }: ShareModalProps) {
|
|||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -6,27 +6,13 @@ import { cn } from '@/lib/utils';
|
|||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Network,
|
||||
Globe,
|
||||
Smartphone,
|
||||
Database,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
Activity,
|
||||
HardDrive,
|
||||
Workflow,
|
||||
GitBranch,
|
||||
Sparkles,
|
||||
Bell,
|
||||
Zap,
|
||||
Radio,
|
||||
Shield,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Sun,
|
||||
BarChart3,
|
||||
DollarSign,
|
||||
SlidersHorizontal,
|
||||
GitCompare,
|
||||
Brain,
|
||||
Search,
|
||||
|
|
@ -130,115 +116,25 @@ const navigationItems: NavItem[] = [
|
|||
icon: AlertTriangle,
|
||||
description: 'Analyses flagged for human review (low confidence or cost-ceiling skipped Opus)',
|
||||
},
|
||||
{
|
||||
title: 'IT Glue — Applications',
|
||||
href: '/analyzer/itglue/applications',
|
||||
icon: Database,
|
||||
description: 'Audit IT Glue Application records against ticket history; admins can apply or revert documentation changes',
|
||||
},
|
||||
{
|
||||
title: 'IT Glue — Configurations',
|
||||
href: '/analyzer/itglue/configurations',
|
||||
icon: Database,
|
||||
description: 'Audit IT Glue Configuration records (servers, workstations, devices) against ticket history; admins can apply or revert',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Admin',
|
||||
href: '/admin',
|
||||
icon: Activity,
|
||||
children: [
|
||||
{
|
||||
title: 'Integrations & Sync',
|
||||
href: '/admin/sync',
|
||||
icon: RefreshCw,
|
||||
description: 'Manage sync across PSA, RMM, NMS, Backup, and Apple RMM'
|
||||
},
|
||||
{
|
||||
title: 'NMS Mapping (Auvik)',
|
||||
href: '/auvik-mappings',
|
||||
icon: Network,
|
||||
description: 'Map Auvik tenants to companies'
|
||||
},
|
||||
{
|
||||
title: 'RMM Mapping (Datto)',
|
||||
href: '/rmm-mappings',
|
||||
icon: Globe,
|
||||
description: 'Map RMM sites to companies'
|
||||
},
|
||||
{
|
||||
title: 'Zabbix WAN Monitor',
|
||||
href: '/admin/zabbix-wan',
|
||||
icon: Radio,
|
||||
description: 'Sync RMM site WAN IPs to Zabbix with Autotask routing'
|
||||
},
|
||||
{
|
||||
title: 'Apple RMM Mapping (Addigy)',
|
||||
href: '/addigy-mappings',
|
||||
icon: Smartphone,
|
||||
description: 'Map Addigy devices to companies'
|
||||
},
|
||||
{
|
||||
title: 'Ticket Workflows',
|
||||
href: '/admin/workflow',
|
||||
icon: Workflow,
|
||||
description: 'Automated ticket triage and classification'
|
||||
},
|
||||
{
|
||||
title: 'Classification Rules',
|
||||
href: '/admin/workflow/classification-rules',
|
||||
icon: GitBranch,
|
||||
description: 'Keyword-based classification rules'
|
||||
},
|
||||
{
|
||||
title: 'AI Templates',
|
||||
href: '/admin/workflow/ai-templates',
|
||||
icon: Sparkles,
|
||||
description: 'AI prompt templates for enhancement'
|
||||
},
|
||||
{
|
||||
title: 'Webhook Pipelines',
|
||||
href: '/admin/workflow/pipelines',
|
||||
icon: Zap,
|
||||
description: 'Automated webhook processing workflows'
|
||||
},
|
||||
{
|
||||
title: 'Morning NOC Summary',
|
||||
href: '/admin/morning-summary',
|
||||
icon: Sun,
|
||||
description: 'Daily Zabbix overnight summary posted to Teams channels via webhook'
|
||||
},
|
||||
{
|
||||
title: 'Ticket Digest Reports',
|
||||
href: '/admin/ticket-digest',
|
||||
icon: BarChart3,
|
||||
description: 'LLM-analyzed ticket reports — noise, SLA, workload — daily/weekly/monthly'
|
||||
},
|
||||
{
|
||||
title: 'Notification Channels',
|
||||
href: '/admin/workflow/channels',
|
||||
icon: Bell,
|
||||
description: 'Teams, Telegram, and webhook notifications'
|
||||
},
|
||||
{
|
||||
title: 'IT Glue Sync',
|
||||
href: '/admin/sync/itglue',
|
||||
icon: Shield,
|
||||
description: 'IT Glue documentation backup — organizations, configs, passwords, flexible assets'
|
||||
},
|
||||
{
|
||||
title: 'SentinelOne Sync',
|
||||
href: '/admin/sync/sentinelone',
|
||||
icon: Shield,
|
||||
description: 'SentinelOne EDR — sites, agents, threats sync'
|
||||
},
|
||||
{
|
||||
title: 'QuickBooks Online',
|
||||
href: '/admin/qbo',
|
||||
icon: DollarSign,
|
||||
description: 'Sync invoices, payments, deposits, transactions and financial reports'
|
||||
},
|
||||
{
|
||||
title: 'Display Settings',
|
||||
href: '/admin/display-settings',
|
||||
icon: SlidersHorizontal,
|
||||
description: 'Configure company filters for Kiosk and Mobile dashboards'
|
||||
},
|
||||
{
|
||||
title: 'Data Browser',
|
||||
href: '/admin/data-browser',
|
||||
icon: Database,
|
||||
description: 'Browse and query system data'
|
||||
},
|
||||
]
|
||||
description: 'Sync, mappings, workflow, reports, tools & access'
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
189
components/rmm/rmm-dispatch-dialog.tsx
Normal file
189
components/rmm/rmm-dispatch-dialog.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* Per-row "Run RMM" dialog. Lists asset-self scripts from /api/rmm/scripts,
|
||||
* dispatches against a known Datto deviceUid, and surfaces live execution
|
||||
* status via the existing RmmExecutionStream — without leaving the page.
|
||||
*
|
||||
* Used from /configuration-items so admins don't have to construct hidden
|
||||
* /analyzer/itglue/configurations/<id> URLs by hand.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2, Terminal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { RmmExecutionStream } from './rmm-execution-stream';
|
||||
|
||||
interface Script {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
target_type: 'site_anchor' | 'asset_self';
|
||||
expected_runtime_seconds: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface RmmDispatchDialogProps {
|
||||
deviceUid: string;
|
||||
hostname?: string | null;
|
||||
companyId?: number | string | null;
|
||||
triggerLabel?: string;
|
||||
}
|
||||
|
||||
export function RmmDispatchDialog({
|
||||
deviceUid,
|
||||
hostname,
|
||||
companyId,
|
||||
triggerLabel = 'Run RMM',
|
||||
}: RmmDispatchDialogProps) {
|
||||
const { data: session } = useSession();
|
||||
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
|
||||
const canExecute = role === 'admin' || role === 'super-admin';
|
||||
|
||||
const [scripts, setScripts] = useState<Script[] | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [running, setRunning] = useState<string | null>(null);
|
||||
const [activeExecutionId, setActiveExecutionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || scripts !== null) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/rmm/scripts');
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { scripts: Script[] };
|
||||
if (!cancelled) setScripts(data.scripts);
|
||||
} catch {
|
||||
// Silent — the dialog just won't populate.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, scripts]);
|
||||
|
||||
async function dispatch(s: Script): Promise<void> {
|
||||
if (!canExecute) return;
|
||||
setRunning(s.id);
|
||||
try {
|
||||
const res = await fetch('/api/rmm/executions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scriptId: s.id,
|
||||
target: {
|
||||
type: 'asset_self',
|
||||
deviceUid,
|
||||
hostname: hostname ?? null,
|
||||
companyId: companyId ?? null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message ?? data.error ?? 'Execution failed');
|
||||
}
|
||||
setActiveExecutionId(data.executionId);
|
||||
toast.success(`${s.name}: queued`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Could not dispatch');
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
}
|
||||
|
||||
const visible = scripts?.filter((s) => s.target_type === 'asset_self') ?? [];
|
||||
const disabledReason = !canExecute
|
||||
? 'Requires admin'
|
||||
: !deviceUid
|
||||
? 'No Datto device id'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
setOpen(o);
|
||||
// Reset active execution when the dialog is closed so the next open
|
||||
// starts fresh. Status stays visible until the user closes.
|
||||
if (!o) setActiveExecutionId(null);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!!disabledReason}
|
||||
title={disabledReason ?? 'Dispatch a Datto RMM script for this device'}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Terminal className="w-3.5 h-3.5 mr-1.5" />
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dispatch RMM Script</DialogTitle>
|
||||
<DialogDescription>
|
||||
Target: <span className="font-mono">{hostname ?? deviceUid}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{scripts === null ? 'Loading…' : 'No asset-targeted scripts in the registry.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y border rounded-md max-h-[40vh] overflow-auto">
|
||||
{visible.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent flex items-start gap-2 disabled:opacity-50"
|
||||
onClick={() => dispatch(s)}
|
||||
disabled={running !== null || activeExecutionId !== null}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
{s.name}
|
||||
<Badge variant="outline" className="text-[10px] py-0">
|
||||
~{s.expected_runtime_seconds}s
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{s.description}
|
||||
</p>
|
||||
</div>
|
||||
{running === s.id && (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{activeExecutionId && (
|
||||
<div className="mt-2">
|
||||
<RmmExecutionStream executionId={activeExecutionId} />
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
183
components/rmm/rmm-execution-stream.tsx
Normal file
183
components/rmm/rmm-execution-stream.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, CheckCircle2, AlertTriangle, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ExecutionRow {
|
||||
id: string;
|
||||
scriptId: string;
|
||||
jobName: string;
|
||||
targetHostname: string | null;
|
||||
status: 'queued' | 'running' | 'complete' | 'failed' | 'timeout';
|
||||
exitCode: number | null;
|
||||
rawStdout: string | null;
|
||||
rawStderr: string | null;
|
||||
parsedEvidence: unknown;
|
||||
parseError: string | null;
|
||||
errorMessage: string | null;
|
||||
queuedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
const POLL_MS = 3000;
|
||||
const POLL_TIMEOUT_MS = 6 * 60 * 1000; // 6 min — slightly longer than the server-side hard cap.
|
||||
|
||||
export function RmmExecutionStream({
|
||||
executionId,
|
||||
onComplete,
|
||||
}: {
|
||||
executionId: string;
|
||||
onComplete?: () => void;
|
||||
}) {
|
||||
const [exec, setExec] = useState<ExecutionRow | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [closed, setClosed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (closed) return;
|
||||
let cancelled = false;
|
||||
const start = Date.now();
|
||||
|
||||
async function tick() {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const res = await fetch(`/api/rmm/executions/${executionId}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { execution: ExecutionRow };
|
||||
if (cancelled) return;
|
||||
setExec(data.execution);
|
||||
if (
|
||||
data.execution.status === 'complete' ||
|
||||
data.execution.status === 'failed' ||
|
||||
data.execution.status === 'timeout'
|
||||
) {
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - start > POLL_TIMEOUT_MS) {
|
||||
setError('Polling timed out — check execution status manually.');
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, POLL_MS);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
void tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [executionId, closed, onComplete]);
|
||||
|
||||
if (closed) return null;
|
||||
|
||||
const status = exec?.status ?? 'queued';
|
||||
const isDone =
|
||||
status === 'complete' || status === 'failed' || status === 'timeout';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{!isDone ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
) : status === 'complete' ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600" />
|
||||
)}
|
||||
{exec?.jobName ?? 'Discovery script'}
|
||||
<Badge
|
||||
variant={
|
||||
status === 'complete'
|
||||
? 'default'
|
||||
: status === 'failed' || status === 'timeout'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{exec?.targetHostname ? `target: ${exec.targetHostname} · ` : ''}
|
||||
execution {executionId}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setClosed(true)}
|
||||
title="Hide"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{isDone && exec?.parseError && (
|
||||
<p className="text-xs text-amber-600">
|
||||
Output parser failed: {exec.parseError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isDone && exec?.errorMessage && (
|
||||
<p className="text-xs text-destructive">{exec.errorMessage}</p>
|
||||
)}
|
||||
|
||||
{isDone && exec?.parsedEvidence !== undefined && exec.parsedEvidence !== null && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">
|
||||
Parsed evidence
|
||||
</p>
|
||||
<pre className="text-[11px] bg-muted/50 rounded p-2 max-h-72 overflow-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(exec.parsedEvidence, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDone && exec?.rawStdout && (
|
||||
<details>
|
||||
<summary className="text-xs font-semibold uppercase tracking-wide text-muted-foreground cursor-pointer">
|
||||
Raw stdout ({exec.rawStdout.length} chars)
|
||||
</summary>
|
||||
<pre className="text-[11px] bg-muted/50 rounded p-2 max-h-72 overflow-auto whitespace-pre-wrap mt-1">
|
||||
{exec.rawStdout.slice(0, 50000)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{isDone && exec?.rawStderr && (
|
||||
<details>
|
||||
<summary className="text-xs font-semibold uppercase tracking-wide text-muted-foreground cursor-pointer">
|
||||
Raw stderr
|
||||
</summary>
|
||||
<pre className="text-[11px] bg-muted/50 rounded p-2 max-h-40 overflow-auto whitespace-pre-wrap mt-1">
|
||||
{exec.rawStderr.slice(0, 20000)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{!isDone && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Polling every {POLL_MS / 1000}s — Datto typically returns within
|
||||
~30-90s for asset-self scripts and ~60-180s for site-anchored.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
198
components/rmm/rmm-script-picker.tsx
Normal file
198
components/rmm/rmm-script-picker.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Loader2, Terminal, Server, Layers } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { RmmExecutionStream } from './rmm-execution-stream';
|
||||
|
||||
export interface RmmScriptCatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
target_type: 'site_anchor' | 'asset_self';
|
||||
expected_runtime_seconds: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface RmmScriptPickerProps {
|
||||
/**
|
||||
* Filter the picker to scripts compatible with this target.
|
||||
* - 'site_anchor': site-wide scripts (DC, AD, DHCP, DNS).
|
||||
* - 'asset_self': scripts that target a specific device (the audited Configuration).
|
||||
*/
|
||||
filter: 'site_anchor' | 'asset_self';
|
||||
/** Used for site_anchor scripts. */
|
||||
companyId?: number | string;
|
||||
/** Used for asset_self scripts. */
|
||||
deviceUid?: string;
|
||||
hostname?: string | null;
|
||||
/** Optional bookkeeping. */
|
||||
assetType?: 'flexible_asset' | 'configuration';
|
||||
assetId?: number | string;
|
||||
/** Refresh callback when an execution completes (so the parent re-fetches). */
|
||||
onComplete?: (executionId: string) => void;
|
||||
}
|
||||
|
||||
export function RmmScriptPicker({
|
||||
filter,
|
||||
companyId,
|
||||
deviceUid,
|
||||
hostname,
|
||||
assetType,
|
||||
assetId,
|
||||
onComplete,
|
||||
}: RmmScriptPickerProps) {
|
||||
const { data: session } = useSession();
|
||||
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
|
||||
const canExecute = role === 'admin' || role === 'super-admin';
|
||||
|
||||
const [scripts, setScripts] = useState<RmmScriptCatalogEntry[] | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [running, setRunning] = useState<string | null>(null);
|
||||
const [activeExecutionId, setActiveExecutionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/rmm/scripts');
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { scripts: RmmScriptCatalogEntry[] };
|
||||
if (!cancelled) setScripts(data.scripts);
|
||||
} catch {
|
||||
// Silent — picker just won't populate.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function dispatch(script: RmmScriptCatalogEntry): Promise<void> {
|
||||
if (!canExecute) return;
|
||||
setRunning(script.id);
|
||||
try {
|
||||
const target =
|
||||
script.target_type === 'site_anchor'
|
||||
? { type: 'site_anchor' as const, companyId: companyId! }
|
||||
: {
|
||||
type: 'asset_self' as const,
|
||||
deviceUid: deviceUid!,
|
||||
hostname: hostname ?? null,
|
||||
companyId: companyId ?? null,
|
||||
assetType,
|
||||
assetId,
|
||||
};
|
||||
const res = await fetch('/api/rmm/executions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scriptId: script.id,
|
||||
target,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message ?? data.error ?? 'Execution failed');
|
||||
}
|
||||
setActiveExecutionId(data.executionId);
|
||||
toast.success(`${script.name}: queued`);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Could not dispatch');
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
}
|
||||
|
||||
const visible = scripts?.filter((s) => s.target_type === filter) ?? [];
|
||||
const disabledReason = !canExecute
|
||||
? 'Requires admin'
|
||||
: filter === 'site_anchor' && !companyId
|
||||
? 'No client mapped'
|
||||
: filter === 'asset_self' && !deviceUid
|
||||
? 'No Datto device id'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!!disabledReason || scripts === null}
|
||||
title={disabledReason ?? 'Run a discovery script via Datto RMM Overshell'}
|
||||
>
|
||||
<Terminal className="w-3.5 h-3.5 mr-1.5" />
|
||||
Run discovery
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 p-0" align="end">
|
||||
<div className="px-3 py-2 border-b text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1">
|
||||
{filter === 'site_anchor' ? (
|
||||
<>
|
||||
<Layers className="w-3.5 h-3.5" /> Site-anchored discovery
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Server className="w-3.5 h-3.5" /> Asset-specific discovery
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{visible.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{scripts === null ? 'Loading…' : 'No scripts in the registry for this target.'}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y max-h-80 overflow-auto">
|
||||
{visible.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent flex items-start gap-2 disabled:opacity-50"
|
||||
onClick={() => dispatch(s)}
|
||||
disabled={running !== null}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
{s.name}
|
||||
<Badge variant="outline" className="text-[10px] py-0">
|
||||
~{s.expected_runtime_seconds}s
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{s.description}
|
||||
</p>
|
||||
</div>
|
||||
{running === s.id && (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{activeExecutionId && (
|
||||
<RmmExecutionStream
|
||||
executionId={activeExecutionId}
|
||||
onComplete={() => {
|
||||
setActiveExecutionId(null);
|
||||
onComplete?.(activeExecutionId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
docs/LogLift Review.json
Normal file
362
docs/LogLift Review.json
Normal file
File diff suppressed because one or more lines are too long
604
docs/itglue-asset-audit-spec.md
Normal file
604
docs/itglue-asset-audit-spec.md
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
# Feature: IT Glue Asset Audit & Documentation Write-back
|
||||
|
||||
**Status:** shipped (Phases 4 + 4.1 of the AI Ticket Analyzer)
|
||||
**Migrations:** `075_itglue_audit.sql`, `076_itglue_ticket_xrefs.sql`
|
||||
**Build notes:** `docs/wulf-pulse-ticket-analyzer-build-notes.md` → Phase 4 + 4.1
|
||||
**Operator runbook:** `docs/wulf-pulse-ticket-analyzer-runbook.md` → "IT Glue asset audits"
|
||||
|
||||
---
|
||||
|
||||
## What it is, in one paragraph
|
||||
|
||||
For each IT Glue **flexible-asset Application record** *and* **Configuration
|
||||
record** (server, workstation, network device), Pulse runs an LLM audit
|
||||
that compares the record's current contents against (a) the field schema
|
||||
with hints, (b) other well-filled records at the same client, (c) recent
|
||||
ticket history that mentions the asset. It surfaces **field gaps** ("Wulf
|
||||
Application Champion is empty — Jake Hammel is the de-facto SME per
|
||||
T20260502.0033"), **note promotions** (free text in Notes that belongs in a
|
||||
structured field), and **contradictions** (Notes say "2-3 VMs" but
|
||||
Application-on-Device tags only 1). Admins can apply a suggestion with one
|
||||
click — Pulse PATCHes IT Glue and records every change with full
|
||||
before/after diff and revert capability.
|
||||
|
||||
Two complementary entry points:
|
||||
|
||||
- **Asset-first** (Phase 4) — admin browses lowest-scoring Application or
|
||||
Configuration records and runs an audit against all-time history. Useful
|
||||
for periodic backlog sweeps.
|
||||
- **Ticket-first** (Phase 4.1) — every analyzed ticket gets a "Check IT
|
||||
Glue documentation" button on its analysis page. Click → matched IT Glue
|
||||
records appear → audit per record uses *just this ticket's evidence*.
|
||||
Findings frame as "what did this ticket teach us that the documentation
|
||||
doesn't say?" Drives a forward-only **cross-reference index**
|
||||
(`itglue_ticket_xrefs`) of which tickets referenced or updated which
|
||||
documentation, which is also the lookup index for a future RAG
|
||||
automation.
|
||||
|
||||
---
|
||||
|
||||
## Why we built it
|
||||
|
||||
The analyzer already extracts `documentation_gaps_observed` per ticket
|
||||
(Phase 2.4 fingerprint). Until Phase 4 nothing acted on the signal. The
|
||||
catalyzing example was T20260502.0033 (Hynes — tags not printing from
|
||||
Simple Shop Floor), resolved by Collin + Jake identifying a stopped Windows
|
||||
service on MISYS-SQL processing BarTender scan-folder text files. The IT
|
||||
Glue Application record `MISYS 6.3` had **5/17 fields filled**, missing:
|
||||
|
||||
- Wulf Application Champion (Jake is the SME, but only by tribal knowledge)
|
||||
- Vendor Maintenance/Support (escalation contact "Steve Cianflone" buried
|
||||
in free-text Notes)
|
||||
- The integration architecture (Simple Shop Floor → MISYS-SQL → BarTender
|
||||
scan folder)
|
||||
- The named services on each VM
|
||||
|
||||
The next tech who hits this issue would re-discover everything. Phase 4
|
||||
turns the analyzer's passive "documentation_gaps_observed" output into an
|
||||
actionable backlog with a write-back path.
|
||||
|
||||
---
|
||||
|
||||
## User-facing flows
|
||||
|
||||
### List view — `/analyzer/itglue/applications`
|
||||
|
||||
Shows every Application record across all clients, sorted by **lowest audit
|
||||
score first** (so unaudited and worst-scored assets bubble up). Each row:
|
||||
asset name, client, populated-field count, last-audit timestamp +
|
||||
provider, score badge.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ MISYS 6.3 ▌ 55% │
|
||||
│ Hynes Industries · 5 fields populated · last audited today (Claude)│
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Detail view — `/analyzer/itglue/applications/[id]`
|
||||
|
||||
Three regions:
|
||||
|
||||
1. **Header** — asset name, client, score badge, "Open in IT Glue" link, the
|
||||
`<ProviderToggle>` (Claude vs DeepSeek), and a primary **Run audit** button.
|
||||
2. **Audit findings** (visible after a run) —
|
||||
- **Field gaps** — severity-toned cards (red/amber/blue by confidence),
|
||||
each with: field name, why-it-matters, suggested value (when the LLM
|
||||
has evidence), evidence ticket links, and an **Apply** button.
|
||||
- **Promote from Notes** — quoted substring → target field → suggested
|
||||
structured value, with **Apply**.
|
||||
- **Contradictions** — pure observations (no Apply, by design).
|
||||
3. **Current fields** — every field rendered in IT Glue's order; populated
|
||||
shown normally, empty shown muted with the field's hint inline as
|
||||
guidance.
|
||||
4. **Write history** — every Apply/Revert for this asset, with inline
|
||||
before/after diffs and a **Revert** button on committed writes.
|
||||
5. **Audit history** — score over time across runs.
|
||||
|
||||
### Admin view — `/admin/itglue-writes`
|
||||
|
||||
Cross-asset write log, status filters (pending / committed / failed /
|
||||
reverted), full diff per row, ordered most-recent-first.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
| Action | Permission |
|
||||
|---|---|
|
||||
| Read audit / View asset detail | `requireAuth()` (any signed-in user) |
|
||||
| Run a fresh audit | `requireAuth()` |
|
||||
| Apply a suggestion (PATCH IT Glue) | `requirePermission('itglue', 'write')` |
|
||||
| Revert a previous write | `requirePermission('itglue', 'write')` |
|
||||
| `/admin/itglue-writes` cross-asset feed | `requirePermission('admin', 'access')` |
|
||||
|
||||
`itglue.write` is granted to `admin` and `super-admin` roles only (see
|
||||
`lib/permissions.ts`). The Apply / Revert buttons render on the page for
|
||||
non-admins but are **disabled** with a tooltip explaining why.
|
||||
|
||||
---
|
||||
|
||||
## Pipeline
|
||||
|
||||
Single LLM call, provider-aware via `stageModelsFor(provider).deep_analysis`
|
||||
(Claude Sonnet for Anthropic; DeepSeek V4 Pro for OpenRouter).
|
||||
|
||||
### Inputs (six context arms)
|
||||
|
||||
1. **Asset snapshot** — the asset's current `traits` JSONB, redacted via
|
||||
`lib/services/analyzer/itglue-redact.ts` to strip any password/secret/
|
||||
key/token/credential-keyed values.
|
||||
2. **Field schema with hints** — every field on the asset type, in IT
|
||||
Glue's display order, with the `hint` text IT Glue surfaces in its own
|
||||
editor (e.g. *"Tag any application servers or Devices it's installed
|
||||
on"*).
|
||||
3. **Peer exemplars from same client** — top 5 most-filled flexible
|
||||
assets of the same type at the same client (ranked by populated trait
|
||||
keys), redacted.
|
||||
4. **Best-in-class peers across all clients** — top 3 most-filled
|
||||
instances of the same asset type globally, redacted.
|
||||
5. **Per-field fill-rate stats** — the % of (a) this client's assets and
|
||||
(b) all assets of this type that have field X populated. Pure SQL
|
||||
aggregate; no token cost. Lets the LLM rank gaps by "unusual to be
|
||||
missing" vs "usually missing anyway."
|
||||
6. **Ticket evidence** — up to 20 most-recent complete analyses for the
|
||||
client whose summary or fingerprint mentions the asset name, with
|
||||
their `aggregate_fingerprint` payload included in full.
|
||||
|
||||
### Output schema (`AssetAuditResponse` in `lib/types/analyzer.ts`)
|
||||
|
||||
```ts
|
||||
{
|
||||
field_gaps: Array<{
|
||||
field_name: string, // must exist on the asset type
|
||||
why_missing_matters: string,
|
||||
suggested_value: string | null, // null if no concrete evidence
|
||||
evidence_ticket_numbers: string[],
|
||||
confidence: 'high' | 'medium' | 'low',
|
||||
}>,
|
||||
notes_promotions: Array<{
|
||||
quoted_note_text: string, // exact substring of Notes
|
||||
target_field: string,
|
||||
suggested_value: string,
|
||||
confidence: 'high' | 'medium' | 'low',
|
||||
}>,
|
||||
contradictions: Array<{
|
||||
description: string,
|
||||
evidence: string,
|
||||
}>,
|
||||
overall_score: number, // 0..1 self-rated completeness
|
||||
}
|
||||
```
|
||||
|
||||
### System-prompt rules (highlights)
|
||||
|
||||
- Never invent a field name not present in the schema.
|
||||
- Never suggest a value you cannot point to evidence for — use `null`
|
||||
instead.
|
||||
- Don't suggest password/secret/key/token/credential-shaped fields. (The
|
||||
Apply endpoint also blocks these defensively.)
|
||||
- Use fill-rate stats: a gap that's empty here but populated >80%
|
||||
elsewhere is a stronger gap than one that's empty 80% of the time
|
||||
globally.
|
||||
|
||||
### Pipeline files
|
||||
|
||||
- `lib/services/analyzer/asset-audit/data-builder.ts` — collects all six
|
||||
inputs.
|
||||
- `lib/services/analyzer/asset-audit/prompt.ts` — system prompt + payload
|
||||
builder (with 80KB cap; trims peer_global first, oldest tickets next).
|
||||
- `lib/services/analyzer/asset-audit/runner.ts` — single `callLLMStage`
|
||||
call, persistence on success or failure.
|
||||
- `lib/services/analyzer/asset-audit/persistence.ts` — typed read/write of
|
||||
both DB tables.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
### `itglue_asset_audits` (one row per audit run)
|
||||
|
||||
| Column | Notes |
|
||||
|---|---|
|
||||
| `id` UUID | PK |
|
||||
| `asset_type` TEXT | Currently `'flexible_asset'` only |
|
||||
| `asset_id` BIGINT | IT Glue resource id |
|
||||
| `asset_type_id`, `organization_id` BIGINT | Denormalized for fast filters |
|
||||
| `generated_by_user_id` TEXT | FK `user(id)`, nullable on user delete |
|
||||
| `generated_at` TIMESTAMPTZ | |
|
||||
| `provider`, `model_used` | Which LLM produced the analysis |
|
||||
| `asset_snapshot` JSONB | Redacted traits at audit time |
|
||||
| `ticket_count` INT | How many fingerprints fed in |
|
||||
| `field_gaps`, `notes_promotions`, `contradictions` JSONB | LLM output |
|
||||
| `overall_score` NUMERIC(3,2) | |
|
||||
| `estimated_cost_usd`, `total_input_tokens`, `total_output_tokens` | Cost telemetry |
|
||||
| `status` | `pending` / `running` / `complete` / `failed` |
|
||||
| `error_message` TEXT | |
|
||||
|
||||
### `itglue_writes` (one row per write attempt)
|
||||
|
||||
| Column | Notes |
|
||||
|---|---|
|
||||
| `id` UUID | PK |
|
||||
| `audit_id` UUID | FK to the audit that prompted the change (nullable) |
|
||||
| `asset_type`, `asset_id` | What was written |
|
||||
| `field_name` TEXT | Human field name (mapped to trait key on apply) |
|
||||
| `before_value`, `after_value` JSONB | Pre/post diff |
|
||||
| `performed_by_user_id` TEXT | FK `user(id)` |
|
||||
| `performed_at` TIMESTAMPTZ | |
|
||||
| `status` | `pending` → `committed` / `failed` / `reverted` |
|
||||
| `itglue_response` JSONB | Raw API response for forensics |
|
||||
| `error_message` TEXT | |
|
||||
| `source_evidence` JSONB | `{ ticket_numbers, gap_description }` or `{ reverts_write_id }` |
|
||||
|
||||
### Generic `audit_log` (existing — also written for every change)
|
||||
|
||||
`audit.log()` is called on every successful Apply/Revert with action
|
||||
`itglue.write` or `itglue.revert`, resource `flexible_asset`, resourceId =
|
||||
asset_id, and details = `{ field_name, before, after, audit_id }`. Surfaces
|
||||
in `/admin/audit-log` next to every other admin action.
|
||||
|
||||
---
|
||||
|
||||
## API contract
|
||||
|
||||
All routes under `/api/analyzer/itglue/`.
|
||||
|
||||
### `GET /applications`
|
||||
List all Application records joined to their latest audit.
|
||||
Returns: `{ applications: [{ id, name, organizationId, organizationName, traitCount, latestAudit }] }`.
|
||||
|
||||
### `GET /applications/[id]`
|
||||
Asset detail + field schema for rendering.
|
||||
Returns: `{ asset, fields }`.
|
||||
|
||||
### `GET /applications/[id]/audit?history=1`
|
||||
Latest audit (and optionally history of last 20).
|
||||
Returns: `{ audit: AssetAuditRow | null, history?: AssetAuditRow[] }`.
|
||||
|
||||
### `POST /applications/[id]/audit`
|
||||
Body: `{ provider?: 'anthropic' | 'openrouter' }`. Runs a fresh audit.
|
||||
Cost-guard records `action='itglue_audit'` in `analyzer_cost_audit`.
|
||||
Returns: `{ audit: AssetAuditRow }`.
|
||||
|
||||
### `POST /applications/[id]/apply` *(admin)*
|
||||
Body: `{ auditId, fieldName, suggestedValue, sourceEvidence? }`.
|
||||
Inserts pending `itglue_writes` row → calls `updateFlexibleAsset` on the IT
|
||||
Glue client → marks committed/failed → refreshes the local mirror →
|
||||
writes generic `audit_log`.
|
||||
Returns: `{ writeId, status: 'committed', asset }` or 502 on IT Glue error.
|
||||
|
||||
### `POST /applications/[id]/revert/[writeId]` *(admin)*
|
||||
Re-applies the original `before_value`. Inserts a new write row with
|
||||
swapped before/after; original row → `status='reverted'`.
|
||||
Returns: `{ writeId, revertedWriteId, status: 'committed', asset }`.
|
||||
|
||||
### `GET /applications/[id]/writes`
|
||||
Per-asset write history.
|
||||
Returns: `{ writes: AssetWriteRow[] }`.
|
||||
|
||||
### `GET /writes` *(admin)*
|
||||
Cross-asset write log with status filter.
|
||||
Query: `?status=committed&limit=100&offset=0`.
|
||||
Returns: `{ writes: AssetWriteRow[] }`.
|
||||
|
||||
---
|
||||
|
||||
## Safeguards
|
||||
|
||||
### Three-layer credential refusal
|
||||
|
||||
1. **Prompt** — system prompt instructs the LLM not to suggest password,
|
||||
secret, key, token, or credential fields.
|
||||
2. **Redaction** — `redact()` from `lib/services/analyzer/itglue-redact.ts`
|
||||
strips matching keys from the asset snapshot, peer traits, and all
|
||||
payloads before they reach the LLM.
|
||||
3. **Endpoint** — `POST /apply` regex-blocks any `field_name` matching
|
||||
`/(password|secret|key|token|credential)/i` and returns 400 even if a
|
||||
compromised payload made it through the first two layers.
|
||||
|
||||
### Three-layer audit trail
|
||||
|
||||
| Layer | Captures | Retention |
|
||||
|---|---|---|
|
||||
| `itglue_asset_audits` | Every audit run with full LLM context (asset snapshot, model used, cost, ticket count) | Forever |
|
||||
| `itglue_writes` | Every PATCH attempt with before/after diff, status (pending → committed \| failed \| reverted), audit provenance, raw IT Glue API response, source_evidence | Forever |
|
||||
| `audit_log` (generic) | One row per Apply/Revert in the format admins are used to | Per existing policy |
|
||||
|
||||
### Revert chain semantics
|
||||
|
||||
A revert produces a **new** `itglue_writes` row whose `before_value` and
|
||||
`after_value` are swapped from the original. The new row carries
|
||||
`source_evidence = { reverts_write_id }`. The original row's status flips
|
||||
to `'reverted'`. To trace any state, walk the `audit_id` and `reverts_write_id`
|
||||
graph — it's always complete.
|
||||
|
||||
### Per-record sync after every write
|
||||
|
||||
`refreshFlexibleAssetById()` runs after a successful PATCH (or a
|
||||
successful revert) so `itg_flexible_assets.traits` reflects the change
|
||||
immediately. The route also returns the freshly-PATCH'd asset in the
|
||||
response so the UI can update without waiting for the mirror.
|
||||
|
||||
---
|
||||
|
||||
## Cost & latency
|
||||
|
||||
| Provider | Per-audit cost (typical) | Per-audit latency |
|
||||
|---|---|---|
|
||||
| Anthropic (Sonnet) | ~$0.10 | 30–60s |
|
||||
| OpenRouter (DeepSeek V4 Pro) | ~$0.01 | 60–180s |
|
||||
|
||||
Apply is one IT Glue PATCH + one local upsert + one audit_log insert —
|
||||
typically < 1s.
|
||||
|
||||
A full audit + 5 applies on a typical Application record: ~$0.10 (Claude)
|
||||
or ~$0.01 (DeepSeek), 3–5 minutes including click-through.
|
||||
|
||||
---
|
||||
|
||||
## Limitations & non-goals
|
||||
|
||||
- **Applications only in v1.** The schema generalizes (`asset_type` is a
|
||||
column), but Configurations / Procedures / Domains / Passwords each have
|
||||
different prompt nuances. Adding more asset types is straightforward —
|
||||
duplicate the data-builder and prompt files, add a new asset detail
|
||||
page.
|
||||
- **No bulk-apply.** Admin clicks each suggestion individually. If a
|
||||
single audit produces 10 gaps that's 10 clicks. Bulk-apply is an easy
|
||||
fast-follow once we trust quality.
|
||||
- **No two-step approval workflow.** Admin-direct write per the design
|
||||
decision; the audit log is the safety net. The existing `approval_requests`
|
||||
table from the pipeline engine is available if we later want to require
|
||||
tech-proposes / admin-approves.
|
||||
- **No auto-create of new asset records.** Apply only updates existing
|
||||
assets. If an audit reveals a totally missing record, the suggestion is
|
||||
shown but Apply is disabled with an explainer; admins create the stub
|
||||
manually in IT Glue.
|
||||
- **No inline editor for suggested values.** Admin sees the LLM's
|
||||
suggestion verbatim; if they want to tweak, they edit the value in IT
|
||||
Glue afterward. v2 could add an inline editor.
|
||||
- **No password / secret / key / token / credential writes through this
|
||||
surface — ever.** Refused at three layers (above).
|
||||
|
||||
---
|
||||
|
||||
## Inspecting state (operational SQL)
|
||||
|
||||
### Audits, lowest score first
|
||||
|
||||
```sql
|
||||
SELECT a.asset_id,
|
||||
fa.name AS application_name, fa.organization_name,
|
||||
a.overall_score,
|
||||
jsonb_array_length(a.field_gaps) AS gap_count,
|
||||
jsonb_array_length(a.notes_promotions) AS promo_count,
|
||||
a.provider, a.estimated_cost_usd,
|
||||
a.generated_at
|
||||
FROM itglue_asset_audits a
|
||||
JOIN itg_flexible_assets fa ON fa.id = a.asset_id::bigint
|
||||
WHERE a.status = 'complete'
|
||||
ORDER BY a.generated_at DESC, a.overall_score ASC;
|
||||
```
|
||||
|
||||
### Writes in the last 7 days
|
||||
|
||||
```sql
|
||||
SELECT performed_at, field_name, status,
|
||||
before_value, after_value,
|
||||
performed_by_user_id, audit_id
|
||||
FROM itglue_writes
|
||||
WHERE performed_at >= NOW() - INTERVAL '7 days'
|
||||
ORDER BY performed_at DESC;
|
||||
```
|
||||
|
||||
### Find what's still waiting on which write
|
||||
|
||||
```sql
|
||||
SELECT id, field_name, before_value, after_value,
|
||||
source_evidence ->> 'reverts_write_id' AS reverts_id,
|
||||
status, performed_at
|
||||
FROM itglue_writes
|
||||
WHERE asset_id = '17096940'
|
||||
ORDER BY performed_at;
|
||||
```
|
||||
|
||||
### Audit cost-guard decisions
|
||||
|
||||
```sql
|
||||
SELECT created_at, user_id, action, estimated_cost,
|
||||
decision, decision_reason
|
||||
FROM analyzer_cost_audit
|
||||
WHERE action = 'itglue_audit'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How a typical session looks
|
||||
|
||||
1. Tech opens `/analyzer/itglue/applications`. MISYS 6.3 is at the top with
|
||||
no audit yet.
|
||||
2. Tech clicks the row → lands on the detail page. They see 12/17 fields
|
||||
are empty.
|
||||
3. Tech clicks **Run audit** (DeepSeek selected for cost). 60–180s later,
|
||||
the audit panel populates: 4 high-confidence field gaps, 1 notes
|
||||
promotion, 1 contradiction, score 0.55.
|
||||
4. Tech (admin) clicks **Apply** on `Wulf Application Champion` →
|
||||
suggested value "Jake Hammel". A PATCH lands on IT Glue, the local
|
||||
mirror refreshes, the field shows the new value, a write row appears
|
||||
in the History section.
|
||||
5. Tech clicks **Apply** on the Vendor Maintenance/Support promotion
|
||||
("Steve Cianflone" extracted from Notes).
|
||||
6. The next time someone opens this asset in IT Glue, the documentation
|
||||
reflects what tickets have been telling us all along.
|
||||
|
||||
---
|
||||
|
||||
## Files (quick reference)
|
||||
|
||||
**Schema**: `migrations/075_itglue_audit.sql`
|
||||
|
||||
**Pipeline**: `lib/services/analyzer/asset-audit/`
|
||||
- `data-builder.ts`, `prompt.ts`, `runner.ts`, `persistence.ts`, `runner.test.ts`
|
||||
|
||||
**API**: `app/api/analyzer/itglue/applications/`
|
||||
- `route.ts` (list)
|
||||
- `[id]/route.ts` (detail)
|
||||
- `[id]/audit/route.ts` (GET/POST audit)
|
||||
- `[id]/apply/route.ts` (admin write)
|
||||
- `[id]/revert/[writeId]/route.ts` (admin revert)
|
||||
- `[id]/writes/route.ts` (per-asset history)
|
||||
|
||||
Plus `app/api/analyzer/itglue/writes/route.ts` (admin cross-asset feed).
|
||||
|
||||
**UI**:
|
||||
- `app/analyzer/itglue/applications/page.tsx` (list)
|
||||
- `app/analyzer/itglue/applications/[id]/page.tsx` (detail)
|
||||
- `app/admin/itglue-writes/page.tsx` (admin cross-asset)
|
||||
|
||||
**Modified**:
|
||||
- `lib/services/itglue-client.ts` — `updateFlexibleAsset`, `refreshFlexibleAsset`,
|
||||
`getRawSingle`, `isITGlueConfigured`, internal `patch`
|
||||
- `lib/services/itglue-sync-service.ts` — `refreshFlexibleAssetById`
|
||||
- `lib/permissions.ts` — `itglue: ['read', 'write']`
|
||||
- `lib/types/analyzer.ts` — `AssetAuditResponse` schema + request types
|
||||
- `components/navigation/app-navigation.tsx` — nav entry
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.1 additions
|
||||
|
||||
### Ticket-first capture flow
|
||||
|
||||
Trigger: opt-in. The user clicks **"Check IT Glue documentation"** on an
|
||||
analysis detail page (`/analyzer/analysis/[id]`).
|
||||
|
||||
Pipeline:
|
||||
1. `GET /api/analyzer/analyses/[id]/itglue-suggestions` — runs
|
||||
`matchAssetsForAnalysis(id)` and returns matched flexible_assets +
|
||||
configurations (top 5 each by score) along with any existing
|
||||
ticket-scoped audits.
|
||||
2. User clicks **"Audit for this ticket"** on a matched asset → `POST` to
|
||||
the same endpoint with `{ assetType, assetId, provider }`.
|
||||
3. The runner builds context with `ticketScopeAnalysisId` set, so ticket
|
||||
evidence is exactly the one analysis the user clicked from. The system
|
||||
prompt picks up a ticket-scoped suffix instructing the LLM to frame
|
||||
findings as "what *this ticket* taught us."
|
||||
4. The audit row is persisted with `triggered_by_ticket_number` +
|
||||
`triggered_by_analysis_id` populated.
|
||||
5. Apply works from the same per-asset routes as the asset-first flow.
|
||||
When the audit is ticket-scoped, the resulting `itglue_writes` row also
|
||||
carries `triggered_by_ticket_number`, and the apply path inserts an
|
||||
xref row with `relationship='updated'`.
|
||||
|
||||
Asset matching is loose: substring + word-boundary match between the
|
||||
ticket's `aggregate_fingerprint.{applications_involved, device_classes,
|
||||
vendors_involved}` and `itg_flexible_assets.name` (Application type only)
|
||||
+ `itg_configurations.{name, hostname}` for the same client. Score 3
|
||||
(exact) > 2 (word-boundary) > 1 (substring). Top 5 per kind.
|
||||
|
||||
### Configuration support
|
||||
|
||||
Same audit/apply/revert pattern as Applications, with these differences:
|
||||
|
||||
- **Flat schema** — Configurations have ~17 editable top-level columns
|
||||
rather than a `traits` JSONB blob. The data-builder synthesizes a
|
||||
trait-style map for prompt consistency, then the apply route maps
|
||||
field name → IT Glue dash-case attribute (`primary_ip` →
|
||||
`'primary-ip'`).
|
||||
- **Hand-curated field hints** — IT Glue Configurations don't expose a
|
||||
`_fields` table; the 16 hand-written hints live in
|
||||
`lib/services/analyzer/asset-audit/data-builder.ts`
|
||||
(`CONFIGURATION_FIELDS` constant). Each hint is the per-field
|
||||
documentation the LLM sees.
|
||||
- **Configuration-flavored prompt** — focuses on hostname/FQDN
|
||||
consistency, OS version currency, named services capture (in
|
||||
`operating_system_notes`), IP/MAC hygiene, contact ownership.
|
||||
- **Column allowlist on apply** — even though the audit's output schema
|
||||
doesn't restrict `field_name`, the configuration apply route refuses
|
||||
anything outside `name | hostname | primary_ip | mac_address |
|
||||
serial_number | asset_tag | position | notes |
|
||||
operating_system_notes`. FK-shaped fields (manufacturer_id, model_id,
|
||||
operating_system_id, contact_id, location_id) are read-only via this
|
||||
surface for v1.
|
||||
- **Per-record sync helper** — `refreshConfigurationById(id)` mirrors
|
||||
`refreshFlexibleAssetById(id)`; called after every successful Apply.
|
||||
|
||||
### Cross-reference index — `itglue_ticket_xrefs`
|
||||
|
||||
```
|
||||
ticket_number | analysis_id | asset_type | asset_id | relationship | source | confidence | details | created_at
|
||||
```
|
||||
|
||||
Three relationship types:
|
||||
|
||||
| Relationship | Meaning | Source |
|
||||
|---|---|---|
|
||||
| `referenced` | The analyzer cited this asset/doc when analyzing the ticket. Comes from `analyzer_analyses.itglue_docs_referenced`. | `analyzer_referenced` |
|
||||
| `updated` | A ticket-driven audit produced a write on this asset. | `audit_write` |
|
||||
| `should_have_referenced` | Reserved — gap text suggests we should have found this asset/doc but didn't. Not auto-populated yet. | `manual` (future) |
|
||||
|
||||
Populated by:
|
||||
- **Post-analysis hook** in `lib/services/analyzer/worker.ts` —
|
||||
`insertReferencedXrefsFromAnalysis` runs after every successful
|
||||
analysis insertion. Maps each `ITGlueDocReference.doc_type` →
|
||||
asset_type. Best-effort.
|
||||
- **Post-apply hook** in the apply routes — `insertUpdatedXref` runs
|
||||
after a successful PATCH if the audit was ticket-scoped. Best-effort.
|
||||
- **No backfill** — table fills forward. The unique index
|
||||
`(ticket_number, analysis_id, asset_type, asset_id, relationship,
|
||||
source)` makes ingestion idempotent.
|
||||
|
||||
Two views consume it:
|
||||
|
||||
1. **Asset detail page** — "Tickets that touched this asset" section
|
||||
under the audit panel, with sub-sections for `Referenced by` (with
|
||||
the LLM's relevance reason) and `Updated by` (with the field name and
|
||||
write history link).
|
||||
2. **`GET /api/analyzer/tickets/[ticketNumber]/itglue-xrefs`** —
|
||||
ticket-side view; available for future ticket-page surfacing.
|
||||
|
||||
This table is also the lookup index a future RAG automation will use:
|
||||
given a new ticket's entities, fetch historically-referenced docs as
|
||||
warm candidates for the analyzer's IT Glue retrieval stage.
|
||||
|
||||
### Ticket-linkage columns
|
||||
|
||||
Both audit and write tables carry denormalized ticket linkage:
|
||||
|
||||
- `itglue_asset_audits.triggered_by_ticket_number` (and `triggered_by_analysis_id`)
|
||||
— set on ticket-scoped audits. Null on asset-first audits.
|
||||
- `itglue_writes.triggered_by_ticket_number` — copied forward from the
|
||||
audit on Apply, so "every write a given ticket drove" is a one-query
|
||||
lookup. Null when the audit was asset-first.
|
||||
|
||||
### Files added in 4.1
|
||||
|
||||
- `migrations/076_itglue_ticket_xrefs.sql`
|
||||
- `lib/services/analyzer/asset-audit/xrefs.ts`
|
||||
- `lib/services/analyzer/asset-audit/asset-matcher.ts`
|
||||
- `app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts`
|
||||
- Configuration parallel route tree under `app/api/analyzer/itglue/configurations/`
|
||||
- xref endpoints: `app/api/analyzer/itglue/applications/[id]/xrefs/route.ts`,
|
||||
`app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts`,
|
||||
`app/api/analyzer/tickets/[ticketNumber]/itglue-xrefs/route.ts`
|
||||
- `app/analyzer/itglue/configurations/page.tsx` (list)
|
||||
- `app/analyzer/itglue/configurations/[id]/page.tsx` (detail)
|
||||
- `components/analyzer/itglue-suggestions-panel.tsx`
|
||||
|
||||
### Files modified in 4.1
|
||||
|
||||
- `lib/services/itglue-client.ts` — `updateConfiguration`, `refreshConfiguration`
|
||||
- `lib/services/itglue-sync-service.ts` — `refreshConfigurationById`
|
||||
- `lib/services/analyzer/asset-audit/data-builder.ts` — assetType dispatch + ticket-scope mode + Configuration field schema
|
||||
- `lib/services/analyzer/asset-audit/prompt.ts` — Configuration prompt + ticket-scoped suffix + assetType-aware payload
|
||||
- `lib/services/analyzer/asset-audit/runner.ts` — accepts `assetType` and `ticketScopeAnalysisId`; persists triggered_by_*
|
||||
- `lib/services/analyzer/asset-audit/persistence.ts` — asset_type union extended; `getLatestTicketScopedAudit`; `createPendingWrite` accepts asset_type + ticket linkage
|
||||
- `lib/services/analyzer/worker.ts` — post-analysis xref ingestion hook
|
||||
- `app/api/analyzer/itglue/applications/[id]/apply/route.ts` — ticket linkage + xref insert on Apply
|
||||
- `app/api/analyzer/itglue/applications/[id]/revert/[writeId]/route.ts` — asset_type now passed to createPendingWrite
|
||||
- `app/analyzer/analysis/[id]/page.tsx` — `<ItglueSuggestionsPanel/>` rendered
|
||||
- `app/analyzer/itglue/applications/[id]/page.tsx` — "Tickets that touched this asset" section
|
||||
- `components/navigation/app-navigation.tsx` — split entries
|
||||
271
docs/loglift-eventlog-pipeline-spec.md
Normal file
271
docs/loglift-eventlog-pipeline-spec.md
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# LogLift Event-Log Pipeline (Phase 4.3)
|
||||
|
||||
End-to-end spec for the LogLift evidence path: a Datto RMM-deployed
|
||||
PowerShell collector captures a Windows endpoint's event logs + system
|
||||
context, gzips the JSON, uploads it to Backblaze B2, then POSTs metadata
|
||||
to Pulse. Pulse downloads the gzip, slims it, persists it as
|
||||
`rmm_executions` evidence, and (when the hostname uniquely matches an IT
|
||||
Glue Configuration) auto-triggers an asset-first audit.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Phase 4.2 wired Datto RMM Overshell PowerShell evidence into the audit
|
||||
pipeline, but Overshell stdout is capped at ~50KB practical — too small
|
||||
for full event logs. Wulf already runs a richer evidence path through
|
||||
n8n: collector → B2 → n8n decompress + LLM → Telegram. Phase 4.3 makes
|
||||
Pulse the receiver instead of n8n so:
|
||||
|
||||
- LogLift evidence lands in the same `rmm_executions` table.
|
||||
- The audit pipeline's `rmm_evidence` arm picks it up automatically.
|
||||
- Admins can dispatch a LogLift run from the Configuration page (Datto
|
||||
Quick Job into the registered LogLift component).
|
||||
- Successful uploads matched to a unique IT Glue Configuration auto-fire
|
||||
an asset-first audit so documentation suggestions surface immediately.
|
||||
|
||||
## Components
|
||||
|
||||
```
|
||||
┌──────────────────────────┐ ┌────────────────────────┐
|
||||
│ Windows endpoint │ │ Datto RMM │
|
||||
│ ─ collector PowerShell │ ◀───── │ ─ LogLift component │
|
||||
│ ─ gzip event logs │ │ (job dispatched │
|
||||
│ ─ upload to B2 │ ────▶ │ by Pulse) │
|
||||
│ ─ POST webhook to Pulse │ │ │
|
||||
└──────────────────────────┘ └────────────────────────┘
|
||||
│ ▲
|
||||
│ B2 PUT │ runQuickJob
|
||||
▼ │
|
||||
┌──────────────────────────┐ ┌────────────────────────┐
|
||||
│ Backblaze B2 │ ◀─SigV4─│ Pulse │
|
||||
│ bucket: wulf-audits │ │ /api/rmm/loglift/ │
|
||||
│ region: us-west-002 │ GET │ upload │
|
||||
│ │ ────▶ │ ─ download + slim │
|
||||
│ │ │ ─ persist evidence │
|
||||
└──────────────────────────┘ │ ─ auto-audit (single │
|
||||
│ match only) │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
## Object-key convention
|
||||
|
||||
```
|
||||
{datto_site_uid}/{computer_name}/eventlogs_{YYYYMMDD_HHMMSS}.json.gz
|
||||
```
|
||||
|
||||
Pulse rejects anything not matching:
|
||||
|
||||
```
|
||||
^[A-Za-z0-9_-]+/[A-Za-z0-9_.-]+/eventlogs_[0-9_]+\.json\.gz$
|
||||
```
|
||||
|
||||
## Webhook contract
|
||||
|
||||
`POST /api/rmm/loglift/upload`
|
||||
|
||||
Auth header: `x-openclaw-key: <OPENCLAW_API_KEY>` (constant-time match).
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "pulse_a1b2c3_1714672800000",
|
||||
"clientId": "f7a8b9c0-…",
|
||||
"computerName": "YNGHYNWNP01",
|
||||
"deviceUid": "f7a8b9c0-… (optional)",
|
||||
"summary": {
|
||||
"totalEvents": 4012,
|
||||
"criticalEvents": 0,
|
||||
"errorCount": 23,
|
||||
"warningCount": 187,
|
||||
"timeRange": "Last 24 hours"
|
||||
},
|
||||
"objectKey": "f7a8b9c0/YNGHYNWNP01/eventlogs_20260502_120000.json.gz",
|
||||
"collectedAt": "2026-05-02T12:00:03.124Z",
|
||||
"rmmContext": {
|
||||
"siteName": "Hynes — Youngstown",
|
||||
"siteUid": "f7a8b9c0-…",
|
||||
"accountUid": "…"
|
||||
},
|
||||
"issueDescription": "(optional, free-text)",
|
||||
"ticketNumber": "T20260502.0019"
|
||||
}
|
||||
```
|
||||
|
||||
Response (200):
|
||||
|
||||
```json
|
||||
{
|
||||
"executionId": "0193…",
|
||||
"matched": {
|
||||
"datto_site_id": 42,
|
||||
"datto_device_uid": "f7a8b9c0-…",
|
||||
"autotask_company_id": "29861375",
|
||||
"configuration_id": "12345",
|
||||
"configuration_single_match": true
|
||||
},
|
||||
"parsed": {
|
||||
"total_events": 4012,
|
||||
"critical_events": 0,
|
||||
"error_count": 23,
|
||||
"warning_count": 187,
|
||||
"time_range": "Last 24 hours"
|
||||
},
|
||||
"auditId": "0193…"
|
||||
}
|
||||
```
|
||||
|
||||
## Pulse-driven dispatch
|
||||
|
||||
Configuration page → "Run discovery" picker → LogLift entry. The picker
|
||||
filters by `target_type='asset_self'`. Dispatch path:
|
||||
|
||||
1. Resolve the device's Datto site uid (`datto_rmm_sites.uid`) → use as
|
||||
`ClientId`.
|
||||
2. Resolve the LogLift component uid from `rmm_settings`. If not cached,
|
||||
the executor calls `discoverLogliftComponent()` which scans the Datto
|
||||
API for components matching `/loglift|eventlog/i`.
|
||||
3. Generate `runId = pulse_<hex8>_<ms>`.
|
||||
4. Insert `rmm_executions` row with `transport='b2_upload'`, `run_id`,
|
||||
`status='queued'`. The row's `variables` column stores the
|
||||
non-secret variables (RunId, ClientId, WebhookUrl) — the secret is
|
||||
stripped before persistence.
|
||||
5. `runQuickJob` with the LogLift component_uid + variables:
|
||||
- `RunId` — webhook correlation
|
||||
- `ClientId` — Datto site uid
|
||||
- `WebhookUrl` — `${BETTER_AUTH_URL}/api/rmm/loglift/upload`
|
||||
- `WebhookSecret` — `OPENCLAW_API_KEY`
|
||||
6. On Quick Job ack: flip to `running` + persist `job_uid`.
|
||||
7. Worker skips `b2_upload` rows during stdout-poll. Webhook is the
|
||||
completion event. The 5-minute timeout sweep still applies — stuck
|
||||
rows get marked `timeout`.
|
||||
|
||||
## Out-of-band (collector-driven) ingest
|
||||
|
||||
If the LogLift collector fires from its own schedule (e.g. n8n still
|
||||
runs in parallel during cutover), the webhook receiver inserts a fresh
|
||||
`rmm_executions` row with `triggered_by_user_id=NULL`,
|
||||
`status='running'`, then completes it in the same handler.
|
||||
|
||||
`run_id` has a unique index; replays of the same upload no-op cleanly.
|
||||
|
||||
## Slim-evidence shape
|
||||
|
||||
Stored in `rmm_executions.parsed_evidence` (JSONB). The full gzip stays
|
||||
in B2 forever (forensic replay via the presigned-GET helper):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"transport": "b2_upload",
|
||||
"object_key": "…",
|
||||
"collected_at": "…",
|
||||
"rmm_context": {…},
|
||||
"issue_description": "…",
|
||||
"ticket_number": "…",
|
||||
"webhook_summary": {…}, // raw counts from the webhook
|
||||
"metadata": {…}, // from inside the gzip
|
||||
"system_context": { // OS, hardware, disks, updates …
|
||||
"OS": {…},
|
||||
"Hardware": {…},
|
||||
"Memory": {…},
|
||||
"Disks": [{…}],
|
||||
"RecentUpdates": [{…}],
|
||||
"Uptime": "…",
|
||||
"LastBoot": "…",
|
||||
"PendingReboot": false,
|
||||
"RebootReasons": []
|
||||
},
|
||||
"summary": { // from inside the gzip
|
||||
"TotalEvents": 4012,
|
||||
"CriticalEvents": 0,
|
||||
"ByLevel": {"Error": 23, "Warning": 187},
|
||||
"TimeRange": "Last 24 hours",
|
||||
"TopEventIds": [{"Id": 7036, "Count": 145}]
|
||||
},
|
||||
"top_events": [ // top 100 by severity then recency
|
||||
{"TimeCreated":"…", "LevelDisplayName":"Error", "Id":7036, "Source":"Service Control Manager", "Message":"…"}
|
||||
],
|
||||
"event_count_total": 4012,
|
||||
"top_events_truncated": true
|
||||
}
|
||||
```
|
||||
|
||||
Output runs through `redact()` before persistence.
|
||||
|
||||
## Auto-audit hook
|
||||
|
||||
When the resolved IT Glue Configuration is **single-match** (exactly
|
||||
one row matches the company + hostname), Pulse fires
|
||||
`runAssetAudit({ assetType: 'configuration', assetId, generatedByUserId: null, provider: 'anthropic' })`.
|
||||
|
||||
The new audit row appears on the Configuration's audit page with
|
||||
`triggered_by_user_id=null`. Multi-match Configurations are logged but
|
||||
skipped — auditing the wrong asset is worse than no audit.
|
||||
|
||||
`audit_log` actions:
|
||||
|
||||
| Action | Resource | When |
|
||||
| ----------------------------- | --------------------- | ------------------------------------------ |
|
||||
| `rmm.loglift.dispatched` | datto_device | Pulse-driven Quick Job accepted |
|
||||
| `rmm.loglift.received` | datto_device | Webhook landed + evidence persisted |
|
||||
| `rmm.loglift.matched` | itg_configuration | Configuration matched + single |
|
||||
| `rmm.loglift.audit_triggered` | itg_configuration | Auto-audit completed |
|
||||
|
||||
## Safety + cost guards
|
||||
|
||||
- `OBJECT_KEY_REGEX` — path-traversal guard.
|
||||
- B2 download cap: 25MB hard.
|
||||
- Decompress cap: refuse if gzip ISIZE > 100MB (zip-bomb defense),
|
||||
re-checked after inflation.
|
||||
- `redact()` runs on the slim payload before storage.
|
||||
- Auto-audit only on single-match Configurations.
|
||||
- `analyzer_cost_audit` records on the dispatch path (rate-limited the
|
||||
same as Overshell). Inbound webhooks are NOT rate-limited — the
|
||||
agent decides cadence; we trust the agent.
|
||||
- The collector's own B2 credentials never travel through Pulse. Pulse
|
||||
uses its own `B2_KEY_ID` / `B2_APP_KEY` to download.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Required for dispatch + receive:
|
||||
|
||||
| Var | Default | Notes |
|
||||
| -------------------- | -------------------------------- | ---------------------------------- |
|
||||
| `B2_KEY_ID` | — | B2 application key id |
|
||||
| `B2_APP_KEY` | — | B2 application key secret |
|
||||
| `B2_BUCKET` | `wulf-audits` | matches existing n8n config |
|
||||
| `B2_REGION` | `us-west-002` | |
|
||||
| `B2_ENDPOINT` | `s3.us-west-002.backblazeb2.com` | no scheme |
|
||||
| `OPENCLAW_API_KEY` | — | webhook auth + collector variable |
|
||||
| `BETTER_AUTH_URL` | — | base URL the collector POSTs to |
|
||||
|
||||
## Verification checklist
|
||||
|
||||
1. `\d rmm_executions` shows `transport`, `evidence_object_key`, `run_id`.
|
||||
2. `B2 client.test.ts` passes (SigV4 fixture + path-traversal rejection).
|
||||
3. Webhook auth: missing/bad `x-openclaw-key` → 401; good key + valid
|
||||
body → 200.
|
||||
4. `/admin/rmm-overshell` → "Re-discover LogLift" populates
|
||||
`rmm_settings.loglift_component_uid`.
|
||||
5. Configuration page → "Run discovery" → LogLift selection → row goes
|
||||
`running` (`transport='b2_upload'`, `run_id` set).
|
||||
6. Webhook handler downloads from B2, flips row to `complete`,
|
||||
`parsed_evidence` populated.
|
||||
7. Single-match Configuration → new `itglue_asset_audits` row with
|
||||
`triggered_by_user_id=null`.
|
||||
8. Asset-audit prompt's `=== LIVE RMM EVIDENCE ===` block contains the
|
||||
slim LogLift payload (system_context + summary + top_events).
|
||||
9. Non-admin direct webhook POST without the openclaw key returns 401.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No Telegram summary / non-technical second LLM pass (notification,
|
||||
not data path).
|
||||
- No replacement of the existing collector PowerShell. The Datto
|
||||
component is registered by name; the dispatch path passes `RunId`,
|
||||
`ClientId`, `WebhookUrl`, `WebhookSecret` as variables.
|
||||
- No bulk-replay of historical B2 objects. Manual replay can be added
|
||||
later via an admin endpoint that takes an `objectKey`.
|
||||
- No HMAC signature on the webhook body — `x-openclaw-key` is the auth
|
||||
boundary. Adding HMAC is a fast follow if we expand external
|
||||
integrations.
|
||||
412
docs/rmm-overshell-evidence-spec.md
Normal file
412
docs/rmm-overshell-evidence-spec.md
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
# Feature: Datto RMM Overshell Evidence Pipeline
|
||||
|
||||
**Status:** shipped (Phase 4.2 of the AI Ticket Analyzer)
|
||||
**Migration:** `077_rmm_overshell.sql`
|
||||
**Build notes:** `docs/wulf-pulse-ticket-analyzer-build-notes.md` → Phase 4.2
|
||||
**Related:** `docs/itglue-asset-audit-spec.md` (where evidence feeds into audits)
|
||||
|
||||
---
|
||||
|
||||
## What it is
|
||||
|
||||
Pulse dispatches a curated library of read-only PowerShell scripts via the
|
||||
Datto RMM **Overshell** component, captures the structured output, and
|
||||
makes it available to the IT Glue audit pipeline as authoritative live
|
||||
evidence. Every dispatch is recorded with full audit trail; admins
|
||||
trigger; results land within 30–180 seconds; subsequent audits cite the
|
||||
fresh data with high confidence.
|
||||
|
||||
The motivating example: an AD health summary at Hynes Industries
|
||||
(replication, dcdiag, named services, IP conflicts) — captured by openclaw
|
||||
on 2026-04-25 — was useful for incident response but stayed in chat. With
|
||||
Phase 4.2 the same intel can be reproduced from Pulse directly, persisted,
|
||||
and cited by the LLM next time we audit a Hynes Configuration.
|
||||
|
||||
---
|
||||
|
||||
## How it fits
|
||||
|
||||
```
|
||||
Audit pipeline Phase 4.2 → live state
|
||||
───────────────── ─────────────────────
|
||||
Ticket history ─┐ ▲
|
||||
├─► AssetAuditContext ─► LLM ─► Suggestions │
|
||||
IT Glue schema ─┤ │
|
||||
│ │
|
||||
Peer exemplars ─┤ │
|
||||
│ │
|
||||
Live RMM evidence ◄─── rmm_executions ◄─── Worker ◄──── Datto RMM
|
||||
(this phase) (poller) ▲
|
||||
│
|
||||
rmm_settings ┌───┘
|
||||
(component_uid)│
|
||||
│
|
||||
POST /api/rmm/executions
|
||||
│
|
||||
user clicks "Run discovery"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User-facing flows
|
||||
|
||||
### Site-anchored discovery — `/analyzer/itglue/sites/[companyId]`
|
||||
|
||||
Admin opens a client's site page, picks a script from the dropdown
|
||||
(filtered to `target_type='site_anchor'`), and clicks. Pulse:
|
||||
|
||||
1. Resolves the WNP endpoint via `datto_rmm_devices` matching `LLLCCCWNPNN`.
|
||||
2. Inserts a pending row in `rmm_executions`.
|
||||
3. Calls `runQuickJob` with the Overshell `component_uid` and the script
|
||||
body as a variable.
|
||||
4. The worker polls every 5s; finalizes when stdout/stderr arrive.
|
||||
5. The page lists the run + parsed evidence inline.
|
||||
|
||||
Site-anchored scripts: `get-ad-health`, `get-dhcp-scopes`, `get-dns-zones`,
|
||||
`get-network-discovery`.
|
||||
|
||||
### Asset-specific discovery — Configuration detail page
|
||||
|
||||
When viewing an IT Glue Configuration that maps to a Datto RMM device
|
||||
(via `itg_configurations.rmm_id` or hostname match), the picker offers
|
||||
asset-self scripts: `get-services`, `get-installed-software`,
|
||||
`get-event-log-recent`. Output is captured against the asset; subsequent
|
||||
audits on that asset see it.
|
||||
|
||||
### Application detail page
|
||||
|
||||
Application records belong to a client, not a specific server. The picker
|
||||
on the Application page offers **site-anchored** scripts targeting the
|
||||
client's WNP — same as the site page, just discoverable in-context.
|
||||
|
||||
### Admin settings — `/admin/rmm-overshell`
|
||||
|
||||
- Current Overshell `component_uid` + name
|
||||
- Variable name (default `CommandLine`; editable per tenant)
|
||||
- "Re-discover" button (forces a full component scan)
|
||||
- 24-hour activity counters (total / running / failed)
|
||||
- Recent execution log
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
| Action | Permission |
|
||||
|---|---|
|
||||
| List scripts / list executions / view execution detail | `requireAuth()` |
|
||||
| Trigger an execution | `requirePermission('rmm','execute')` |
|
||||
| Edit Overshell settings, force discovery | `requirePermission('admin','access')` |
|
||||
|
||||
`rmm.execute` is granted to `admin` + `super-admin` only (see
|
||||
`lib/permissions.ts`).
|
||||
|
||||
---
|
||||
|
||||
## Script library
|
||||
|
||||
Each script is a TypeScript module exporting an `RmmScript`:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'get-services',
|
||||
name: 'Running services',
|
||||
description: 'Snapshot of all running Windows services …',
|
||||
target_type: 'asset_self',
|
||||
expected_runtime_seconds: 15,
|
||||
version: 1,
|
||||
body: '<PowerShell>',
|
||||
parseOutput: (stdout) => parseJsonOutput(stdout),
|
||||
}
|
||||
```
|
||||
|
||||
Convention: scripts end with `ConvertTo-Json -Depth N -Compress` so
|
||||
parsing is `JSON.parse`. The body file in `lib/services/rmm/scripts/` is
|
||||
the source of truth — DB never stores executable code.
|
||||
|
||||
Adding a new script:
|
||||
|
||||
1. Create `lib/services/rmm/scripts/<id>.ts` exporting an `RmmScript`.
|
||||
2. Import + add to `_all` in `lib/services/rmm/scripts/index.ts`.
|
||||
3. Bump `version` if you change body or output shape.
|
||||
4. `npm test` — registry validates uniqueness, presence of required
|
||||
fields, and absence of credential-shaped patterns.
|
||||
|
||||
### Current library
|
||||
|
||||
| Script | Target | Notes |
|
||||
|---|---|---|
|
||||
| `get-services` | asset_self | Get-Service running list (Name/DisplayName/StartType/ServiceType). |
|
||||
| `get-installed-software` | asset_self | Win32 + WoW64 uninstall registry. |
|
||||
| `get-event-log-recent` | asset_self | Last 24h Errors+Warnings from System+Application logs (cap 50). |
|
||||
| `get-ad-health` | site_anchor | Per-DC replication, services, dcdiag pass/fail, recent Netlogon/DNS errors. |
|
||||
| `get-dhcp-scopes` | site_anchor | All authorized DHCP servers, scopes, statistics, reservations. |
|
||||
| `get-dns-zones` | site_anchor | Zones + forwarders + conditional forwarders per DC. |
|
||||
| `get-network-discovery` | site_anchor | Local NIC config + ARP table + IP-conflict detection (catches the proven test case). |
|
||||
|
||||
---
|
||||
|
||||
## Target resolution
|
||||
|
||||
### Site-anchored
|
||||
|
||||
1. `companies → datto_rmm_sites` via `autotask_company_id`.
|
||||
2. `datto_rmm_devices WHERE site_id = $1 AND hostname ~* '^[A-Z]{3}[A-Z]{3}WNP[0-9]{2}$'`.
|
||||
3. Strict regex check in JS (PostgreSQL regex is permissive; JS pins the
|
||||
shape exactly).
|
||||
4. Sort: online first, then ascending suffix number.
|
||||
5. Returns `{ device_uid, hostname, online }` or null.
|
||||
|
||||
### Asset-self
|
||||
|
||||
1. `IT Glue itg_configurations.rmm_id` → `datto_rmm_devices.uid`.
|
||||
2. Fallback: hostname match on `datto_rmm_devices.hostname`.
|
||||
|
||||
If neither resolves, the picker is disabled with the tooltip "No Datto
|
||||
device id."
|
||||
|
||||
---
|
||||
|
||||
## Execution lifecycle
|
||||
|
||||
```
|
||||
queued ─► running ─► complete | failed | timeout
|
||||
▲ │
|
||||
│ │
|
||||
└──────── markExecutionRunning(jobUid)
|
||||
after runQuickJob success
|
||||
```
|
||||
|
||||
- **queued**: row inserted, `runQuickJob` not yet returned.
|
||||
- **running**: `job_uid` stored, worker is polling. Bounded by `timeout_at`
|
||||
(5 minutes from queue).
|
||||
- **complete**: terminal status from Datto, exit_code 0.
|
||||
- **failed**: terminal status with non-zero exit code, OR runQuickJob
|
||||
threw, OR Datto returned no job_uid.
|
||||
- **timeout**: `timeout_at < NOW()` and worker hasn't seen completion.
|
||||
|
||||
The worker also writes generic `audit_log` rows on every state change:
|
||||
`rmm.execute` (queue), `rmm.execute.complete`, `rmm.execute.failed`,
|
||||
`rmm.execute.timeout`.
|
||||
|
||||
---
|
||||
|
||||
## Audit-pipeline integration
|
||||
|
||||
`buildAssetAuditContext` (in `lib/services/analyzer/asset-audit/data-builder.ts`)
|
||||
loads `rmm_evidence` as a 7th LLM context arm:
|
||||
|
||||
```
|
||||
loadRmmEvidence(itglueOrgId, assetType, assetId)
|
||||
├─ map IT Glue org → Autotask company (case-insensitive name)
|
||||
├─ listLatestEvidenceForCompany(companyId, 7) // site-anchored, last 7 days
|
||||
└─ listLatestEvidenceForAsset(assetType, assetId) // asset-self, all-time
|
||||
```
|
||||
|
||||
The prompt renders a new section:
|
||||
|
||||
```
|
||||
=== LIVE RMM EVIDENCE (most recent successful Overshell runs; AUTHORITATIVE current state) ===
|
||||
[ {execution_id, script_id, target_type, target_hostname, captured_at, parsed}, … ]
|
||||
```
|
||||
|
||||
System prompt rule (in `prompt.ts`):
|
||||
|
||||
> *"Treat parsed contents as authoritative current state … Use it to
|
||||
> justify suggested values with high confidence — e.g. if Get-Services
|
||||
> lists 'BartenderProcessService' running on the target and a ticket
|
||||
> asked about BarTender printing, suggest adding that service name to
|
||||
> operating_system_notes with confidence=high. Cite execution_id
|
||||
> alongside ticket numbers in evidence_ticket_numbers."*
|
||||
|
||||
Trim priority on overflow: `peer_global → ticket_evidence → rmm_evidence`
|
||||
(rmm last — highest-value section).
|
||||
|
||||
---
|
||||
|
||||
## API contract
|
||||
|
||||
| Method | Route | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/admin/rmm/settings` | admin | Settings + 24h counts |
|
||||
| PATCH | `/api/admin/rmm/settings` | admin | Update `overshellVariableName` |
|
||||
| POST | `/api/admin/rmm/settings/discover` | admin | Force component scan |
|
||||
| GET | `/api/rmm/scripts` | auth | Library catalog (no bodies) |
|
||||
| GET | `/api/rmm/executions` | auth | List (filters: companyId, scriptId, status, assetType, assetId) |
|
||||
| POST | `/api/rmm/executions` | rmm.execute | Queue a fresh execution |
|
||||
| GET | `/api/rmm/executions/[id]` | auth | Single execution detail |
|
||||
| GET | `/api/analyzer/itglue/sites/[companyId]` | auth | Site discovery summary |
|
||||
|
||||
POST body:
|
||||
|
||||
```ts
|
||||
{
|
||||
scriptId: string,
|
||||
target:
|
||||
| { type: 'site_anchor', companyId: number | string }
|
||||
| { type: 'asset_self', deviceUid: string, hostname?, companyId?, assetType?, assetId? },
|
||||
triggeredByAuditId?: string,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Safety
|
||||
|
||||
### Three-layer audit trail
|
||||
|
||||
| Table | What it captures |
|
||||
|---|---|
|
||||
| `rmm_executions` | Every dispatch — full lifecycle, raw output (redacted), parsed evidence, target, audit/asset linkage. Forever-retained. |
|
||||
| `analyzer_cost_audit` | Rate-limit decision row per request — approved / blocked. Same view as LLM cost decisions. |
|
||||
| Generic `audit_log` | `rmm.execute`, `rmm.execute.complete`, `rmm.execute.failed`, `rmm.execute.timeout`. Surfaces in `/admin/audit-log`. |
|
||||
|
||||
### Rate limit
|
||||
|
||||
50 executions per user per 24-hour rolling window. Trips before
|
||||
`runQuickJob` is called; logs the block to `analyzer_cost_audit` with
|
||||
`decision='blocked'`.
|
||||
|
||||
### Hard timeout
|
||||
|
||||
5 minutes per execution. The worker sweeps `timeout_at < NOW()` rows on
|
||||
every tick and marks them `timeout` (with an `error_message` recording
|
||||
the cause).
|
||||
|
||||
### Output redaction
|
||||
|
||||
Every `raw_stdout` and `raw_stderr` passes through
|
||||
`lib/services/analyzer/itglue-redact.ts:redact()` before persistence and
|
||||
again before the audit prompt sees it. Strips any key matching
|
||||
`/password|secret|key|token|credential|api[_-]?key/i`. Belt-and-braces
|
||||
defense even though the curated library has no credential-handling
|
||||
scripts.
|
||||
|
||||
### Registry gating
|
||||
|
||||
Only ids in the in-code `SCRIPTS` registry can run. The endpoint rejects
|
||||
unknown ids with 400 before any Datto API call. There's no UI to paste
|
||||
ad-hoc PowerShell — everything goes through the typed `RmmScript`
|
||||
interface.
|
||||
|
||||
---
|
||||
|
||||
## Operational SQL
|
||||
|
||||
### Most-recent successful run per (company, script)
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT ON (target_company_id, script_id)
|
||||
target_company_id, script_id, target_hostname,
|
||||
completed_at, exit_code
|
||||
FROM rmm_executions
|
||||
WHERE status = 'complete'
|
||||
AND completed_at >= NOW() - INTERVAL '14 days'
|
||||
ORDER BY target_company_id, script_id, completed_at DESC;
|
||||
```
|
||||
|
||||
### Stuck (queued/running past timeout)
|
||||
|
||||
```sql
|
||||
SELECT id, script_id, target_hostname, status, queued_at, timeout_at
|
||||
FROM rmm_executions
|
||||
WHERE status IN ('queued','running')
|
||||
AND timeout_at <= NOW()
|
||||
ORDER BY queued_at;
|
||||
```
|
||||
|
||||
(The worker should sweep these on the next tick.)
|
||||
|
||||
### Per-user daily activity
|
||||
|
||||
```sql
|
||||
SELECT performed_by_user_id,
|
||||
COUNT(*) FILTER (WHERE queued_at >= NOW() - INTERVAL '24 hours') AS last_24h,
|
||||
COUNT(*) FILTER (WHERE status = 'complete' AND queued_at >= NOW() - INTERVAL '24 hours') AS completed_24h
|
||||
FROM rmm_executions
|
||||
WHERE performed_by_user_id IS NOT NULL
|
||||
GROUP BY performed_by_user_id
|
||||
ORDER BY last_24h DESC;
|
||||
```
|
||||
|
||||
### Verify a particular script's parser
|
||||
|
||||
```sql
|
||||
SELECT id, status, parse_error, jsonb_pretty(parsed_evidence) AS parsed
|
||||
FROM rmm_executions
|
||||
WHERE script_id = 'get-ad-health'
|
||||
ORDER BY queued_at DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Limitations & non-goals
|
||||
|
||||
- **Forward-only capture.** No backfill of historical Datto Overshell
|
||||
jobs (per user direction).
|
||||
- **WNP-only target resolution.** Direct-to-DC role detection is a
|
||||
fast-follow if AD scripts that need native DC execution become
|
||||
important.
|
||||
- **Read-only scripts only.** No Overshell-driven configuration changes.
|
||||
Apply documentation updates via the IT Glue Phase 4 path.
|
||||
- **No ad-hoc PowerShell input from the UI.** Registry-listed scripts only.
|
||||
- **No audit-driven auto-execution in v1.** Admin clicks the button. A
|
||||
future version could let the audit panel propose "Run Get-Services to
|
||||
fill this gap" with one-click confirm.
|
||||
- **Credential output never enters the audit prompt.** Three-layer refusal
|
||||
at script-library curation, output redaction, and prompt-side rules.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
**New:**
|
||||
- `migrations/077_rmm_overshell.sql`
|
||||
- `lib/services/rmm/settings.ts`
|
||||
- `lib/services/rmm/persistence.ts`
|
||||
- `lib/services/rmm/target-resolver.ts` + test
|
||||
- `lib/services/rmm/executor.ts`
|
||||
- `lib/services/rmm/worker.ts` + test
|
||||
- `lib/services/rmm/scripts/types.ts`
|
||||
- `lib/services/rmm/scripts/{get-services,get-installed-software,get-event-log-recent,get-ad-health,get-dhcp-scopes,get-dns-zones,get-network-discovery}.ts`
|
||||
- `lib/services/rmm/scripts/index.ts` + registry test
|
||||
- `app/api/admin/rmm/settings/route.ts`
|
||||
- `app/api/admin/rmm/settings/discover/route.ts`
|
||||
- `app/api/rmm/scripts/route.ts`
|
||||
- `app/api/rmm/executions/route.ts`
|
||||
- `app/api/rmm/executions/[id]/route.ts`
|
||||
- `app/api/analyzer/itglue/sites/[companyId]/route.ts`
|
||||
- `app/admin/rmm-overshell/page.tsx`
|
||||
- `app/analyzer/itglue/sites/[companyId]/page.tsx`
|
||||
- `components/rmm/rmm-script-picker.tsx`
|
||||
- `components/rmm/rmm-execution-stream.tsx`
|
||||
|
||||
**Modified:**
|
||||
- `lib/permissions.ts` — `rmm: ['read','execute']`
|
||||
- `lib/services/datto-rmm-client.ts` — `findOvershellComponent`
|
||||
- `lib/services/analyzer/asset-audit/data-builder.ts` — 7th evidence arm
|
||||
- `lib/services/analyzer/asset-audit/prompt.ts` — RMM evidence section + system rule
|
||||
- `app/api/analyzer/itglue/applications/[id]/route.ts` — surface `autotaskCompanyId`
|
||||
- `app/api/analyzer/itglue/configurations/[id]/route.ts` — surface `dattoDeviceUid` + `autotaskCompanyId`
|
||||
- `app/analyzer/itglue/applications/[id]/page.tsx` — embedded `<RmmScriptPicker filter='site_anchor'/>`
|
||||
- `app/analyzer/itglue/configurations/[id]/page.tsx` — embedded `<RmmScriptPicker filter='asset_self'/>`
|
||||
- `components/navigation/app-navigation.tsx` — Admin → "RMM Overshell"
|
||||
|
||||
## Phase 4.3: LogLift transport variant
|
||||
|
||||
The same `rmm_executions` table also stores LogLift event-log uploads
|
||||
(`transport='b2_upload'`). Different transport, same evidence pipeline:
|
||||
|
||||
- The Datto RMM-registered LogLift component handles its own PowerShell
|
||||
collection + B2 upload. Pulse never sees the script body.
|
||||
- Pulse dispatches via `runQuickJob` with `RunId`, `ClientId`,
|
||||
`WebhookUrl`, `WebhookSecret` variables (same factory + same audit
|
||||
trail as Overshell).
|
||||
- The collector POSTs to `/api/rmm/loglift/upload` when the upload
|
||||
finishes. Pulse downloads from B2, slims, and persists.
|
||||
- The audit-context `rmm_evidence` arm picks the LogLift row up by
|
||||
`script_id='loglift-eventlogs'` automatically — same
|
||||
`listLatestEvidenceForAsset` join as any other asset-self script.
|
||||
|
||||
See `docs/loglift-eventlog-pipeline-spec.md` for the full webhook
|
||||
contract + slim-shape spec.
|
||||
|
|
@ -794,3 +794,764 @@ one Phase 2 push.
|
|||
| 2.6 | 128 | clean | aggregate reports (071, runner, 3 endpoints, 3 pages) |
|
||||
| 2.7 | 128 | clean | cost guards (072, audit log, threshold gating) |
|
||||
| 2.8 | 128 | clean | runbook + build notes |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Link-aware bundle analysis
|
||||
|
||||
**Why**
|
||||
|
||||
Single-ticket analysis misses the bigger picture for master/problem tickets,
|
||||
which are explicitly aggregator records — a "Master problem ticket" with a
|
||||
`RELATED TICKETS:` block in its description naming the constituent
|
||||
incidents. Aggregate reports already existed (Phase 2.6) but required the
|
||||
user to pre-analyze every constituent and hand-pick them on
|
||||
`/analyzer/reports/new`. Phase 3 closes the gap: one click on a problem
|
||||
ticket fans out individual analyses for each linked ticket and chains them
|
||||
into an aggregate report.
|
||||
|
||||
**Delivered**
|
||||
|
||||
- `migrations/073_analyzer_link_aware_bundles.sql`:
|
||||
- `analyzer_aggregate_reports.expected_ticket_numbers TEXT[]` — the full
|
||||
set of ticket numbers a bundle is waiting on.
|
||||
- `analyzer_aggregate_reports.triggered_by_ticket_number TEXT` — the
|
||||
master ticket the bundle was launched from.
|
||||
- Status check extended to include `'pending_analyses'` (waiting for
|
||||
individual analyses) before transitioning to `'pending'` (ready for
|
||||
aggregate-reduce).
|
||||
- Replaced the partial pending-status index to cover the new state; added
|
||||
a GIN index on `expected_ticket_numbers` filtered to
|
||||
`pending_analyses` for the worker chain-trigger lookup.
|
||||
- `lib/services/analyzer/link-discovery.ts`:
|
||||
- **Explicit arm** (no LLM, deterministic): regex scan over the ticket
|
||||
description and each retained note for `T\d{8}\.\d{4}` references,
|
||||
detection of the structured `RELATED TICKETS:` block (refs inside it
|
||||
flagged `confidence: 'high'`), and resolution of
|
||||
`tickets.problem_ticket_id` to a ticket number. Self-references and
|
||||
refs not present in the local mirror are dropped silently. Capped at
|
||||
`MAX_EXPLICIT_LINKS = 15`.
|
||||
- **Suggested arm** (Haiku, opt-in): one LLM pass over recent
|
||||
same-company tickets (±30 days, capped at 50 candidates). Returns up
|
||||
to 5 candidates with one-sentence reasons. Hallucination-guarded —
|
||||
drops any number not in the candidate list.
|
||||
- `detectProblemTicket()` returns boolean + signal list; UI uses signals
|
||||
to decide whether to highlight the bundle CTA as the primary action.
|
||||
- `app/api/analyzer/tickets/[ticketNumber]/links/route.ts`:
|
||||
- `GET` returns the explicit arm only (cheap, called on page load).
|
||||
- `POST { includeSuggested: true }` runs both arms.
|
||||
- `app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts`:
|
||||
- Validates master + linked tickets exist locally (single SQL roundtrip).
|
||||
- Runs per-ticket idempotency: existing complete analyses with matching
|
||||
content hash short-circuit; missing tickets are queued via the existing
|
||||
`queueJob()` helper.
|
||||
- Cost guard runs against the **new work only** — already-complete
|
||||
analyses don't add cost. Per-ticket estimate is a flat $0.15
|
||||
(Sonnet-tier pessimistic) plus `estimateAggregateReportCost()` for the
|
||||
reduce step.
|
||||
- Creates one `analyzer_aggregate_reports` row in
|
||||
`'pending_analyses'` (or straight to `'pending'` and fires
|
||||
`runAggregateReport()` if everything was already complete).
|
||||
- Bundle cap: `MAX_BUNDLE_SIZE = 25`.
|
||||
- `lib/services/analyzer/aggregate-persistence.ts`:
|
||||
- `createAggregateReport` accepts `expectedTicketNumbers` and
|
||||
`triggeredByTicketNumber`. When set, status starts as
|
||||
`'pending_analyses'`.
|
||||
- `chainTriggerForCompletedAnalysis(ticketNumber, analysisId)` — called
|
||||
by the worker after each successful job. Atomically appends the
|
||||
analysis_id to every pending_analyses bundle expecting that ticket
|
||||
(deduped via `analysis_ids @> ARRAY[…]` guard) and re-checks whether
|
||||
the full set is now satisfied. Returns `readyReportIds` for the worker
|
||||
to fire `runAggregateReport()` on.
|
||||
- `lib/services/analyzer/worker.ts` — chain-trigger fires from both
|
||||
the success branch and the idempotent-short-circuit branch (the bundle
|
||||
endpoint's idempotency check happens at submit time, but a parallel
|
||||
analysis can complete between then and when the worker picks the job
|
||||
up). Failures here are logged but never fail the underlying job.
|
||||
- `components/analyzer/related-tickets-panel.tsx` — renders above the
|
||||
existing `<AnalyzeButton>` on `/analyzer/ticket/[ticketNumber]`:
|
||||
- Cheap GET on mount populates the panel only when refs exist or the
|
||||
ticket looks like a problem ticket — otherwise the component renders
|
||||
nothing.
|
||||
- Pre-checked checkboxes for explicit refs; Switch toggle to load
|
||||
AI-suggested refs (additive, suggestions show with a badge,
|
||||
unchecked by default).
|
||||
- Primary CTA is bundle ("Analyze with N linked tickets") highlighted
|
||||
when `isProblemTicket=true`. Single-ticket flow is preserved
|
||||
untouched on the existing AnalyzeButton in the parent header.
|
||||
- Polls `GET /api/analyzer/aggregate-reports/:id` every 3s after submit;
|
||||
routes to `/analyzer/reports/:id` on completion.
|
||||
- `lib/services/analyzer/link-discovery.test.ts` — 18 new tests covering
|
||||
the regex, RELATED TICKETS section bounds, problem-ticket signal
|
||||
detection, dedup/self-skip, mirror filtering, `MAX_EXPLICIT_LINKS` cap,
|
||||
and confidence-based sorting.
|
||||
|
||||
**Decisions worth flagging**
|
||||
|
||||
- **Bundle is opt-in via the panel, not auto.** A ticket that mentions
|
||||
another ticket once in passing (e.g. "see T20260101.0001 for context")
|
||||
shouldn't quietly trigger 2× the LLM cost on every analysis. The panel
|
||||
is the consent surface — pre-checked when explicit refs exist, but the
|
||||
user explicitly picks the CTA.
|
||||
- **`RELATED TICKETS:` is a strong signal, not a parser-required format.**
|
||||
The regex catches T-numbers anywhere; the structured block just
|
||||
promotes them to high confidence and acts as a problem-ticket signal.
|
||||
No new format is imposed on whoever writes the master ticket.
|
||||
- **Suggested arm uses Haiku, not Sonnet.** ~$0.005 per call against the
|
||||
$5 per-request confirmation threshold — never trips the modal. We never
|
||||
fail the whole call if the suggestion arm throws (logged-and-suppressed
|
||||
via try/catch in `discoverLinks`).
|
||||
- **Per-ticket cost estimate is flat $0.15.** We could compute it from the
|
||||
preprocessed event count, but at the bundle's typical size (3-10
|
||||
tickets) that's $0.45 – $1.50 — far below the $5 confirmation
|
||||
threshold. Worth revisiting if we see real false-positive blocks.
|
||||
- **`pending_analyses` is the new status, distinct from `'pending'`.**
|
||||
Explicit two-step state lets the runner stay simple — it never has to
|
||||
ask "are all my analyses ready?" — that gate is the chain-trigger's
|
||||
job. Existing manual-multi-select reports continue to start at
|
||||
`'pending'`; their flow is untouched.
|
||||
- **`expected_ticket_numbers` matches via array containment, not a
|
||||
separate join table.** Postgres GIN gives us O(log n) lookup and the
|
||||
data lives where it's used — no new table, no foreign-key cascade
|
||||
decisions to make.
|
||||
- **Self-references and unknown tickets are dropped silently.** The user
|
||||
isn't asked to pick from a list; they get a clean "you have N linked
|
||||
tickets" panel. A ghost reference (T-number that doesn't exist in the
|
||||
mirror) is a sync gap, not a bundle decision.
|
||||
- **Fixture migration: `T20260424.0045.input.json` updated** to include
|
||||
`problem_ticket_id: null` so the type-strict load through the
|
||||
preprocessor still parses. The column is nullable in the data-access
|
||||
query and on the row type.
|
||||
|
||||
**Deliberately left out**
|
||||
|
||||
- No retroactive linking for already-completed analyses. If a master
|
||||
ticket got a single-ticket analysis before this shipped, the user
|
||||
re-runs from the panel to bundle.
|
||||
- No editing the bundle composition after submit. Re-run with a different
|
||||
selection if you want a different scope.
|
||||
- No time-window auto-correlation arm (e.g. "all tickets at this client in
|
||||
the last 6 hours"). At Hynes Industries on 2026-05-01 we observed 84
|
||||
tickets in one day — auto-grouping by time would have been useless
|
||||
noise. Same-company time-window ranking is what the Haiku suggested arm
|
||||
is for.
|
||||
- No UI for the `/analyzer/reports/[id]` page to flag itself as a "bundle"
|
||||
vs a manual report. The new fields are surfaced in the API response but
|
||||
the page renders the same regardless.
|
||||
|
||||
## Status after Phase 3
|
||||
|
||||
| Phase | Tests | tsc | Notes |
|
||||
|---|---|---|---|
|
||||
| 3 | 146 | clean | link discovery, bundle endpoint, chain-trigger, panel (073) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — IT Glue asset audit + documentation write-back
|
||||
|
||||
**Why**
|
||||
|
||||
Single-ticket analysis already extracts `documentation_gaps_observed` per
|
||||
ticket (in `aggregate_fingerprint`), but nothing acts on them. The companion
|
||||
direction — audit IT Glue records *against* ticket history — converts a
|
||||
passive output into an actionable backlog and offers direct write-back.
|
||||
Concrete proof: T20260502.0033 (Hynes — tags not printing) was resolved by
|
||||
identifying a stopped Windows service on MISYS-SQL processing BarTender
|
||||
scan-folder text files. The IT Glue record `MISYS 6.3` (asset 17096940) had
|
||||
12/17 fields empty — including Wulf Application Champion and Vendor
|
||||
Maintenance/Support — so the next tech with this issue would re-discover
|
||||
everything.
|
||||
|
||||
**Delivered**
|
||||
|
||||
- `migrations/075_itglue_audit.sql` — two new tables:
|
||||
- `itglue_asset_audits` — one row per audit run with full LLM context
|
||||
snapshot (asset traits at audit time, redacted), the gaps/promotions/
|
||||
contradictions output, and cost.
|
||||
- `itglue_writes` — one row per PATCH attempt; before/after diff, who, when,
|
||||
status pending → committed | failed | reverted, audit_id provenance,
|
||||
raw IT Glue API response, source_evidence (tickets that prompted the gap).
|
||||
- `lib/services/itglue-client.ts`:
|
||||
- `updateFlexibleAsset(id, traits)` — PATCH /flexible_assets/:id with
|
||||
JSON:API body.
|
||||
- `refreshFlexibleAsset(id)` thin wrapper around getFlexibleAsset.
|
||||
- `getRawSingle(path, params)` — for callers that need raw attributes
|
||||
(created-at/updated-at) for upserts.
|
||||
- `isITGlueConfigured()` helper.
|
||||
- Internal `patch(path, body)` mirrors the existing `request` pattern.
|
||||
- `lib/services/itglue-sync-service.ts`:
|
||||
- `refreshFlexibleAssetById(id)` per-record sync helper. Avoids running
|
||||
the full 27-entity `fullSync()` after every write.
|
||||
- `lib/permissions.ts`:
|
||||
- New `itglue: ['read', 'write']` permission. Admin + super-admin get write;
|
||||
user gets read-only.
|
||||
- `lib/services/analyzer/asset-audit/`:
|
||||
- `data-builder.ts` — collects all six context arms: asset snapshot,
|
||||
field schema with hints, peer exemplars same-client (top 5 by trait
|
||||
fill count), peer exemplars across all clients (top 3), per-field
|
||||
fill-rate stats (per-client + global), recent ticket fingerprints
|
||||
matching the asset's name. Redacts asset traits + every peer.
|
||||
- `prompt.ts` — single Sonnet/V4 Pro call; system prompt categorizes
|
||||
findings into field_gaps / notes_promotions / contradictions; refuses
|
||||
to suggest credential-shaped fields. Payload cap 80KB; trims peer_global
|
||||
first, then oldest tickets.
|
||||
- `runner.ts` — `runAssetAudit({assetId, generatedByUserId, provider})`.
|
||||
Provider-aware via `stageModelsFor(provider).deep_analysis`. Persists
|
||||
`itglue_asset_audits` row (or `failed` row on throw).
|
||||
- `persistence.ts` — typed read/write of both tables; `fieldNameToTraitKey()`
|
||||
helper matches IT Glue's `lower-hyphen-strip` convention.
|
||||
- `runner.test.ts` — 12 unit tests covering fillCount semantics, trait-key
|
||||
conversion, AssetAuditResponse Zod validation, payload trimming.
|
||||
- API routes:
|
||||
- `GET /api/analyzer/itglue/applications` — list of all Application records
|
||||
joined to latest audit, ordered worst-score-first.
|
||||
- `GET /api/analyzer/itglue/applications/[id]` — asset detail with field
|
||||
schema (the renderer uses field order + populated state).
|
||||
- `GET /api/analyzer/itglue/applications/[id]/audit?history=1` — latest
|
||||
audit + history.
|
||||
- `POST /api/analyzer/itglue/applications/[id]/audit` — runs a fresh audit;
|
||||
cost-guard via `recordCostAuditDecision({action:'itglue_audit'})`.
|
||||
- `POST /api/analyzer/itglue/applications/[id]/apply` — admin-only; inserts
|
||||
pending row, calls IT Glue PATCH, marks committed/failed, refreshes
|
||||
mirror, writes generic audit_log row.
|
||||
- `POST /api/analyzer/itglue/applications/[id]/revert/[writeId]` — admin-only;
|
||||
inserts a new write row with reversed before/after, applies, marks
|
||||
original status='reverted'.
|
||||
- `GET /api/analyzer/itglue/applications/[id]/writes` — auth-only per-asset
|
||||
history.
|
||||
- `GET /api/analyzer/itglue/writes` — admin-only cross-asset write log.
|
||||
- UI:
|
||||
- `/analyzer/itglue/applications` — list with score badges + filter input.
|
||||
- `/analyzer/itglue/applications/[id]` — asset header (with link to IT Glue),
|
||||
audit panel (gaps with severity-toned cards, notes promotions,
|
||||
contradictions), current fields rendered with hints for empty ones,
|
||||
write history with revert button, audit history with score timeline.
|
||||
`<ProviderToggle/>` reused from Phase 3.
|
||||
- `/admin/itglue-writes` — admin-only global write log with status filters.
|
||||
- Navigation: new "IT Glue audit" entry under the Analyzer dropdown.
|
||||
|
||||
**Audit-trail design**
|
||||
|
||||
Three layers of trail, all permanent:
|
||||
|
||||
1. `itglue_asset_audits` — every audit run with full LLM context.
|
||||
2. `itglue_writes` — every write attempt. Before/after, who, when, status,
|
||||
audit provenance. Reverts produce a new row with reversed diff; original
|
||||
row → `status='reverted'`. The chain is always traceable.
|
||||
3. Generic `audit_log` (existing migration 014) — written in parallel via
|
||||
`audit.log()`. Action `itglue.write` / `itglue.revert`, resource
|
||||
`flexible_asset`, resourceId = asset_id, details = `{ field_name, before,
|
||||
after, audit_id }`. Surfaces in `/admin/audit-log` next to every other
|
||||
admin action.
|
||||
|
||||
**Decisions worth flagging**
|
||||
|
||||
- **Two domain-specific tables, not one generic events table.** Audits
|
||||
carry full LLM context (heavy, infrequent). Writes are atomic per-field
|
||||
decisions with hard-typed before/after diffs. The generic `audit_log`'s
|
||||
free-form JSONB doesn't model the diff cleanly — but we still write to it
|
||||
so admins see a unified feed.
|
||||
- **Admin-direct write, no two-step approval.** Per the user's call. The
|
||||
audit log is the safety net. We left `approval_requests` as a possible v2
|
||||
if mistakes start happening.
|
||||
- **Per-record sync helper instead of `fullSync()` after every write.**
|
||||
`refreshFlexibleAssetById()` does one GET + one upsert. Keeping
|
||||
`fullSync()` available for ops + scheduler; bypassing it on the write
|
||||
path keeps Apply latency under a second after the IT Glue PATCH lands.
|
||||
- **Credential refusal at three layers.** Prompt instructs the LLM not to
|
||||
suggest password/secret/key/token/credential fields. The Apply endpoint
|
||||
also regex-blocks any field name matching that pattern. The `redact()`
|
||||
utility from `itglue-redact.ts` strips matching keys from any payload
|
||||
flowing into the LLM in the first place.
|
||||
- **Trait-key derivation in code, not hard-coded.** IT Glue's convention is
|
||||
field-name lowercased, non-alphanum → single hyphen, stripped. We compute
|
||||
this from each field's `name` (verified against the live Hynes MISYS 6.3
|
||||
trait map). If a future field doesn't match the rule, it'd surface as an
|
||||
unmatched fill-rate / value-not-applied — easy to spot.
|
||||
- **Fill-rate computation in JS, not SQL.** Avoids one query per field. At
|
||||
the example data volume (~92 active Hynes assets, ~few-thousand globally
|
||||
per type) this stays sub-100ms; can revisit if it grows.
|
||||
- **Peer-exemplar ranking by populated-key count.** Cheap proxy for
|
||||
"well-documented." Doesn't penalize asset types whose fields are
|
||||
legitimately optional. If the LLM starts producing strange suggestions
|
||||
we can refine to require/expected-field weighting.
|
||||
- **Asset detail page renders from local mirror, not IT Glue API.** Means a
|
||||
user could see a stale value for a few seconds between Apply and the
|
||||
per-record sync landing. Acceptable for a read view; the API response
|
||||
from Apply returns the freshly-PATCH'd asset so the UI can immediately
|
||||
reflect the new state.
|
||||
|
||||
**Deliberately left out**
|
||||
|
||||
- v1 covers Applications only (`flexible_asset_type_id = 3790`). The schema
|
||||
generalizes (`asset_type` is a column), but Configurations / Procedures /
|
||||
Domains have different shapes and prompts. One type at a time.
|
||||
- No bulk-apply. Admin clicks each suggestion. If a single audit produces 10
|
||||
gaps that's 10 clicks — fine for v1; bulk-apply is an easy follow-on.
|
||||
- No two-step approval workflow.
|
||||
- No auto-create of new asset records — Apply only updates existing.
|
||||
- No inline editor for suggested values — admin sees the LLM's suggestion
|
||||
verbatim and clicks Apply, then edits in IT Glue if they want to tweak.
|
||||
- Passwords / Secrets / Keys / Tokens / Credentials: never written via
|
||||
this surface, ever. Refused at prompt + endpoint + redaction layers.
|
||||
|
||||
## Status after Phase 4
|
||||
|
||||
| Phase | Tests | tsc | Notes |
|
||||
|---|---|---|---|
|
||||
| 4 | 158 | clean | IT Glue asset audit (075), runner + 6 endpoints + 3 pages, write-back with revert |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.1 — Ticket-first capture + Configurations + cross-reference index
|
||||
|
||||
**Why**
|
||||
|
||||
Phase 4 was asset-first (admin browses worst-scoring records). Phase 4.1
|
||||
flips perspective: every time we analyze a ticket, learn whether *this
|
||||
ticket* taught us something documentable. Plus extends write-back to IT
|
||||
Glue **Configurations** (servers, workstations, devices) — the prior
|
||||
flexible-asset-only scope missed records like MISYS-SQL where the
|
||||
T20260502.0033 root cause actually lived. Plus a cross-reference table so
|
||||
both perspectives become one-query lookups, and the future RAG automation
|
||||
has its lookup index.
|
||||
|
||||
**Delivered**
|
||||
|
||||
- `migrations/076_itglue_ticket_xrefs.sql`:
|
||||
- `triggered_by_ticket_number` + `triggered_by_analysis_id` on
|
||||
`itglue_asset_audits`; `triggered_by_ticket_number` on `itglue_writes`
|
||||
(denormalized per user's call so "every write a ticket drove" is a
|
||||
direct query).
|
||||
- asset_type CHECK extended to include `'configuration'` on both audit +
|
||||
write tables.
|
||||
- New `itglue_ticket_xrefs` table — ticket↔asset linkage with
|
||||
relationship type (`referenced` | `updated` | `should_have_referenced`),
|
||||
source (`analyzer_referenced` | `audit_write` | `manual`), unique
|
||||
constraint preventing dup ingestion.
|
||||
- `lib/services/itglue-client.ts`:
|
||||
- `updateConfiguration(id, attributes)` — PATCH /configurations/:id with
|
||||
flat JSON:API attributes (no traits blob).
|
||||
- `refreshConfiguration(id)` thin wrapper.
|
||||
- `lib/services/itglue-sync-service.ts`:
|
||||
- `refreshConfigurationById(id)` per-record refresh (mirrors the bulk
|
||||
syncConfigurations 33-column upsert).
|
||||
- `lib/services/analyzer/asset-audit/`:
|
||||
- `data-builder.ts` generalized: dispatches on `assetType`, supports
|
||||
`ticketScopeAnalysisId` for ticket-first audits. Configurations get a
|
||||
hand-curated 16-field schema with hints (since IT Glue Configurations
|
||||
don't have a `_fields` table). Synthesizes a "traits" map from flat
|
||||
columns so the prompt stays type-agnostic.
|
||||
- `prompt.ts` — two system prompts (Application-flavored vs
|
||||
Configuration-flavored, the latter focused on hostname/FQDN, OS
|
||||
currency, named services, IP/MAC, contact ownership). Ticket-scoped
|
||||
suffix when an analysis is the source so the LLM frames findings as
|
||||
"what did this ticket teach us?"
|
||||
- `runner.ts` accepts `assetType`, `ticketScopeAnalysisId`; persists
|
||||
`triggered_by_*` columns.
|
||||
- `persistence.ts` — `asset_type` union extended; new `getLatestTicketScopedAudit`
|
||||
helper for the analysis-page panel; `createPendingWrite` accepts asset_type
|
||||
+ triggered_by_ticket_number.
|
||||
- `xrefs.ts` (new) — bulk-insert helpers: `insertReferencedXrefsFromAnalysis`
|
||||
(post-analysis hook), `insertUpdatedXref` (post-apply hook), with
|
||||
listXrefsForAsset / listXrefsForTicket queries.
|
||||
- `asset-matcher.ts` (new) — given an analysis_id, returns matched
|
||||
flexible_assets + configurations for the ticket's client based on
|
||||
fingerprint terms (applications_involved, device_classes, vendors_involved).
|
||||
Score = exact (3) > word-boundary (2) > substring (1); top 5 per kind.
|
||||
- `lib/services/analyzer/worker.ts` — post-analysis hook calls
|
||||
`insertReferencedXrefsFromAnalysis` for every doc the LLM cited;
|
||||
best-effort, never fails the job.
|
||||
- API routes:
|
||||
- `GET /api/analyzer/analyses/[id]/itglue-suggestions` — match assets +
|
||||
return any existing ticket-scoped audits keyed by (assetType, assetId).
|
||||
- `POST /api/analyzer/analyses/[id]/itglue-suggestions` — body
|
||||
`{ assetType, assetId, provider }`, runs a ticket-scoped audit.
|
||||
- Full Configurations route tree mirroring Applications: list, detail,
|
||||
audit (GET/POST), apply (admin), revert (admin), writes, xrefs.
|
||||
- `GET /api/analyzer/applications/[id]/xrefs` (new) and
|
||||
`GET /api/analyzer/tickets/[ticketNumber]/itglue-xrefs` (new).
|
||||
- UI:
|
||||
- `<ItglueSuggestionsPanel/>` rendered on the analysis detail page.
|
||||
Opt-in trigger ("Check IT Glue documentation"); shows matched
|
||||
Applications + Configurations grouped, per-asset ticket-scoped audit
|
||||
buttons, inline gap cards with Apply (admin-only), score badges.
|
||||
Reuses `<ProviderToggle/>` from Phase 3.
|
||||
- `/analyzer/itglue/configurations` — list mirroring Applications.
|
||||
- `/analyzer/itglue/configurations/[id]` — detail mirroring Applications,
|
||||
plus the new "Tickets that touched this configuration" section.
|
||||
- Application detail page — added the same xref section.
|
||||
- Navigation split into "IT Glue — Applications" + "IT Glue — Configurations".
|
||||
|
||||
**Decisions worth flagging**
|
||||
|
||||
- **Two separate Configuration write methods + two route trees instead of
|
||||
one polymorphic surface.** The codebase has no other `[assetType]`-style
|
||||
polymorphism; existing patterns favor parallel resource paths. Added
|
||||
~80 LOC duplication on the page side, but each surface is independently
|
||||
testable + obvious in URL routing.
|
||||
- **Configuration field schema is hand-curated**, not loaded from IT Glue.
|
||||
IT Glue exposes flexible-asset field metadata via `/flexible_asset_fields`
|
||||
but Configurations have no equivalent endpoint. The 16 hand-written
|
||||
hints (in `data-builder.ts`) are what the LLM sees as field documentation.
|
||||
Versioned in code; PR review is the change control.
|
||||
- **Apply on Configurations is column-allowlisted.** Even with the audit
|
||||
pipeline picking `field_name`, the apply route refuses anything outside
|
||||
`name | hostname | primary_ip | mac_address | serial_number | asset_tag |
|
||||
position | notes | operating_system_notes`. Stops the LLM from suggesting
|
||||
edits to read-only/derived fields like `manufacturer_id` (which is an FK
|
||||
resolved by IT Glue, not a free-text field).
|
||||
- **xref ingestion happens in the worker after a successful analysis**,
|
||||
not as a separate batch job. Best-effort wrap means a transient DB
|
||||
hiccup never fails the analysis itself. The unique index on the xref
|
||||
table ensures retries are idempotent.
|
||||
- **ticketScopeAnalysisId narrows ticket evidence to one row.** This is
|
||||
the key prompt-shaping decision for Phase 4.1: the LLM sees just the
|
||||
one analysis the user clicked from, plus the asset state + schema + peer
|
||||
exemplars. Findings frame as "what *this ticket* revealed" rather than
|
||||
all-time history.
|
||||
- **No backfill of existing analyses.** Per user's call. The xref table
|
||||
fills forward; backfill is a future opt-in script if needed.
|
||||
- **Asset matching is loose** — substring + word-boundary. A ticket
|
||||
mentioning "MISYS" matches both `MISYS 6.3` (the Application) and
|
||||
`MISYS-SQL` (the Configuration), and the user picks per-asset which to
|
||||
audit. Less false-negative-y than strict matching; user controls
|
||||
confirmation.
|
||||
- **Configuration audits don't write to manufacturer/model/OS-name/contact/location**
|
||||
— those are FK fields IT Glue resolves by id, not free-text. The
|
||||
audit prompt can suggest changes but Apply blocks them. Future iteration
|
||||
could resolve names → ids via the IT Glue manufacturers/models endpoints.
|
||||
|
||||
**Deliberately left out**
|
||||
|
||||
- **Datto RMM script execution** (Phase 4.2 — separate plan). Ability to
|
||||
run PowerShell via Datto RMM Overshell on Wulf Nurse endpoints to gather
|
||||
fresh evidence (DHCP scopes, DNS zones, AD info, named services) and
|
||||
feed it into the audit pipeline. Decisions logged: generic Overshell
|
||||
component + Pulse-managed scripts; admin-direct with audit log; new
|
||||
`rmm.execute` permission.
|
||||
- No backfill of the xref table.
|
||||
- No bulk-apply across multiple gaps; admin clicks each one.
|
||||
- No two-step approval workflow. Audit log + role gating remain the
|
||||
safety net.
|
||||
- No Configuration write for FK-shaped fields (manufacturer, model, OS,
|
||||
contact, location) — only flat editable columns.
|
||||
|
||||
## Status after Phase 4.1
|
||||
|
||||
| Phase | Tests | tsc | Notes |
|
||||
|---|---|---|---|
|
||||
| 4.1 | 160 | clean | xref table (076), Configurations parity, ticket-first capture, analysis-page panel |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.2 — Datto RMM Overshell evidence pipeline
|
||||
|
||||
**Why**
|
||||
|
||||
Phase 4.1 wires ticket history + IT Glue field schemas into LLM-driven
|
||||
documentation suggestions. The next leverage point is **fresh evidence
|
||||
from the live environment** — service lists, AD health, DHCP scopes, DNS
|
||||
zones, event logs — that ticket history can't surface. Without it, audits
|
||||
flag "the named service that processes BarTender scan-folder text files
|
||||
isn't documented" but can't suggest the actual service name. With it, we
|
||||
suggest the literal value pulled from the running server seconds ago.
|
||||
|
||||
The proven test case: openclaw produced an AD health summary at Hynes
|
||||
around 2026-04-25 (IP conflicts, ZR006 missing trust account, DNS
|
||||
forwarders timing out, Hendricks site missing site-links) by orchestrating
|
||||
Datto RMM Overshell. Phase 4.2 lets Pulse produce the same intel directly
|
||||
from a button on the asset page, store it, and feed it back into audits.
|
||||
|
||||
**Delivered**
|
||||
|
||||
- `migrations/077_rmm_overshell.sql`:
|
||||
- `rmm_settings` singleton — caches the discovered Overshell `component_uid`,
|
||||
`component_name`, `variable_name` (default `CommandLine`).
|
||||
- `rmm_executions` — full lifecycle row per dispatch: queued → running →
|
||||
complete | failed | timeout. Captures `target_device_uid`,
|
||||
`target_hostname`, `target_company_id`, optional audit/asset linkage,
|
||||
`job_uid`, raw stdout/stderr (redacted), `parsed_evidence`, exit code,
|
||||
`timeout_at`. 8 indexes covering all query paths the audit pipeline +
|
||||
UI need.
|
||||
- `lib/services/rmm/scripts/`:
|
||||
- 7 v1 scripts, all read-only. Each is a typed `RmmScript` exporting
|
||||
`body` (PowerShell), `target_type`, `parseOutput`, `expected_runtime_seconds`,
|
||||
`version`. Bodies end with `ConvertTo-Json -Depth … -Compress` so the
|
||||
parser is just `JSON.parse`. Registry validates uniqueness at load.
|
||||
- asset_self: `get-services`, `get-installed-software`, `get-event-log-recent`.
|
||||
- site_anchor: `get-ad-health` (mirrors the openclaw test case),
|
||||
`get-dhcp-scopes`, `get-dns-zones`, `get-network-discovery` (catches
|
||||
the IP-conflict pattern from the proven test case).
|
||||
- `lib/services/rmm/target-resolver.ts`:
|
||||
- `resolveSiteAnchorTarget(companyId)` — looks up `datto_rmm_sites`
|
||||
where `autotask_company_id = $1`, finds devices matching
|
||||
`^[A-Z]{3}[A-Z]{3}WNP\d{2}$`, picks online + lowest numeric suffix.
|
||||
- `resolveAssetSelfTarget(deviceUid)` — direct lookup.
|
||||
- `resolveDeviceByHostname(hostname)` — fallback when an IT Glue
|
||||
Configuration's `rmm_id` doesn't resolve cleanly.
|
||||
- `lib/services/rmm/settings.ts`:
|
||||
- `discoverOvershellComponent()` — calls
|
||||
`client.findOvershellComponent(/overshell/i)` and persists the uid.
|
||||
- `resolveOvershellComponent()` — read-cache-or-discover; throws if
|
||||
nothing matches.
|
||||
- `lib/services/rmm/executor.ts`:
|
||||
- `queueExecution({ scriptId, target, performedByUserId, triggeredByAuditId? })`
|
||||
— validates registry, resolves target, runs cost-guard rate limit
|
||||
(50/user/24h, decision logged to `analyzer_cost_audit`), inserts
|
||||
pending row, calls `runQuickJob`, captures `jobUid`, flips to
|
||||
`running`. Generic `audit.log` entry on success.
|
||||
- `lib/services/rmm/worker.ts`:
|
||||
- 5-second poll loop, self-init pattern matching `analyzerWorker`.
|
||||
- Sweeps timed-out rows first (status → `timeout`).
|
||||
- Polls `running` rows via `client.getJobResults` per `target_device_uid`.
|
||||
On terminal status: redacts stdout/stderr, runs script's `parseOutput`,
|
||||
persists. Parse errors are non-fatal — raw output still kept.
|
||||
- `lib/services/rmm/persistence.ts`:
|
||||
- Typed `RmmExecutionRow` + status helpers, plus the audit-pipeline
|
||||
queries `listLatestEvidenceForCompany(companyId, days)` and
|
||||
`listLatestEvidenceForAsset(assetType, assetId)`.
|
||||
- `lib/services/datto-rmm-client.ts` — added `findOvershellComponent(pattern)`.
|
||||
- `lib/services/analyzer/asset-audit/data-builder.ts`:
|
||||
- 7th LLM context arm `rmm_evidence` populated from
|
||||
`listLatestEvidenceForCompany` (site-anchored, last 7 days) +
|
||||
`listLatestEvidenceForAsset` (asset-self, all-time).
|
||||
- Joins via `companies → itg_organizations` on case-insensitive
|
||||
`company_name` match (same join the ticket-evidence loader uses).
|
||||
- `lib/services/analyzer/asset-audit/prompt.ts`:
|
||||
- New `=== LIVE RMM EVIDENCE ===` section emits when
|
||||
`ctx.rmm_evidence.length > 0`. Trim path drops it last (highest-value
|
||||
section).
|
||||
- `LIVE_EVIDENCE_NOTE` injected into the system prompt: *"Treat parsed
|
||||
contents as authoritative current state … Cite execution_id alongside
|
||||
ticket numbers."*
|
||||
- `lib/permissions.ts` — new `rmm: ['read','execute']`. Admin + super-admin
|
||||
get both; user gets read.
|
||||
- API routes:
|
||||
- `GET/PATCH /api/admin/rmm/settings` — admin-only, view + edit variable name.
|
||||
- `POST /api/admin/rmm/settings/discover` — admin-only, force component scan.
|
||||
- `GET /api/rmm/scripts` — auth, library catalog (no bodies).
|
||||
- `GET/POST /api/rmm/executions` — list (auth) + queue (`rmm.execute`).
|
||||
- `GET /api/rmm/executions/[id]` — auth, polls one execution.
|
||||
- `GET /api/analyzer/itglue/sites/[companyId]` — site-discovery summary.
|
||||
- UI:
|
||||
- `<RmmScriptPicker filter='site_anchor'|'asset_self' …/>` — popover
|
||||
listing applicable scripts, dispatches on click, disables for
|
||||
non-admins.
|
||||
- `<RmmExecutionStream/>` — polls every 3s, shows status + parsed
|
||||
evidence + raw stdout (collapsible).
|
||||
- `/admin/rmm-overshell` — settings + recent execution log.
|
||||
- `/analyzer/itglue/sites/[companyId]` — site-discovery view.
|
||||
- Embedded picker on Application detail (site-anchor with parent
|
||||
client) and Configuration detail (asset-self if `rmm_id` resolves to
|
||||
a Datto device).
|
||||
- Nav entry: Admin → "RMM Overshell".
|
||||
|
||||
**Decisions worth flagging**
|
||||
|
||||
- **Component discovery is automatic and cached.** Pulse scans for any
|
||||
component matching `/overshell/i` on first dispatch, persists the uid,
|
||||
and never re-scans unless an admin clicks "Re-discover". The variable
|
||||
name defaults to `CommandLine` (Datto's "Run Command" component). If
|
||||
Wulf's Overshell uses a different variable, admin sets it once via
|
||||
`/admin/rmm-overshell`.
|
||||
- **Script bodies live in code, not the DB.** Three reasons: PR review is
|
||||
the change-control mechanism; nothing in the database is treated as
|
||||
executable PowerShell; the 7 scripts are already curated and we don't
|
||||
need (or want) ad-hoc paste-a-script UX.
|
||||
- **WNP-only target resolution.** Site-anchored scripts hit the Wulf
|
||||
Nurse Production endpoint (`LLLCCCWNPNN`); PowerShell uses native AD
|
||||
cmdlets to reach the DC over the network. Direct-to-DC role detection
|
||||
is a fast-follow.
|
||||
- **5-minute hard timeout + 50/user/24h rate limit.** Both enforced
|
||||
server-side in the executor. The cost-guard rows in `analyzer_cost_audit`
|
||||
give admins a unified view of LLM and RMM activity per user.
|
||||
- **Output is redacted before persistence.** Same `redact()` from the
|
||||
IT Glue redaction module; strips any password/secret/key/token/credential
|
||||
keyed values from stdout/stderr before the parser sees them.
|
||||
- **Live RMM evidence trims last.** When the audit prompt overflows the
|
||||
80KB cap, peer_global → ticket_evidence → rmm_evidence (in that order).
|
||||
Live evidence is the most novel signal; it's worth keeping.
|
||||
- **Worker is in-process, not a separate service.** Same auto-start
|
||||
pattern as `analyzerWorker`. `RMM_WORKER_AUTOSTART=1` opt-in for dev.
|
||||
Multiple Next.js workers are safe — each row's `jobUid` is set once and
|
||||
the poll loop is idempotent.
|
||||
- **`getJobResults` response shape is variable across Datto tenants.** The
|
||||
worker handles both top-level and `results[*]` payloads, picks the
|
||||
per-device result when present, and falls back to the first array entry.
|
||||
|
||||
**Deliberately left out**
|
||||
|
||||
- **No backfill of existing Overshell jobs.** Per user's call. Pulse
|
||||
starts capturing from the first dispatch.
|
||||
- **No DC-role detection.** WNP-only. Add later if AD scripts that need
|
||||
native DC execution become important.
|
||||
- **No ad-hoc PowerShell paste-in.** Only registry-listed scripts run.
|
||||
- **No openclaw integration.** Phase 4.2 talks directly to Datto RMM.
|
||||
- **No audit-driven auto-execution.** v1 is admin-clicks-button. The
|
||||
audit panel will gain a "Run Get-Services to fill this gap?" prompt in
|
||||
a fast-follow once we trust the safety layers.
|
||||
- **No Overshell write operations.** All scripts are read-only / discovery.
|
||||
Configuration changes happen via IT Glue (Phase 4) or manually.
|
||||
- **No per-script per-user permissions.** Anyone with `rmm.execute` can
|
||||
run any script. Per-script gating is a fast-follow if needed.
|
||||
- **No credential output ever.** Three-layer refusal:
|
||||
1. Script library has no credential-handling scripts; tests verify
|
||||
bodies don't reference `$plaintext` password patterns.
|
||||
2. `redact()` strips matching keys from output before persistence.
|
||||
3. The audit prompt's existing credential refusal applies to anything
|
||||
that does sneak through.
|
||||
|
||||
## Status after Phase 4.2
|
||||
|
||||
| Phase | Tests | tsc | Notes |
|
||||
|---|---|---|---|
|
||||
| 4.2 | 174 | clean | RMM Overshell pipeline (077), 7 scripts, executor + worker, audit-context arm |
|
||||
|
||||
## Phase 4.3 — LogLift event-log ingestion
|
||||
|
||||
### Why
|
||||
|
||||
Overshell stdout caps around ~50KB practical — fine for service lists
|
||||
or installed-software dumps, too small for full Windows event logs
|
||||
across critical/error/warning levels. Wulf already runs a richer
|
||||
collector via n8n: PowerShell on each endpoint gathers logs + system
|
||||
context, gzips it, uploads to a Backblaze B2 bucket
|
||||
(`wulf-audits` / `us-west-002`), then POSTs metadata. n8n decompresses,
|
||||
runs an LLM analysis, and posts a Telegram summary.
|
||||
|
||||
Phase 4.3 makes Pulse the receiver instead of n8n so:
|
||||
|
||||
- LogLift evidence lands in the same `rmm_executions` table 4.2 introduced.
|
||||
- The audit pipeline's `rmm_evidence` arm picks it up automatically.
|
||||
- Admins can dispatch a LogLift run directly from the Configuration page.
|
||||
- Successful uploads matched to a unique IT Glue Configuration auto-fire
|
||||
an asset-first audit so documentation suggestions surface immediately.
|
||||
|
||||
### Shape
|
||||
|
||||
`migrations/078_loglift_uploads.sql` adds three columns to
|
||||
`rmm_executions` (`transport`, `evidence_object_key`, `run_id` — with a
|
||||
unique index on `run_id`), and three to `rmm_settings`
|
||||
(`loglift_component_uid`, `loglift_component_name`,
|
||||
`loglift_discovered_at`). The transport column has a CHECK constraint
|
||||
restricting it to `overshell_stdout` | `b2_upload`.
|
||||
|
||||
`lib/services/b2/client.ts` is a from-scratch SigV4 implementation
|
||||
ported from `docs/LogLift Review.json`: presigned GET + PUT (different
|
||||
expiries), 25MB hard download cap, path-traversal-safe object key regex,
|
||||
and a `B2NotConfiguredError` when env vars are missing. 8 tests cover
|
||||
the regex + signature stability + signing-key derivation.
|
||||
|
||||
`lib/services/rmm/scripts/loglift-eventlogs.ts` registers the script:
|
||||
`target_type='asset_self'`, `transport='b2_upload'`, empty body (the
|
||||
collector PowerShell lives in the Datto-registered LogLift component, not
|
||||
in Pulse). The registry's body-length sanity test skips `b2_upload`
|
||||
scripts.
|
||||
|
||||
### Dispatch path
|
||||
|
||||
`executor.ts` forks on `script.transport`:
|
||||
|
||||
- `overshell_stdout` (default) — unchanged 4.2 path: resolve Overshell
|
||||
component, dispatch with `{Variable: body}`, worker polls for stdout.
|
||||
- `b2_upload` — new fork. Resolves the Datto site uid from the device,
|
||||
resolves the LogLift component (discover-on-demand), generates a
|
||||
`runId` (`pulse_<hex>_<ms>`), inserts an `rmm_executions` row with
|
||||
`transport='b2_upload'`, dispatches the Quick Job with variables
|
||||
`RunId`, `ClientId`, `WebhookUrl`, `WebhookSecret`. The persisted
|
||||
`variables` column strips `WebhookSecret` so admins can read the row
|
||||
without exposing the OPENCLAW key.
|
||||
|
||||
### Receive path
|
||||
|
||||
`POST /api/rmm/loglift/upload` (public per `middleware.ts`,
|
||||
`x-openclaw-key` validated):
|
||||
|
||||
1. Zod validate body + object-key regex.
|
||||
2. Resolve `clientId` (Datto site uid) → `datto_rmm_sites.id` →
|
||||
`autotask_company_id` (FK or name fallback — same as 4.2 multi-site
|
||||
work).
|
||||
3. Resolve `computerName` → Datto device uid (case-insensitive).
|
||||
4. Resolve `computerName` + company → `itg_configurations.id`. Two-pass
|
||||
(count + fetch) sets `single_match=true` only when exactly one
|
||||
Configuration matches.
|
||||
5. Correlate to a Pulse-dispatched execution by `run_id`. If no match
|
||||
(out-of-band collector), insert a fresh `running` row.
|
||||
6. Download from B2 (25MB cap), gunzip with zip-bomb guard
|
||||
(refuse > 100MB inflated, checked via gzip ISIZE before decompression
|
||||
and again after).
|
||||
7. Slim: keep `system_context` + `summary` + top 100 events sorted by
|
||||
severity (Critical → Error → Warning → Info), then recency. Drop the
|
||||
raw `events` array; the full gzip stays in B2 forever.
|
||||
8. `redact()` the slim object, persist with `markExecutionFromB2Upload`.
|
||||
9. Auto-audit hook: if Configuration matched single, fire
|
||||
`runAssetAudit({assetType:'configuration', assetId})` synchronously
|
||||
(still in the webhook handler — the LLM call is the bottleneck but
|
||||
the agent doesn't care about webhook latency past ~30s). On failure,
|
||||
log + continue — webhook still 200s.
|
||||
|
||||
`audit_log` actions: `rmm.loglift.dispatched`, `rmm.loglift.received`,
|
||||
`rmm.loglift.matched`, `rmm.loglift.audit_triggered`.
|
||||
|
||||
### Worker change
|
||||
|
||||
`worker.ts` filters `transport='b2_upload'` rows out of the running
|
||||
poll list — no stdout to fetch. The 5-minute timeout sweep still
|
||||
applies; stuck rows get marked `timeout`.
|
||||
|
||||
### Prompt update
|
||||
|
||||
`LIVE_EVIDENCE_NOTE` extended to teach the LLM about the LogLift slim
|
||||
shape: cite events as `event:<EventId>` or `execution:<id>`,
|
||||
`event_count_total` is the original count (top 100 only in the prompt),
|
||||
and `system_context` is authoritative for OS / hardware / disk / memory
|
||||
facts on the matched Configuration.
|
||||
|
||||
### UI surfaces
|
||||
|
||||
- `/admin/rmm-overshell` gets a second "LogLift component" block with a
|
||||
"Re-discover LogLift" button next to the existing Overshell discovery.
|
||||
`discoverLogliftComponent()` matches `/loglift|eventlog/i`.
|
||||
- Configuration page picker (filtered by `target_type='asset_self'`)
|
||||
surfaces the LogLift entry automatically — Phase 4.2's executor + UI
|
||||
scaffolding handles it through the new dispatch fork.
|
||||
|
||||
### Files
|
||||
|
||||
**New:** `migrations/078_loglift_uploads.sql`, `lib/services/b2/client.ts`
|
||||
(+ test), `lib/services/rmm/scripts/loglift-eventlogs.ts`,
|
||||
`lib/services/rmm/loglift-matcher.ts`,
|
||||
`lib/services/rmm/loglift-receiver.ts`,
|
||||
`app/api/rmm/loglift/upload/route.ts`,
|
||||
`app/api/admin/rmm/settings/discover-loglift/route.ts`,
|
||||
`docs/loglift-eventlog-pipeline-spec.md`.
|
||||
|
||||
**Modified:** `lib/services/datto-rmm-client.ts` (generalized
|
||||
`findOvershellComponent` → `findComponentByName`),
|
||||
`lib/services/rmm/settings.ts` (LogLift discover/resolve),
|
||||
`lib/services/rmm/persistence.ts` (transport + run_id + new
|
||||
`findExecutionByRunId`, `createOutOfBandUploadExecution`,
|
||||
`markExecutionFromB2Upload`), `lib/services/rmm/executor.ts` (b2_upload
|
||||
fork), `lib/services/rmm/worker.ts` (skip b2_upload poll),
|
||||
`lib/services/rmm/scripts/index.ts` (register), `…/scripts/types.ts`
|
||||
(transport field), `…/scripts/registry.test.ts` (8-script expectation +
|
||||
b2_upload body skip), `lib/services/analyzer/asset-audit/prompt.ts`
|
||||
(LIVE_EVIDENCE_NOTE), `app/admin/rmm-overshell/page.tsx`
|
||||
(LogLift block + button), `middleware.ts` (`/api/rmm/loglift` public).
|
||||
|
||||
### Refusals + guards
|
||||
|
||||
1. Object-key regex (`^[A-Za-z0-9_-]+/[A-Za-z0-9_.-]+/eventlogs_[0-9_]+\.json\.gz$`).
|
||||
2. B2 25MB download cap.
|
||||
3. Decompress 100MB cap (gzip ISIZE pre-check + post-inflate re-check).
|
||||
4. `redact()` on slim payload before persistence.
|
||||
5. Auto-audit only on single-match Configurations — multiple matches
|
||||
logged + skipped.
|
||||
6. Webhook secret stripped from persisted `variables` column.
|
||||
|
||||
### Status after Phase 4.3
|
||||
|
||||
| Phase | Tests | tsc | Notes |
|
||||
|---|---|---|---|
|
||||
| 4.3 | TBD (target ~182) | TBD | LogLift pipeline (078), B2 SigV4, b2_upload transport, slim + auto-audit |
|
||||
|
|
|
|||
|
|
@ -485,3 +485,515 @@ either caching the current hash on the tickets row (sync change) or
|
|||
computing it on read for the visible page (slow). The date heuristic
|
||||
gets ~95% of the value at zero compute cost; revisit when there's real
|
||||
load signal.
|
||||
|
||||
---
|
||||
|
||||
## Link-aware bundles (Phase 3)
|
||||
|
||||
A "bundle" is an aggregate report launched from a single ticket page —
|
||||
typically a master/problem ticket that names other tickets in its
|
||||
description. Instead of forcing the user to analyze each constituent
|
||||
manually and then visit `/analyzer/reports/new`, the bundle endpoint
|
||||
fans out per-ticket analyses and chains them into an aggregate report
|
||||
automatically.
|
||||
|
||||
### How it works end-to-end
|
||||
|
||||
1. User loads `/analyzer/ticket/<ticket-number>`.
|
||||
2. `<RelatedTicketsPanel/>` calls `GET /api/analyzer/tickets/:tn/links`
|
||||
(cheap, no LLM) — regex extraction over the description and retained
|
||||
notes for `T\d{8}\.\d{4}` references, plus the structured
|
||||
`RELATED TICKETS:` block detector and `problem_ticket_id` resolution.
|
||||
The panel renders only when refs exist or the ticket looks like a
|
||||
problem ticket.
|
||||
3. (Optional) User flips the "AI-suggest more" Switch — this POSTs the
|
||||
same endpoint with `includeSuggested: true`, runs one Haiku pass over
|
||||
recent same-company tickets (±30 days, capped at 50 candidates), and
|
||||
returns up to 5 suggestions with one-line reasons.
|
||||
4. User clicks **"Analyze with N linked tickets"**. The panel POSTs to
|
||||
`/api/analyzer/tickets/:tn/analyze-bundle` with
|
||||
`linkedTicketNumbers: [...]`.
|
||||
5. The bundle endpoint:
|
||||
- Verifies every ticket exists locally (one SQL round-trip).
|
||||
- Per ticket: idempotency-checks via content hash. If a complete
|
||||
analysis exists, it's reused; otherwise a fresh `analyzer_jobs` row
|
||||
is queued.
|
||||
- Cost-guard runs against **new work only** plus the aggregate-reduce
|
||||
step. $5 confirmation threshold and $50 daily hard block are the
|
||||
same gates as standalone aggregate reports.
|
||||
- Inserts an `analyzer_aggregate_reports` row in `'pending_analyses'`
|
||||
state with `expected_ticket_numbers` populated (or straight to
|
||||
`'pending'` and immediately fires the runner if everything was
|
||||
already complete).
|
||||
6. Worker polls and runs each queued job. After each successful
|
||||
analysis, `chainTriggerForCompletedAnalysis()` looks up bundles
|
||||
waiting on that ticket, appends the new analysis_id, and (if the full
|
||||
set is now satisfied) flips status to `'pending'` and fires
|
||||
`runAggregateReport()`.
|
||||
7. Frontend polls `GET /api/analyzer/aggregate-reports/:id` every 3s and
|
||||
navigates to `/analyzer/reports/:id` on completion.
|
||||
|
||||
### Status state machine
|
||||
|
||||
```
|
||||
pending_analyses ──── all expected analyses complete ────► pending
|
||||
│
|
||||
▼
|
||||
running
|
||||
│
|
||||
▼
|
||||
complete | failed
|
||||
```
|
||||
|
||||
`'pending_analyses'` is the new state Phase 3 introduces.
|
||||
Manual-multi-select reports created via `/analyzer/reports/new` skip it
|
||||
and start at `'pending'` (their analyses must already be complete to
|
||||
even submit).
|
||||
|
||||
### Inspecting a bundle
|
||||
|
||||
```sql
|
||||
SELECT id, status, ticket_count,
|
||||
array_length(expected_ticket_numbers, 1) AS expected,
|
||||
array_length(analysis_ids, 1) AS collected,
|
||||
triggered_by_ticket_number,
|
||||
generated_at
|
||||
FROM analyzer_aggregate_reports
|
||||
WHERE expected_ticket_numbers IS NOT NULL
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
Find which expected tickets a stuck `pending_analyses` bundle is still
|
||||
waiting on:
|
||||
|
||||
```sql
|
||||
WITH r AS (
|
||||
SELECT id, expected_ticket_numbers, analysis_ids
|
||||
FROM analyzer_aggregate_reports
|
||||
WHERE id = '<report-id>'
|
||||
)
|
||||
SELECT etn.ticket_number,
|
||||
(SELECT bool_or(aa.id = ANY(r.analysis_ids))
|
||||
FROM analyzer_analyses aa
|
||||
WHERE aa.ticket_number = etn.ticket_number
|
||||
AND aa.status = 'complete') AS has_collected_analysis
|
||||
FROM r,
|
||||
LATERAL UNNEST(r.expected_ticket_numbers) AS etn(ticket_number);
|
||||
```
|
||||
|
||||
The `false` rows are the tickets we're still waiting on. Cross-reference
|
||||
with `analyzer_jobs` filtered by those ticket numbers to see whether the
|
||||
job is queued, in-flight, or failed.
|
||||
|
||||
### Cost shape
|
||||
|
||||
For a typical 4-ticket problem bundle on fresh tickets:
|
||||
|
||||
| Step | Model | Approx cost |
|
||||
|---|---|---|
|
||||
| Link discovery (explicit) | none | ~free |
|
||||
| AI-suggested arm (if toggled) | Haiku | ~$0.005 |
|
||||
| Per-ticket pipeline × 4 | Haiku → Sonnet (+ optional Opus) | $0.20 – $1.20 |
|
||||
| Aggregate reduce | Opus | ~$0.50 |
|
||||
| **Total** | | **~$1 – $2** |
|
||||
|
||||
The bundle endpoint's per-ticket estimate is a flat $0.15 (pessimistic
|
||||
Sonnet) used purely for the cost guard. Real spend is captured per row
|
||||
on `analyzer_analyses.estimated_cost_usd` once each pipeline run
|
||||
completes.
|
||||
|
||||
### When the panel doesn't render
|
||||
|
||||
The panel is intentionally invisible on tickets that aren't candidates
|
||||
for bundling:
|
||||
|
||||
- No `T<YYYYMMDD>.<####>` references found in description or retained
|
||||
notes
|
||||
- `tickets.problem_ticket_id` is null
|
||||
- Title contains neither "master problem ticket" nor "problem ticket"
|
||||
|
||||
If a user expects to see the panel and doesn't, the most common reason
|
||||
is that the referenced tickets aren't in our local mirror yet (sync
|
||||
gap) — `discoverExplicitLinks` filters refs against
|
||||
`tickets.ticket_number` to keep ghost links out of the UI. Run the
|
||||
ticket sync and reload.
|
||||
|
||||
### Suggested-arm limits
|
||||
|
||||
The Haiku call drops any suggestion whose ticket_number isn't in the
|
||||
candidate list it was given (hallucination guard). Suggestions are
|
||||
capped at 5 and never auto-included — the user has to tick the
|
||||
checkbox. If the LLM call throws, the failure is logged and the panel
|
||||
still shows the explicit refs (the suggestion arm is opportunistic, not
|
||||
load-bearing).
|
||||
|
||||
---
|
||||
|
||||
## IT Glue asset audits (Phase 4)
|
||||
|
||||
The asset-audit pipeline analyzes one IT Glue Application record at a time
|
||||
against ticket history + IT Glue's own field schema, surfaces documentation
|
||||
gaps and "promote-from-Notes" suggestions, and lets admins push approved
|
||||
changes back to IT Glue. Every change is recorded in three audit layers
|
||||
(see Phase 4 build notes).
|
||||
|
||||
Permissions:
|
||||
|
||||
- Read audit / run audit → any authenticated user (cheap, ~$0.01).
|
||||
- Apply or Revert → `requirePermission('itglue', 'write')` — admin or
|
||||
super-admin only.
|
||||
- `/admin/itglue-writes` → admin-only.
|
||||
|
||||
### Inspecting audits
|
||||
|
||||
```sql
|
||||
-- Latest audit per asset, lowest scoring first
|
||||
SELECT a.asset_id,
|
||||
fa.name AS application_name,
|
||||
fa.organization_name,
|
||||
a.overall_score,
|
||||
a.ticket_count,
|
||||
jsonb_array_length(a.field_gaps) AS gap_count,
|
||||
jsonb_array_length(a.notes_promotions) AS promo_count,
|
||||
a.provider, a.model_used,
|
||||
a.estimated_cost_usd,
|
||||
a.generated_at
|
||||
FROM itglue_asset_audits a
|
||||
JOIN itg_flexible_assets fa ON fa.id = a.asset_id::bigint
|
||||
WHERE a.status = 'complete'
|
||||
ORDER BY a.asset_id, a.generated_at DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Failed audits (forensics)
|
||||
SELECT id, asset_id, generated_at, provider, model_used,
|
||||
LEFT(error_message, 200) AS error
|
||||
FROM itglue_asset_audits
|
||||
WHERE status = 'failed'
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
### Inspecting writes
|
||||
|
||||
```sql
|
||||
-- All committed writes in the last 7 days, with provenance
|
||||
SELECT w.performed_at,
|
||||
w.field_name,
|
||||
w.before_value, w.after_value,
|
||||
w.performed_by_user_id,
|
||||
w.audit_id,
|
||||
fa.name AS application_name,
|
||||
fa.organization_name
|
||||
FROM itglue_writes w
|
||||
JOIN itg_flexible_assets fa ON fa.id = w.asset_id::bigint
|
||||
WHERE w.status = 'committed'
|
||||
AND w.performed_at >= NOW() - INTERVAL '7 days'
|
||||
ORDER BY w.performed_at DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Failed writes (admin should investigate)
|
||||
SELECT id, asset_id, field_name, error_message, performed_at
|
||||
FROM itglue_writes
|
||||
WHERE status = 'failed'
|
||||
ORDER BY performed_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
### How a revert works
|
||||
|
||||
Reverts produce a brand-new `itglue_writes` row whose `before_value` /
|
||||
`after_value` are swapped from the original, and mark the original row
|
||||
`status='reverted'`. The chain is always traceable:
|
||||
|
||||
```sql
|
||||
-- Find every write + its revert (if any) for one asset
|
||||
SELECT id, field_name, status,
|
||||
before_value, after_value,
|
||||
performed_at,
|
||||
audit_id,
|
||||
source_evidence ->> 'reverts_write_id' AS reverts_id
|
||||
FROM itglue_writes
|
||||
WHERE asset_id = '17096940'
|
||||
ORDER BY performed_at;
|
||||
```
|
||||
|
||||
### Cost-guard
|
||||
|
||||
Audit runs charge ~$0.01 (DeepSeek) or ~$0.10 (Claude) per call. Same
|
||||
guard primitives as ticket analyses:
|
||||
|
||||
```sql
|
||||
SELECT created_at, user_id, action, estimated_cost,
|
||||
decision, decision_reason
|
||||
FROM analyzer_cost_audit
|
||||
WHERE action = 'itglue_audit'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50;
|
||||
```
|
||||
|
||||
### When the data-builder returns no ticket evidence
|
||||
|
||||
`buildAssetAuditContext` joins via `companies.company_name = itg_organizations.name`
|
||||
(case-insensitive) to map IT Glue org → Autotask company id, then matches
|
||||
ticket fingerprints whose summary or fingerprint mention the asset name. Two
|
||||
common reasons for an empty `ticket_evidence` array:
|
||||
|
||||
1. The IT Glue org's `name` doesn't match any `companies.company_name`
|
||||
exactly — fix by aligning the names, or extend the join.
|
||||
2. The asset's `name` is too generic ("Office", "Email") and the ILIKE match
|
||||
pulls nothing distinctive — accept the audit will rely on field schema +
|
||||
peer exemplars only, no per-ticket grounding.
|
||||
|
||||
### Generic audit_log surfaces every change too
|
||||
|
||||
`audit.log()` is called after every successful Apply/Revert with action
|
||||
`itglue.write` or `itglue.revert`. `/admin/audit-log` shows it. So admins
|
||||
have two views: domain-specific at `/admin/itglue-writes` (with diffs +
|
||||
revert button), and the generic admin feed at `/admin/audit-log`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.1: ticket-first capture, Configurations, xrefs
|
||||
|
||||
### Inspecting the cross-reference index
|
||||
|
||||
```sql
|
||||
-- Every ticket↔asset linkage in the last 7 days
|
||||
SELECT created_at,
|
||||
ticket_number,
|
||||
relationship,
|
||||
asset_type,
|
||||
asset_id,
|
||||
source,
|
||||
confidence,
|
||||
details
|
||||
FROM itglue_ticket_xrefs
|
||||
WHERE created_at >= NOW() - INTERVAL '7 days'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Most-referenced IT Glue assets across all tickets (heat-map for which
|
||||
-- docs the analyzer leans on most)
|
||||
SELECT asset_type,
|
||||
asset_id,
|
||||
COUNT(*) AS reference_count,
|
||||
COUNT(DISTINCT ticket_number) AS distinct_tickets,
|
||||
MAX(created_at) AS last_referenced
|
||||
FROM itglue_ticket_xrefs
|
||||
WHERE relationship = 'referenced'
|
||||
GROUP BY asset_type, asset_id
|
||||
ORDER BY reference_count DESC
|
||||
LIMIT 30;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Assets that have been UPDATED via ticket-driven audits but never
|
||||
-- REFERENCED — possibly newly-introduced docs that haven't proven their
|
||||
-- worth yet, or docs the analyzer's retrieval stage isn't finding.
|
||||
SELECT u.asset_type, u.asset_id, COUNT(*) AS update_count
|
||||
FROM itglue_ticket_xrefs u
|
||||
WHERE u.relationship = 'updated'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM itglue_ticket_xrefs r
|
||||
WHERE r.asset_type = u.asset_type
|
||||
AND r.asset_id = u.asset_id
|
||||
AND r.relationship = 'referenced'
|
||||
)
|
||||
GROUP BY u.asset_type, u.asset_id
|
||||
ORDER BY update_count DESC;
|
||||
```
|
||||
|
||||
### Ticket-scoped audits
|
||||
|
||||
```sql
|
||||
-- All audits triggered from a specific ticket
|
||||
SELECT a.id, a.asset_type, a.asset_id,
|
||||
fa.name AS asset_name,
|
||||
a.overall_score,
|
||||
jsonb_array_length(a.field_gaps) AS gap_count,
|
||||
a.provider, a.estimated_cost_usd,
|
||||
a.generated_at
|
||||
FROM itglue_asset_audits a
|
||||
LEFT JOIN itg_flexible_assets fa
|
||||
ON fa.id = a.asset_id::bigint AND a.asset_type = 'flexible_asset'
|
||||
WHERE a.triggered_by_ticket_number = 'T20260502.0033'
|
||||
ORDER BY a.generated_at DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Every write a given ticket drove (denormalized for one-query lookup)
|
||||
SELECT w.performed_at, w.asset_type, w.asset_id, w.field_name,
|
||||
w.before_value, w.after_value, w.status
|
||||
FROM itglue_writes w
|
||||
WHERE w.triggered_by_ticket_number = 'T20260502.0033'
|
||||
ORDER BY w.performed_at;
|
||||
```
|
||||
|
||||
### Configuration audits
|
||||
|
||||
Same shape as Application audits but `asset_type='configuration'`:
|
||||
|
||||
```sql
|
||||
SELECT a.id, c.name, c.hostname, c.configuration_type_name,
|
||||
a.overall_score, jsonb_array_length(a.field_gaps) AS gap_count,
|
||||
a.provider, a.generated_at
|
||||
FROM itglue_asset_audits a
|
||||
JOIN itg_configurations c ON c.id = a.asset_id::bigint
|
||||
WHERE a.asset_type = 'configuration'
|
||||
AND a.status = 'complete'
|
||||
ORDER BY a.generated_at DESC
|
||||
LIMIT 30;
|
||||
```
|
||||
|
||||
### When asset-matcher returns nothing
|
||||
|
||||
`matchAssetsForAnalysis(analysisId)` joins `companies → itg_organizations`
|
||||
on `LOWER(name)`. If a ticket comes back with `flexibleAssets: []` and
|
||||
`configurations: []`, two common causes:
|
||||
|
||||
1. The IT Glue org name doesn't match the Autotask company name (case-
|
||||
insensitive exact). Fix by aligning the names in either system, or
|
||||
extend the join in `lib/services/analyzer/asset-audit/asset-matcher.ts`.
|
||||
2. The fingerprint's `applications_involved` / `device_classes` don't
|
||||
contain any term that substring-matches a real asset name. The audit
|
||||
panel will show "No matches" — the user can still manually navigate
|
||||
to the relevant asset's detail page and run an asset-first audit.
|
||||
|
||||
### Cost shape for ticket-first audits
|
||||
|
||||
Per-asset audit on a ticket-scoped run:
|
||||
|
||||
| Provider | Cost | Latency |
|
||||
|---|---|---|
|
||||
| Anthropic (Sonnet) | ~$0.05 | 30-60s |
|
||||
| OpenRouter (DeepSeek V4 Pro) | ~$0.005 | 60-180s |
|
||||
|
||||
A typical ticket with 1 Application match + 1 Configuration match audited
|
||||
on DeepSeek runs ~$0.01 and ~3 minutes total.
|
||||
|
||||
|
||||
## Phase 4.3: LogLift event-log evidence pipeline
|
||||
|
||||
### Required env vars
|
||||
|
||||
```
|
||||
B2_KEY_ID=… # Backblaze B2 application key id
|
||||
B2_APP_KEY=… # Backblaze B2 application key secret
|
||||
B2_BUCKET=wulf-audits # default; matches existing n8n bucket
|
||||
B2_REGION=us-west-002 # default
|
||||
B2_ENDPOINT=s3.us-west-002.backblazeb2.com # default; no scheme
|
||||
OPENCLAW_API_KEY=… # webhook auth + collector variable
|
||||
BETTER_AUTH_URL=… # base URL the collector POSTs back to
|
||||
```
|
||||
|
||||
### One-time discovery
|
||||
|
||||
```bash
|
||||
# Sign in as admin → /admin/rmm-overshell → click "Re-discover LogLift"
|
||||
# Or via curl with an admin session cookie:
|
||||
curl -X POST "$BETTER_AUTH_URL/api/admin/rmm/settings/discover-loglift" \
|
||||
-H "Cookie: better-auth.session_token=…" -i
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
```sql
|
||||
SELECT loglift_component_uid, loglift_component_name, loglift_discovered_at
|
||||
FROM rmm_settings WHERE id = true;
|
||||
```
|
||||
|
||||
### Inspecting LogLift uploads
|
||||
|
||||
```sql
|
||||
-- Recent LogLift executions, with match status
|
||||
SELECT e.id, e.run_id, e.target_hostname, e.status,
|
||||
e.evidence_object_key,
|
||||
e.parsed_evidence -> 'event_count_total' AS event_count,
|
||||
e.parsed_evidence -> 'webhook_summary' -> 'criticalEvents' AS critical_events,
|
||||
e.queued_at, e.completed_at
|
||||
FROM rmm_executions e
|
||||
WHERE e.transport = 'b2_upload'
|
||||
ORDER BY e.queued_at DESC
|
||||
LIMIT 25;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- LogLift uploads that didn't match a Configuration (review for hostname
|
||||
-- typos or unmapped Configurations)
|
||||
SELECT e.run_id, e.target_hostname, e.target_company_id,
|
||||
e.queued_at
|
||||
FROM rmm_executions e
|
||||
WHERE e.transport = 'b2_upload'
|
||||
AND e.status = 'complete'
|
||||
AND e.asset_id IS NULL
|
||||
ORDER BY e.queued_at DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- LogLift uploads that auto-fired an audit
|
||||
SELECT a.id AS audit_id, a.asset_id AS configuration_id,
|
||||
a.overall_score, a.generated_at,
|
||||
l.action, l.created_at AS triggered_at
|
||||
FROM audit_log l
|
||||
JOIN itglue_asset_audits a ON a.id::text = (l.details ->> 'audit_id')
|
||||
WHERE l.action = 'rmm.loglift.audit_triggered'
|
||||
ORDER BY l.created_at DESC LIMIT 25;
|
||||
```
|
||||
|
||||
### Manual replay of a B2 object
|
||||
|
||||
If a webhook came in but Pulse was down, you can replay by re-POSTing
|
||||
the original webhook payload (the collector keeps the metadata; if not,
|
||||
build it from the object key + B2's metadata API). The receiver is
|
||||
idempotent on `run_id` — a duplicate replay with the same `run_id` will
|
||||
update the existing row rather than insert a duplicate.
|
||||
|
||||
### Inspecting a stuck b2_upload row
|
||||
|
||||
```sql
|
||||
SELECT id, run_id, target_hostname, status, started_at, timeout_at,
|
||||
evidence_object_key
|
||||
FROM rmm_executions
|
||||
WHERE transport = 'b2_upload'
|
||||
AND status = 'running'
|
||||
ORDER BY started_at;
|
||||
```
|
||||
|
||||
After 5 minutes, the Overshell worker's timeout sweep flips stuck rows
|
||||
to `timeout`. If the upload arrives later, the receiver still updates
|
||||
the same row by `run_id` (the unique index makes this safe).
|
||||
|
||||
### Force a one-off audit replay from existing evidence
|
||||
|
||||
If the auto-audit failed at upload time (e.g. LLM timeout) and the
|
||||
evidence is already in `rmm_executions`, you can re-fire the audit:
|
||||
|
||||
```sql
|
||||
-- Find the configuration_id from the most recent LogLift upload
|
||||
SELECT asset_id::text AS configuration_id
|
||||
FROM rmm_executions
|
||||
WHERE transport = 'b2_upload'
|
||||
AND target_hostname ILIKE 'YNGHYNWNP01'
|
||||
ORDER BY completed_at DESC LIMIT 1;
|
||||
```
|
||||
|
||||
Then trigger via the existing audit endpoint:
|
||||
|
||||
```bash
|
||||
curl -X POST "$BETTER_AUTH_URL/api/itglue/asset-audit/run" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Cookie: better-auth.session_token=…" \
|
||||
-d '{"assetType":"configuration","assetId":"<config_id>","provider":"anthropic"}'
|
||||
```
|
||||
|
||||
### Why no Telegram summary?
|
||||
|
||||
Out of scope for v1 — that flow was a notification, not a data path.
|
||||
Audits show up on the Configuration page automatically. If you want a
|
||||
Slack/Teams ping when an auto-audit completes, hook it off the
|
||||
`rmm.loglift.audit_triggered` audit_log entry.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ export const statement = {
|
|||
|
||||
// Settings management
|
||||
settings: ["read", "update"],
|
||||
|
||||
// IT Glue documentation read/write (Phase 4 — asset audit + write-back)
|
||||
itglue: ["read", "write"],
|
||||
|
||||
// Datto RMM Overshell evidence (Phase 4.2 — read jobs / execute scripts)
|
||||
rmm: ["read", "execute"],
|
||||
} as const;
|
||||
|
||||
// Create access control instance
|
||||
|
|
@ -36,6 +42,8 @@ export const superAdminRole = ac.newRole({
|
|||
roles: ["create", "read", "update", "delete"],
|
||||
auditLog: ["read"],
|
||||
settings: ["read", "update"],
|
||||
itglue: ["read", "write"],
|
||||
rmm: ["read", "execute"],
|
||||
});
|
||||
|
||||
// Admin role - access to admin panel and user management, but not role management
|
||||
|
|
@ -47,6 +55,8 @@ export const adminRole = ac.newRole({
|
|||
roles: ["read"],
|
||||
auditLog: ["read"],
|
||||
settings: ["read"],
|
||||
itglue: ["read", "write"],
|
||||
rmm: ["read", "execute"],
|
||||
});
|
||||
|
||||
// User role - basic access
|
||||
|
|
@ -58,6 +68,8 @@ export const userRole = ac.newRole({
|
|||
roles: [],
|
||||
auditLog: [],
|
||||
settings: [],
|
||||
itglue: ["read"],
|
||||
rmm: ["read"],
|
||||
});
|
||||
|
||||
// Helper function to check if a user has a specific permission
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ interface AggregateReportRow {
|
|||
itglue_context_included: boolean | null;
|
||||
status: AggregateReportStatus;
|
||||
error_message: string | null;
|
||||
expected_ticket_numbers: string[] | null;
|
||||
triggered_by_ticket_number: string | null;
|
||||
}
|
||||
|
||||
export interface AggregateReportSummary {
|
||||
|
|
@ -76,6 +78,9 @@ export interface AggregateReportSummary {
|
|||
totalOutputTokens: number | null;
|
||||
estimatedCostUsd: number | null;
|
||||
modelUsed: string | null;
|
||||
// Bundle (Phase 3)
|
||||
expectedTicketNumbers: string[] | null;
|
||||
triggeredByTicketNumber: string | null;
|
||||
}
|
||||
|
||||
function rowToSummary(r: AggregateReportRow): AggregateReportSummary {
|
||||
|
|
@ -107,6 +112,8 @@ function rowToSummary(r: AggregateReportRow): AggregateReportSummary {
|
|||
totalOutputTokens: r.total_output_tokens,
|
||||
estimatedCostUsd: r.estimated_cost_usd === null ? null : Number(r.estimated_cost_usd),
|
||||
modelUsed: r.model_used,
|
||||
expectedTicketNumbers: r.expected_ticket_numbers,
|
||||
triggeredByTicketNumber: r.triggered_by_ticket_number,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +130,8 @@ const REPORT_SELECT = `
|
|||
total_input_tokens, total_output_tokens,
|
||||
estimated_cost_usd::text AS estimated_cost_usd,
|
||||
model_used, itglue_context_included,
|
||||
status, error_message
|
||||
status, error_message,
|
||||
expected_ticket_numbers, triggered_by_ticket_number
|
||||
`;
|
||||
|
||||
export interface CreateAggregateReportInput {
|
||||
|
|
@ -133,16 +141,29 @@ export interface CreateAggregateReportInput {
|
|||
ticketCount: number;
|
||||
includeItglueContext: boolean;
|
||||
reportTitle: string | null;
|
||||
/**
|
||||
* Bundle mode (Phase 3): when set, the report is created in the
|
||||
* 'pending_analyses' state and the worker will transition it to 'pending'
|
||||
* once every expected ticket has a complete analysis. Leave undefined for
|
||||
* the legacy manual-multi-select flow.
|
||||
*/
|
||||
expectedTicketNumbers?: string[];
|
||||
triggeredByTicketNumber?: string;
|
||||
}
|
||||
|
||||
export async function createAggregateReport(
|
||||
input: CreateAggregateReportInput
|
||||
): Promise<{ id: string }> {
|
||||
const isBundle =
|
||||
Array.isArray(input.expectedTicketNumbers) &&
|
||||
input.expectedTicketNumbers.length > 0;
|
||||
const initialStatus = isBundle ? 'pending_analyses' : 'pending';
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`INSERT INTO analyzer_aggregate_reports
|
||||
(generated_by_user_id, filter_criteria, analysis_ids,
|
||||
ticket_count, include_itglue_context, report_title, status)
|
||||
VALUES ($1, $2::jsonb, $3::uuid[], $4, $5, $6, 'pending')
|
||||
ticket_count, include_itglue_context, report_title, status,
|
||||
expected_ticket_numbers, triggered_by_ticket_number)
|
||||
VALUES ($1, $2::jsonb, $3::uuid[], $4, $5, $6, $7, $8::text[], $9)
|
||||
RETURNING id::text AS id`,
|
||||
[
|
||||
input.generatedByUserId,
|
||||
|
|
@ -151,11 +172,96 @@ export async function createAggregateReport(
|
|||
input.ticketCount,
|
||||
input.includeItglueContext,
|
||||
input.reportTitle,
|
||||
initialStatus,
|
||||
input.expectedTicketNumbers ?? null,
|
||||
input.triggeredByTicketNumber ?? null,
|
||||
]
|
||||
);
|
||||
return { id: res.rows[0].id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker chain-trigger.
|
||||
*
|
||||
* Called after a single-ticket analysis completes successfully. For each
|
||||
* pending_analyses report waiting on this ticket: append the analysis_id (if
|
||||
* not already present), and if all expected tickets now have a complete
|
||||
* analysis, transition status='pending' and fire runAggregateReport.
|
||||
*
|
||||
* Idempotent: safe to invoke multiple times for the same analysis (the
|
||||
* deduplicating UPDATE skips no-ops; the status transition is gated on the
|
||||
* full set being present so the second call is a no-op).
|
||||
*/
|
||||
export async function chainTriggerForCompletedAnalysis(
|
||||
ticketNumber: string,
|
||||
analysisId: string
|
||||
): Promise<{ readyReportIds: string[]; touchedReportIds: string[] }> {
|
||||
const res = await postgresClient.query<{
|
||||
id: string;
|
||||
expected_ticket_numbers: string[];
|
||||
analysis_ids: string[];
|
||||
}>(
|
||||
`SELECT id::text AS id,
|
||||
expected_ticket_numbers,
|
||||
analysis_ids::text[] AS analysis_ids
|
||||
FROM analyzer_aggregate_reports
|
||||
WHERE status = 'pending_analyses'
|
||||
AND expected_ticket_numbers @> ARRAY[$1]::text[]`,
|
||||
[ticketNumber]
|
||||
);
|
||||
|
||||
const touched: string[] = [];
|
||||
const ready: string[] = [];
|
||||
|
||||
for (const r of res.rows) {
|
||||
if (!r.analysis_ids.includes(analysisId)) {
|
||||
await postgresClient.query(
|
||||
`UPDATE analyzer_aggregate_reports
|
||||
SET analysis_ids = analysis_ids || $2::uuid
|
||||
WHERE id = $1
|
||||
AND NOT (analysis_ids @> ARRAY[$2::uuid])`,
|
||||
[r.id, analysisId]
|
||||
);
|
||||
touched.push(r.id);
|
||||
}
|
||||
|
||||
// Re-check whether the full set is now satisfied: every expected ticket
|
||||
// must have at least one complete analysis whose id is in analysis_ids.
|
||||
// Reads the latest analysis_ids (the UPDATE above isn't reflected in the
|
||||
// copy we loaded earlier).
|
||||
const ready_check = await postgresClient.query<{ satisfied: boolean }>(
|
||||
`SELECT (
|
||||
(SELECT COUNT(DISTINCT aa.ticket_number)
|
||||
FROM analyzer_analyses aa
|
||||
JOIN analyzer_aggregate_reports r ON r.id = $1
|
||||
WHERE aa.id = ANY(r.analysis_ids)
|
||||
AND aa.status = 'complete'
|
||||
AND aa.ticket_number = ANY(r.expected_ticket_numbers))
|
||||
=
|
||||
(SELECT array_length(expected_ticket_numbers, 1)
|
||||
FROM analyzer_aggregate_reports WHERE id = $1)
|
||||
) AS satisfied`,
|
||||
[r.id]
|
||||
);
|
||||
|
||||
if (ready_check.rows[0]?.satisfied) {
|
||||
const transition = await postgresClient.query<{ id: string }>(
|
||||
`UPDATE analyzer_aggregate_reports
|
||||
SET status = 'pending'
|
||||
WHERE id = $1
|
||||
AND status = 'pending_analyses'
|
||||
RETURNING id::text AS id`,
|
||||
[r.id]
|
||||
);
|
||||
if (transition.rowCount && transition.rowCount > 0) {
|
||||
ready.push(r.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { readyReportIds: ready, touchedReportIds: touched };
|
||||
}
|
||||
|
||||
export async function getAggregateReport(
|
||||
id: string
|
||||
): Promise<AggregateReportSummary | null> {
|
||||
|
|
@ -402,23 +508,32 @@ export async function runAggregateReport(reportId: string): Promise<void> {
|
|||
}
|
||||
|
||||
// ── Step 3: reduce LLM call ──
|
||||
// Honour the provider the bundle was created with. Manual aggregate reports
|
||||
// (no provider in filter_criteria) default to anthropic.
|
||||
const reduceProvider: 'anthropic' | 'openrouter' =
|
||||
(report.filterCriteria as { provider?: string } | null)?.provider === 'openrouter'
|
||||
? 'openrouter'
|
||||
: 'anthropic';
|
||||
const reduceStart = new Date();
|
||||
let reduceResult;
|
||||
try {
|
||||
reduceResult = await runAggregateReduceStage({
|
||||
distributions: {
|
||||
category_distribution: categories,
|
||||
client_distribution: clients,
|
||||
resolution_path_distribution: resolutionPaths,
|
||||
root_cause_distribution: rootCauses,
|
||||
date_range_actual: dateRange,
|
||||
reduceResult = await runAggregateReduceStage(
|
||||
{
|
||||
distributions: {
|
||||
category_distribution: categories,
|
||||
client_distribution: clients,
|
||||
resolution_path_distribution: resolutionPaths,
|
||||
root_cause_distribution: rootCauses,
|
||||
date_range_actual: dateRange,
|
||||
},
|
||||
fingerprints: fingerprints.map((f) => ({
|
||||
ticket_number: f.ticket_number,
|
||||
fingerprint: f.fingerprint,
|
||||
})),
|
||||
itglue_doc_titles: itglueDocTitles,
|
||||
},
|
||||
fingerprints: fingerprints.map((f) => ({
|
||||
ticket_number: f.ticket_number,
|
||||
fingerprint: f.fingerprint,
|
||||
})),
|
||||
itglue_doc_titles: itglueDocTitles,
|
||||
});
|
||||
{ provider: reduceProvider }
|
||||
);
|
||||
} catch (err) {
|
||||
const reduceEnd = new Date();
|
||||
stageRecords.push({
|
||||
|
|
|
|||
264
lib/services/analyzer/asset-audit/asset-matcher.ts
Normal file
264
lib/services/analyzer/asset-audit/asset-matcher.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* Match an analyzer_analyses row to candidate IT Glue assets to audit.
|
||||
*
|
||||
* Inputs from the analysis fingerprint:
|
||||
* - applications_involved → match against itg_flexible_assets.name (Application type)
|
||||
* - device_classes / vendors_involved + ticket-mentioned device names →
|
||||
* match against itg_configurations.name + hostname
|
||||
*
|
||||
* Same-client only (looked up via companies → itg_organizations name match).
|
||||
* Score: exact match (3) > word-boundary match (2) > substring (1).
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const APPLICATION_TYPE_ID = 3790;
|
||||
const PER_KIND_LIMIT = 5;
|
||||
|
||||
export interface MatchedFlexibleAsset {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type_name: string | null;
|
||||
score: number;
|
||||
matched_term: string;
|
||||
}
|
||||
|
||||
export interface MatchedConfiguration {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
type_name: string | null;
|
||||
score: number;
|
||||
matched_term: string;
|
||||
}
|
||||
|
||||
export interface AssetMatchResult {
|
||||
ticketNumber: string;
|
||||
organizationId: string | null;
|
||||
organizationName: string | null;
|
||||
flexibleAssets: MatchedFlexibleAsset[];
|
||||
configurations: MatchedConfiguration[];
|
||||
}
|
||||
|
||||
interface AnalysisRow {
|
||||
ticket_number: string;
|
||||
fingerprint: Record<string, unknown> | null;
|
||||
company_id: string | null;
|
||||
itglue_org_id: string | null;
|
||||
itglue_org_name: string | null;
|
||||
}
|
||||
|
||||
function normalizeTerm(s: string): string {
|
||||
return s.toLowerCase().trim();
|
||||
}
|
||||
|
||||
function scoreMatch(haystack: string | null, needle: string): number {
|
||||
if (!haystack) return 0;
|
||||
const h = haystack.toLowerCase();
|
||||
const n = needle.toLowerCase();
|
||||
if (h === n) return 3;
|
||||
// Word boundary: surrounded by non-alphanum or start/end.
|
||||
const re = new RegExp(`(^|[^a-z0-9])${n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`);
|
||||
if (re.test(h)) return 2;
|
||||
if (h.includes(n)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function dedupeByKey<T extends { id: string }>(items: T[]): T[] {
|
||||
const seen = new Map<string, T>();
|
||||
for (const item of items) {
|
||||
const existing = seen.get(item.id);
|
||||
if (
|
||||
!existing ||
|
||||
('score' in item &&
|
||||
'score' in existing &&
|
||||
(item as unknown as { score: number }).score >
|
||||
(existing as unknown as { score: number }).score)
|
||||
) {
|
||||
seen.set(item.id, item);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the analysis + ticket + IT Glue org mapping in one round-trip.
|
||||
* Returns null if the analysis isn't complete or the company can't be
|
||||
* mapped to an IT Glue organization.
|
||||
*/
|
||||
async function loadAnalysisContext(analysisId: string): Promise<AnalysisRow | null> {
|
||||
const res = await postgresClient.query<AnalysisRow>(
|
||||
`SELECT aa.ticket_number,
|
||||
aa.aggregate_fingerprint AS fingerprint,
|
||||
t.company_id::text AS company_id,
|
||||
o.id::text AS itglue_org_id,
|
||||
o.name AS itglue_org_name
|
||||
FROM analyzer_analyses aa
|
||||
JOIN tickets t ON t.ticket_number = aa.ticket_number
|
||||
LEFT JOIN companies c ON c.id = t.company_id
|
||||
LEFT JOIN itg_organizations o ON LOWER(o.name) = LOWER(c.company_name)
|
||||
WHERE aa.id = $1
|
||||
AND aa.status = 'complete'
|
||||
LIMIT 1`,
|
||||
[analysisId]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
interface FlexAssetCandidate {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type_name: string | null;
|
||||
}
|
||||
|
||||
interface ConfigCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
type_name: string | null;
|
||||
}
|
||||
|
||||
async function loadFlexAssetCandidates(orgId: string): Promise<FlexAssetCandidate[]> {
|
||||
const res = await postgresClient.query<FlexAssetCandidate>(
|
||||
`SELECT id::text AS id,
|
||||
name,
|
||||
flexible_asset_type_name AS type_name
|
||||
FROM itg_flexible_assets
|
||||
WHERE organization_id = $1
|
||||
AND flexible_asset_type_id = $2
|
||||
AND COALESCE(archived, false) = false`,
|
||||
[orgId, APPLICATION_TYPE_ID]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function loadConfigurationCandidates(
|
||||
orgId: string
|
||||
): Promise<ConfigCandidate[]> {
|
||||
const res = await postgresClient.query<ConfigCandidate>(
|
||||
`SELECT id::text AS id,
|
||||
name, hostname,
|
||||
configuration_type_name AS type_name
|
||||
FROM itg_configurations
|
||||
WHERE organization_id = $1`,
|
||||
[orgId]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
function fingerprintTerms(fp: Record<string, unknown> | null): string[] {
|
||||
if (!fp) return [];
|
||||
const out = new Set<string>();
|
||||
for (const key of [
|
||||
'applications_involved',
|
||||
'device_classes',
|
||||
'vendors_involved',
|
||||
]) {
|
||||
const arr = fp[key];
|
||||
if (Array.isArray(arr)) {
|
||||
for (const v of arr) {
|
||||
if (typeof v === 'string' && v.trim().length > 0) {
|
||||
out.add(normalizeTerm(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
export async function matchAssetsForAnalysis(
|
||||
analysisId: string
|
||||
): Promise<AssetMatchResult | null> {
|
||||
const ctx = await loadAnalysisContext(analysisId);
|
||||
if (!ctx) return null;
|
||||
if (!ctx.itglue_org_id) {
|
||||
return {
|
||||
ticketNumber: ctx.ticket_number,
|
||||
organizationId: null,
|
||||
organizationName: null,
|
||||
flexibleAssets: [],
|
||||
configurations: [],
|
||||
};
|
||||
}
|
||||
|
||||
const terms = fingerprintTerms(ctx.fingerprint);
|
||||
if (terms.length === 0) {
|
||||
return {
|
||||
ticketNumber: ctx.ticket_number,
|
||||
organizationId: ctx.itglue_org_id,
|
||||
organizationName: ctx.itglue_org_name,
|
||||
flexibleAssets: [],
|
||||
configurations: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [flexCandidates, configCandidates] = await Promise.all([
|
||||
loadFlexAssetCandidates(ctx.itglue_org_id),
|
||||
loadConfigurationCandidates(ctx.itglue_org_id),
|
||||
]);
|
||||
|
||||
const flexHits: MatchedFlexibleAsset[] = [];
|
||||
for (const c of flexCandidates) {
|
||||
let bestScore = 0;
|
||||
let bestTerm = '';
|
||||
for (const term of terms) {
|
||||
const s = scoreMatch(c.name, term);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestTerm = term;
|
||||
}
|
||||
}
|
||||
if (bestScore > 0) {
|
||||
flexHits.push({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type_name: c.type_name,
|
||||
score: bestScore,
|
||||
matched_term: bestTerm,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const configHits: MatchedConfiguration[] = [];
|
||||
for (const c of configCandidates) {
|
||||
let bestScore = 0;
|
||||
let bestTerm = '';
|
||||
for (const term of terms) {
|
||||
const sName = scoreMatch(c.name, term);
|
||||
const sHost = scoreMatch(c.hostname, term);
|
||||
const s = Math.max(sName, sHost);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestTerm = term;
|
||||
}
|
||||
}
|
||||
if (bestScore > 0) {
|
||||
configHits.push({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
hostname: c.hostname,
|
||||
type_name: c.type_name,
|
||||
score: bestScore,
|
||||
matched_term: bestTerm,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sortedFlex = dedupeByKey(flexHits)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, PER_KIND_LIMIT);
|
||||
const sortedConfig = dedupeByKey(configHits)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, PER_KIND_LIMIT);
|
||||
|
||||
return {
|
||||
ticketNumber: ctx.ticket_number,
|
||||
organizationId: ctx.itglue_org_id,
|
||||
organizationName: ctx.itglue_org_name,
|
||||
flexibleAssets: sortedFlex,
|
||||
configurations: sortedConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export const _MATCHER_INTERNALS = { scoreMatch, fingerprintTerms };
|
||||
719
lib/services/analyzer/asset-audit/data-builder.ts
Normal file
719
lib/services/analyzer/asset-audit/data-builder.ts
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
/**
|
||||
* Data builder for the IT Glue asset audit pipeline.
|
||||
*
|
||||
* Pulls every input the LLM needs to evaluate one IT Glue record (Application
|
||||
* flexible asset OR Configuration) against ticket history and IT Glue's own
|
||||
* field schema:
|
||||
*
|
||||
* 1. The asset's current contents (redacted).
|
||||
* 2. The field schema with hints (IT Glue's per-field documentation, or
|
||||
* curated hints for Configurations which don't expose a *_fields table).
|
||||
* 3. Peer exemplars from the same client — well-filled assets of the same
|
||||
* type, redacted.
|
||||
* 4. Best-in-class exemplars across all clients of the same type.
|
||||
* 5. Per-field fill-rate stats (per-client + global).
|
||||
* 6. Recent ticket fingerprints whose applications/devices overlap the
|
||||
* asset, with summaries.
|
||||
*
|
||||
* Phase 4.1 additions:
|
||||
* - assetType dispatch: 'flexible_asset' | 'configuration'
|
||||
* - ticketScopeAnalysisId: when set, ticket evidence is the single source
|
||||
* analysis only, so the LLM looks for what *this ticket* taught us.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { redact } from '../itglue-redact';
|
||||
import {
|
||||
listLatestEvidenceForAsset,
|
||||
listLatestEvidenceForCompany,
|
||||
} from '@/lib/services/rmm/persistence';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AuditAssetType = 'flexible_asset' | 'configuration';
|
||||
|
||||
/**
|
||||
* Generic asset shape the prompt consumes. For flexible assets, `traits` is
|
||||
* the IT Glue traits blob. For configurations, `traits` is a synthesized
|
||||
* map of editable columns → current values, so the prompt stays
|
||||
* type-agnostic.
|
||||
*/
|
||||
export interface AuditAssetRow {
|
||||
id: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
type_id: string | null;
|
||||
type_name: string | null;
|
||||
name: string | null;
|
||||
traits: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AuditFieldRow {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string | null;
|
||||
hint: string | null;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface FillRateRow {
|
||||
field_name: string;
|
||||
fill_rate: number; // 0..1
|
||||
}
|
||||
|
||||
export interface TicketEvidenceRow {
|
||||
ticket_number: string;
|
||||
triggered_at: string;
|
||||
summary: string | null;
|
||||
fingerprint: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4.2: live evidence captured by an Overshell execution. The audit
|
||||
* pipeline injects this as a 7th LLM context arm.
|
||||
*/
|
||||
export interface RmmEvidenceRow {
|
||||
execution_id: string;
|
||||
script_id: string;
|
||||
target_type: 'site_anchor' | 'asset_self';
|
||||
target_hostname: string | null;
|
||||
captured_at: string;
|
||||
parsed: unknown;
|
||||
}
|
||||
|
||||
export interface AssetAuditContext {
|
||||
asset_type: AuditAssetType;
|
||||
asset: AuditAssetRow;
|
||||
type_id: number | null;
|
||||
type_name: string | null;
|
||||
fields: AuditFieldRow[];
|
||||
peer_same_client: AuditAssetRow[];
|
||||
peer_global: AuditAssetRow[];
|
||||
fill_rate_client: FillRateRow[];
|
||||
fill_rate_global: FillRateRow[];
|
||||
ticket_evidence: TicketEvidenceRow[];
|
||||
/** Set when the audit was launched from a single ticket's analysis. */
|
||||
ticket_scope: { analysis_id: string; ticket_number: string } | null;
|
||||
rmm_evidence: RmmEvidenceRow[];
|
||||
}
|
||||
|
||||
// ─── Tunables ─────────────────────────────────────────────────────────────
|
||||
|
||||
const PEER_SAME_CLIENT_LIMIT = 5;
|
||||
const PEER_GLOBAL_LIMIT = 3;
|
||||
const TICKET_EVIDENCE_LIMIT = 20;
|
||||
|
||||
// ─── Errors ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class AssetAuditNotFoundError extends Error {
|
||||
constructor(assetType: AuditAssetType, assetId: string | number) {
|
||||
super(`${assetType} ${assetId} not found in mirror`);
|
||||
this.name = 'AssetAuditNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function fillCount(traits: Record<string, unknown> | null | undefined): number {
|
||||
if (!traits) return 0;
|
||||
let n = 0;
|
||||
for (const k of Object.keys(traits)) {
|
||||
const v = traits[k];
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'string' && v.trim().length === 0) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ─── Flexible-asset loaders ───────────────────────────────────────────────
|
||||
|
||||
interface RawFlexAsset {
|
||||
id: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
flexible_asset_type_id: string;
|
||||
flexible_asset_type_name: string | null;
|
||||
name: string | null;
|
||||
traits: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function flexAssetToAudit(r: RawFlexAsset): AuditAssetRow {
|
||||
return {
|
||||
id: r.id,
|
||||
organization_id: r.organization_id,
|
||||
organization_name: r.organization_name,
|
||||
type_id: r.flexible_asset_type_id,
|
||||
type_name: r.flexible_asset_type_name,
|
||||
name: r.name,
|
||||
traits: r.traits ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFlexAsset(assetId: string | number): Promise<AuditAssetRow & { _raw: RawFlexAsset }> {
|
||||
const res = await postgresClient.query<RawFlexAsset>(
|
||||
`SELECT id::text AS id,
|
||||
organization_id::text AS organization_id,
|
||||
organization_name,
|
||||
flexible_asset_type_id::text AS flexible_asset_type_id,
|
||||
flexible_asset_type_name,
|
||||
name,
|
||||
traits
|
||||
FROM itg_flexible_assets
|
||||
WHERE id = $1
|
||||
AND COALESCE(archived, false) = false
|
||||
LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
if (res.rowCount === 0) throw new AssetAuditNotFoundError('flexible_asset', assetId);
|
||||
const r = res.rows[0];
|
||||
return { ...flexAssetToAudit(r), _raw: r };
|
||||
}
|
||||
|
||||
async function loadFlexFields(typeId: string): Promise<AuditFieldRow[]> {
|
||||
const res = await postgresClient.query<AuditFieldRow>(
|
||||
`SELECT id::text AS id, name, kind, hint, required
|
||||
FROM itg_flexible_asset_fields
|
||||
WHERE flexible_asset_type_id = $1
|
||||
ORDER BY id`,
|
||||
[typeId]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function loadFlexPeerSameClient(
|
||||
orgId: string | null,
|
||||
typeId: string,
|
||||
excludeId: string,
|
||||
limit: number
|
||||
): Promise<AuditAssetRow[]> {
|
||||
if (!orgId) return [];
|
||||
const res = await postgresClient.query<RawFlexAsset>(
|
||||
`SELECT id::text AS id,
|
||||
organization_id::text AS organization_id,
|
||||
organization_name,
|
||||
flexible_asset_type_id::text AS flexible_asset_type_id,
|
||||
flexible_asset_type_name,
|
||||
name,
|
||||
traits
|
||||
FROM itg_flexible_assets
|
||||
WHERE organization_id = $1
|
||||
AND flexible_asset_type_id = $2
|
||||
AND id <> $3
|
||||
AND COALESCE(archived, false) = false`,
|
||||
[orgId, typeId, excludeId]
|
||||
);
|
||||
const rows = res.rows.map(flexAssetToAudit);
|
||||
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
async function loadFlexPeerGlobal(
|
||||
typeId: string,
|
||||
excludeId: string,
|
||||
excludeOrgId: string | null,
|
||||
limit: number
|
||||
): Promise<AuditAssetRow[]> {
|
||||
const res = await postgresClient.query<RawFlexAsset>(
|
||||
`SELECT id::text AS id,
|
||||
organization_id::text AS organization_id,
|
||||
organization_name,
|
||||
flexible_asset_type_id::text AS flexible_asset_type_id,
|
||||
flexible_asset_type_name,
|
||||
name,
|
||||
traits
|
||||
FROM itg_flexible_assets
|
||||
WHERE flexible_asset_type_id = $1
|
||||
AND id <> $2
|
||||
AND ($3::text IS NULL OR organization_id::text <> $3)
|
||||
AND COALESCE(archived, false) = false
|
||||
ORDER BY synced_at DESC
|
||||
LIMIT 200`,
|
||||
[typeId, excludeId, excludeOrgId]
|
||||
);
|
||||
const rows = res.rows.map(flexAssetToAudit);
|
||||
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
function flexFieldKey(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
async function loadFlexFillRates(
|
||||
typeId: string,
|
||||
orgIdFilter: string | null,
|
||||
fields: AuditFieldRow[]
|
||||
): Promise<FillRateRow[]> {
|
||||
const params: unknown[] = [typeId];
|
||||
let where = `flexible_asset_type_id = $1 AND COALESCE(archived, false) = false`;
|
||||
if (orgIdFilter) {
|
||||
params.push(orgIdFilter);
|
||||
where += ` AND organization_id = $${params.length}`;
|
||||
}
|
||||
const res = await postgresClient.query<{ traits: Record<string, unknown> | null }>(
|
||||
`SELECT traits FROM itg_flexible_assets WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const total = res.rowCount ?? 0;
|
||||
if (total === 0) return fields.map((f) => ({ field_name: f.name, fill_rate: 0 }));
|
||||
return fields.map((f) => {
|
||||
const key = flexFieldKey(f.name);
|
||||
let filled = 0;
|
||||
for (const row of res.rows) {
|
||||
const v = row.traits?.[key];
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'string' && v.trim().length === 0) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
if (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
'values' in (v as Record<string, unknown>) &&
|
||||
Array.isArray((v as { values: unknown[] }).values) &&
|
||||
(v as { values: unknown[] }).values.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
filled += 1;
|
||||
}
|
||||
return {
|
||||
field_name: f.name,
|
||||
fill_rate: Math.round((filled / total) * 100) / 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Configuration loaders ────────────────────────────────────────────────
|
||||
|
||||
interface RawConfiguration {
|
||||
id: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
configuration_type_id: string | null;
|
||||
configuration_type_name: string | null;
|
||||
configuration_status_id: string | null;
|
||||
configuration_status_name: string | null;
|
||||
manufacturer_id: string | null;
|
||||
manufacturer_name: string | null;
|
||||
model_id: string | null;
|
||||
model_name: string | null;
|
||||
operating_system_id: string | null;
|
||||
operating_system_name: string | null;
|
||||
contact_id: string | null;
|
||||
location_id: string | null;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
primary_ip: string | null;
|
||||
mac_address: string | null;
|
||||
serial_number: string | null;
|
||||
asset_tag: string | null;
|
||||
position: string | null;
|
||||
notes: string | null;
|
||||
operating_system_notes: string | null;
|
||||
}
|
||||
|
||||
const CONFIGURATION_FIELDS: AuditFieldRow[] = [
|
||||
{ id: '1', name: 'name', kind: 'Text', hint: 'Display name. Match the hostname or a stable label techs recognize.', required: true },
|
||||
{ id: '2', name: 'hostname', kind: 'Text', hint: 'FQDN or NetBIOS name as it appears on the network. Should match what shows up in DNS and on the asset itself.', required: false },
|
||||
{ id: '3', name: 'primary_ip', kind: 'Text', hint: 'Primary IP address. Static where possible; capture even if DHCP-assigned for current state.', required: false },
|
||||
{ id: '4', name: 'mac_address', kind: 'Text', hint: 'Primary network adapter MAC.', required: false },
|
||||
{ id: '5', name: 'serial_number', kind: 'Text', hint: 'Hardware serial / VM UUID — needed for vendor warranty calls.', required: false },
|
||||
{ id: '6', name: 'asset_tag', kind: 'Text', hint: 'Physical or logical asset tag if the customer uses one.', required: false },
|
||||
{ id: '7', name: 'position', kind: 'Text', hint: 'Rack U position, room location, or VM cluster placement.', required: false },
|
||||
{ id: '8', name: 'configuration_type_name', kind: 'Select', hint: 'Server, Workstation, Printer, Firewall, Switch, etc. Drives downstream filtering and audit prompts.', required: false },
|
||||
{ id: '9', name: 'configuration_status_name', kind: 'Select', hint: 'Active, Inactive, Decommissioned, Spare. Stale Active records hide retired infra.', required: false },
|
||||
{ id: '10', name: 'manufacturer_name', kind: 'Tag', hint: 'Hardware/VM platform vendor (Dell, HPE, VMware, Hyper-V).', required: false },
|
||||
{ id: '11', name: 'model_name', kind: 'Tag', hint: 'Model identifier (PowerEdge R650, Surface Laptop 5).', required: false },
|
||||
{ id: '12', name: 'operating_system_name', kind: 'Tag', hint: 'OS + version (Windows Server 2019, Ubuntu 22.04). Stale OS = stale patch picture.', required: false },
|
||||
{ id: '13', name: 'operating_system_notes', kind: 'Textbox', hint: 'OS-specific gotchas: hotfix levels, schedule windows, named services and what they do, important roles installed (DC, DHCP, DNS, RDS).', required: false },
|
||||
{ id: '14', name: 'notes', kind: 'Textbox', hint: 'Free-text general notes. Keep architectural details (integration paths, data flows) here only if no structured field fits.', required: false },
|
||||
{ id: '15', name: 'contact_id', kind: 'Tag', hint: 'Primary user / responsible contact (workstation = end user; server = champion).', required: false },
|
||||
{ id: '16', name: 'location_id', kind: 'Tag', hint: 'IT Glue location/site this configuration lives at.', required: false },
|
||||
];
|
||||
|
||||
function configToAudit(r: RawConfiguration): AuditAssetRow {
|
||||
// Synthesize a traits-style map for prompt consistency.
|
||||
const traits: Record<string, unknown> = {
|
||||
name: r.name ?? null,
|
||||
hostname: r.hostname ?? null,
|
||||
primary_ip: r.primary_ip ?? null,
|
||||
mac_address: r.mac_address ?? null,
|
||||
serial_number: r.serial_number ?? null,
|
||||
asset_tag: r.asset_tag ?? null,
|
||||
position: r.position ?? null,
|
||||
configuration_type_name: r.configuration_type_name ?? null,
|
||||
configuration_status_name: r.configuration_status_name ?? null,
|
||||
manufacturer_name: r.manufacturer_name ?? null,
|
||||
model_name: r.model_name ?? null,
|
||||
operating_system_name: r.operating_system_name ?? null,
|
||||
operating_system_notes: r.operating_system_notes ?? null,
|
||||
notes: r.notes ?? null,
|
||||
contact_id: r.contact_id ?? null,
|
||||
location_id: r.location_id ?? null,
|
||||
};
|
||||
return {
|
||||
id: r.id,
|
||||
organization_id: r.organization_id,
|
||||
organization_name: r.organization_name,
|
||||
type_id: r.configuration_type_id,
|
||||
type_name: r.configuration_type_name,
|
||||
name: r.name,
|
||||
traits,
|
||||
};
|
||||
}
|
||||
|
||||
const CONFIG_SELECT = `
|
||||
id::text AS id,
|
||||
organization_id::text AS organization_id,
|
||||
organization_name,
|
||||
configuration_type_id::text AS configuration_type_id,
|
||||
configuration_type_name,
|
||||
configuration_status_id::text AS configuration_status_id,
|
||||
configuration_status_name,
|
||||
manufacturer_id::text AS manufacturer_id,
|
||||
manufacturer_name,
|
||||
model_id::text AS model_id,
|
||||
model_name,
|
||||
operating_system_id::text AS operating_system_id,
|
||||
operating_system_name,
|
||||
contact_id::text AS contact_id,
|
||||
location_id::text AS location_id,
|
||||
name, hostname, primary_ip, mac_address, serial_number, asset_tag,
|
||||
position, notes, operating_system_notes
|
||||
`;
|
||||
|
||||
async function loadConfiguration(assetId: string | number): Promise<AuditAssetRow & { type_id_str: string | null }> {
|
||||
const res = await postgresClient.query<RawConfiguration>(
|
||||
`SELECT ${CONFIG_SELECT} FROM itg_configurations WHERE id = $1 LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
if (res.rowCount === 0) throw new AssetAuditNotFoundError('configuration', assetId);
|
||||
return { ...configToAudit(res.rows[0]), type_id_str: res.rows[0].configuration_type_id };
|
||||
}
|
||||
|
||||
async function loadConfigPeerSameClient(
|
||||
orgId: string | null,
|
||||
typeId: string | null,
|
||||
excludeId: string,
|
||||
limit: number
|
||||
): Promise<AuditAssetRow[]> {
|
||||
if (!orgId || !typeId) return [];
|
||||
const res = await postgresClient.query<RawConfiguration>(
|
||||
`SELECT ${CONFIG_SELECT}
|
||||
FROM itg_configurations
|
||||
WHERE organization_id = $1
|
||||
AND configuration_type_id = $2
|
||||
AND id <> $3`,
|
||||
[orgId, typeId, excludeId]
|
||||
);
|
||||
const rows = res.rows.map(configToAudit);
|
||||
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
async function loadConfigPeerGlobal(
|
||||
typeId: string | null,
|
||||
excludeId: string,
|
||||
excludeOrgId: string | null,
|
||||
limit: number
|
||||
): Promise<AuditAssetRow[]> {
|
||||
if (!typeId) return [];
|
||||
const res = await postgresClient.query<RawConfiguration>(
|
||||
`SELECT ${CONFIG_SELECT}
|
||||
FROM itg_configurations
|
||||
WHERE configuration_type_id = $1
|
||||
AND id <> $2
|
||||
AND ($3::text IS NULL OR organization_id::text <> $3)
|
||||
LIMIT 200`,
|
||||
[typeId, excludeId, excludeOrgId]
|
||||
);
|
||||
const rows = res.rows.map(configToAudit);
|
||||
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
async function loadConfigFillRates(
|
||||
typeId: string | null,
|
||||
orgIdFilter: string | null
|
||||
): Promise<FillRateRow[]> {
|
||||
if (!typeId) {
|
||||
return CONFIGURATION_FIELDS.map((f) => ({ field_name: f.name, fill_rate: 0 }));
|
||||
}
|
||||
const params: unknown[] = [typeId];
|
||||
let where = `configuration_type_id = $1`;
|
||||
if (orgIdFilter) {
|
||||
params.push(orgIdFilter);
|
||||
where += ` AND organization_id = $${params.length}`;
|
||||
}
|
||||
const res = await postgresClient.query<RawConfiguration>(
|
||||
`SELECT ${CONFIG_SELECT} FROM itg_configurations WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const total = res.rowCount ?? 0;
|
||||
if (total === 0) {
|
||||
return CONFIGURATION_FIELDS.map((f) => ({ field_name: f.name, fill_rate: 0 }));
|
||||
}
|
||||
return CONFIGURATION_FIELDS.map((f) => {
|
||||
let filled = 0;
|
||||
for (const row of res.rows) {
|
||||
const v = (row as unknown as Record<string, unknown>)[f.name];
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'string' && v.trim().length === 0) continue;
|
||||
filled += 1;
|
||||
}
|
||||
return {
|
||||
field_name: f.name,
|
||||
fill_rate: Math.round((filled / total) * 100) / 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Ticket evidence (shared) ────────────────────────────────────────────
|
||||
|
||||
async function loadTicketEvidence(
|
||||
orgId: string | null,
|
||||
assetName: string | null,
|
||||
limit: number
|
||||
): Promise<TicketEvidenceRow[]> {
|
||||
if (!orgId || !assetName) return [];
|
||||
const compRes = await postgresClient.query<{ id: string }>(
|
||||
`SELECT c.id::text AS id
|
||||
FROM companies c
|
||||
JOIN itg_organizations o ON LOWER(c.company_name) = LOWER(o.name)
|
||||
WHERE o.id = $1
|
||||
LIMIT 1`,
|
||||
[orgId]
|
||||
);
|
||||
if (compRes.rowCount === 0) return [];
|
||||
const companyId = compRes.rows[0].id;
|
||||
const lowerName = assetName.toLowerCase();
|
||||
const res = await postgresClient.query<{
|
||||
ticket_number: string;
|
||||
triggered_at: Date;
|
||||
summary: string | null;
|
||||
fingerprint: Record<string, unknown> | null;
|
||||
}>(
|
||||
`SELECT aa.ticket_number, aa.triggered_at, aa.summary,
|
||||
aa.aggregate_fingerprint AS fingerprint
|
||||
FROM analyzer_analyses aa
|
||||
JOIN tickets t ON t.ticket_number = aa.ticket_number
|
||||
WHERE t.company_id = $1
|
||||
AND aa.status = 'complete'
|
||||
AND aa.aggregate_fingerprint IS NOT NULL
|
||||
AND (
|
||||
LOWER(COALESCE(aa.summary, '')) LIKE '%' || $2 || '%'
|
||||
OR aa.aggregate_fingerprint::text ILIKE '%' || $2 || '%'
|
||||
)
|
||||
ORDER BY aa.triggered_at DESC
|
||||
LIMIT $3`,
|
||||
[companyId, lowerName, limit]
|
||||
);
|
||||
return res.rows.map((r) => ({
|
||||
ticket_number: r.ticket_number,
|
||||
triggered_at: r.triggered_at.toISOString(),
|
||||
summary: r.summary,
|
||||
fingerprint: r.fingerprint ?? {},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket-scoped evidence — returns just the one analysis the audit was
|
||||
* launched from. Used when the user clicks "Check IT Glue documentation"
|
||||
* on a specific analysis page; the audit looks for what *this ticket*
|
||||
* taught us, not all-time history.
|
||||
*/
|
||||
/**
|
||||
* Phase 4.2: pull the latest successful Overshell evidence for the audit's
|
||||
* client + asset. Site-anchored scripts: latest per script_id within 7
|
||||
* days. Asset-self scripts: latest per script_id, no time limit
|
||||
* (asset-self facts are durable until something changes).
|
||||
*/
|
||||
async function loadRmmEvidence(
|
||||
itglueOrgId: string | null,
|
||||
assetType: AuditAssetType,
|
||||
assetId: string
|
||||
): Promise<RmmEvidenceRow[]> {
|
||||
if (!itglueOrgId) return [];
|
||||
// Map IT Glue org → Autotask company (same join the ticket-evidence loader uses).
|
||||
const compRes = await postgresClient.query<{ id: string }>(
|
||||
`SELECT c.id::text AS id
|
||||
FROM companies c
|
||||
JOIN itg_organizations o ON LOWER(c.company_name) = LOWER(o.name)
|
||||
WHERE o.id = $1
|
||||
LIMIT 1`,
|
||||
[itglueOrgId]
|
||||
);
|
||||
if (compRes.rowCount === 0) return [];
|
||||
const companyId = compRes.rows[0].id;
|
||||
|
||||
const [siteAnchored, assetSelf] = await Promise.all([
|
||||
listLatestEvidenceForCompany(companyId, 7),
|
||||
listLatestEvidenceForAsset(assetType, assetId),
|
||||
]);
|
||||
|
||||
// Combine: site-anchored first (general context), then asset-self.
|
||||
const out: RmmEvidenceRow[] = [];
|
||||
for (const e of siteAnchored) {
|
||||
if (e.targetType !== 'site_anchor') continue;
|
||||
out.push({
|
||||
execution_id: e.id,
|
||||
script_id: e.scriptId,
|
||||
target_type: e.targetType,
|
||||
target_hostname: e.targetHostname,
|
||||
captured_at: e.completedAt ?? e.queuedAt,
|
||||
parsed: redact(e.parsedEvidence ?? null),
|
||||
});
|
||||
}
|
||||
for (const e of assetSelf) {
|
||||
out.push({
|
||||
execution_id: e.id,
|
||||
script_id: e.scriptId,
|
||||
target_type: e.targetType,
|
||||
target_hostname: e.targetHostname,
|
||||
captured_at: e.completedAt ?? e.queuedAt,
|
||||
parsed: redact(e.parsedEvidence ?? null),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadSingleTicketEvidence(
|
||||
analysisId: string
|
||||
): Promise<TicketEvidenceRow[]> {
|
||||
const res = await postgresClient.query<{
|
||||
ticket_number: string;
|
||||
triggered_at: Date;
|
||||
summary: string | null;
|
||||
fingerprint: Record<string, unknown> | null;
|
||||
}>(
|
||||
`SELECT ticket_number, triggered_at, summary,
|
||||
aggregate_fingerprint AS fingerprint
|
||||
FROM analyzer_analyses
|
||||
WHERE id = $1 AND status = 'complete'
|
||||
LIMIT 1`,
|
||||
[analysisId]
|
||||
);
|
||||
if (res.rowCount === 0) return [];
|
||||
const r = res.rows[0];
|
||||
return [
|
||||
{
|
||||
ticket_number: r.ticket_number,
|
||||
triggered_at: r.triggered_at.toISOString(),
|
||||
summary: r.summary,
|
||||
fingerprint: r.fingerprint ?? {},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Top-level builder ────────────────────────────────────────────────────
|
||||
|
||||
export interface BuildAssetAuditContextInput {
|
||||
assetType: AuditAssetType;
|
||||
assetId: string | number;
|
||||
/** When set, ticket evidence is just this one analysis (ticket-first mode). */
|
||||
ticketScopeAnalysisId?: string;
|
||||
}
|
||||
|
||||
export async function buildAssetAuditContext(
|
||||
input: BuildAssetAuditContextInput
|
||||
): Promise<AssetAuditContext> {
|
||||
const { assetType, assetId, ticketScopeAnalysisId } = input;
|
||||
|
||||
if (assetType === 'flexible_asset') {
|
||||
const asset = await loadFlexAsset(assetId);
|
||||
const typeId = asset.type_id ?? '';
|
||||
const fields = await loadFlexFields(typeId);
|
||||
|
||||
const [peerSameClient, peerGlobal, fillRateClient, fillRateGlobal, rmmEvidence] =
|
||||
await Promise.all([
|
||||
loadFlexPeerSameClient(asset.organization_id, typeId, asset.id, PEER_SAME_CLIENT_LIMIT),
|
||||
loadFlexPeerGlobal(typeId, asset.id, asset.organization_id, PEER_GLOBAL_LIMIT),
|
||||
loadFlexFillRates(typeId, asset.organization_id, fields),
|
||||
loadFlexFillRates(typeId, null, fields),
|
||||
loadRmmEvidence(asset.organization_id, 'flexible_asset', asset.id),
|
||||
]);
|
||||
|
||||
let ticketEvidence: TicketEvidenceRow[];
|
||||
let ticketScope: AssetAuditContext['ticket_scope'] = null;
|
||||
if (ticketScopeAnalysisId) {
|
||||
ticketEvidence = await loadSingleTicketEvidence(ticketScopeAnalysisId);
|
||||
if (ticketEvidence.length > 0) {
|
||||
ticketScope = {
|
||||
analysis_id: ticketScopeAnalysisId,
|
||||
ticket_number: ticketEvidence[0].ticket_number,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
ticketEvidence = await loadTicketEvidence(asset.organization_id, asset.name, TICKET_EVIDENCE_LIMIT);
|
||||
}
|
||||
|
||||
return {
|
||||
asset_type: 'flexible_asset',
|
||||
asset: { ...asset, traits: redact(asset.traits) },
|
||||
type_id: Number(typeId) || null,
|
||||
type_name: asset.type_name,
|
||||
fields,
|
||||
peer_same_client: peerSameClient.map((p) => ({ ...p, traits: redact(p.traits) })),
|
||||
peer_global: peerGlobal.map((p) => ({ ...p, traits: redact(p.traits) })),
|
||||
fill_rate_client: fillRateClient,
|
||||
fill_rate_global: fillRateGlobal,
|
||||
ticket_evidence: ticketEvidence,
|
||||
ticket_scope: ticketScope,
|
||||
rmm_evidence: rmmEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
// Configurations
|
||||
const config = await loadConfiguration(assetId);
|
||||
const typeId = config.type_id_str;
|
||||
|
||||
const [peerSameClient, peerGlobal, fillRateClient, fillRateGlobal, rmmEvidence] =
|
||||
await Promise.all([
|
||||
loadConfigPeerSameClient(config.organization_id, typeId, config.id, PEER_SAME_CLIENT_LIMIT),
|
||||
loadConfigPeerGlobal(typeId, config.id, config.organization_id, PEER_GLOBAL_LIMIT),
|
||||
loadConfigFillRates(typeId, config.organization_id),
|
||||
loadConfigFillRates(typeId, null),
|
||||
loadRmmEvidence(config.organization_id, 'configuration', config.id),
|
||||
]);
|
||||
|
||||
let ticketEvidence: TicketEvidenceRow[];
|
||||
let ticketScope: AssetAuditContext['ticket_scope'] = null;
|
||||
if (ticketScopeAnalysisId) {
|
||||
ticketEvidence = await loadSingleTicketEvidence(ticketScopeAnalysisId);
|
||||
if (ticketEvidence.length > 0) {
|
||||
ticketScope = {
|
||||
analysis_id: ticketScopeAnalysisId,
|
||||
ticket_number: ticketEvidence[0].ticket_number,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// For configurations, also try matching on hostname for richer evidence.
|
||||
const matchTerm = config.name ?? (config.traits.hostname as string | null) ?? null;
|
||||
ticketEvidence = await loadTicketEvidence(config.organization_id, matchTerm, TICKET_EVIDENCE_LIMIT);
|
||||
}
|
||||
|
||||
return {
|
||||
asset_type: 'configuration',
|
||||
asset: { ...config, traits: redact(config.traits) },
|
||||
type_id: typeId !== null ? Number(typeId) : null,
|
||||
type_name: config.type_name,
|
||||
fields: CONFIGURATION_FIELDS,
|
||||
peer_same_client: peerSameClient.map((p) => ({ ...p, traits: redact(p.traits) })),
|
||||
peer_global: peerGlobal.map((p) => ({ ...p, traits: redact(p.traits) })),
|
||||
fill_rate_client: fillRateClient,
|
||||
fill_rate_global: fillRateGlobal,
|
||||
ticket_evidence: ticketEvidence,
|
||||
ticket_scope: ticketScope,
|
||||
rmm_evidence: rmmEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
// Exported for tests + observability.
|
||||
export const _ASSET_AUDIT_INTERNALS = {
|
||||
PEER_SAME_CLIENT_LIMIT,
|
||||
PEER_GLOBAL_LIMIT,
|
||||
TICKET_EVIDENCE_LIMIT,
|
||||
CONFIGURATION_FIELDS,
|
||||
fillCount,
|
||||
};
|
||||
451
lib/services/analyzer/asset-audit/persistence.ts
Normal file
451
lib/services/analyzer/asset-audit/persistence.ts
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
/**
|
||||
* Persistence helpers for `itglue_asset_audits` and `itglue_writes`.
|
||||
*
|
||||
* Audit rows accumulate full LLM context snapshots (forever-retained).
|
||||
* Write rows record every IT Glue PATCH attempt with before/after diffs
|
||||
* and provenance back to the audit that prompted the change.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import type {
|
||||
AssetAuditResponse,
|
||||
AuditConfidence,
|
||||
} from '@/lib/types/analyzer';
|
||||
|
||||
// ─── Audit rows ───────────────────────────────────────────────────────────
|
||||
|
||||
export type AuditStatus = 'pending' | 'running' | 'complete' | 'failed';
|
||||
export type WriteStatus = 'pending' | 'committed' | 'failed' | 'reverted';
|
||||
|
||||
export interface AssetAuditRow {
|
||||
id: string;
|
||||
asset_type: 'flexible_asset' | 'configuration';
|
||||
asset_id: string;
|
||||
asset_type_id: string | null;
|
||||
organization_id: string | null;
|
||||
generated_by_user_id: string | null;
|
||||
generated_at: string;
|
||||
provider: 'anthropic' | 'openrouter';
|
||||
model_used: string | null;
|
||||
asset_snapshot: Record<string, unknown>;
|
||||
ticket_count: number;
|
||||
field_gaps: AssetAuditResponse['field_gaps'];
|
||||
notes_promotions: AssetAuditResponse['notes_promotions'];
|
||||
contradictions: AssetAuditResponse['contradictions'];
|
||||
overall_score: number | null;
|
||||
estimated_cost_usd: number | null;
|
||||
total_input_tokens: number | null;
|
||||
total_output_tokens: number | null;
|
||||
status: AuditStatus;
|
||||
error_message: string | null;
|
||||
triggered_by_ticket_number: string | null;
|
||||
triggered_by_analysis_id: string | null;
|
||||
}
|
||||
|
||||
const AUDIT_SELECT = `
|
||||
id::text AS id,
|
||||
asset_type, asset_id::text AS asset_id,
|
||||
asset_type_id::text AS asset_type_id,
|
||||
organization_id::text AS organization_id,
|
||||
generated_by_user_id, generated_at,
|
||||
provider, model_used,
|
||||
asset_snapshot,
|
||||
ticket_count,
|
||||
field_gaps, notes_promotions, contradictions,
|
||||
overall_score::float8 AS overall_score,
|
||||
estimated_cost_usd::float8 AS estimated_cost_usd,
|
||||
total_input_tokens, total_output_tokens,
|
||||
status, error_message,
|
||||
triggered_by_ticket_number,
|
||||
triggered_by_analysis_id::text AS triggered_by_analysis_id
|
||||
`;
|
||||
|
||||
interface RawAuditRow {
|
||||
id: string;
|
||||
asset_type: 'flexible_asset' | 'configuration';
|
||||
asset_id: string;
|
||||
asset_type_id: string | null;
|
||||
organization_id: string | null;
|
||||
generated_by_user_id: string | null;
|
||||
generated_at: Date;
|
||||
provider: 'anthropic' | 'openrouter';
|
||||
model_used: string | null;
|
||||
asset_snapshot: Record<string, unknown>;
|
||||
ticket_count: number;
|
||||
field_gaps: AssetAuditResponse['field_gaps'];
|
||||
notes_promotions: AssetAuditResponse['notes_promotions'];
|
||||
contradictions: AssetAuditResponse['contradictions'];
|
||||
overall_score: number | null;
|
||||
estimated_cost_usd: number | null;
|
||||
total_input_tokens: number | null;
|
||||
total_output_tokens: number | null;
|
||||
status: AuditStatus;
|
||||
error_message: string | null;
|
||||
triggered_by_ticket_number: string | null;
|
||||
triggered_by_analysis_id: string | null;
|
||||
}
|
||||
|
||||
function rowToAudit(r: RawAuditRow): AssetAuditRow {
|
||||
return {
|
||||
...r,
|
||||
generated_at: r.generated_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateAuditInput {
|
||||
asset_type: 'flexible_asset' | 'configuration';
|
||||
asset_id: number | string;
|
||||
asset_type_id: number | null;
|
||||
organization_id: number | string | null;
|
||||
generated_by_user_id: string | null;
|
||||
provider: 'anthropic' | 'openrouter';
|
||||
model_used: string;
|
||||
asset_snapshot: Record<string, unknown>;
|
||||
ticket_count: number;
|
||||
response: AssetAuditResponse;
|
||||
estimated_cost_usd: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
/** Phase 4.1: ticket-first audit linkage. Both null for asset-first audits. */
|
||||
triggered_by_ticket_number?: string | null;
|
||||
triggered_by_analysis_id?: string | null;
|
||||
}
|
||||
|
||||
export async function insertAssetAudit(input: CreateAuditInput): Promise<{ id: string }> {
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`INSERT INTO itglue_asset_audits
|
||||
(asset_type, asset_id, asset_type_id, organization_id,
|
||||
generated_by_user_id, provider, model_used,
|
||||
asset_snapshot, ticket_count,
|
||||
field_gaps, notes_promotions, contradictions,
|
||||
overall_score, estimated_cost_usd,
|
||||
total_input_tokens, total_output_tokens,
|
||||
status,
|
||||
triggered_by_ticket_number, triggered_by_analysis_id)
|
||||
VALUES ($1, $2, $3, $4,
|
||||
$5, $6, $7,
|
||||
$8::jsonb, $9,
|
||||
$10::jsonb, $11::jsonb, $12::jsonb,
|
||||
$13, $14,
|
||||
$15, $16,
|
||||
'complete',
|
||||
$17, $18)
|
||||
RETURNING id::text AS id`,
|
||||
[
|
||||
input.asset_type,
|
||||
input.asset_id,
|
||||
input.asset_type_id,
|
||||
input.organization_id,
|
||||
input.generated_by_user_id,
|
||||
input.provider,
|
||||
input.model_used,
|
||||
JSON.stringify(input.asset_snapshot),
|
||||
input.ticket_count,
|
||||
JSON.stringify(input.response.field_gaps),
|
||||
JSON.stringify(input.response.notes_promotions),
|
||||
JSON.stringify(input.response.contradictions),
|
||||
input.response.overall_score,
|
||||
input.estimated_cost_usd,
|
||||
input.total_input_tokens,
|
||||
input.total_output_tokens,
|
||||
input.triggered_by_ticket_number ?? null,
|
||||
input.triggered_by_analysis_id ?? null,
|
||||
]
|
||||
);
|
||||
return { id: res.rows[0].id };
|
||||
}
|
||||
|
||||
export async function insertFailedAssetAudit(input: {
|
||||
asset_type: 'flexible_asset' | 'configuration';
|
||||
asset_id: number | string;
|
||||
asset_type_id: number | null;
|
||||
organization_id: number | string | null;
|
||||
generated_by_user_id: string | null;
|
||||
provider: 'anthropic' | 'openrouter';
|
||||
model_used: string | null;
|
||||
asset_snapshot: Record<string, unknown>;
|
||||
ticket_count: number;
|
||||
error_message: string;
|
||||
triggered_by_ticket_number?: string | null;
|
||||
triggered_by_analysis_id?: string | null;
|
||||
}): Promise<{ id: string }> {
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`INSERT INTO itglue_asset_audits
|
||||
(asset_type, asset_id, asset_type_id, organization_id,
|
||||
generated_by_user_id, provider, model_used,
|
||||
asset_snapshot, ticket_count,
|
||||
field_gaps, notes_promotions, contradictions,
|
||||
status, error_message,
|
||||
triggered_by_ticket_number, triggered_by_analysis_id)
|
||||
VALUES ($1, $2, $3, $4,
|
||||
$5, $6, $7,
|
||||
$8::jsonb, $9,
|
||||
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb,
|
||||
'failed', $10,
|
||||
$11, $12)
|
||||
RETURNING id::text AS id`,
|
||||
[
|
||||
input.asset_type,
|
||||
input.asset_id,
|
||||
input.asset_type_id,
|
||||
input.organization_id,
|
||||
input.generated_by_user_id,
|
||||
input.provider,
|
||||
input.model_used,
|
||||
JSON.stringify(input.asset_snapshot),
|
||||
input.ticket_count,
|
||||
input.error_message,
|
||||
input.triggered_by_ticket_number ?? null,
|
||||
input.triggered_by_analysis_id ?? null,
|
||||
]
|
||||
);
|
||||
return { id: res.rows[0].id };
|
||||
}
|
||||
|
||||
export async function getLatestAssetAudit(
|
||||
assetId: string | number,
|
||||
assetType: 'flexible_asset' | 'configuration' = 'flexible_asset'
|
||||
): Promise<AssetAuditRow | null> {
|
||||
const res = await postgresClient.query<RawAuditRow>(
|
||||
`SELECT ${AUDIT_SELECT}
|
||||
FROM itglue_asset_audits
|
||||
WHERE asset_type = $1
|
||||
AND asset_id = $2
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1`,
|
||||
[assetType, assetId]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
return rowToAudit(res.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4.1: fetch the most recent ticket-scoped audit for a given
|
||||
* (analysis, assetType, assetId) triple. Used by the analysis page to show
|
||||
* "we already audited this asset for this ticket — here's what came back".
|
||||
*/
|
||||
export async function getLatestTicketScopedAudit(
|
||||
analysisId: string,
|
||||
assetType: 'flexible_asset' | 'configuration',
|
||||
assetId: string | number
|
||||
): Promise<AssetAuditRow | null> {
|
||||
const res = await postgresClient.query<RawAuditRow>(
|
||||
`SELECT ${AUDIT_SELECT}
|
||||
FROM itglue_asset_audits
|
||||
WHERE asset_type = $1
|
||||
AND asset_id = $2
|
||||
AND triggered_by_analysis_id = $3
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1`,
|
||||
[assetType, assetId, analysisId]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
return rowToAudit(res.rows[0]);
|
||||
}
|
||||
|
||||
export async function getAssetAuditById(id: string): Promise<AssetAuditRow | null> {
|
||||
const res = await postgresClient.query<RawAuditRow>(
|
||||
`SELECT ${AUDIT_SELECT} FROM itglue_asset_audits WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
return rowToAudit(res.rows[0]);
|
||||
}
|
||||
|
||||
export async function listAssetAudits(
|
||||
assetId: string | number,
|
||||
assetType: 'flexible_asset' | 'configuration' = 'flexible_asset',
|
||||
limit = 20
|
||||
): Promise<AssetAuditRow[]> {
|
||||
const res = await postgresClient.query<RawAuditRow>(
|
||||
`SELECT ${AUDIT_SELECT}
|
||||
FROM itglue_asset_audits
|
||||
WHERE asset_type = $1
|
||||
AND asset_id = $2
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT $3`,
|
||||
[assetType, assetId, limit]
|
||||
);
|
||||
return res.rows.map(rowToAudit);
|
||||
}
|
||||
|
||||
// ─── Write rows ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface AssetWriteRow {
|
||||
id: string;
|
||||
audit_id: string | null;
|
||||
asset_type: 'flexible_asset' | 'configuration';
|
||||
asset_id: string;
|
||||
field_name: string;
|
||||
before_value: unknown;
|
||||
after_value: unknown;
|
||||
performed_by_user_id: string | null;
|
||||
performed_at: string;
|
||||
status: WriteStatus;
|
||||
itglue_response: unknown;
|
||||
error_message: string | null;
|
||||
source_evidence: unknown;
|
||||
}
|
||||
|
||||
const WRITE_SELECT = `
|
||||
id::text AS id,
|
||||
audit_id::text AS audit_id,
|
||||
asset_type, asset_id::text AS asset_id,
|
||||
field_name,
|
||||
before_value, after_value,
|
||||
performed_by_user_id, performed_at,
|
||||
status,
|
||||
itglue_response, error_message, source_evidence
|
||||
`;
|
||||
|
||||
interface RawWriteRow {
|
||||
id: string;
|
||||
audit_id: string | null;
|
||||
asset_type: 'flexible_asset' | 'configuration';
|
||||
asset_id: string;
|
||||
field_name: string;
|
||||
before_value: unknown;
|
||||
after_value: unknown;
|
||||
performed_by_user_id: string | null;
|
||||
performed_at: Date;
|
||||
status: WriteStatus;
|
||||
itglue_response: unknown;
|
||||
error_message: string | null;
|
||||
source_evidence: unknown;
|
||||
}
|
||||
|
||||
function rowToWrite(r: RawWriteRow): AssetWriteRow {
|
||||
return { ...r, performed_at: r.performed_at.toISOString() };
|
||||
}
|
||||
|
||||
export async function createPendingWrite(input: {
|
||||
audit_id: string | null;
|
||||
asset_type: 'flexible_asset' | 'configuration';
|
||||
asset_id: number | string;
|
||||
field_name: string;
|
||||
before_value: unknown;
|
||||
after_value: unknown;
|
||||
performed_by_user_id: string | null;
|
||||
source_evidence: unknown;
|
||||
/** Phase 4.1: ticket linkage carried forward from the audit row. */
|
||||
triggered_by_ticket_number?: string | null;
|
||||
}): Promise<{ id: string }> {
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`INSERT INTO itglue_writes
|
||||
(audit_id, asset_type, asset_id, field_name,
|
||||
before_value, after_value,
|
||||
performed_by_user_id, status, source_evidence,
|
||||
triggered_by_ticket_number)
|
||||
VALUES ($1, $2, $3, $4,
|
||||
$5::jsonb, $6::jsonb,
|
||||
$7, 'pending', $8::jsonb,
|
||||
$9)
|
||||
RETURNING id::text AS id`,
|
||||
[
|
||||
input.audit_id,
|
||||
input.asset_type,
|
||||
input.asset_id,
|
||||
input.field_name,
|
||||
JSON.stringify(input.before_value ?? null),
|
||||
JSON.stringify(input.after_value),
|
||||
input.performed_by_user_id,
|
||||
JSON.stringify(input.source_evidence ?? null),
|
||||
input.triggered_by_ticket_number ?? null,
|
||||
]
|
||||
);
|
||||
return { id: res.rows[0].id };
|
||||
}
|
||||
|
||||
export async function markWriteCommitted(
|
||||
id: string,
|
||||
itglueResponse: unknown
|
||||
): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`UPDATE itglue_writes
|
||||
SET status = 'committed',
|
||||
itglue_response = $2::jsonb
|
||||
WHERE id = $1`,
|
||||
[id, JSON.stringify(itglueResponse ?? null)]
|
||||
);
|
||||
}
|
||||
|
||||
export async function markWriteFailed(id: string, errorMessage: string): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`UPDATE itglue_writes
|
||||
SET status = 'failed', error_message = $2
|
||||
WHERE id = $1`,
|
||||
[id, errorMessage]
|
||||
);
|
||||
}
|
||||
|
||||
export async function markWriteReverted(id: string): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`UPDATE itglue_writes SET status = 'reverted' WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getWriteById(id: string): Promise<AssetWriteRow | null> {
|
||||
const res = await postgresClient.query<RawWriteRow>(
|
||||
`SELECT ${WRITE_SELECT} FROM itglue_writes WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
return rowToWrite(res.rows[0]);
|
||||
}
|
||||
|
||||
export async function listWritesForAsset(
|
||||
assetId: string | number,
|
||||
limit = 50
|
||||
): Promise<AssetWriteRow[]> {
|
||||
const res = await postgresClient.query<RawWriteRow>(
|
||||
`SELECT ${WRITE_SELECT}
|
||||
FROM itglue_writes
|
||||
WHERE asset_type = 'flexible_asset'
|
||||
AND asset_id = $1
|
||||
ORDER BY performed_at DESC
|
||||
LIMIT $2`,
|
||||
[assetId, limit]
|
||||
);
|
||||
return res.rows.map(rowToWrite);
|
||||
}
|
||||
|
||||
export async function listAllWrites(opts: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: WriteStatus;
|
||||
}): Promise<AssetWriteRow[]> {
|
||||
const limit = Math.min(opts.limit ?? 100, 500);
|
||||
const offset = opts.offset ?? 0;
|
||||
const params: unknown[] = [limit, offset];
|
||||
let where = '';
|
||||
if (opts.status) {
|
||||
params.push(opts.status);
|
||||
where = `WHERE status = $${params.length}`;
|
||||
}
|
||||
const res = await postgresClient.query<RawWriteRow>(
|
||||
`SELECT ${WRITE_SELECT}
|
||||
FROM itglue_writes
|
||||
${where}
|
||||
ORDER BY performed_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
params
|
||||
);
|
||||
return res.rows.map(rowToWrite);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The IT Glue convention: trait keys are field names lowercased, hyphenated,
|
||||
* stripped of repeated/leading/trailing hyphens. Used to map a human field
|
||||
* name (e.g. "Wulf Application Champion(s)") to its trait key.
|
||||
*/
|
||||
export function fieldNameToTraitKey(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export type { AssetAuditResponse, AuditConfidence };
|
||||
236
lib/services/analyzer/asset-audit/prompt.ts
Normal file
236
lib/services/analyzer/asset-audit/prompt.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* System prompt + user payload builder for the IT Glue asset audit stage.
|
||||
*
|
||||
* The output schema is `AssetAuditResponse` from `lib/types/analyzer.ts` —
|
||||
* field gaps, notes promotions, contradictions, overall score. The prompt
|
||||
* is deliberately tight on what counts as a "gap":
|
||||
* - empty field that other tickets needed → high confidence
|
||||
* - empty field with no ticket evidence → suggested_value: null, low/medium
|
||||
* (LLM may flag as opportunity but with no concrete value)
|
||||
*
|
||||
* Provider-agnostic: the same prompt runs on Claude Sonnet or DeepSeek V4
|
||||
* Pro via callLLMStage. Phase 4.1 supports both Application (flexible
|
||||
* asset) and Configuration audits via assetType-aware prompt selection.
|
||||
*/
|
||||
|
||||
import type { AssetAuditContext } from './data-builder';
|
||||
|
||||
const LIVE_EVIDENCE_NOTE = `When a "LIVE RMM EVIDENCE" section is present, treat its parsed contents as authoritative current state of the environment, captured by remote PowerShell within the last few days. Use it to justify suggested values with high confidence — e.g. if Get-Services lists "BartenderProcessService" running on the target and a ticket asked about BarTender printing, suggest adding that service name to operating_system_notes with confidence=high. Cite execution_id alongside ticket numbers in evidence_ticket_numbers (it's fine to mix them).
|
||||
|
||||
When a "loglift-eventlogs" evidence row is present, the parsed_evidence contains a slim view of a Windows event-log + system-context capture: system_context (OS, hardware, uptime, last boot, pending reboot, memory, disks, recent updates), summary (TotalEvents / CriticalEvents / ByLevel / TimeRange / TopEventIds), and top_events — the highest-severity events sorted Critical → Error → Warning → Information, then most-recent. event_count_total is the original count; top_events is capped at 100. When citing event evidence in evidence_ticket_numbers it's fine to write "event:<EventId>" or "execution:<execution_id>". Do NOT claim "no errors observed" if event_count_total is large — say "of the top events captured" instead. Treat system_context as authoritative for OS / hardware / disk / memory facts on the matched Configuration.
|
||||
`;
|
||||
|
||||
const COMMON_RULES = `${LIVE_EVIDENCE_NOTE}
|
||||
Categorize findings into three buckets:
|
||||
|
||||
1. **field_gaps** — empty or anemic fields that, given the ticket evidence, would have measurably helped a tech diagnose or escalate faster. For each:
|
||||
- field_name: the EXACT field name from the provided schema (do not invent fields)
|
||||
- why_missing_matters: 1 sentence connecting the gap to a real ticket scenario
|
||||
- suggested_value: a concrete value derived from the ticket evidence, or null if you cannot infer one with high confidence
|
||||
- evidence_ticket_numbers: tickets that demonstrate the need
|
||||
- confidence: high (clear evidence + suggestion), medium (clear gap, weaker suggestion), low (opportunity, no concrete value)
|
||||
|
||||
2. **notes_promotions** — substrings of the existing free-text Notes / Operating-System-Notes field that are actually structured data and belong in a dedicated field. For each:
|
||||
- quoted_note_text: the exact substring from the Notes field
|
||||
- target_field: the field where it belongs (must exist in schema)
|
||||
- suggested_value: how the value should look in the structured field
|
||||
- confidence
|
||||
|
||||
3. **contradictions** — places where the record's fields disagree with each other or with ticket evidence (e.g. Notes say "2-3 VMs" but only 1 VM is tagged; OS field says Server 2016 but a ticket recently mentioned PowerShell 7 features).
|
||||
|
||||
Rules:
|
||||
- Never invent fields that aren't in the provided schema.
|
||||
- Never suggest a value you cannot point to evidence for. Use null instead.
|
||||
- Prefer high signal over volume — 3 strong gaps beats 10 generic ones.
|
||||
- The "fill_rate" stats tell you what's normal for this asset type. A field that's empty here but populated >80% of the time elsewhere is a stronger gap than one that's empty 80% of the time across all clients.
|
||||
- DO NOT suggest values for password, secret, key, token, or credential fields — those are out of scope.
|
||||
- overall_score is your 0-1 self-rated assessment of how complete the record is for diagnostic purposes (1 = nothing missing, 0 = empty).
|
||||
|
||||
Respond ONLY with JSON. No prose, no code fences.
|
||||
|
||||
Schema:
|
||||
{
|
||||
"field_gaps": [{"field_name": str, "why_missing_matters": str, "suggested_value": str | null, "evidence_ticket_numbers": str[], "confidence": "high" | "medium" | "low"}],
|
||||
"notes_promotions": [{"quoted_note_text": str, "target_field": str, "suggested_value": str, "confidence": "high" | "medium" | "low"}],
|
||||
"contradictions": [{"description": str, "evidence": str}],
|
||||
"overall_score": number
|
||||
}`;
|
||||
|
||||
const FLEXIBLE_ASSET_PROMPT = `You are auditing an IT Glue **flexible-asset** record (typically an Application) for Wulf Consulting, an MSP. Your job is to identify what should be documented in this record based on (a) the asset type's field schema with hints, (b) what comparable records contain, (c) what tickets actually needed to know.
|
||||
|
||||
${COMMON_RULES}`;
|
||||
|
||||
const CONFIGURATION_PROMPT = `You are auditing an IT Glue **Configuration** record (a server, workstation, network device, etc.) for Wulf Consulting, an MSP. Your job is to identify what should be documented in this record based on (a) the field schema with hints below, (b) what comparable Configuration records contain, (c) what tickets actually needed to know.
|
||||
|
||||
Configuration audits care especially about:
|
||||
- **Hostname / FQDN consistency** — the name field, hostname, and what tickets call the device should agree.
|
||||
- **Operating-system currency** — OS version drives patch posture, support tier, escalation path.
|
||||
- **Named services** — when tickets resolve by restarting or fixing a specific Windows service, that service name should be captured (in operating_system_notes ideally) so the next tech finds it without grepping ticket history.
|
||||
- **Networking facts** — primary IP, MAC, position. If tickets reveal an IP change or a new NIC, surface it.
|
||||
- **Architecture relationships** — which apps run on this server, which integrations flow through it. If the Notes field is the only place this lives, flag a notes_promotion to a more visible field where the schema allows.
|
||||
- **Contact ownership** — workstations should have an end-user contact; servers should have a champion or responsible team.
|
||||
|
||||
The 'name' field is the IT Glue display name. 'hostname' is the technical name on the network. They are often the same; flag when they disagree.
|
||||
|
||||
${COMMON_RULES}`;
|
||||
|
||||
const TICKET_SCOPED_SUFFIX = `
|
||||
|
||||
THIS AUDIT IS SCOPED TO A SINGLE TICKET. The ticket evidence section contains exactly one analysis — the ticket the user just analyzed. Frame your gaps as "what did this ticket teach us that the documentation doesn't say?" rather than all-time history. Cite ticket numbers, not theoretical scenarios.`;
|
||||
|
||||
const PAYLOAD_CHAR_CAP = 80_000;
|
||||
|
||||
export function getSystemPrompt(ctx: AssetAuditContext): string {
|
||||
const base =
|
||||
ctx.asset_type === 'configuration'
|
||||
? CONFIGURATION_PROMPT
|
||||
: FLEXIBLE_ASSET_PROMPT;
|
||||
return ctx.ticket_scope ? base + TICKET_SCOPED_SUFFIX : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user payload. If we exceed the size cap (rare), trim the
|
||||
* peer_global section first (least-load-bearing), then drop older ticket
|
||||
* evidence one at a time. Schema and fill rates are never dropped — they're
|
||||
* the lookup table the LLM needs to answer correctly.
|
||||
*/
|
||||
export function buildAssetAuditUserPayload(ctx: AssetAuditContext): {
|
||||
payload: string;
|
||||
trimmed: { peer_global_dropped: number; tickets_dropped: number; rmm_dropped: number };
|
||||
} {
|
||||
const trimmed = { peer_global_dropped: 0, tickets_dropped: 0, rmm_dropped: 0 };
|
||||
|
||||
const peerGlobal = ctx.peer_global.slice();
|
||||
const tickets = ctx.ticket_evidence.slice();
|
||||
const rmmEvidence = (ctx.rmm_evidence ?? []).slice();
|
||||
|
||||
const assetTypeLabel =
|
||||
ctx.asset_type === 'configuration'
|
||||
? 'CONFIGURATION'
|
||||
: 'FLEXIBLE ASSET';
|
||||
|
||||
const ticketHeader = ctx.ticket_scope
|
||||
? `=== TICKET EVIDENCE (single ticket — this audit is scoped to ${ctx.ticket_scope.ticket_number}) ===`
|
||||
: `=== TICKET EVIDENCE (this client, recent, mentions of the asset) ===`;
|
||||
|
||||
function render(): string {
|
||||
const sections: string[] = [
|
||||
`=== ${assetTypeLabel} UNDER AUDIT ===`,
|
||||
JSON.stringify(
|
||||
{
|
||||
id: ctx.asset.id,
|
||||
name: ctx.asset.name,
|
||||
organization_name: ctx.asset.organization_name,
|
||||
type_name: ctx.type_name,
|
||||
fields: ctx.asset.traits,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
``,
|
||||
`=== FIELD SCHEMA (with hints) ===`,
|
||||
JSON.stringify(ctx.fields, null, 2),
|
||||
``,
|
||||
`=== FILL-RATE STATS ===`,
|
||||
JSON.stringify(
|
||||
{
|
||||
this_client: ctx.fill_rate_client,
|
||||
across_all_clients: ctx.fill_rate_global,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
``,
|
||||
`=== PEER EXEMPLARS — SAME CLIENT ===`,
|
||||
JSON.stringify(
|
||||
ctx.peer_same_client.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
fields: p.traits,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
),
|
||||
``,
|
||||
`=== PEER EXEMPLARS — BEST-IN-CLASS ACROSS ALL CLIENTS ===`,
|
||||
JSON.stringify(
|
||||
peerGlobal.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
organization_name: p.organization_name,
|
||||
fields: p.traits,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
),
|
||||
];
|
||||
|
||||
if (rmmEvidence.length > 0) {
|
||||
sections.push(
|
||||
``,
|
||||
`=== LIVE RMM EVIDENCE (most recent successful Overshell runs; AUTHORITATIVE current state) ===`,
|
||||
JSON.stringify(
|
||||
rmmEvidence.map((e) => ({
|
||||
execution_id: e.execution_id,
|
||||
script_id: e.script_id,
|
||||
target_type: e.target_type,
|
||||
target_hostname: e.target_hostname,
|
||||
captured_at: e.captured_at,
|
||||
parsed: e.parsed,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
``,
|
||||
ticketHeader,
|
||||
JSON.stringify(
|
||||
tickets.map((t) => ({
|
||||
ticket_number: t.ticket_number,
|
||||
triggered_at: t.triggered_at,
|
||||
summary: t.summary,
|
||||
fingerprint: t.fingerprint,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
let payload = render();
|
||||
while (payload.length > PAYLOAD_CHAR_CAP) {
|
||||
// Drop in this priority order: peer_global → ticket_evidence (oldest) →
|
||||
// rmm_evidence (least recent first). RMM evidence drops last because
|
||||
// it's the highest-value live data.
|
||||
if (peerGlobal.length > 0) {
|
||||
peerGlobal.pop();
|
||||
trimmed.peer_global_dropped += 1;
|
||||
} else if (tickets.length > 0) {
|
||||
tickets.shift();
|
||||
trimmed.tickets_dropped += 1;
|
||||
} else if (rmmEvidence.length > 0) {
|
||||
rmmEvidence.pop();
|
||||
trimmed.rmm_dropped += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
payload = render();
|
||||
}
|
||||
return { payload, trimmed };
|
||||
}
|
||||
|
||||
// Back-compat export for tests + any external callers that still import the
|
||||
// flexible-asset prompt directly.
|
||||
export const SYSTEM_PROMPT = FLEXIBLE_ASSET_PROMPT;
|
||||
|
||||
export const _PROMPT_INTERNALS = {
|
||||
PAYLOAD_CHAR_CAP,
|
||||
FLEXIBLE_ASSET_PROMPT,
|
||||
CONFIGURATION_PROMPT,
|
||||
TICKET_SCOPED_SUFFIX,
|
||||
};
|
||||
204
lib/services/analyzer/asset-audit/runner.test.ts
Normal file
204
lib/services/analyzer/asset-audit/runner.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { _ASSET_AUDIT_INTERNALS } from './data-builder';
|
||||
import { fieldNameToTraitKey } from './persistence';
|
||||
import { buildAssetAuditUserPayload, _PROMPT_INTERNALS } from './prompt';
|
||||
import { AssetAuditResponse } from '@/lib/types/analyzer';
|
||||
|
||||
describe('fillCount', () => {
|
||||
const { fillCount } = _ASSET_AUDIT_INTERNALS;
|
||||
|
||||
it('counts populated trait keys', () => {
|
||||
expect(fillCount({ a: 'x', b: 'y' })).toBe(2);
|
||||
});
|
||||
|
||||
it('drops empty strings, empty arrays, null, undefined', () => {
|
||||
expect(
|
||||
fillCount({
|
||||
a: '',
|
||||
b: ' ',
|
||||
c: null,
|
||||
d: undefined,
|
||||
e: [],
|
||||
f: 'real',
|
||||
})
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('counts non-empty objects/arrays', () => {
|
||||
expect(fillCount({ a: { values: [1] }, b: [1, 2] })).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 for null/undefined input', () => {
|
||||
expect(fillCount(null)).toBe(0);
|
||||
expect(fillCount(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fieldNameToTraitKey', () => {
|
||||
it('matches IT Glue trait-key convention', () => {
|
||||
expect(fieldNameToTraitKey('Name')).toBe('name');
|
||||
expect(fieldNameToTraitKey('Wulf Application Champion(s)')).toBe(
|
||||
'wulf-application-champion-s'
|
||||
);
|
||||
expect(fieldNameToTraitKey('Application on Device(s)')).toBe(
|
||||
'application-on-device-s'
|
||||
);
|
||||
expect(fieldNameToTraitKey('Client/Server Software Installation Media Location'))
|
||||
.toBe('client-server-software-installation-media-location');
|
||||
});
|
||||
|
||||
it('strips leading/trailing/repeated hyphens', () => {
|
||||
expect(fieldNameToTraitKey(' --Foo-- ')).toBe('foo');
|
||||
expect(fieldNameToTraitKey('A & B')).toBe('a-b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssetAuditResponse Zod schema', () => {
|
||||
it('accepts a well-formed response', () => {
|
||||
const ok = AssetAuditResponse.safeParse({
|
||||
field_gaps: [
|
||||
{
|
||||
field_name: 'Wulf Application Champion(s)',
|
||||
why_missing_matters: 'Jake Hammel is the SME but not recorded.',
|
||||
suggested_value: 'Jake Hammel',
|
||||
evidence_ticket_numbers: ['T20260502.0033'],
|
||||
confidence: 'high',
|
||||
},
|
||||
],
|
||||
notes_promotions: [
|
||||
{
|
||||
quoted_note_text: 'Per Jake if down send to Steve Cianflone',
|
||||
target_field: 'Vendor Maintenance/Support',
|
||||
suggested_value: 'Steve Cianflone (Kastech)',
|
||||
confidence: 'medium',
|
||||
},
|
||||
],
|
||||
contradictions: [
|
||||
{
|
||||
description: 'Notes say 2-3 VMs, Application-on-Device-s lists 1',
|
||||
evidence: 'Notes field; application-on-device-s.values',
|
||||
},
|
||||
],
|
||||
overall_score: 0.55,
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows null suggested_value', () => {
|
||||
const ok = AssetAuditResponse.safeParse({
|
||||
field_gaps: [
|
||||
{
|
||||
field_name: 'URL',
|
||||
why_missing_matters: 'Vendor admin console URL not recorded.',
|
||||
suggested_value: null,
|
||||
evidence_ticket_numbers: [],
|
||||
confidence: 'low',
|
||||
},
|
||||
],
|
||||
notes_promotions: [],
|
||||
contradictions: [],
|
||||
overall_score: 0.7,
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects out-of-range overall_score', () => {
|
||||
const bad = AssetAuditResponse.safeParse({
|
||||
field_gaps: [],
|
||||
notes_promotions: [],
|
||||
contradictions: [],
|
||||
overall_score: 1.5,
|
||||
});
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects bogus confidence values', () => {
|
||||
const bad = AssetAuditResponse.safeParse({
|
||||
field_gaps: [
|
||||
{
|
||||
field_name: 'X',
|
||||
why_missing_matters: 'y',
|
||||
suggested_value: null,
|
||||
evidence_ticket_numbers: [],
|
||||
confidence: 'sky-high',
|
||||
},
|
||||
],
|
||||
notes_promotions: [],
|
||||
contradictions: [],
|
||||
overall_score: 0.5,
|
||||
});
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAssetAuditUserPayload', () => {
|
||||
function makeCtx(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
asset_type: 'flexible_asset',
|
||||
asset: {
|
||||
id: '1',
|
||||
organization_id: '100',
|
||||
organization_name: 'Acme',
|
||||
type_id: '3790',
|
||||
type_name: 'Applications',
|
||||
name: 'MISYS',
|
||||
traits: { name: 'MISYS', version: '6.3' },
|
||||
},
|
||||
type_id: 3790,
|
||||
type_name: 'Applications',
|
||||
fields: [
|
||||
{ id: '1', name: 'Name', kind: 'Text', hint: null, required: true },
|
||||
],
|
||||
peer_same_client: [],
|
||||
peer_global: [],
|
||||
fill_rate_client: [{ field_name: 'Name', fill_rate: 1.0 }],
|
||||
fill_rate_global: [{ field_name: 'Name', fill_rate: 0.95 }],
|
||||
ticket_evidence: [],
|
||||
ticket_scope: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders all sections (flexible asset)', () => {
|
||||
const ctx = makeCtx();
|
||||
const { payload, trimmed } = buildAssetAuditUserPayload(ctx as never);
|
||||
expect(payload).toContain('=== FLEXIBLE ASSET UNDER AUDIT ===');
|
||||
expect(payload).toContain('=== FIELD SCHEMA');
|
||||
expect(payload).toContain('=== FILL-RATE STATS ===');
|
||||
expect(payload).toContain('=== PEER EXEMPLARS — SAME CLIENT ===');
|
||||
expect(payload).toContain('=== PEER EXEMPLARS — BEST-IN-CLASS');
|
||||
expect(payload).toContain('=== TICKET EVIDENCE');
|
||||
expect(trimmed.peer_global_dropped).toBe(0);
|
||||
expect(trimmed.tickets_dropped).toBe(0);
|
||||
});
|
||||
|
||||
it('renders configuration header when assetType is configuration', () => {
|
||||
const ctx = makeCtx({ asset_type: 'configuration' });
|
||||
const { payload } = buildAssetAuditUserPayload(ctx as never);
|
||||
expect(payload).toContain('=== CONFIGURATION UNDER AUDIT ===');
|
||||
});
|
||||
|
||||
it('renders ticket-scoped header when ticket_scope is set', () => {
|
||||
const ctx = makeCtx({
|
||||
ticket_scope: { analysis_id: 'a', ticket_number: 'T20260502.0033' },
|
||||
});
|
||||
const { payload } = buildAssetAuditUserPayload(ctx as never);
|
||||
expect(payload).toContain('single ticket');
|
||||
expect(payload).toContain('T20260502.0033');
|
||||
});
|
||||
|
||||
it('trims peer_global before tickets when over the cap', () => {
|
||||
const bigPeer = {
|
||||
id: 'x',
|
||||
organization_id: '99',
|
||||
organization_name: 'Other',
|
||||
type_id: '3790',
|
||||
type_name: 'Applications',
|
||||
name: 'BigPeer',
|
||||
traits: { huge: 'x'.repeat(_PROMPT_INTERNALS.PAYLOAD_CHAR_CAP) },
|
||||
};
|
||||
const ctx = makeCtx({ peer_global: [bigPeer, bigPeer] });
|
||||
const { trimmed } = buildAssetAuditUserPayload(ctx as never);
|
||||
expect(trimmed.peer_global_dropped).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
136
lib/services/analyzer/asset-audit/runner.ts
Normal file
136
lib/services/analyzer/asset-audit/runner.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Runs one audit against an IT Glue record.
|
||||
*
|
||||
* Phase 4 supported only Application (flexible_asset) audits, all-time
|
||||
* scoped. Phase 4.1 generalizes:
|
||||
* - assetType: 'flexible_asset' | 'configuration'
|
||||
* - ticketScopeAnalysisId: when set, ticket evidence is the single source
|
||||
* analysis only ("ticket-first" mode).
|
||||
*
|
||||
* Flow:
|
||||
* 1. buildAssetAuditContext() — pulls everything; redacted.
|
||||
* 2. callLLMStage() — single shot using the deep-analysis tier model for
|
||||
* the requested provider (Sonnet for Anthropic, V4 Pro for OpenRouter).
|
||||
* 3. insertAssetAudit() — persists the row, including ticket linkage.
|
||||
*/
|
||||
|
||||
import { callLLMStage } from '@/lib/services/llm/call';
|
||||
import {
|
||||
type Provider,
|
||||
stageModelsFor,
|
||||
} from '@/lib/services/llm/models';
|
||||
import { AssetAuditResponse } from '@/lib/types/analyzer';
|
||||
import {
|
||||
buildAssetAuditContext,
|
||||
type AuditAssetType,
|
||||
} from './data-builder';
|
||||
import { getSystemPrompt, buildAssetAuditUserPayload } from './prompt';
|
||||
import {
|
||||
insertAssetAudit,
|
||||
insertFailedAssetAudit,
|
||||
} from './persistence';
|
||||
|
||||
const STAGE_MAX_TOKENS = 8_000;
|
||||
|
||||
export interface RunAssetAuditInput {
|
||||
assetType: AuditAssetType;
|
||||
assetId: number | string;
|
||||
generatedByUserId: string | null;
|
||||
provider?: Provider;
|
||||
/** Phase 4.1: when set, ticket evidence narrows to just this analysis. */
|
||||
ticketScopeAnalysisId?: string;
|
||||
}
|
||||
|
||||
export interface RunAssetAuditResult {
|
||||
auditId: string;
|
||||
status: 'complete' | 'failed';
|
||||
ticketCount: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export async function runAssetAudit(
|
||||
input: RunAssetAuditInput
|
||||
): Promise<RunAssetAuditResult> {
|
||||
const provider: Provider = input.provider ?? 'anthropic';
|
||||
const model = stageModelsFor(provider).deep_analysis;
|
||||
|
||||
const ctx = await buildAssetAuditContext({
|
||||
assetType: input.assetType,
|
||||
assetId: input.assetId,
|
||||
ticketScopeAnalysisId: input.ticketScopeAnalysisId,
|
||||
});
|
||||
const ticketCount = ctx.ticket_evidence.length;
|
||||
|
||||
const assetSnapshot = {
|
||||
id: ctx.asset.id,
|
||||
name: ctx.asset.name,
|
||||
organization_id: ctx.asset.organization_id,
|
||||
organization_name: ctx.asset.organization_name,
|
||||
asset_type: ctx.asset_type,
|
||||
type_id: ctx.type_id,
|
||||
type_name: ctx.type_name,
|
||||
fields: ctx.asset.traits,
|
||||
};
|
||||
|
||||
const triggeredByTicketNumber = ctx.ticket_scope?.ticket_number ?? null;
|
||||
const triggeredByAnalysisId = ctx.ticket_scope?.analysis_id ?? null;
|
||||
|
||||
const { payload } = buildAssetAuditUserPayload(ctx);
|
||||
const systemPrompt = getSystemPrompt(ctx);
|
||||
|
||||
try {
|
||||
const result = await callLLMStage({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
user: payload,
|
||||
schema: AssetAuditResponse,
|
||||
maxTokens: STAGE_MAX_TOKENS,
|
||||
});
|
||||
|
||||
const inserted = await insertAssetAudit({
|
||||
asset_type: ctx.asset_type,
|
||||
asset_id: ctx.asset.id,
|
||||
asset_type_id: ctx.type_id,
|
||||
organization_id: ctx.asset.organization_id,
|
||||
generated_by_user_id: input.generatedByUserId,
|
||||
provider,
|
||||
model_used: model,
|
||||
asset_snapshot: assetSnapshot,
|
||||
ticket_count: ticketCount,
|
||||
response: result.data,
|
||||
estimated_cost_usd: result.estimated_cost_usd,
|
||||
total_input_tokens: result.usage.input_tokens,
|
||||
total_output_tokens: result.usage.output_tokens,
|
||||
triggered_by_ticket_number: triggeredByTicketNumber,
|
||||
triggered_by_analysis_id: triggeredByAnalysisId,
|
||||
});
|
||||
|
||||
return {
|
||||
auditId: inserted.id,
|
||||
status: 'complete',
|
||||
ticketCount,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const failed = await insertFailedAssetAudit({
|
||||
asset_type: ctx.asset_type,
|
||||
asset_id: ctx.asset.id,
|
||||
asset_type_id: ctx.type_id,
|
||||
organization_id: ctx.asset.organization_id,
|
||||
generated_by_user_id: input.generatedByUserId,
|
||||
provider,
|
||||
model_used: model,
|
||||
asset_snapshot: assetSnapshot,
|
||||
ticket_count: ticketCount,
|
||||
error_message: message,
|
||||
triggered_by_ticket_number: triggeredByTicketNumber,
|
||||
triggered_by_analysis_id: triggeredByAnalysisId,
|
||||
});
|
||||
return {
|
||||
auditId: failed.id,
|
||||
status: 'failed',
|
||||
ticketCount,
|
||||
errorMessage: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
223
lib/services/analyzer/asset-audit/xrefs.ts
Normal file
223
lib/services/analyzer/asset-audit/xrefs.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* Cross-reference persistence between tickets and IT Glue assets.
|
||||
*
|
||||
* Three relationship types:
|
||||
* - 'referenced' — the analyzer cited this asset/doc when
|
||||
* analyzing the ticket (from
|
||||
* analyzer_analyses.itglue_docs_referenced)
|
||||
* - 'updated' — a ticket-driven audit produced a write to
|
||||
* the asset
|
||||
* - 'should_have_referenced' — a gap text suggests we needed this asset/doc
|
||||
* but didn't find it (reserved for future use;
|
||||
* not populated automatically yet)
|
||||
*
|
||||
* Inserts use ON CONFLICT DO NOTHING against the unique index so re-runs and
|
||||
* idempotent retries don't pollute the table.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export type XrefAssetType = 'flexible_asset' | 'configuration' | 'document';
|
||||
export type XrefRelationship = 'referenced' | 'updated' | 'should_have_referenced';
|
||||
export type XrefSource = 'analyzer_referenced' | 'audit_write' | 'manual';
|
||||
export type XrefConfidence = 'high' | 'medium' | 'low' | null;
|
||||
|
||||
export interface XrefRow {
|
||||
id: string;
|
||||
ticketNumber: string;
|
||||
analysisId: string | null;
|
||||
assetType: XrefAssetType;
|
||||
assetId: string;
|
||||
relationship: XrefRelationship;
|
||||
source: XrefSource;
|
||||
confidence: XrefConfidence;
|
||||
details: unknown;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface RawXrefRow {
|
||||
id: string;
|
||||
ticket_number: string;
|
||||
analysis_id: string | null;
|
||||
asset_type: XrefAssetType;
|
||||
asset_id: string;
|
||||
relationship: XrefRelationship;
|
||||
source: XrefSource;
|
||||
confidence: XrefConfidence;
|
||||
details: unknown;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
const XREF_SELECT = `
|
||||
id::text AS id,
|
||||
ticket_number,
|
||||
analysis_id::text AS analysis_id,
|
||||
asset_type,
|
||||
asset_id::text AS asset_id,
|
||||
relationship, source, confidence,
|
||||
details,
|
||||
created_at
|
||||
`;
|
||||
|
||||
function rowToXref(r: RawXrefRow): XrefRow {
|
||||
return {
|
||||
id: r.id,
|
||||
ticketNumber: r.ticket_number,
|
||||
analysisId: r.analysis_id,
|
||||
assetType: r.asset_type,
|
||||
assetId: r.asset_id,
|
||||
relationship: r.relationship,
|
||||
source: r.source,
|
||||
confidence: r.confidence,
|
||||
details: r.details,
|
||||
createdAt: r.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Inserts ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface InsertXrefRowInput {
|
||||
ticketNumber: string;
|
||||
analysisId: string | null;
|
||||
assetType: XrefAssetType;
|
||||
assetId: string | number;
|
||||
relationship: XrefRelationship;
|
||||
source: XrefSource;
|
||||
confidence?: XrefConfidence;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export async function insertXref(input: InsertXrefRowInput): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itglue_ticket_xrefs
|
||||
(ticket_number, analysis_id, asset_type, asset_id,
|
||||
relationship, source, confidence, details)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[
|
||||
input.ticketNumber,
|
||||
input.analysisId,
|
||||
input.assetType,
|
||||
input.assetId,
|
||||
input.relationship,
|
||||
input.source,
|
||||
input.confidence ?? null,
|
||||
JSON.stringify(input.details ?? null),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-insert xref rows from an analyzer_analyses.itglue_docs_referenced
|
||||
* payload. Each entry is an ITGlueDocReference: { id, name, url, doc_type,
|
||||
* relevance_reason }. We map doc_type → xref asset_type.
|
||||
*/
|
||||
export interface AnalyzerDocReference {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
url?: string | null;
|
||||
doc_type?: string | null;
|
||||
relevance_reason?: string | null;
|
||||
}
|
||||
|
||||
function mapDocTypeToAssetType(docType: string | null | undefined): XrefAssetType | null {
|
||||
if (!docType) return null;
|
||||
const t = docType.toLowerCase();
|
||||
if (t === 'flexible_asset' || t === 'flexible-asset' || t === 'flex_asset') return 'flexible_asset';
|
||||
if (t === 'configuration') return 'configuration';
|
||||
if (t === 'document') return 'document';
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function insertReferencedXrefsFromAnalysis(input: {
|
||||
ticketNumber: string;
|
||||
analysisId: string;
|
||||
references: AnalyzerDocReference[];
|
||||
}): Promise<{ inserted: number; skipped: number }> {
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
for (const ref of input.references) {
|
||||
const assetType = mapDocTypeToAssetType(ref.doc_type ?? null);
|
||||
if (!assetType) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const numericId = Number(ref.id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await insertXref({
|
||||
ticketNumber: input.ticketNumber,
|
||||
analysisId: input.analysisId,
|
||||
assetType,
|
||||
assetId: numericId,
|
||||
relationship: 'referenced',
|
||||
source: 'analyzer_referenced',
|
||||
confidence: 'high',
|
||||
details: {
|
||||
name: ref.name ?? null,
|
||||
url: ref.url ?? null,
|
||||
relevance_reason: ref.relevance_reason ?? null,
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
return { inserted, skipped };
|
||||
}
|
||||
|
||||
export async function insertUpdatedXref(input: {
|
||||
ticketNumber: string;
|
||||
analysisId: string | null;
|
||||
assetType: 'flexible_asset' | 'configuration';
|
||||
assetId: string | number;
|
||||
writeId: string;
|
||||
fieldName: string;
|
||||
}): Promise<void> {
|
||||
await insertXref({
|
||||
ticketNumber: input.ticketNumber,
|
||||
analysisId: input.analysisId,
|
||||
assetType: input.assetType,
|
||||
assetId: input.assetId,
|
||||
relationship: 'updated',
|
||||
source: 'audit_write',
|
||||
confidence: 'high',
|
||||
details: {
|
||||
write_id: input.writeId,
|
||||
field_name: input.fieldName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Queries ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listXrefsForAsset(
|
||||
assetType: 'flexible_asset' | 'configuration',
|
||||
assetId: string | number,
|
||||
limit = 100
|
||||
): Promise<XrefRow[]> {
|
||||
const res = await postgresClient.query<RawXrefRow>(
|
||||
`SELECT ${XREF_SELECT}
|
||||
FROM itglue_ticket_xrefs
|
||||
WHERE asset_type = $1 AND asset_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3`,
|
||||
[assetType, assetId, limit]
|
||||
);
|
||||
return res.rows.map(rowToXref);
|
||||
}
|
||||
|
||||
export async function listXrefsForTicket(
|
||||
ticketNumber: string,
|
||||
limit = 100
|
||||
): Promise<XrefRow[]> {
|
||||
const res = await postgresClient.query<RawXrefRow>(
|
||||
`SELECT ${XREF_SELECT}
|
||||
FROM itglue_ticket_xrefs
|
||||
WHERE ticket_number = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[ticketNumber, limit]
|
||||
);
|
||||
return res.rows.map(rowToXref);
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ interface TicketRow {
|
|||
create_date: Date | string;
|
||||
last_activity_date: Date | string;
|
||||
resolved_date_time: Date | string | null;
|
||||
problem_ticket_id: string | null;
|
||||
}
|
||||
|
||||
interface NoteRow {
|
||||
|
|
@ -112,7 +113,8 @@ export async function loadTicketBundle(
|
|||
AS assignee_email,
|
||||
t.create_date AS create_date,
|
||||
t.last_activity_date AS last_activity_date,
|
||||
t.resolved_date_time AS resolved_date_time
|
||||
t.resolved_date_time AS resolved_date_time,
|
||||
t.problem_ticket_id::text AS problem_ticket_id
|
||||
FROM tickets t
|
||||
WHERE t.ticket_number = $1
|
||||
AND COALESCE(t.is_deleted, false) = false
|
||||
|
|
@ -197,6 +199,7 @@ export async function loadTicketBundle(
|
|||
create_date: toIsoRequired(t.create_date),
|
||||
last_activity_date: toIsoRequired(t.last_activity_date),
|
||||
resolved_date_time: toIso(t.resolved_date_time),
|
||||
problem_ticket_id: t.problem_ticket_id ? Number(t.problem_ticket_id) : null,
|
||||
},
|
||||
notes: notesRes.rows.map((n) => ({
|
||||
id: Number(n.id),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
"assignee_email": "cimler@wulfconsulting.com",
|
||||
"create_date": "2026-04-24T12:53:50.163Z",
|
||||
"last_activity_date": "2026-04-29T13:09:58.070Z",
|
||||
"resolved_date_time": null
|
||||
"resolved_date_time": null,
|
||||
"problem_ticket_id": null
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
|
|
|
|||
340
lib/services/analyzer/link-discovery.test.ts
Normal file
340
lib/services/analyzer/link-discovery.test.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
extractExplicitFromText,
|
||||
detectProblemTicket,
|
||||
TICKET_NUMBER_REGEX,
|
||||
MAX_EXPLICIT_LINKS,
|
||||
discoverExplicitLinks,
|
||||
} from './link-discovery';
|
||||
import type { RawTicketBundle } from './preprocessor';
|
||||
|
||||
vi.mock('@/lib/services/postgres-client', () => ({
|
||||
default: {
|
||||
query: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const mockedQuery = postgresClient.query as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function bundle(partial: Partial<RawTicketBundle['ticket']> = {}): RawTicketBundle {
|
||||
return {
|
||||
ticket: {
|
||||
id: 1,
|
||||
ticket_number: 'T20260430.0084',
|
||||
title: 'Master problem ticket — Hynes',
|
||||
description: null,
|
||||
status: 1,
|
||||
status_label: 'New',
|
||||
priority: 1,
|
||||
priority_label: 'High',
|
||||
queue_id: null,
|
||||
queue_label: null,
|
||||
company_id: 100,
|
||||
company_name: 'Hynes Industries',
|
||||
contact_id: null,
|
||||
contact_name: null,
|
||||
contact_email: null,
|
||||
assigned_resource_id: null,
|
||||
assignee_name: null,
|
||||
assignee_email: null,
|
||||
create_date: '2026-04-30T12:00:00Z',
|
||||
last_activity_date: '2026-04-30T12:00:00Z',
|
||||
resolved_date_time: null,
|
||||
problem_ticket_id: null,
|
||||
...partial,
|
||||
},
|
||||
notes: [],
|
||||
time_entries: [],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedQuery.mockReset();
|
||||
});
|
||||
|
||||
describe('TICKET_NUMBER_REGEX', () => {
|
||||
it('matches the canonical Pulse format', () => {
|
||||
const m = 'see T20260430.0084 and T20260427.0142'.match(TICKET_NUMBER_REGEX);
|
||||
expect(m).toEqual(['T20260430.0084', 'T20260427.0142']);
|
||||
});
|
||||
|
||||
it('does not match invalid lengths', () => {
|
||||
expect('T2026.0084'.match(TICKET_NUMBER_REGEX)).toBeNull();
|
||||
expect('T20260430.84'.match(TICKET_NUMBER_REGEX)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractExplicitFromText', () => {
|
||||
it('returns medium-confidence refs from a free-text mention', () => {
|
||||
const r = extractExplicitFromText(
|
||||
'See T20260427.0142 for context.',
|
||||
'note_mention'
|
||||
);
|
||||
expect(r.refs).toEqual([
|
||||
{ ticket_number: 'T20260427.0142', source: 'note_mention', confidence: 'medium' },
|
||||
]);
|
||||
expect(r.hasRelatedTicketsSection).toBe(false);
|
||||
});
|
||||
|
||||
it('marks refs in a RELATED TICKETS: block as high confidence', () => {
|
||||
const text = `Master problem ticket.
|
||||
|
||||
RELATED TICKETS:
|
||||
T20260428.0053 — Allison Leone packet loss (OPEN)
|
||||
T20260427.0142 — George Droder Zoom dropping (OPEN)
|
||||
|
||||
AFFECTED USERS:
|
||||
Allison, George`;
|
||||
const r = extractExplicitFromText(text, 'description_mention');
|
||||
expect(r.hasRelatedTicketsSection).toBe(true);
|
||||
expect(r.refs).toEqual([
|
||||
{
|
||||
ticket_number: 'T20260428.0053',
|
||||
source: 'related_tickets_section',
|
||||
confidence: 'high',
|
||||
},
|
||||
{
|
||||
ticket_number: 'T20260427.0142',
|
||||
source: 'related_tickets_section',
|
||||
confidence: 'high',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mark refs after the RELATED TICKETS section ends as high', () => {
|
||||
const text = `RELATED TICKETS:
|
||||
T20260428.0053 — first
|
||||
|
||||
OTHER NOTES:
|
||||
Background investigation found T20260101.0001 was a duplicate.`;
|
||||
const r = extractExplicitFromText(text, 'description_mention');
|
||||
const high = r.refs.find((x) => x.ticket_number === 'T20260428.0053');
|
||||
const other = r.refs.find((x) => x.ticket_number === 'T20260101.0001');
|
||||
expect(high?.confidence).toBe('high');
|
||||
expect(other?.confidence).toBe('medium');
|
||||
expect(other?.source).toBe('description_mention');
|
||||
});
|
||||
|
||||
it('dedupes within a single text', () => {
|
||||
const r = extractExplicitFromText(
|
||||
'T20260427.0142 first, T20260427.0142 again, and T20260427.0142 once more.',
|
||||
'note_mention'
|
||||
);
|
||||
expect(r.refs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty for empty input', () => {
|
||||
expect(extractExplicitFromText('', 'note_mention').refs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectProblemTicket', () => {
|
||||
it('flags master-problem-ticket title', () => {
|
||||
const r = detectProblemTicket(
|
||||
bundle({ title: 'Master problem ticket — recurring degradation' }),
|
||||
false
|
||||
);
|
||||
expect(r.isProblemTicket).toBe(true);
|
||||
expect(r.signals).toContain('title:master_problem_ticket');
|
||||
});
|
||||
|
||||
it('flags problem-ticket title', () => {
|
||||
const r = detectProblemTicket(
|
||||
bundle({ title: 'Problem ticket: keyboard outage' }),
|
||||
false
|
||||
);
|
||||
expect(r.isProblemTicket).toBe(true);
|
||||
expect(r.signals).toContain('title:problem_ticket');
|
||||
});
|
||||
|
||||
it('flags presence of RELATED TICKETS section', () => {
|
||||
const r = detectProblemTicket(
|
||||
bundle({ title: 'Plain ticket' }),
|
||||
true
|
||||
);
|
||||
expect(r.isProblemTicket).toBe(true);
|
||||
expect(r.signals).toContain('description:related_tickets_section');
|
||||
});
|
||||
|
||||
it('flags problem_ticket_id column', () => {
|
||||
const r = detectProblemTicket(
|
||||
bundle({ title: 'Plain ticket', problem_ticket_id: 999 }),
|
||||
false
|
||||
);
|
||||
expect(r.isProblemTicket).toBe(true);
|
||||
expect(r.signals).toContain('column:problem_ticket_id');
|
||||
});
|
||||
|
||||
it('returns false when none of the signals are present', () => {
|
||||
const r = detectProblemTicket(bundle({ title: 'Plain ticket' }), false);
|
||||
expect(r.isProblemTicket).toBe(false);
|
||||
expect(r.signals).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverExplicitLinks', () => {
|
||||
it('skips self-references and unknown tickets', async () => {
|
||||
const b = bundle({
|
||||
description:
|
||||
'master ref T20260430.0084 (self), real ref T20260428.0053, ghost T20260101.9999',
|
||||
});
|
||||
// First call: meta lookup. Only T20260428.0053 exists.
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
ticket_number: 'T20260428.0053',
|
||||
title: 'Allison Leone',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-30T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await discoverExplicitLinks(b);
|
||||
expect(r.explicit).toHaveLength(1);
|
||||
expect(r.explicit[0].ticket_number).toBe('T20260428.0053');
|
||||
});
|
||||
|
||||
it('caps explicit refs at MAX_EXPLICIT_LINKS', async () => {
|
||||
const refs = Array.from({ length: 30 }, (_, i) => `T2026010${i}.0001`).join(', ');
|
||||
const b = bundle({ description: `Many refs: ${refs}` });
|
||||
// Return meta for all 15 it queries.
|
||||
mockedQuery.mockImplementationOnce(async (_sql: string, params: unknown[]) => {
|
||||
const numbers = params[0] as string[];
|
||||
expect(numbers.length).toBeLessThanOrEqual(MAX_EXPLICIT_LINKS);
|
||||
return {
|
||||
rowCount: numbers.length,
|
||||
rows: numbers.map((n) => ({
|
||||
ticket_number: n,
|
||||
title: 't',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-30T10:00:00Z',
|
||||
})),
|
||||
};
|
||||
});
|
||||
const r = await discoverExplicitLinks(b);
|
||||
expect(r.explicit.length).toBeLessThanOrEqual(MAX_EXPLICIT_LINKS);
|
||||
});
|
||||
|
||||
it('resolves problem_ticket_id and dedupes against text mention', async () => {
|
||||
const b = bundle({
|
||||
description: 'See T20260427.0142 for context',
|
||||
problem_ticket_id: 555,
|
||||
});
|
||||
// Call 1: resolve problem_ticket_id → ticket_number.
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [{ ticket_number: 'T20260427.0142' }],
|
||||
});
|
||||
// Call 2: meta lookup.
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
ticket_number: 'T20260427.0142',
|
||||
title: 'George Droder',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-29T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await discoverExplicitLinks(b);
|
||||
// Same ticket from two sources should appear once at the higher confidence.
|
||||
expect(r.explicit).toHaveLength(1);
|
||||
expect(r.explicit[0].confidence).toBe('high');
|
||||
expect(r.explicit[0].source).toBe('problem_ticket_id');
|
||||
});
|
||||
|
||||
it('sorts high confidence first, then by activity date desc', async () => {
|
||||
const b = bundle({
|
||||
description: `Master.
|
||||
|
||||
RELATED TICKETS:
|
||||
T20260428.0053 — high
|
||||
|
||||
Body mention: T20260427.0142 — medium`,
|
||||
});
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 2,
|
||||
rows: [
|
||||
{
|
||||
ticket_number: 'T20260427.0142',
|
||||
title: 'a',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-30T10:00:00Z',
|
||||
},
|
||||
{
|
||||
ticket_number: 'T20260428.0053',
|
||||
title: 'b',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-29T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await discoverExplicitLinks(b);
|
||||
expect(r.explicit.map((x) => x.ticket_number)).toEqual([
|
||||
'T20260428.0053',
|
||||
'T20260427.0142',
|
||||
]);
|
||||
expect(r.isProblemTicket).toBe(true);
|
||||
expect(r.problemTicketSignals).toContain('description:related_tickets_section');
|
||||
});
|
||||
|
||||
it('returns empty when there are no refs and no signals', async () => {
|
||||
const b = bundle({ description: 'No ticket refs in here.', title: 'Plain ticket' });
|
||||
mockedQuery.mockResolvedValueOnce({ rowCount: 0, rows: [] });
|
||||
const r = await discoverExplicitLinks(b);
|
||||
expect(r.explicit).toEqual([]);
|
||||
expect(r.isProblemTicket).toBe(false);
|
||||
});
|
||||
|
||||
it('parses refs out of retained notes too, ignoring workflow noise', async () => {
|
||||
const b = bundle({
|
||||
description: null,
|
||||
title: 'Plain',
|
||||
});
|
||||
// Inject a workflow-noise note (filtered) and a real note (kept).
|
||||
b.notes = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Workflow Rule "Foo" fired.',
|
||||
description: 'Mentions T20260101.0001 but should be ignored',
|
||||
note_type: 13,
|
||||
publish: 1,
|
||||
creator_resource_id: 4,
|
||||
creator_name: 'Autotask Administrator',
|
||||
creator_email: null,
|
||||
creator_type: 1,
|
||||
create_date_time: '2026-04-30T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Tech note',
|
||||
description: 'See T20260427.0142 for the related issue',
|
||||
note_type: 1,
|
||||
publish: 1,
|
||||
creator_resource_id: 50,
|
||||
creator_name: 'Tech',
|
||||
creator_email: 'tech@wulfconsulting.com',
|
||||
creator_type: 1,
|
||||
create_date_time: '2026-04-30T13:00:00Z',
|
||||
},
|
||||
];
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
ticket_number: 'T20260427.0142',
|
||||
title: 'real',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-30T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await discoverExplicitLinks(b);
|
||||
expect(r.explicit.map((x) => x.ticket_number)).toEqual(['T20260427.0142']);
|
||||
expect(r.explicit[0].source).toBe('note_mention');
|
||||
});
|
||||
});
|
||||
441
lib/services/analyzer/link-discovery.ts
Normal file
441
lib/services/analyzer/link-discovery.ts
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
/**
|
||||
* Link discovery for the AI Ticket Analyzer.
|
||||
*
|
||||
* Given a ticket bundle (from data-access.loadTicketBundle), find every other
|
||||
* ticket the analyzer should bundle in. Two arms:
|
||||
*
|
||||
* 1. Explicit (cheap, deterministic): regex over the description + each
|
||||
* retained note for ticket-number references, recognition of the
|
||||
* structured "RELATED TICKETS:" block, and the ticket's
|
||||
* problem_ticket_id column. No LLM calls.
|
||||
*
|
||||
* 2. Suggested (Haiku, opt-in): one LLM pass over recent same-company
|
||||
* tickets ranking semantic similarity to the master.
|
||||
*
|
||||
* Returns refs paired with confidence + source so the UI can surface the
|
||||
* provenance of each suggestion.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
type DiscoveredLinks,
|
||||
type LinkConfidence,
|
||||
type LinkSource,
|
||||
type TicketRef,
|
||||
} from '@/lib/types/analyzer';
|
||||
import type { RawTicketBundle } from './preprocessor';
|
||||
import { isWorkflowNoise, isEmailNotification } from './preprocessor';
|
||||
import { callLLMStage } from '@/lib/services/llm/call';
|
||||
import { HAIKU } from '@/lib/services/llm/models';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Pulse ticket-number format: T<YYYYMMDD>.<####>. Confirmed against
|
||||
* tickets.ticket_number in migration 001 and Autotask's webhook payloads.
|
||||
*/
|
||||
export const TICKET_NUMBER_REGEX = /T\d{8}\.\d{4}/g;
|
||||
|
||||
export const MAX_EXPLICIT_LINKS = 15;
|
||||
export const MAX_SUGGESTED_LINKS = 5;
|
||||
const SUGGESTED_CANDIDATE_LIMIT = 50;
|
||||
const SUGGESTED_CANDIDATE_DAYS = 30;
|
||||
const SUGGESTED_DESCRIPTION_CHAR_CAP = 1024;
|
||||
const SUGGESTED_MAX_TOKENS = 1500;
|
||||
|
||||
interface RawRef {
|
||||
ticket_number: string;
|
||||
source: LinkSource;
|
||||
confidence: LinkConfidence;
|
||||
}
|
||||
|
||||
interface ExtractedExplicit {
|
||||
refs: RawRef[];
|
||||
hasRelatedTicketsSection: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every T-number out of a single chunk of free text. Refs that appear
|
||||
* inside (or directly after) the literal "RELATED TICKETS:" header are flagged
|
||||
* 'high' confidence; others are 'medium'.
|
||||
*/
|
||||
export function extractExplicitFromText(
|
||||
text: string,
|
||||
source: LinkSource
|
||||
): ExtractedExplicit {
|
||||
if (!text) return { refs: [], hasRelatedTicketsSection: false };
|
||||
|
||||
const refs: RawRef[] = [];
|
||||
|
||||
// Detect a "RELATED TICKETS:" block: header line, followed by lines containing
|
||||
// T-numbers, until either an empty line or a new section header (UPPER CASE
|
||||
// followed by colon at start of line).
|
||||
const sectionHeaderMatch = /^[ \t]*RELATED TICKETS\s*:?\s*$/im.exec(text);
|
||||
let sectionRefs = new Set<string>();
|
||||
if (sectionHeaderMatch && sectionHeaderMatch.index !== undefined) {
|
||||
const after = text.slice(
|
||||
sectionHeaderMatch.index + sectionHeaderMatch[0].length
|
||||
);
|
||||
// Lookahead: stop at next blank line, or at a line that looks like a new
|
||||
// ALL-CAPS section header. This is permissive — the format we've seen at
|
||||
// Wulf is `T20260428.0053 — note text\nT20260427.0142 — note text\n\n`.
|
||||
const sectionEnd = after.search(/\n\s*\n|\n[A-Z][A-Z _]+:/);
|
||||
const section = sectionEnd === -1 ? after : after.slice(0, sectionEnd);
|
||||
const matches = section.match(TICKET_NUMBER_REGEX) ?? [];
|
||||
sectionRefs = new Set(matches);
|
||||
}
|
||||
|
||||
const allMatches = text.match(TICKET_NUMBER_REGEX) ?? [];
|
||||
const seen = new Set<string>();
|
||||
for (const num of allMatches) {
|
||||
if (seen.has(num)) continue;
|
||||
seen.add(num);
|
||||
if (sectionRefs.has(num)) {
|
||||
refs.push({
|
||||
ticket_number: num,
|
||||
source: 'related_tickets_section',
|
||||
confidence: 'high',
|
||||
});
|
||||
} else {
|
||||
refs.push({ ticket_number: num, source, confidence: 'medium' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
refs,
|
||||
hasRelatedTicketsSection: sectionRefs.size > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Look at the title + description for hints that this is a master/problem
|
||||
* ticket. Used purely as a UI signal — does not gate any behavior.
|
||||
*/
|
||||
export function detectProblemTicket(
|
||||
bundle: RawTicketBundle,
|
||||
hasRelatedTicketsSection: boolean
|
||||
): { isProblemTicket: boolean; signals: string[] } {
|
||||
const signals: string[] = [];
|
||||
const title = (bundle.ticket.title ?? '').toLowerCase();
|
||||
if (title.includes('master problem ticket')) signals.push('title:master_problem_ticket');
|
||||
else if (title.includes('problem ticket')) signals.push('title:problem_ticket');
|
||||
if (hasRelatedTicketsSection) signals.push('description:related_tickets_section');
|
||||
if (bundle.ticket.problem_ticket_id !== null) signals.push('column:problem_ticket_id');
|
||||
return { isProblemTicket: signals.length > 0, signals };
|
||||
}
|
||||
|
||||
interface MetaRow {
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
status_label: string | null;
|
||||
last_activity_date: Date | string | null;
|
||||
}
|
||||
|
||||
async function loadTicketMeta(
|
||||
ticketNumbers: string[]
|
||||
): Promise<Map<string, MetaRow>> {
|
||||
const out = new Map<string, MetaRow>();
|
||||
if (ticketNumbers.length === 0) return out;
|
||||
const res = await postgresClient.query<{
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
status_label: string | null;
|
||||
last_activity_date: Date | string | null;
|
||||
}>(
|
||||
`SELECT t.ticket_number,
|
||||
t.title,
|
||||
(SELECT label FROM statuses WHERE value = t.status) AS status_label,
|
||||
t.last_activity_date
|
||||
FROM tickets t
|
||||
WHERE t.ticket_number = ANY($1::text[])
|
||||
AND COALESCE(t.is_deleted, false) = false`,
|
||||
[ticketNumbers]
|
||||
);
|
||||
for (const r of res.rows) out.set(r.ticket_number, r);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function resolveProblemTicketNumber(
|
||||
problemTicketId: number
|
||||
): Promise<string | null> {
|
||||
const res = await postgresClient.query<{ ticket_number: string }>(
|
||||
`SELECT ticket_number FROM tickets
|
||||
WHERE id = $1 AND COALESCE(is_deleted, false) = false LIMIT 1`,
|
||||
[problemTicketId]
|
||||
);
|
||||
return res.rowCount === 0 ? null : res.rows[0].ticket_number;
|
||||
}
|
||||
|
||||
function toIsoOrNull(d: Date | string | null): string | null {
|
||||
if (d === null || d === undefined) return null;
|
||||
if (d instanceof Date) return d.toISOString();
|
||||
return new Date(d).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a TicketRef list, dedup-merging the same ticket_number across multiple
|
||||
* sources (highest confidence wins; first-seen source is preserved).
|
||||
*/
|
||||
function consolidate(
|
||||
raw: RawRef[],
|
||||
meta: Map<string, MetaRow>
|
||||
): TicketRef[] {
|
||||
const merged = new Map<string, RawRef>();
|
||||
for (const r of raw) {
|
||||
const existing = merged.get(r.ticket_number);
|
||||
if (!existing) {
|
||||
merged.set(r.ticket_number, r);
|
||||
continue;
|
||||
}
|
||||
// Promote to high if any source claims high.
|
||||
if (existing.confidence !== 'high' && r.confidence === 'high') {
|
||||
merged.set(r.ticket_number, r);
|
||||
}
|
||||
}
|
||||
const out: TicketRef[] = [];
|
||||
for (const [num, ref] of merged.entries()) {
|
||||
const m = meta.get(num);
|
||||
if (!m) continue; // not in our local mirror — drop silently
|
||||
out.push({
|
||||
ticket_number: num,
|
||||
title: m.title,
|
||||
status_label: m.status_label,
|
||||
last_activity_date: toIsoOrNull(m.last_activity_date),
|
||||
source: ref.source,
|
||||
confidence: ref.confidence,
|
||||
reason: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function discoverExplicitLinks(
|
||||
bundle: RawTicketBundle
|
||||
): Promise<{
|
||||
explicit: TicketRef[];
|
||||
isProblemTicket: boolean;
|
||||
problemTicketSignals: string[];
|
||||
}> {
|
||||
const raw: RawRef[] = [];
|
||||
let hasSection = false;
|
||||
|
||||
// 1. Description.
|
||||
if (bundle.ticket.description) {
|
||||
const r = extractExplicitFromText(
|
||||
bundle.ticket.description,
|
||||
'description_mention'
|
||||
);
|
||||
raw.push(...r.refs);
|
||||
if (r.hasRelatedTicketsSection) hasSection = true;
|
||||
}
|
||||
|
||||
// 2. Each retained note (filtered the same way the preprocessor does).
|
||||
for (const note of bundle.notes) {
|
||||
if (isWorkflowNoise(note) || isEmailNotification(note)) continue;
|
||||
if (!note.description) continue;
|
||||
const r = extractExplicitFromText(note.description, 'note_mention');
|
||||
raw.push(...r.refs);
|
||||
if (r.hasRelatedTicketsSection) hasSection = true;
|
||||
}
|
||||
|
||||
// 3. problem_ticket_id column.
|
||||
if (bundle.ticket.problem_ticket_id !== null) {
|
||||
const ptn = await resolveProblemTicketNumber(bundle.ticket.problem_ticket_id);
|
||||
if (ptn) {
|
||||
raw.push({
|
||||
ticket_number: ptn,
|
||||
source: 'problem_ticket_id',
|
||||
confidence: 'high',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Drop self-references.
|
||||
const selfNumber = bundle.ticket.ticket_number;
|
||||
const filteredRaw = raw.filter((r) => r.ticket_number !== selfNumber);
|
||||
|
||||
// 5. Cap and verify against local mirror.
|
||||
const uniqueNumbers = Array.from(
|
||||
new Set(filteredRaw.map((r) => r.ticket_number))
|
||||
).slice(0, MAX_EXPLICIT_LINKS);
|
||||
const meta = await loadTicketMeta(uniqueNumbers);
|
||||
const refsInSet = filteredRaw.filter((r) => uniqueNumbers.includes(r.ticket_number));
|
||||
const explicit = consolidate(refsInSet, meta);
|
||||
|
||||
// 6. Sort: high confidence first, then most-recent activity.
|
||||
explicit.sort((a, b) => {
|
||||
if (a.confidence !== b.confidence) {
|
||||
return a.confidence === 'high' ? -1 : 1;
|
||||
}
|
||||
const at = a.last_activity_date ?? '';
|
||||
const bt = b.last_activity_date ?? '';
|
||||
return bt.localeCompare(at);
|
||||
});
|
||||
|
||||
const ptDetect = detectProblemTicket(bundle, hasSection);
|
||||
return {
|
||||
explicit,
|
||||
isProblemTicket: ptDetect.isProblemTicket,
|
||||
problemTicketSignals: ptDetect.signals,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LLM-suggested arm (Haiku) — opt-in.
|
||||
// =============================================================================
|
||||
|
||||
const SuggestedSchema = z.object({
|
||||
suggestions: z
|
||||
.array(
|
||||
z.object({
|
||||
ticket_number: z.string(),
|
||||
reason: z.string(),
|
||||
})
|
||||
)
|
||||
.max(MAX_SUGGESTED_LINKS),
|
||||
});
|
||||
|
||||
const SUGGEST_SYSTEM_PROMPT = `You are helping decide which other tickets at this MSP client are likely related to a master ticket the user is investigating.
|
||||
|
||||
You will receive:
|
||||
- The master ticket (number, title, first ~1KB of description).
|
||||
- A list of recent same-client tickets with their numbers and titles.
|
||||
|
||||
Return up to ${MAX_SUGGESTED_LINKS} candidate tickets that look semantically related — same affected systems, users, sites, vendors, symptoms, or recurrence patterns. Skip generic alert tickets that aren't clearly related. Skip tickets that share only the client name.
|
||||
|
||||
Respond ONLY with JSON. No prose, no code fences.
|
||||
|
||||
Schema:
|
||||
{ "suggestions": [{ "ticket_number": "T20260430.0084", "reason": "one short sentence" }] }`;
|
||||
|
||||
interface CandidateRow {
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
status_label: string | null;
|
||||
last_activity_date: Date | string | null;
|
||||
create_date: Date | string;
|
||||
}
|
||||
|
||||
async function loadSuggestionCandidates(
|
||||
bundle: RawTicketBundle,
|
||||
excludeNumbers: Set<string>
|
||||
): Promise<CandidateRow[]> {
|
||||
const res = await postgresClient.query<CandidateRow>(
|
||||
`SELECT t.ticket_number,
|
||||
t.title,
|
||||
(SELECT label FROM statuses WHERE value = t.status) AS status_label,
|
||||
t.last_activity_date,
|
||||
t.create_date
|
||||
FROM tickets t
|
||||
WHERE t.company_id = $1
|
||||
AND COALESCE(t.is_deleted, false) = false
|
||||
AND t.ticket_number <> $2
|
||||
AND t.create_date >= ($3::timestamp - ($4::int || ' days')::interval)
|
||||
AND t.create_date <= ($3::timestamp + INTERVAL '1 day')
|
||||
ORDER BY t.create_date DESC
|
||||
LIMIT $5`,
|
||||
[
|
||||
bundle.ticket.company_id,
|
||||
bundle.ticket.ticket_number,
|
||||
bundle.ticket.create_date,
|
||||
SUGGESTED_CANDIDATE_DAYS,
|
||||
SUGGESTED_CANDIDATE_LIMIT + excludeNumbers.size,
|
||||
]
|
||||
);
|
||||
return res.rows.filter((r) => !excludeNumbers.has(r.ticket_number)).slice(
|
||||
0,
|
||||
SUGGESTED_CANDIDATE_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
export async function suggestRelatedLinks(
|
||||
bundle: RawTicketBundle,
|
||||
excludeTicketNumbers: string[]
|
||||
): Promise<TicketRef[]> {
|
||||
const exclude = new Set(excludeTicketNumbers);
|
||||
exclude.add(bundle.ticket.ticket_number);
|
||||
|
||||
const candidates = await loadSuggestionCandidates(bundle, exclude);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const description = (bundle.ticket.description ?? '').slice(
|
||||
0,
|
||||
SUGGESTED_DESCRIPTION_CHAR_CAP
|
||||
);
|
||||
|
||||
const userPayload = [
|
||||
`=== MASTER TICKET ===`,
|
||||
JSON.stringify(
|
||||
{
|
||||
ticket_number: bundle.ticket.ticket_number,
|
||||
title: bundle.ticket.title,
|
||||
description,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
``,
|
||||
`=== CANDIDATE TICKETS (recent same-client, newest first) ===`,
|
||||
JSON.stringify(
|
||||
candidates.map((c) => ({
|
||||
ticket_number: c.ticket_number,
|
||||
title: c.title,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const result = await callLLMStage({
|
||||
model: HAIKU,
|
||||
system: SUGGEST_SYSTEM_PROMPT,
|
||||
user: userPayload,
|
||||
schema: SuggestedSchema,
|
||||
maxTokens: SUGGESTED_MAX_TOKENS,
|
||||
});
|
||||
|
||||
const candidateMap = new Map(candidates.map((c) => [c.ticket_number, c]));
|
||||
const out: TicketRef[] = [];
|
||||
for (const s of result.data.suggestions) {
|
||||
const c = candidateMap.get(s.ticket_number);
|
||||
if (!c) continue; // hallucination guard — model named a non-candidate
|
||||
if (exclude.has(s.ticket_number)) continue;
|
||||
out.push({
|
||||
ticket_number: s.ticket_number,
|
||||
title: c.title,
|
||||
status_label: c.status_label,
|
||||
last_activity_date: toIsoOrNull(c.last_activity_date),
|
||||
source: 'llm_suggested',
|
||||
confidence: 'medium',
|
||||
reason: s.reason,
|
||||
});
|
||||
if (out.length >= MAX_SUGGESTED_LINKS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function discoverLinks(
|
||||
bundle: RawTicketBundle,
|
||||
options: { includeSuggested?: boolean } = {}
|
||||
): Promise<DiscoveredLinks> {
|
||||
const explicitResult = await discoverExplicitLinks(bundle);
|
||||
let suggested: TicketRef[] = [];
|
||||
if (options.includeSuggested) {
|
||||
const exclude = explicitResult.explicit.map((r) => r.ticket_number);
|
||||
try {
|
||||
suggested = await suggestRelatedLinks(bundle, exclude);
|
||||
} catch (err) {
|
||||
// Suggestion is opportunistic — never fail the whole call on its
|
||||
// account. Surface the failure to logs only.
|
||||
console.warn(
|
||||
`[ANALYZER-LINKS] suggestion arm failed for ${bundle.ticket.ticket_number}:`,
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
explicit: explicitResult.explicit,
|
||||
suggested,
|
||||
isProblemTicket: explicitResult.isProblemTicket,
|
||||
problemTicketSignals: explicitResult.problemTicketSignals,
|
||||
};
|
||||
}
|
||||
|
|
@ -28,6 +28,9 @@ export interface InsertAnalysisInput {
|
|||
/** When the analysis run finished (now() if undefined). */
|
||||
completed_at?: Date;
|
||||
|
||||
/** anthropic | openrouter — defaults to 'anthropic' for back-compat. */
|
||||
provider?: 'anthropic' | 'openrouter';
|
||||
|
||||
haiku_used: boolean;
|
||||
sonnet_used: boolean;
|
||||
opus_used: boolean;
|
||||
|
|
@ -49,40 +52,49 @@ export interface InsertAnalysisInput {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the next monotonic analysis_version for this ticket. Uses MAX(...)+1
|
||||
* — there is a small race if two workers call this simultaneously, but the
|
||||
* UNIQUE (ticket_number, analysis_version) constraint catches it: the loser
|
||||
* sees a 23505 unique_violation and the worker should retry with a fresh
|
||||
* version number.
|
||||
* Returns the next monotonic analysis_version for this ticket **and provider**.
|
||||
* Uses MAX(...)+1 — there is a small race if two workers call this
|
||||
* simultaneously, but the UNIQUE (ticket_number, provider, analysis_version)
|
||||
* constraint catches it: the loser sees a 23505 unique_violation and the
|
||||
* worker should retry with a fresh version number.
|
||||
*/
|
||||
export async function getNextAnalysisVersion(ticketNumber: string): Promise<number> {
|
||||
export async function getNextAnalysisVersion(
|
||||
ticketNumber: string,
|
||||
provider: 'anthropic' | 'openrouter' = 'anthropic'
|
||||
): Promise<number> {
|
||||
const res = await postgresClient.query<{ next_version: string }>(
|
||||
`SELECT COALESCE(MAX(analysis_version), 0) + 1 AS next_version
|
||||
FROM analyzer_analyses
|
||||
WHERE ticket_number = $1`,
|
||||
[ticketNumber]
|
||||
WHERE ticket_number = $1
|
||||
AND provider = $2`,
|
||||
[ticketNumber, provider]
|
||||
);
|
||||
return Number(res.rows[0].next_version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotency check: returns the most recent COMPLETE analysis row whose
|
||||
* content_hash matches, if any. Used to short-circuit re-runs when the source
|
||||
* data hasn't changed and `force=false`.
|
||||
* content_hash matches **for the given provider**, if any. Used to
|
||||
* short-circuit re-runs when the source data hasn't changed and `force=false`.
|
||||
*
|
||||
* Provider-scoped so a Claude run doesn't short-circuit a request for a
|
||||
* DeepSeek run (and vice versa) — the user wants a parallel analysis.
|
||||
*/
|
||||
export async function findExistingAnalysisByContentHash(
|
||||
ticketNumber: string,
|
||||
contentHash: string
|
||||
contentHash: string,
|
||||
provider: 'anthropic' | 'openrouter' = 'anthropic'
|
||||
): Promise<{ id: string; analysis_version: number } | null> {
|
||||
const res = await postgresClient.query<{ id: string; analysis_version: string }>(
|
||||
`SELECT id::text AS id, analysis_version::text AS analysis_version
|
||||
FROM analyzer_analyses
|
||||
WHERE ticket_number = $1
|
||||
AND content_hash_at_analysis = $2
|
||||
AND provider = $3
|
||||
AND status = 'complete'
|
||||
ORDER BY analysis_version DESC
|
||||
LIMIT 1`,
|
||||
[ticketNumber, contentHash]
|
||||
[ticketNumber, contentHash, provider]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
const row = res.rows[0];
|
||||
|
|
@ -100,7 +112,8 @@ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
|
|||
id: string;
|
||||
analysis_version: number;
|
||||
}> {
|
||||
const version = await getNextAnalysisVersion(input.ticket_number);
|
||||
const provider = input.provider ?? 'anthropic';
|
||||
const version = await getNextAnalysisVersion(input.ticket_number, provider);
|
||||
const completedAt = input.completed_at ?? new Date();
|
||||
const a = input.analysis;
|
||||
|
||||
|
|
@ -116,7 +129,7 @@ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
|
|||
gaps, next_step, next_step_rationale, post_resolution_analysis,
|
||||
confidence_score, needs_human_review, human_review_reasons,
|
||||
itglue_docs_referenced, model_traces, filtered_noise_count, error_message,
|
||||
source_snapshot
|
||||
source_snapshot, provider
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
|
|
@ -128,7 +141,7 @@ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
|
|||
$18::jsonb, $19, $20, $21,
|
||||
$22, $23, $24::jsonb,
|
||||
$25::jsonb, $26::jsonb, $27, $28,
|
||||
$29::jsonb
|
||||
$29::jsonb, $30
|
||||
)
|
||||
RETURNING id::text AS id
|
||||
`,
|
||||
|
|
@ -164,6 +177,7 @@ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
|
|||
input.filtered_noise_count,
|
||||
input.error_message ?? null,
|
||||
input.source_snapshot ? JSON.stringify(input.source_snapshot) : null,
|
||||
provider,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -249,6 +263,7 @@ export async function insertFailedAnalysis(input: {
|
|||
haiku_used: boolean;
|
||||
sonnet_used: boolean;
|
||||
opus_used: boolean;
|
||||
provider?: 'anthropic' | 'openrouter';
|
||||
}): Promise<{ id: string; analysis_version: number }> {
|
||||
return await insertAnalysis({
|
||||
ticket_number: input.ticket_number,
|
||||
|
|
@ -267,6 +282,7 @@ export async function insertFailedAnalysis(input: {
|
|||
model_traces: {},
|
||||
source_snapshot: input.source_snapshot,
|
||||
error_message: input.error_message,
|
||||
provider: input.provider,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -282,11 +298,13 @@ export async function claimQueuedJob(): Promise<{
|
|||
id: string;
|
||||
ticket_number: string;
|
||||
queued_by_user_id: string | null;
|
||||
provider: 'anthropic' | 'openrouter';
|
||||
} | null> {
|
||||
const res = await postgresClient.query<{
|
||||
id: string;
|
||||
ticket_number: string;
|
||||
queued_by_user_id: string | null;
|
||||
provider: 'anthropic' | 'openrouter';
|
||||
}>(
|
||||
`
|
||||
UPDATE analyzer_jobs
|
||||
|
|
@ -298,7 +316,7 @@ export async function claimQueuedJob(): Promise<{
|
|||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id::text AS id, ticket_number, queued_by_user_id
|
||||
RETURNING id::text AS id, ticket_number, queued_by_user_id, provider
|
||||
`
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
|
|
@ -365,14 +383,15 @@ export async function failJob(jobId: string, errorMessage: string): Promise<void
|
|||
export interface QueueJobInput {
|
||||
ticket_number: string;
|
||||
queued_by_user_id: string | null;
|
||||
provider?: 'anthropic' | 'openrouter';
|
||||
}
|
||||
|
||||
export async function queueJob(input: QueueJobInput): Promise<{ id: string }> {
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`INSERT INTO analyzer_jobs (ticket_number, queued_by_user_id)
|
||||
VALUES ($1, $2)
|
||||
`INSERT INTO analyzer_jobs (ticket_number, queued_by_user_id, provider)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id::text AS id`,
|
||||
[input.ticket_number, input.queued_by_user_id]
|
||||
[input.ticket_number, input.queued_by_user_id, input.provider ?? 'anthropic']
|
||||
);
|
||||
return { id: res.rows[0].id };
|
||||
}
|
||||
|
|
@ -444,6 +463,7 @@ interface AnalysisRow {
|
|||
itglue_docs_referenced: unknown;
|
||||
filtered_noise_count: number;
|
||||
error_message: string | null;
|
||||
provider: 'anthropic' | 'openrouter';
|
||||
}
|
||||
|
||||
function rowToPersistedAnalysis(r: AnalysisRow): PersistedAnalysis {
|
||||
|
|
@ -483,6 +503,7 @@ function rowToPersistedAnalysis(r: AnalysisRow): PersistedAnalysis {
|
|||
(r.itglue_docs_referenced as PersistedAnalysis['itglueDocsReferenced']) ?? [],
|
||||
filteredNoiseCount: r.filtered_noise_count,
|
||||
errorMessage: r.error_message,
|
||||
provider: r.provider,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -505,7 +526,8 @@ const ANALYSIS_SELECT = `
|
|||
human_review_reasons,
|
||||
itglue_docs_referenced,
|
||||
filtered_noise_count,
|
||||
error_message
|
||||
error_message,
|
||||
provider
|
||||
`;
|
||||
|
||||
export async function getAnalysisById(
|
||||
|
|
@ -522,11 +544,15 @@ export async function getAnalysisById(
|
|||
export async function listAnalysesByTicketNumber(
|
||||
ticketNumber: string
|
||||
): Promise<PersistedAnalysis[]> {
|
||||
// Order chronologically (most recent first) so the latest run shows up at
|
||||
// the top of the history regardless of provider. Two providers maintain
|
||||
// their own monotonic version numbers, so a strict version sort would
|
||||
// interleave them oddly.
|
||||
const res = await postgresClient.query<AnalysisRow>(
|
||||
`SELECT ${ANALYSIS_SELECT}
|
||||
FROM analyzer_analyses
|
||||
WHERE ticket_number = $1
|
||||
ORDER BY analysis_version DESC`,
|
||||
ORDER BY triggered_at DESC, analysis_version DESC`,
|
||||
[ticketNumber]
|
||||
);
|
||||
return res.rows.map(rowToPersistedAnalysis);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ import {
|
|||
findExistingAnalysisByContentHash,
|
||||
} from './persistence';
|
||||
import type { TokenUsage } from '@/lib/services/llm/pricing';
|
||||
import {
|
||||
type Provider,
|
||||
stageModelsFor,
|
||||
} from '@/lib/services/llm/models';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
/**
|
||||
|
|
@ -64,6 +68,8 @@ export interface PipelineInput {
|
|||
force?: boolean;
|
||||
/** Override Stage 4 — useful for tests + cost-conscious operators. */
|
||||
forceSkipOpus?: boolean;
|
||||
/** LLM provider for this run. Defaults to 'anthropic' for back-compat. */
|
||||
provider?: Provider;
|
||||
}
|
||||
|
||||
export interface PipelineRunMeta {
|
||||
|
|
@ -209,6 +215,8 @@ export async function runPipeline(
|
|||
): Promise<PipelineResult> {
|
||||
const itglueSearchFn = deps.itglueSearch ?? itglueSearch;
|
||||
const anthropic = deps.anthropic;
|
||||
const provider: Provider = input.provider ?? 'anthropic';
|
||||
const stageModels = stageModelsFor(provider);
|
||||
|
||||
// ── Stage 0: preprocess ──────────────────────────────────────────────────
|
||||
await callbacks.onStage?.('fetching');
|
||||
|
|
@ -242,7 +250,8 @@ export async function runPipeline(
|
|||
if (!input.force) {
|
||||
const existing = await findExistingAnalysisByContentHash(
|
||||
pre.header.ticket_number,
|
||||
pre.content_hash
|
||||
pre.content_hash,
|
||||
provider
|
||||
);
|
||||
if (existing) {
|
||||
return {
|
||||
|
|
@ -264,27 +273,28 @@ export async function runPipeline(
|
|||
let estimatedCostUsd = 0;
|
||||
const traces: PipelineSuccess['model_traces'] = {};
|
||||
|
||||
// ── Stage 1: Haiku triage ────────────────────────────────────────────────
|
||||
// ── Stage 1: triage ──────────────────────────────────────────────────────
|
||||
await callbacks.onStage?.('triaging');
|
||||
const triageModel = stageModels.triage;
|
||||
const triageResult = await recordedStage(
|
||||
{
|
||||
stage: 'triage',
|
||||
stage_order: 2,
|
||||
model_id: 'claude-haiku-4-5',
|
||||
model_id: triageModel,
|
||||
input_payload: {
|
||||
ticket_number: pre.header.ticket_number,
|
||||
events_count: pre.events.length,
|
||||
filtered_noise_count: pre.counts.filtered_noise,
|
||||
},
|
||||
},
|
||||
() => runTriageStage(pre, anthropic),
|
||||
() => runTriageStage(pre, anthropic, triageModel),
|
||||
callbacks,
|
||||
(r) => r.data
|
||||
);
|
||||
usage = addUsage(usage, triageResult.usage);
|
||||
estimatedCostUsd += triageResult.estimated_cost_usd;
|
||||
traces.triage = {
|
||||
model: 'claude-haiku-4-5',
|
||||
model: triageModel,
|
||||
attempts: triageResult.attempts,
|
||||
input_tokens: triageResult.usage.input_tokens,
|
||||
output_tokens: triageResult.usage.output_tokens,
|
||||
|
|
@ -349,13 +359,14 @@ export async function runPipeline(
|
|||
};
|
||||
}
|
||||
|
||||
// ── Stage 3: Sonnet deep analysis ────────────────────────────────────────
|
||||
// ── Stage 3: deep analysis ───────────────────────────────────────────────
|
||||
await callbacks.onStage?.('analyzing');
|
||||
const deepAnalysisModel = stageModels.deep_analysis;
|
||||
const sonnetResult = await recordedStage(
|
||||
{
|
||||
stage: 'analyze',
|
||||
stage_order: 4,
|
||||
model_id: 'claude-sonnet-4-6',
|
||||
model_id: deepAnalysisModel,
|
||||
input_payload: {
|
||||
ticket_number: pre.header.ticket_number,
|
||||
events_count: pre.events.length,
|
||||
|
|
@ -366,7 +377,8 @@ export async function runPipeline(
|
|||
() =>
|
||||
runDeepAnalysisStage(
|
||||
{ pre, triage: triageResult.data, itglue_docs: itglueDocs },
|
||||
anthropic
|
||||
anthropic,
|
||||
deepAnalysisModel
|
||||
),
|
||||
callbacks,
|
||||
(r) => r.data
|
||||
|
|
@ -374,7 +386,7 @@ export async function runPipeline(
|
|||
usage = addUsage(usage, sonnetResult.usage);
|
||||
estimatedCostUsd += sonnetResult.estimated_cost_usd;
|
||||
traces.deep_analysis = {
|
||||
model: 'claude-sonnet-4-6',
|
||||
model: deepAnalysisModel,
|
||||
attempts: sonnetResult.attempts,
|
||||
input_tokens: sonnetResult.usage.input_tokens,
|
||||
output_tokens: sonnetResult.usage.output_tokens,
|
||||
|
|
@ -410,11 +422,12 @@ export async function runPipeline(
|
|||
};
|
||||
} else {
|
||||
await callbacks.onStage?.('deep_review');
|
||||
const deepReasoningModel = stageModels.deep_reasoning;
|
||||
const opusResult = await recordedStage(
|
||||
{
|
||||
stage: 'deep_review',
|
||||
stage_order: 5,
|
||||
model_id: 'claude-opus-4-7',
|
||||
model_id: deepReasoningModel,
|
||||
input_payload: {
|
||||
ticket_number: pre.header.ticket_number,
|
||||
events_count: pre.events.length,
|
||||
|
|
@ -425,7 +438,8 @@ export async function runPipeline(
|
|||
() =>
|
||||
runDeepReasoningStage(
|
||||
{ pre, triage: triageResult.data, sonnet: sonnetResult.data },
|
||||
anthropic
|
||||
anthropic,
|
||||
deepReasoningModel
|
||||
),
|
||||
callbacks,
|
||||
// Per spec: store the FULL Opus response including opus_notes, not
|
||||
|
|
@ -436,7 +450,7 @@ export async function runPipeline(
|
|||
estimatedCostUsd += opusResult.estimated_cost_usd;
|
||||
opusUsed = true;
|
||||
traces.deep_reasoning = {
|
||||
model: 'claude-opus-4-7',
|
||||
model: deepReasoningModel,
|
||||
attempts: opusResult.attempts,
|
||||
input_tokens: opusResult.usage.input_tokens,
|
||||
output_tokens: opusResult.usage.output_tokens,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export interface RawTicketHeader {
|
|||
create_date: string;
|
||||
last_activity_date: string;
|
||||
resolved_date_time: string | null;
|
||||
problem_ticket_id: number | null;
|
||||
}
|
||||
|
||||
export interface RawTicketNote {
|
||||
|
|
|
|||
|
|
@ -112,8 +112,15 @@ export interface AggregateReduceInput {
|
|||
|
||||
export function selectReduceModel(
|
||||
fingerprintCount: number,
|
||||
forceOpus = false
|
||||
forceOpus = false,
|
||||
provider: 'anthropic' | 'openrouter' = 'anthropic'
|
||||
): ModelId {
|
||||
if (provider === 'openrouter') {
|
||||
// OpenRouter side: V4 Pro for the standard reduce; R1 if forceOpus is
|
||||
// requested (extra reasoning depth, higher cost).
|
||||
if (forceOpus) return 'deepseek/deepseek-r1-0528';
|
||||
return 'deepseek/deepseek-v4-pro';
|
||||
}
|
||||
if (forceOpus) return OPUS;
|
||||
// Per spec D.6: Sonnet up to 100; Opus is opt-in. Above 25, send only the
|
||||
// structured fingerprints (no narrative excerpts) — handled at payload-build time.
|
||||
|
|
@ -153,9 +160,17 @@ function buildUserPayload(input: AggregateReduceInput): string {
|
|||
|
||||
export async function runAggregateReduceStage(
|
||||
input: AggregateReduceInput,
|
||||
options: { forceOpus?: boolean; injectedClient?: Anthropic } = {}
|
||||
options: {
|
||||
forceOpus?: boolean;
|
||||
injectedClient?: Anthropic;
|
||||
provider?: 'anthropic' | 'openrouter';
|
||||
} = {}
|
||||
): Promise<LLMCallResult<AggregateReduceResponse> & { model_used: ModelId }> {
|
||||
const model = selectReduceModel(input.fingerprints.length, options.forceOpus);
|
||||
const model = selectReduceModel(
|
||||
input.fingerprints.length,
|
||||
options.forceOpus,
|
||||
options.provider ?? 'anthropic'
|
||||
);
|
||||
const result = await callLLMStage({
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import { TriageResponse, type PreprocessedTicket, type TaggedEvent } from '@/lib/types/analyzer';
|
||||
import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call';
|
||||
import { HAIKU } from '@/lib/services/llm/models';
|
||||
import { HAIKU, type ModelId } from '@/lib/services/llm/models';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
const STAGE1_MAX_TOKENS = 4_000;
|
||||
|
|
@ -109,12 +109,13 @@ export interface TriageStageResult extends LLMCallResult<TriageResponse> {
|
|||
|
||||
export async function runTriageStage(
|
||||
pre: PreprocessedTicket,
|
||||
injectedClient?: Anthropic
|
||||
injectedClient?: Anthropic,
|
||||
modelOverride?: ModelId
|
||||
): Promise<TriageStageResult> {
|
||||
const { payload, events_dropped } = buildTriageUserPayload(pre);
|
||||
|
||||
const result = await callLLMStage({
|
||||
model: HAIKU,
|
||||
model: modelOverride ?? HAIKU,
|
||||
system: SYSTEM_PROMPT,
|
||||
user: payload,
|
||||
schema: TriageResponse,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
type TriageResponse,
|
||||
} from '@/lib/types/analyzer';
|
||||
import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call';
|
||||
import { SONNET } from '@/lib/services/llm/models';
|
||||
import { SONNET, type ModelId } from '@/lib/services/llm/models';
|
||||
import type { RedactedDoc } from '@/lib/services/analyzer/itglue-search';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
|
|
@ -171,11 +171,12 @@ export interface DeepAnalysisStageResult extends LLMCallResult<DeepAnalysisRespo
|
|||
|
||||
export async function runDeepAnalysisStage(
|
||||
input: DeepAnalysisInput,
|
||||
injectedClient?: Anthropic
|
||||
injectedClient?: Anthropic,
|
||||
modelOverride?: ModelId
|
||||
): Promise<DeepAnalysisStageResult> {
|
||||
const { payload, events_dropped } = buildDeepAnalysisUserPayload(input);
|
||||
const result = await callLLMStage({
|
||||
model: SONNET,
|
||||
model: modelOverride ?? SONNET,
|
||||
system: SYSTEM_PROMPT,
|
||||
user: payload,
|
||||
schema: DeepAnalysisResponse,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
type TriageResponse,
|
||||
} from '@/lib/types/analyzer';
|
||||
import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call';
|
||||
import { OPUS } from '@/lib/services/llm/models';
|
||||
import { OPUS, type ModelId } from '@/lib/services/llm/models';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
const STAGE4_MAX_TOKENS = 16_000;
|
||||
|
|
@ -129,11 +129,12 @@ export interface DeepReasoningStageResult extends LLMCallResult<OpusResponse> {
|
|||
|
||||
export async function runDeepReasoningStage(
|
||||
input: DeepReasoningInput,
|
||||
injectedClient?: Anthropic
|
||||
injectedClient?: Anthropic,
|
||||
modelOverride?: ModelId
|
||||
): Promise<DeepReasoningStageResult> {
|
||||
const { payload, events_dropped } = buildDeepReasoningUserPayload(input);
|
||||
const result = await callLLMStage({
|
||||
model: OPUS,
|
||||
model: modelOverride ?? OPUS,
|
||||
system: SYSTEM_PROMPT,
|
||||
user: payload,
|
||||
schema: OpusResponse,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
type TriageResponse,
|
||||
} from '@/lib/types/analyzer';
|
||||
import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call';
|
||||
import { HAIKU } from '@/lib/services/llm/models';
|
||||
import { HAIKU, type ModelId } from '@/lib/services/llm/models';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
const STAGE6_MAX_TOKENS = 4_000;
|
||||
|
|
@ -109,10 +109,12 @@ export function buildFingerprintUserPayload(input: FingerprintInput): string {
|
|||
|
||||
export async function runFingerprintStage(
|
||||
input: FingerprintInput,
|
||||
injectedClient?: Anthropic
|
||||
injectedClient?: Anthropic,
|
||||
modelOverride?: ModelId
|
||||
): Promise<LLMCallResult<AggregateFingerprint>> {
|
||||
const model = modelOverride ?? HAIKU;
|
||||
const result = await callLLMStage({
|
||||
model: HAIKU,
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
user: buildFingerprintUserPayload(input),
|
||||
schema: AggregateFingerprint,
|
||||
|
|
@ -126,7 +128,7 @@ export async function runFingerprintStage(
|
|||
...result,
|
||||
data: {
|
||||
...result.data,
|
||||
generated_by_model: HAIKU,
|
||||
generated_by_model: model,
|
||||
generated_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,7 +27,12 @@ import {
|
|||
import { loadTicketBundle, TicketNotFoundError } from './data-access';
|
||||
import { runPipeline, type PipelineResult } from './pipeline';
|
||||
import { runFingerprintStage } from './stages/stage6-fingerprint';
|
||||
import { HAIKU } from '@/lib/services/llm/models';
|
||||
import {
|
||||
chainTriggerForCompletedAnalysis,
|
||||
runAggregateReport,
|
||||
} from './aggregate-persistence';
|
||||
import { insertReferencedXrefsFromAnalysis } from './asset-audit/xrefs';
|
||||
import { stageModelsFor, type Provider } from '@/lib/services/llm/models';
|
||||
import type {
|
||||
PreprocessedTicket,
|
||||
StageExecutionRecord,
|
||||
|
|
@ -89,7 +94,12 @@ class AnalyzerWorker {
|
|||
try {
|
||||
const claimed = await claimQueuedJob();
|
||||
if (claimed) {
|
||||
await this.runJob(claimed.id, claimed.ticket_number, claimed.queued_by_user_id);
|
||||
await this.runJob(
|
||||
claimed.id,
|
||||
claimed.ticket_number,
|
||||
claimed.queued_by_user_id,
|
||||
claimed.provider
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ANALYZER-WORKER] poll error:', err);
|
||||
|
|
@ -106,7 +116,8 @@ class AnalyzerWorker {
|
|||
async runJob(
|
||||
jobId: string,
|
||||
ticketNumber: string,
|
||||
triggeredByUserId: string | null
|
||||
triggeredByUserId: string | null,
|
||||
provider: Provider = 'anthropic'
|
||||
): Promise<{ analysis_id: string | null; outcome: PipelineResult['outcome'] | 'failed' }> {
|
||||
// Phase 2: collect per-stage records as the pipeline runs, plus the
|
||||
// preprocessed bundle, so we can persist a failed analyzer_analyses row
|
||||
|
|
@ -118,7 +129,7 @@ class AnalyzerWorker {
|
|||
const bundle = await loadTicketBundle(ticketNumber);
|
||||
|
||||
const result = await runPipeline(
|
||||
{ bundle, force: false },
|
||||
{ bundle, force: false, provider },
|
||||
{},
|
||||
{
|
||||
onStage: (stage) => updateJobStatus(jobId, stage),
|
||||
|
|
@ -134,6 +145,30 @@ class AnalyzerWorker {
|
|||
if (result.outcome === 'idempotent_short_circuit') {
|
||||
// Point the job at the existing analysis so the UI can navigate to it.
|
||||
await completeJob(jobId, result.existing_analysis_id);
|
||||
|
||||
// Chain-trigger may need to run here too: the bundle endpoint queues
|
||||
// jobs for tickets whose content hash didn't match a complete row, but
|
||||
// a parallel analysis may have completed between then and now.
|
||||
try {
|
||||
const chain = await chainTriggerForCompletedAnalysis(
|
||||
ticketNumber,
|
||||
result.existing_analysis_id
|
||||
);
|
||||
for (const reportId of chain.readyReportIds) {
|
||||
void runAggregateReport(reportId).catch((err) => {
|
||||
console.error(
|
||||
`[ANALYZER-WORKER] aggregate report ${reportId} runner threw:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (chainErr) {
|
||||
console.error(
|
||||
'[ANALYZER-WORKER] chain-trigger (short-circuit) failed:',
|
||||
chainErr
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
analysis_id: result.existing_analysis_id,
|
||||
outcome: 'idempotent_short_circuit',
|
||||
|
|
@ -146,6 +181,7 @@ class AnalyzerWorker {
|
|||
content_hash: result.pre.content_hash,
|
||||
triggered_by_user_id: triggeredByUserId,
|
||||
status: 'complete',
|
||||
provider,
|
||||
haiku_used: result.meta.haiku_used,
|
||||
sonnet_used: result.meta.sonnet_used,
|
||||
opus_used: result.meta.opus_used,
|
||||
|
|
@ -159,17 +195,22 @@ class AnalyzerWorker {
|
|||
});
|
||||
|
||||
// Stage 6 — fingerprint. Failure-tolerant: log and continue.
|
||||
const fingerprintModel = stageModelsFor(provider).fingerprint;
|
||||
const fpStart = new Date();
|
||||
let fpInputTokens: number | null = null;
|
||||
let fpOutputTokens: number | null = null;
|
||||
let fpOutput: unknown = {};
|
||||
let fpErr: Error | null = null;
|
||||
try {
|
||||
const fp = await runFingerprintStage({
|
||||
triage: result.triage_response,
|
||||
sonnet: result.sonnet_response,
|
||||
opus: result.opus_response,
|
||||
});
|
||||
const fp = await runFingerprintStage(
|
||||
{
|
||||
triage: result.triage_response,
|
||||
sonnet: result.sonnet_response,
|
||||
opus: result.opus_response,
|
||||
},
|
||||
undefined,
|
||||
fingerprintModel
|
||||
);
|
||||
fpInputTokens = fp.usage.input_tokens;
|
||||
fpOutputTokens = fp.usage.output_tokens;
|
||||
fpOutput = fp.data;
|
||||
|
|
@ -184,7 +225,7 @@ class AnalyzerWorker {
|
|||
stageRecords.push({
|
||||
stage: 'fingerprint',
|
||||
stage_order: 6,
|
||||
model_id: HAIKU,
|
||||
model_id: fingerprintModel,
|
||||
input_payload: {
|
||||
triage_category: result.triage_response.category,
|
||||
ticket_number: result.pre.header.ticket_number,
|
||||
|
|
@ -202,6 +243,53 @@ class AnalyzerWorker {
|
|||
await bulkInsertStageExecutions(inserted.id, stageRecords);
|
||||
|
||||
await completeJob(jobId, inserted.id);
|
||||
|
||||
// Phase 4.1: ingest xref rows for every IT Glue doc the analyzer cited.
|
||||
// Best-effort; never fail the job on xref failure.
|
||||
try {
|
||||
const refs = result.analysis.itglue_docs_referenced ?? [];
|
||||
if (refs.length > 0) {
|
||||
await insertReferencedXrefsFromAnalysis({
|
||||
ticketNumber: result.pre.header.ticket_number,
|
||||
analysisId: inserted.id,
|
||||
references: refs.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
doc_type: r.doc_type,
|
||||
relevance_reason: r.relevance_reason,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (xrefErr) {
|
||||
console.warn(
|
||||
`[ANALYZER-WORKER] xref ingestion failed for analysis ${inserted.id}:`,
|
||||
xrefErr instanceof Error ? xrefErr.message : xrefErr
|
||||
);
|
||||
}
|
||||
|
||||
// Chain-trigger any pending_analyses bundles waiting on this ticket.
|
||||
// Best-effort: a failure here must not fail the job.
|
||||
try {
|
||||
const chain = await chainTriggerForCompletedAnalysis(
|
||||
result.pre.header.ticket_number,
|
||||
inserted.id
|
||||
);
|
||||
for (const reportId of chain.readyReportIds) {
|
||||
void runAggregateReport(reportId).catch((err) => {
|
||||
console.error(
|
||||
`[ANALYZER-WORKER] aggregate report ${reportId} runner threw:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (chainErr) {
|
||||
console.error(
|
||||
'[ANALYZER-WORKER] chain-trigger failed (job already complete):',
|
||||
chainErr
|
||||
);
|
||||
}
|
||||
|
||||
return { analysis_id: inserted.id, outcome: 'complete' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -234,6 +322,7 @@ class AnalyzerWorker {
|
|||
haiku_used: stageRecords.some((r) => r.stage === 'triage'),
|
||||
sonnet_used: stageRecords.some((r) => r.stage === 'analyze'),
|
||||
opus_used: stageRecords.some((r) => r.stage === 'deep_review'),
|
||||
provider,
|
||||
});
|
||||
await bulkInsertStageExecutions(failedAnalysis.id, stageRecords);
|
||||
} catch (persistErr) {
|
||||
|
|
|
|||
130
lib/services/b2/client.test.ts
Normal file
130
lib/services/b2/client.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
OBJECT_KEY_REGEX,
|
||||
presignDownload,
|
||||
presignUpload,
|
||||
B2InvalidObjectKeyError,
|
||||
type B2Config,
|
||||
_B2_INTERNALS,
|
||||
} from './client';
|
||||
|
||||
const FIXTURE_CFG: B2Config = {
|
||||
keyId: 'AKIA-FIXTURE',
|
||||
secret: 'sec-fixture',
|
||||
bucket: 'wulf-audits',
|
||||
region: 'us-west-002',
|
||||
endpoint: 's3.us-west-002.backblazeb2.com',
|
||||
};
|
||||
|
||||
describe('OBJECT_KEY_REGEX', () => {
|
||||
it('accepts the production shape', () => {
|
||||
expect(
|
||||
OBJECT_KEY_REGEX.test(
|
||||
'ba03268b-5528-4dde-ad76-867523446ecd/unknown-server/eventlogs_20251202_173301.json.gz'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
OBJECT_KEY_REGEX.test(
|
||||
'site_uuid_short/MISYS-SQL/eventlogs_20260502_120000.json.gz'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(OBJECT_KEY_REGEX.test('../etc/passwd')).toBe(false);
|
||||
expect(OBJECT_KEY_REGEX.test('site/../../escape/eventlogs_1.json.gz')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects wrong shapes', () => {
|
||||
expect(OBJECT_KEY_REGEX.test('site/host/something.json.gz')).toBe(false); // missing eventlogs_ prefix
|
||||
expect(OBJECT_KEY_REGEX.test('eventlogs_1.json.gz')).toBe(false); // missing prefix dirs
|
||||
expect(OBJECT_KEY_REGEX.test('site/host/eventlogs_1.json')).toBe(false); // missing .gz
|
||||
expect(OBJECT_KEY_REGEX.test('site host/x/eventlogs_1.json.gz')).toBe(false); // space in client id
|
||||
});
|
||||
});
|
||||
|
||||
describe('presignDownload + presignUpload', () => {
|
||||
const realDate = Date;
|
||||
beforeEach(() => {
|
||||
// Pin time so signatures are deterministic.
|
||||
const fixed = new Date('2026-05-02T20:00:00.000Z');
|
||||
vi.stubGlobal(
|
||||
'Date',
|
||||
class extends realDate {
|
||||
constructor(...args: unknown[]) {
|
||||
if (args.length === 0) {
|
||||
super(fixed.getTime());
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
super(...(args as [any]));
|
||||
}
|
||||
}
|
||||
static now() {
|
||||
return fixed.getTime();
|
||||
}
|
||||
} as unknown as DateConstructor
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('produces a stable presigned GET URL', () => {
|
||||
const url = presignDownload(
|
||||
'site/host/eventlogs_20260502_120000.json.gz',
|
||||
600,
|
||||
FIXTURE_CFG
|
||||
);
|
||||
expect(url).toContain('https://s3.us-west-002.backblazeb2.com/wulf-audits/');
|
||||
expect(url).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
|
||||
expect(url).toContain('X-Amz-Credential=AKIA-FIXTURE');
|
||||
expect(url).toContain('X-Amz-Date=20260502T200000Z');
|
||||
expect(url).toContain('X-Amz-Expires=600');
|
||||
expect(url).toContain('X-Amz-SignedHeaders=host');
|
||||
expect(url).toMatch(/X-Amz-Signature=[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('produces a presigned PUT URL with PUT method scope', () => {
|
||||
const url = presignUpload(
|
||||
'site/host/eventlogs_20260502_120000.json.gz',
|
||||
1800,
|
||||
FIXTURE_CFG
|
||||
);
|
||||
expect(url).toContain('X-Amz-Expires=1800');
|
||||
expect(url).toMatch(/X-Amz-Signature=[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('rejects path-traversal object keys', () => {
|
||||
expect(() =>
|
||||
presignDownload('../etc/eventlogs_1.json.gz', 600, FIXTURE_CFG)
|
||||
).toThrow(B2InvalidObjectKeyError);
|
||||
});
|
||||
|
||||
it('different methods produce different signatures (sanity check)', () => {
|
||||
const get = presignDownload(
|
||||
'site/host/eventlogs_20260502_120000.json.gz',
|
||||
600,
|
||||
FIXTURE_CFG
|
||||
);
|
||||
const put = presignUpload(
|
||||
'site/host/eventlogs_20260502_120000.json.gz',
|
||||
600,
|
||||
FIXTURE_CFG
|
||||
);
|
||||
const sigGet = get.split('X-Amz-Signature=')[1];
|
||||
const sigPut = put.split('X-Amz-Signature=')[1];
|
||||
expect(sigGet).not.toBe(sigPut);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveSigningKey', () => {
|
||||
it('produces a 32-byte HMAC-SHA256 chain', () => {
|
||||
const k = _B2_INTERNALS.deriveSigningKey(
|
||||
'sec-fixture',
|
||||
'20260502',
|
||||
'us-west-002',
|
||||
's3'
|
||||
);
|
||||
expect(k.length).toBe(32);
|
||||
});
|
||||
});
|
||||
211
lib/services/b2/client.ts
Normal file
211
lib/services/b2/client.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* Backblaze B2 client (S3-compatible) for the LogLift evidence pipeline.
|
||||
*
|
||||
* Implements AWS Signature Version 4 presigned URLs (matches the n8n
|
||||
* collector's expectations) for both downloads (Pulse fetching uploaded
|
||||
* payloads) and uploads (Pulse handing the collector a presigned PUT
|
||||
* target so the script doesn't carry credentials).
|
||||
*
|
||||
* Port of the SigV4 implementation from `docs/LogLift Review.json` —
|
||||
* battle-tested in production via the existing n8n flow.
|
||||
*/
|
||||
|
||||
import { createHash, createHmac } from 'crypto';
|
||||
|
||||
export interface B2Config {
|
||||
keyId: string;
|
||||
secret: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
/** S3-compatible endpoint, e.g. `s3.us-west-002.backblazeb2.com` (no scheme). */
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
/** Hard cap on bytes Pulse will read from a B2 object. */
|
||||
export const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
/**
|
||||
* Object-key shape we accept from inbound webhooks. Path-traversal guard
|
||||
* — must be `{client_id_or_uuid}/{computer_name}/eventlogs_{timestamp}.json.gz`.
|
||||
*/
|
||||
export const OBJECT_KEY_REGEX =
|
||||
/^[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\/eventlogs_[0-9_]+\.json\.gz$/;
|
||||
|
||||
export class B2NotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'Backblaze B2 is not configured. Set B2_KEY_ID + B2_APP_KEY (and optionally B2_BUCKET / B2_REGION / B2_ENDPOINT).'
|
||||
);
|
||||
this.name = 'B2NotConfiguredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class B2InvalidObjectKeyError extends Error {
|
||||
constructor(objectKey: string) {
|
||||
super(`Invalid object key shape: ${objectKey.slice(0, 200)}`);
|
||||
this.name = 'B2InvalidObjectKeyError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isB2Configured(): boolean {
|
||||
return !!(process.env.B2_KEY_ID && process.env.B2_APP_KEY);
|
||||
}
|
||||
|
||||
export function getB2Config(): B2Config {
|
||||
const keyId = process.env.B2_KEY_ID;
|
||||
const secret = process.env.B2_APP_KEY;
|
||||
if (!keyId || !secret) throw new B2NotConfiguredError();
|
||||
return {
|
||||
keyId,
|
||||
secret,
|
||||
bucket: process.env.B2_BUCKET || 'wulf-audits',
|
||||
region: process.env.B2_REGION || 'us-west-002',
|
||||
endpoint: process.env.B2_ENDPOINT || 's3.us-west-002.backblazeb2.com',
|
||||
};
|
||||
}
|
||||
|
||||
function sign(key: Buffer | string, msg: string): Buffer {
|
||||
return createHmac('sha256', key).update(msg, 'utf8').digest();
|
||||
}
|
||||
|
||||
function deriveSigningKey(
|
||||
secret: string,
|
||||
dateStamp: string,
|
||||
region: string,
|
||||
service: string
|
||||
): Buffer {
|
||||
const kDate = sign('AWS4' + secret, dateStamp);
|
||||
const kRegion = sign(kDate, region);
|
||||
const kService = sign(kRegion, service);
|
||||
return sign(kService, 'aws4_request');
|
||||
}
|
||||
|
||||
interface PresignParams {
|
||||
method: 'GET' | 'PUT';
|
||||
objectKey: string;
|
||||
expiresInSeconds: number;
|
||||
config: B2Config;
|
||||
}
|
||||
|
||||
function presign(params: PresignParams): string {
|
||||
const { method, objectKey, expiresInSeconds, config } = params;
|
||||
const host = config.endpoint;
|
||||
// We do NOT URL-encode slashes in the path itself; SigV4 wants the
|
||||
// literal canonical URI with the object key as-is (slashes intact).
|
||||
const canonicalUri = '/' + config.bucket + '/' + objectKey;
|
||||
const algorithm = 'AWS4-HMAC-SHA256';
|
||||
|
||||
const now = new Date();
|
||||
const amzDate = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
const dateStamp = amzDate.slice(0, 8);
|
||||
const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`;
|
||||
|
||||
const canonicalHeaders = `host:${host}\n`;
|
||||
const signedHeaders = 'host';
|
||||
|
||||
const qs: Record<string, string> = {
|
||||
'X-Amz-Algorithm': algorithm,
|
||||
'X-Amz-Credential': encodeURIComponent(`${config.keyId}/${credentialScope}`),
|
||||
'X-Amz-Date': amzDate,
|
||||
'X-Amz-Expires': String(expiresInSeconds),
|
||||
'X-Amz-SignedHeaders': signedHeaders,
|
||||
};
|
||||
|
||||
const canonicalQueryString = Object.keys(qs)
|
||||
.sort()
|
||||
.map((k) => `${k}=${qs[k]}`)
|
||||
.join('&');
|
||||
|
||||
const payloadHash = 'UNSIGNED-PAYLOAD';
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
canonicalUri,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash,
|
||||
].join('\n');
|
||||
|
||||
const stringToSign = [
|
||||
algorithm,
|
||||
amzDate,
|
||||
credentialScope,
|
||||
createHash('sha256').update(canonicalRequest, 'utf8').digest('hex'),
|
||||
].join('\n');
|
||||
|
||||
const signingKey = deriveSigningKey(config.secret, dateStamp, config.region, 's3');
|
||||
const signature = createHmac('sha256', signingKey)
|
||||
.update(stringToSign, 'utf8')
|
||||
.digest('hex');
|
||||
|
||||
return `https://${host}${canonicalUri}?${canonicalQueryString}&X-Amz-Signature=${signature}`;
|
||||
}
|
||||
|
||||
export function presignDownload(
|
||||
objectKey: string,
|
||||
expiresInSeconds = 600,
|
||||
cfg: B2Config = getB2Config()
|
||||
): string {
|
||||
if (!OBJECT_KEY_REGEX.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey);
|
||||
return presign({ method: 'GET', objectKey, expiresInSeconds, config: cfg });
|
||||
}
|
||||
|
||||
export function presignUpload(
|
||||
objectKey: string,
|
||||
expiresInSeconds = 1800,
|
||||
cfg: B2Config = getB2Config()
|
||||
): string {
|
||||
if (!OBJECT_KEY_REGEX.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey);
|
||||
return presign({ method: 'PUT', objectKey, expiresInSeconds, config: cfg });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a B2 object to a Buffer. Caps at MAX_DOWNLOAD_BYTES — refuses to
|
||||
* read past that even if the server returns more.
|
||||
*/
|
||||
export async function downloadToBuffer(
|
||||
objectKey: string,
|
||||
cfg: B2Config = getB2Config()
|
||||
): Promise<Buffer> {
|
||||
const url = presignDownload(objectKey, 600, cfg);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`B2 GET ${objectKey} → ${res.status} ${res.statusText}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
// Best-effort content-length check before reading the body.
|
||||
const contentLength = res.headers.get('content-length');
|
||||
if (contentLength && Number(contentLength) > MAX_DOWNLOAD_BYTES) {
|
||||
throw new Error(
|
||||
`B2 object ${objectKey} too large: ${contentLength} bytes (cap ${MAX_DOWNLOAD_BYTES})`
|
||||
);
|
||||
}
|
||||
if (!res.body) {
|
||||
throw new Error(`B2 GET ${objectKey} returned no body`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.byteLength;
|
||||
if (total > MAX_DOWNLOAD_BYTES) {
|
||||
try { await reader.cancel(); } catch { /* ignore */ }
|
||||
throw new Error(
|
||||
`B2 object ${objectKey} exceeded ${MAX_DOWNLOAD_BYTES} bytes mid-stream`
|
||||
);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
||||
}
|
||||
|
||||
// Test-only exports.
|
||||
export const _B2_INTERNALS = {
|
||||
deriveSigningKey,
|
||||
presign,
|
||||
};
|
||||
|
|
@ -492,6 +492,40 @@ export class DattoRMMClient {
|
|||
return allComponents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a Datto RMM component whose name matches the given regex
|
||||
* (case-insensitive). Returns the first match, or null.
|
||||
*
|
||||
* Generic helper used to auto-discover both the Overshell component
|
||||
* (Phase 4.2) and the LogLift collector (Phase 4.3) without admins
|
||||
* needing to paste UIDs.
|
||||
*/
|
||||
async findComponentByName(
|
||||
pattern: RegExp
|
||||
): Promise<{ uid: string; name: string } | null> {
|
||||
const components = await this.getComponents();
|
||||
for (const c of components) {
|
||||
const name: string =
|
||||
c?.name ?? c?.componentName ?? c?.displayName ?? '';
|
||||
const uid: string = c?.uid ?? c?.componentUid ?? c?.id ?? '';
|
||||
if (!uid) continue;
|
||||
if (pattern.test(name)) {
|
||||
return { uid, name };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat alias — Phase 4.2 callers expect this name. Defaults to
|
||||
* `/overshell/i`. Equivalent to findComponentByName(/overshell/i).
|
||||
*/
|
||||
async findOvershellComponent(
|
||||
pattern: RegExp = /overshell/i
|
||||
): Promise<{ uid: string; name: string } | null> {
|
||||
return this.findComponentByName(pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a quick job on a device.
|
||||
* PUT /api/v2/device/{deviceUid}/quickjob
|
||||
|
|
|
|||
249
lib/services/device-link-reconciler.ts
Normal file
249
lib/services/device-link-reconciler.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* Device-link reconciler — links unlinked device_external_ids rows to a
|
||||
* configuration_item. Cascading match strategies, highest confidence first.
|
||||
* Conflicts (multiple matches) are logged for admin review, not auto-merged.
|
||||
*
|
||||
* Wire into sync-scheduler.ts as an hourly cron when ready. Not wired yet —
|
||||
* reviewer should approve the match strategies + conflict policy first.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
type LinkConfidence = 'canonical' | 'exact_uid' | 'exact_serial' | 'hostname_in_company' | 'mac' | 'manual';
|
||||
|
||||
interface UnlinkedRow {
|
||||
id: number;
|
||||
source: string;
|
||||
source_id: string;
|
||||
hostname: string | null;
|
||||
serial: string | null;
|
||||
mac: string | null;
|
||||
company_id: number | null;
|
||||
}
|
||||
|
||||
interface MatchCandidate {
|
||||
configuration_item_id: number;
|
||||
link_confidence: LinkConfidence;
|
||||
}
|
||||
|
||||
export interface ReconcileResult {
|
||||
scanned: number;
|
||||
linked: number;
|
||||
conflicts: number;
|
||||
unmatched: number;
|
||||
byConfidence: Record<LinkConfidence, number>;
|
||||
}
|
||||
|
||||
const LINK_CONFIDENCE_RANK: Record<LinkConfidence, number> = {
|
||||
canonical: 100,
|
||||
exact_uid: 90,
|
||||
exact_serial: 80,
|
||||
mac: 70,
|
||||
hostname_in_company: 60,
|
||||
manual: 50,
|
||||
};
|
||||
|
||||
// Common BIOS/inventory placeholder serials that shouldn't be matched on —
|
||||
// hundreds of unrelated CIs share these and any link based on them is noise.
|
||||
const PLACEHOLDER_SERIALS = new Set([
|
||||
'', '0', '1', 'n/a', 'na', 'none', 'null', 'unknown', 'not listed',
|
||||
'not specified', 'not applicable', 'default string', 'to be filled by o.e.m.',
|
||||
'system serial number', 'chassis serial number',
|
||||
'0000000000', '00000000', 'ffffffffffff',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
]);
|
||||
|
||||
function isPlaceholderSerial(serial: string): boolean {
|
||||
const s = serial.trim().toLowerCase();
|
||||
if (s.length < 4) return true;
|
||||
if (PLACEHOLDER_SERIALS.has(s)) return true;
|
||||
// Strings that are all the same character (e.g. "00000000", "FFFFFFFF").
|
||||
if (/^(.)\1+$/.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function findBySerial(serial: string): Promise<MatchCandidate[]> {
|
||||
if (isPlaceholderSerial(serial)) return [];
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`SELECT id::text FROM configuration_items
|
||||
WHERE serial_number IS NOT NULL
|
||||
AND serial_number = $1
|
||||
AND (is_deleted IS NULL OR is_deleted = false)`,
|
||||
[serial]
|
||||
);
|
||||
return res.rows.map((r) => ({
|
||||
configuration_item_id: Number(r.id),
|
||||
link_confidence: 'exact_serial' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
async function findByMac(mac: string): Promise<MatchCandidate[]> {
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`SELECT id::text FROM configuration_items
|
||||
WHERE rmm_device_audit_mac_address IS NOT NULL
|
||||
AND LOWER(rmm_device_audit_mac_address) = LOWER($1)
|
||||
AND (is_deleted IS NULL OR is_deleted = false)`,
|
||||
[mac]
|
||||
);
|
||||
return res.rows.map((r) => ({
|
||||
configuration_item_id: Number(r.id),
|
||||
link_confidence: 'mac' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
async function findByHostnameInCompany(
|
||||
hostname: string,
|
||||
companyId: number | null
|
||||
): Promise<MatchCandidate[]> {
|
||||
if (!companyId) return [];
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`SELECT id::text FROM configuration_items
|
||||
WHERE company_id = $1
|
||||
AND reference_title IS NOT NULL
|
||||
AND LOWER(reference_title) = LOWER($2)
|
||||
AND (is_deleted IS NULL OR is_deleted = false)`,
|
||||
[companyId, hostname]
|
||||
);
|
||||
return res.rows.map((r) => ({
|
||||
configuration_item_id: Number(r.id),
|
||||
link_confidence: 'hostname_in_company' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
async function applyLink(
|
||||
rowId: number,
|
||||
configurationItemId: number,
|
||||
confidence: LinkConfidence
|
||||
): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`UPDATE device_external_ids
|
||||
SET configuration_item_id = $2,
|
||||
link_confidence = $3,
|
||||
linked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND configuration_item_id IS NULL`,
|
||||
[rowId, configurationItemId, confidence]
|
||||
);
|
||||
|
||||
// Propagate the new link into endpoint_audits / device_observations that
|
||||
// were anchored only on the tool-side ID (e.g. an IT Glue config) at the
|
||||
// time they were written. Without this they'd stay "unanchored" in the UI.
|
||||
const linked = await postgresClient.query<{
|
||||
source: string;
|
||||
source_id: string;
|
||||
}>(
|
||||
`SELECT source, source_id FROM device_external_ids WHERE id = $1`,
|
||||
[rowId]
|
||||
);
|
||||
const link = linked.rows[0];
|
||||
if (!link) return;
|
||||
|
||||
if (link.source === 'itglue') {
|
||||
await postgresClient.query(
|
||||
`UPDATE endpoint_audits
|
||||
SET configuration_item_id = $1
|
||||
WHERE configuration_item_id IS NULL
|
||||
AND itglue_configuration_id::text = $2`,
|
||||
[configurationItemId, link.source_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordConflict(
|
||||
rowId: number,
|
||||
candidates: MatchCandidate[]
|
||||
): Promise<void> {
|
||||
// Order candidates highest-confidence-first so the admin UI sees the best
|
||||
// match at the top.
|
||||
const ordered = [...candidates].sort(
|
||||
(a, b) => LINK_CONFIDENCE_RANK[b.link_confidence] - LINK_CONFIDENCE_RANK[a.link_confidence]
|
||||
);
|
||||
const ciIds = ordered.map((c) => c.configuration_item_id);
|
||||
const confidences = ordered.map((c) => c.link_confidence);
|
||||
await postgresClient.query(
|
||||
`INSERT INTO device_link_review (device_external_id, candidate_ci_ids, match_confidences)
|
||||
VALUES ($1, $2::bigint[], $3::text[])
|
||||
ON CONFLICT (device_external_id) WHERE resolved_at IS NULL
|
||||
DO UPDATE SET candidate_ci_ids = EXCLUDED.candidate_ci_ids,
|
||||
match_confidences = EXCLUDED.match_confidences,
|
||||
detected_at = NOW()`,
|
||||
[rowId, ciIds, confidences]
|
||||
);
|
||||
}
|
||||
|
||||
function pickBestCandidate(candidates: MatchCandidate[]): MatchCandidate | null {
|
||||
if (candidates.length === 0) return null;
|
||||
const ids = new Set(candidates.map((c) => c.configuration_item_id));
|
||||
if (ids.size > 1) return null; // ambiguous — admin review
|
||||
return candidates.reduce((best, c) =>
|
||||
LINK_CONFIDENCE_RANK[c.link_confidence] > LINK_CONFIDENCE_RANK[best.link_confidence] ? c : best
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one reconciliation pass over unlinked rows. Idempotent — safe to run
|
||||
* repeatedly. Caller should schedule via sync-scheduler.
|
||||
*/
|
||||
export async function reconcileUnlinkedDevices(opts?: {
|
||||
limit?: number;
|
||||
dryRun?: boolean;
|
||||
}): Promise<ReconcileResult> {
|
||||
const limit = opts?.limit ?? 500;
|
||||
const dryRun = opts?.dryRun ?? false;
|
||||
const result: ReconcileResult = {
|
||||
scanned: 0,
|
||||
linked: 0,
|
||||
conflicts: 0,
|
||||
unmatched: 0,
|
||||
byConfidence: {
|
||||
canonical: 0,
|
||||
exact_uid: 0,
|
||||
exact_serial: 0,
|
||||
mac: 0,
|
||||
hostname_in_company: 0,
|
||||
manual: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const unlinked = await postgresClient.query<UnlinkedRow>(
|
||||
`SELECT id, source, source_id, hostname, serial, mac, company_id
|
||||
FROM device_external_ids
|
||||
WHERE configuration_item_id IS NULL
|
||||
ORDER BY last_seen_at DESC NULLS LAST
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
|
||||
for (const row of unlinked.rows) {
|
||||
result.scanned += 1;
|
||||
const candidates: MatchCandidate[] = [];
|
||||
if (row.serial) candidates.push(...(await findBySerial(row.serial)));
|
||||
if (row.mac) candidates.push(...(await findByMac(row.mac)));
|
||||
if (row.hostname) candidates.push(...(await findByHostnameInCompany(row.hostname, row.company_id)));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
result.unmatched += 1;
|
||||
continue;
|
||||
}
|
||||
const ids = new Set(candidates.map((c) => c.configuration_item_id));
|
||||
if (ids.size > 1) {
|
||||
result.conflicts += 1;
|
||||
if (!dryRun) {
|
||||
await recordConflict(row.id, candidates);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const best = pickBestCandidate(candidates);
|
||||
if (!best) {
|
||||
result.unmatched += 1;
|
||||
continue;
|
||||
}
|
||||
if (!dryRun) {
|
||||
await applyLink(row.id, best.configuration_item_id, best.link_confidence);
|
||||
}
|
||||
result.linked += 1;
|
||||
result.byConfidence[best.link_confidence] += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import * as nodemailer from "nodemailer";
|
||||
import type { Gap } from "@/lib/types/analyzer";
|
||||
|
||||
// SMTP configuration from environment variables
|
||||
const smtpConfig = {
|
||||
|
|
@ -13,6 +14,10 @@ const smtpConfig = {
|
|||
|
||||
const fromAddress = process.env.SMTP_FROM || "noreply@example.com";
|
||||
|
||||
// Display-name + address for the From header so recipient mail clients show
|
||||
// "Pulse" rather than the bare mailbox.
|
||||
const FROM_HEADER = { name: "Pulse", address: fromAddress };
|
||||
|
||||
// Create reusable transporter
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
|
|
@ -23,7 +28,10 @@ function getTransporter(): nodemailer.Transporter {
|
|||
return transporter;
|
||||
}
|
||||
|
||||
// Email templates
|
||||
// =============================================================================
|
||||
// Magic-link sign-in
|
||||
// =============================================================================
|
||||
|
||||
interface MagicLinkEmailParams {
|
||||
email: string;
|
||||
url: string;
|
||||
|
|
@ -44,24 +52,27 @@ export async function sendMagicLinkEmail({
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in to Pulse</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
||||
<h2 style="color: #333; margin-top: 0;">Sign in to your account</h2>
|
||||
<p>Click the button below to sign in to Pulse. This link will expire in 5 minutes.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
||||
Sign in to Pulse
|
||||
</a>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
|
||||
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
|
||||
<div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">PSA Management System</div>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">Sign in to your account</h2>
|
||||
<p style="margin: 0 0 24px; color:#334155;">Click the button below to sign in. This link will expire in 5 minutes.</p>
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="${url}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
|
||||
Sign in to Pulse
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0;">If you didn't request this email, you can safely ignore it.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
||||
<p style="color: #94a3b8; font-size: 12px; margin:0;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${url}" style="color: #2563eb; word-break: break-all;">${url}</a>
|
||||
</p>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 14px;">If you didn't request this email, you can safely ignore it.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -78,7 +89,7 @@ If you didn't request this email, you can safely ignore it.
|
|||
`;
|
||||
|
||||
await transport.sendMail({
|
||||
from: fromAddress,
|
||||
from: FROM_HEADER,
|
||||
to: email,
|
||||
subject: "Sign in to Pulse",
|
||||
text,
|
||||
|
|
@ -86,6 +97,10 @@ If you didn't request this email, you can safely ignore it.
|
|||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Invitation
|
||||
// =============================================================================
|
||||
|
||||
interface InvitationEmailParams {
|
||||
email: string;
|
||||
inviterName: string;
|
||||
|
|
@ -107,25 +122,28 @@ export async function sendInvitationEmail({
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>You're invited to Pulse</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
||||
<h2 style="color: #333; margin-top: 0;">You're invited!</h2>
|
||||
<p><strong>${inviterName}</strong> has invited you to join Pulse.</p>
|
||||
<p>Click the button below to accept the invitation and set up your account.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
||||
Accept Invitation
|
||||
</a>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
|
||||
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
|
||||
<div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">PSA Management System</div>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">You're invited!</h2>
|
||||
<p style="margin: 0 0 12px; color:#334155;"><strong>${escapeHtml(inviterName)}</strong> has invited you to join Pulse.</p>
|
||||
<p style="margin: 0 0 24px; color:#334155;">Click the button below to accept the invitation and set up your account.</p>
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="${url}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
|
||||
Accept invitation
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0;">If you weren't expecting this invitation, you can safely ignore this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
||||
<p style="color: #94a3b8; font-size: 12px; margin: 0;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${url}" style="color: #2563eb; word-break: break-all;">${url}</a>
|
||||
</p>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 14px;">If you weren't expecting this invitation, you can safely ignore this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -144,7 +162,7 @@ If you weren't expecting this invitation, you can safely ignore this email.
|
|||
`;
|
||||
|
||||
await transport.sendMail({
|
||||
from: fromAddress,
|
||||
from: FROM_HEADER,
|
||||
to: email,
|
||||
subject: "You're invited to Pulse",
|
||||
text,
|
||||
|
|
@ -152,14 +170,25 @@ If you weren't expecting this invitation, you can safely ignore this email.
|
|||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Analysis share
|
||||
// =============================================================================
|
||||
|
||||
interface AnalysisShareEmailParams {
|
||||
recipientEmail: string;
|
||||
senderName: string;
|
||||
senderEmail: string;
|
||||
ticketNumber: string;
|
||||
ticketTitle?: string | null;
|
||||
analysisVersion: number;
|
||||
summary: string | null;
|
||||
nextStep: string | null;
|
||||
nextStepRationale?: string | null;
|
||||
whatWasDone?: string[] | null;
|
||||
whatShouldHaveBeenDone?: string[] | null;
|
||||
gaps?: Gap[] | null;
|
||||
confidenceScore?: number | null;
|
||||
modelTier?: "haiku" | "sonnet" | "opus" | null;
|
||||
analysisUrl: string;
|
||||
note?: string;
|
||||
}
|
||||
|
|
@ -173,14 +202,60 @@ function escapeHtml(s: string): string {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const GAP_TONES: Record<Gap["severity"], { border: string; bg: string; label: string }> = {
|
||||
high: { border: "#ef4444", bg: "#fef2f2", label: "HIGH" },
|
||||
medium: { border: "#f59e0b", bg: "#fffbeb", label: "MEDIUM" },
|
||||
low: { border: "#3b82f6", bg: "#eff6ff", label: "LOW" },
|
||||
};
|
||||
|
||||
const MODEL_LABEL: Record<NonNullable<AnalysisShareEmailParams["modelTier"]>, string> = {
|
||||
haiku: "Haiku",
|
||||
sonnet: "Haiku → Sonnet",
|
||||
opus: "Haiku → Sonnet → Opus",
|
||||
};
|
||||
|
||||
function bulletListHtml(items: string[]): string {
|
||||
return `<ul style="margin: 8px 0 16px; padding-left: 20px; color: #334155;">
|
||||
${items
|
||||
.map(
|
||||
(item) =>
|
||||
`<li style="margin: 4px 0;">${escapeHtml(item)}</li>`
|
||||
)
|
||||
.join("")}
|
||||
</ul>`;
|
||||
}
|
||||
|
||||
function gapsHtml(gaps: Gap[]): string {
|
||||
return gaps
|
||||
.map((g) => {
|
||||
const tone = GAP_TONES[g.severity] ?? GAP_TONES.low;
|
||||
return `<div style="border-left: 3px solid ${tone.border}; background: ${tone.bg}; padding: 10px 14px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
||||
<div style="font-size: 11px; font-weight: 700; letter-spacing: 0.05em; color: ${tone.border}; margin-bottom: 4px;">${tone.label}</div>
|
||||
<div style="color: #0f172a;">${escapeHtml(g.description)}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function sectionHeaderHtml(title: string): string {
|
||||
return `<h3 style="font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; margin: 24px 0 8px; padding-bottom: 6px; border-bottom: 1px solid #e2e8f0;">${escapeHtml(title)}</h3>`;
|
||||
}
|
||||
|
||||
export async function sendAnalysisShareEmail({
|
||||
recipientEmail,
|
||||
senderName,
|
||||
senderEmail,
|
||||
ticketNumber,
|
||||
ticketTitle,
|
||||
analysisVersion,
|
||||
summary,
|
||||
nextStep,
|
||||
nextStepRationale,
|
||||
whatWasDone,
|
||||
whatShouldHaveBeenDone,
|
||||
gaps,
|
||||
confidenceScore,
|
||||
modelTier,
|
||||
analysisUrl,
|
||||
note,
|
||||
}: AnalysisShareEmailParams): Promise<void> {
|
||||
|
|
@ -190,19 +265,53 @@ export async function sendAnalysisShareEmail({
|
|||
`Pulse analysis · ${ticketNumber} v${analysisVersion}` +
|
||||
(summary ? ` — ${summary.slice(0, 80)}` : "");
|
||||
|
||||
const summaryHtml = summary
|
||||
? `<p>${escapeHtml(summary)}</p>`
|
||||
: `<p style="color:#999;font-style:italic;">No summary available.</p>`;
|
||||
const nextStepHtml = nextStep
|
||||
? `<h3 style="margin-bottom:4px;">Next step</h3><p>${escapeHtml(nextStep)}</p>`
|
||||
const titleLine = ticketTitle
|
||||
? `<div style="color: #475569; font-size: 14px; margin-top: 4px;">${escapeHtml(ticketTitle)}</div>`
|
||||
: "";
|
||||
const noteHtml = note
|
||||
? `<div style="background:#f6f8fa;border-left:3px solid #667eea;padding:12px 16px;margin:20px 0;">
|
||||
<strong>${escapeHtml(senderName)} added a note:</strong>
|
||||
<p style="margin:8px 0 0;white-space:pre-wrap;">${escapeHtml(note)}</p>
|
||||
|
||||
const summarySection = summary
|
||||
? `${sectionHeaderHtml("Summary")}<p style="color: #0f172a; margin: 0 0 8px; font-size: 15px; line-height: 1.55;">${escapeHtml(summary)}</p>`
|
||||
: "";
|
||||
|
||||
const nextStepSection = nextStep
|
||||
? `${sectionHeaderHtml("Next step")}<p style="color: #0f172a; margin: 0 0 8px; font-weight: 500;">${escapeHtml(nextStep)}</p>${
|
||||
nextStepRationale
|
||||
? `<p style="color: #475569; margin: 4px 0 16px; font-size: 14px;">${escapeHtml(nextStepRationale)}</p>`
|
||||
: ""
|
||||
}`
|
||||
: "";
|
||||
|
||||
const whatWasDoneSection =
|
||||
whatWasDone && whatWasDone.length > 0
|
||||
? `${sectionHeaderHtml("What was done")}${bulletListHtml(whatWasDone)}`
|
||||
: "";
|
||||
|
||||
const whatShouldHaveBeenDoneSection =
|
||||
whatShouldHaveBeenDone && whatShouldHaveBeenDone.length > 0
|
||||
? `${sectionHeaderHtml("What should have been done")}${bulletListHtml(whatShouldHaveBeenDone)}`
|
||||
: "";
|
||||
|
||||
const gapsSection =
|
||||
gaps && gaps.length > 0
|
||||
? `${sectionHeaderHtml("Gaps")}${gapsHtml(gaps)}`
|
||||
: "";
|
||||
|
||||
const noteSection = note
|
||||
? `<div style="background: #f8fafc; border-left: 3px solid #2563eb; padding: 12px 16px; margin: 0 0 24px; border-radius: 0 4px 4px 0;">
|
||||
<div style="font-size: 11px; font-weight: 700; letter-spacing: 0.05em; color: #2563eb; margin-bottom: 4px;">NOTE FROM ${escapeHtml(senderName).toUpperCase()}</div>
|
||||
<div style="color: #0f172a; white-space: pre-wrap;">${escapeHtml(note)}</div>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const confidenceBadge =
|
||||
typeof confidenceScore === "number"
|
||||
? `<span style="display: inline-block; padding: 2px 8px; border-radius: 999px; background: #f1f5f9; color: #475569; font-size: 11px; font-weight: 600; letter-spacing: 0.02em;">${Math.round(confidenceScore * 100)}% confidence</span>`
|
||||
: "";
|
||||
|
||||
const modelBadge = modelTier
|
||||
? `<span style="color:#64748b; font-size: 12px;">${MODEL_LABEL[modelTier]}</span>`
|
||||
: "";
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
|
@ -211,52 +320,102 @@ export async function sendAnalysisShareEmail({
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(subjectLine)}</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
|
||||
<p style="color: rgba(255,255,255,0.9); margin: 4px 0 0;">Ticket analysis · ${escapeHtml(ticketNumber)} · v${analysisVersion}</p>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
||||
<p>${escapeHtml(senderName)} (${escapeHtml(senderEmail)}) shared an analysis with you.</p>
|
||||
${noteHtml}
|
||||
<h3 style="margin-bottom:4px;">Summary</h3>
|
||||
${summaryHtml}
|
||||
${nextStepHtml}
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${analysisUrl}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
||||
Open analysis in Pulse
|
||||
</a>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
|
||||
<div style="max-width: 640px; margin: 0 auto;">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
|
||||
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
|
||||
<div style="color: #94a3b8; font-size: 12px; margin-top: 2px;">Ticket analysis</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
|
||||
<!-- Ticket header -->
|
||||
<div style="margin-bottom: 4px;">
|
||||
<span style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 13px; color: #2563eb; font-weight: 600;">${escapeHtml(ticketNumber)}</span>
|
||||
<span style="color: #94a3b8; font-size: 12px; margin-left: 8px;">v${analysisVersion}</span>
|
||||
${confidenceBadge ? `<span style="margin-left: 8px;">${confidenceBadge}</span>` : ""}
|
||||
</div>
|
||||
${titleLine}
|
||||
|
||||
<!-- Sender -->
|
||||
<p style="color: #475569; font-size: 14px; margin: 16px 0 24px;">
|
||||
<strong style="color: #0f172a;">${escapeHtml(senderName)}</strong> shared this with you.
|
||||
</p>
|
||||
|
||||
${noteSection}
|
||||
${summarySection}
|
||||
${nextStepSection}
|
||||
${whatWasDoneSection}
|
||||
${whatShouldHaveBeenDoneSection}
|
||||
${gapsSection}
|
||||
|
||||
<!-- CTA -->
|
||||
<div style="text-align: center; margin: 32px 0 16px;">
|
||||
<a href="${analysisUrl}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
|
||||
Open full analysis in Pulse
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0 16px;">
|
||||
<table style="width: 100%; font-size: 12px; color: #94a3b8;">
|
||||
<tr>
|
||||
<td style="padding: 0;">${modelBadge}</td>
|
||||
<td style="padding: 0; text-align: right;">Reply to <a href="mailto:${escapeHtml(senderEmail)}" style="color: #64748b;">${escapeHtml(senderEmail)}</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color: #94a3b8; font-size: 11px; margin: 12px 0 0; word-break: break-all;">
|
||||
<a href="${analysisUrl}" style="color: #94a3b8;">${analysisUrl}</a>
|
||||
</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${analysisUrl}" style="color: #667eea; word-break: break-all;">${analysisUrl}</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textParts = [
|
||||
`${senderName} (${senderEmail}) shared a Pulse ticket analysis with you.`,
|
||||
`Ticket: ${ticketNumber} (analysis v${analysisVersion})`,
|
||||
"",
|
||||
];
|
||||
// Plain-text fallback — keep the same content sections so non-HTML clients
|
||||
// see the full analysis, not a truncated nag to "open in browser".
|
||||
const textParts: string[] = [];
|
||||
textParts.push(
|
||||
`${senderName} shared a Pulse ticket analysis with you.`,
|
||||
`Ticket: ${ticketNumber}${ticketTitle ? ` — ${ticketTitle}` : ""} (analysis v${analysisVersion})`,
|
||||
""
|
||||
);
|
||||
if (note) {
|
||||
textParts.push(`Note from ${senderName}:`, note, "");
|
||||
}
|
||||
textParts.push(
|
||||
"Summary:",
|
||||
summary ?? "(no summary available)",
|
||||
""
|
||||
);
|
||||
if (summary) {
|
||||
textParts.push("SUMMARY", summary, "");
|
||||
}
|
||||
if (nextStep) {
|
||||
textParts.push("Next step:", nextStep, "");
|
||||
textParts.push("NEXT STEP", nextStep);
|
||||
if (nextStepRationale) textParts.push(` Rationale: ${nextStepRationale}`);
|
||||
textParts.push("");
|
||||
}
|
||||
if (whatWasDone && whatWasDone.length > 0) {
|
||||
textParts.push("WHAT WAS DONE");
|
||||
whatWasDone.forEach((i) => textParts.push(` - ${i}`));
|
||||
textParts.push("");
|
||||
}
|
||||
if (whatShouldHaveBeenDone && whatShouldHaveBeenDone.length > 0) {
|
||||
textParts.push("WHAT SHOULD HAVE BEEN DONE");
|
||||
whatShouldHaveBeenDone.forEach((i) => textParts.push(` - ${i}`));
|
||||
textParts.push("");
|
||||
}
|
||||
if (gaps && gaps.length > 0) {
|
||||
textParts.push("GAPS");
|
||||
gaps.forEach((g) =>
|
||||
textParts.push(` [${g.severity.toUpperCase()}] ${g.description}`)
|
||||
);
|
||||
textParts.push("");
|
||||
}
|
||||
textParts.push(`Open in Pulse: ${analysisUrl}`);
|
||||
|
||||
await transport.sendMail({
|
||||
from: fromAddress,
|
||||
from: FROM_HEADER,
|
||||
to: recipientEmail,
|
||||
replyTo: senderEmail,
|
||||
subject: subjectLine,
|
||||
|
|
|
|||
159
lib/services/integration-health-alerts.ts
Normal file
159
lib/services/integration-health-alerts.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Daily integration-health alert job.
|
||||
*
|
||||
* Calls checkIntegrationHealth(), summarizes, and posts an Adaptive Card to
|
||||
* morning-summary-webhooks ONLY when something needs attention. Quiet days
|
||||
* stay quiet — no spam.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
checkIntegrationHealth,
|
||||
summarize,
|
||||
type IntegrationHealth,
|
||||
type HealthSummary,
|
||||
} from '@/lib/services/integration-health';
|
||||
|
||||
interface WebhookRow {
|
||||
id: number;
|
||||
label: string;
|
||||
webhook_url: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface HealthAlertResult {
|
||||
summary: HealthSummary;
|
||||
items: IntegrationHealth[];
|
||||
alertSent: boolean;
|
||||
webhooksDelivered: number;
|
||||
}
|
||||
|
||||
function buildHealthAdaptiveCard(items: IntegrationHealth[], summary: HealthSummary): object {
|
||||
const failed = items.filter((i) => i.status === 'auth_failed' || i.status === 'unreachable');
|
||||
const expired = items.filter(
|
||||
(i) => i.tokenExpiry && i.tokenExpiry.daysRemaining <= 0
|
||||
);
|
||||
const expiringSoon = items.filter(
|
||||
(i) => i.tokenExpiry && i.tokenExpiry.daysRemaining > 0 && i.tokenExpiry.daysRemaining <= 14
|
||||
);
|
||||
|
||||
const facts: Array<{ title: string; value: string }> = [];
|
||||
for (const i of failed) {
|
||||
facts.push({
|
||||
title: i.name,
|
||||
value: `${i.status === 'auth_failed' ? '⚠ AUTH FAILED' : '⚠ UNREACHABLE'} — ${i.error?.slice(0, 120) ?? 'no detail'}`,
|
||||
});
|
||||
}
|
||||
for (const i of expired) {
|
||||
facts.push({
|
||||
title: i.name,
|
||||
value: `🔑 token EXPIRED ${Math.abs(i.tokenExpiry!.daysRemaining).toFixed(0)} days ago (${i.tokenExpiry!.envVar})`,
|
||||
});
|
||||
}
|
||||
for (const i of expiringSoon) {
|
||||
facts.push({
|
||||
title: i.name,
|
||||
value: `🔑 token expires in ${i.tokenExpiry!.daysRemaining.toFixed(0)} days (${i.tokenExpiry!.envVar})`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'AdaptiveCard',
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
version: '1.4',
|
||||
body: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
size: 'Large',
|
||||
weight: 'Bolder',
|
||||
text: 'Pulse — Integration Health Alert',
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
spacing: 'None',
|
||||
isSubtle: true,
|
||||
wrap: true,
|
||||
text: `${summary.failed} failing · ${summary.expired} expired · ${summary.expiringWithin14Days} expiring within 14 days`,
|
||||
},
|
||||
{
|
||||
type: 'FactSet',
|
||||
facts,
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
spacing: 'Medium',
|
||||
isSubtle: true,
|
||||
wrap: true,
|
||||
text: `Generated ${new Date().toISOString()}. ${summary.ok}/${summary.total} integrations healthy.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function getEnabledWebhooks(): Promise<WebhookRow[]> {
|
||||
const r = await postgresClient.query<WebhookRow>(
|
||||
`SELECT id, label, webhook_url, enabled
|
||||
FROM morning_summary_webhooks
|
||||
WHERE enabled = true`
|
||||
);
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
async function deliverCard(card: object, webhooks: WebhookRow[]): Promise<number> {
|
||||
const envelope = {
|
||||
type: 'message',
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
contentUrl: null,
|
||||
content: card,
|
||||
},
|
||||
],
|
||||
};
|
||||
let delivered = 0;
|
||||
await Promise.all(
|
||||
webhooks.map(async (w) => {
|
||||
try {
|
||||
const res = await fetch(w.webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(envelope),
|
||||
});
|
||||
if (res.ok) delivered += 1;
|
||||
else console.warn(`[integration-health-alerts] ${w.label} responded ${res.status}`);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[integration-health-alerts] ${w.label} delivery failed:`,
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
return delivered;
|
||||
}
|
||||
|
||||
export async function runIntegrationHealthAlertJob(): Promise<HealthAlertResult> {
|
||||
const items = await checkIntegrationHealth({ skipCache: true });
|
||||
const summary = summarize(items);
|
||||
|
||||
if (!summary.hasIssues) {
|
||||
return { summary, items, alertSent: false, webhooksDelivered: 0 };
|
||||
}
|
||||
|
||||
const webhooks = await getEnabledWebhooks();
|
||||
if (webhooks.length === 0) {
|
||||
console.log(
|
||||
'[integration-health-alerts] issues found but no morning-summary-webhooks configured; skipping delivery'
|
||||
);
|
||||
return { summary, items, alertSent: false, webhooksDelivered: 0 };
|
||||
}
|
||||
|
||||
const card = buildHealthAdaptiveCard(items, summary);
|
||||
const delivered = await deliverCard(card, webhooks);
|
||||
return {
|
||||
summary,
|
||||
items,
|
||||
alertSent: delivered > 0,
|
||||
webhooksDelivered: delivered,
|
||||
};
|
||||
}
|
||||
316
lib/services/integration-health.ts
Normal file
316
lib/services/integration-health.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* Integration auth health + token expiry checks.
|
||||
*
|
||||
* Lives outside any specific integration's client because the goal is to
|
||||
* surface "did anything just break silently" without forcing the dashboard
|
||||
* to depend on every per-tool client. Each check is a minimal authenticated
|
||||
* call against a cheap endpoint of the target API; results are cached
|
||||
* in-process for a few minutes so concurrent dashboard hits don't fan out
|
||||
* into a wave of API calls.
|
||||
*
|
||||
* Usage:
|
||||
* const results = await checkIntegrationHealth();
|
||||
*
|
||||
* Tools covered live: S1, Datto RMM, IT Glue, Autotask. Others report
|
||||
* configured / not_configured only — extending to live checks is mechanical.
|
||||
*/
|
||||
|
||||
export type HealthStatus =
|
||||
| 'ok' // configured, auth succeeded
|
||||
| 'auth_failed' // configured, server returned 401/403
|
||||
| 'unreachable' // configured, network/DNS/TLS error
|
||||
| 'not_configured' // env vars missing
|
||||
| 'unknown'; // configured, no live check implemented
|
||||
|
||||
export interface TokenExpiry {
|
||||
envVar: string;
|
||||
expiresAt: string; // ISO
|
||||
daysRemaining: number; // negative when already expired
|
||||
subject?: string | null;
|
||||
}
|
||||
|
||||
export interface IntegrationHealth {
|
||||
key: string;
|
||||
name: string;
|
||||
category: 'psa' | 'rmm' | 'docs' | 'security' | 'backup' | 'network' | 'identity' | 'mdm' | 'mail' | 'finance' | 'productivity' | 'llm';
|
||||
status: HealthStatus;
|
||||
configured: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string | null;
|
||||
tokenExpiry?: TokenExpiry | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt: number;
|
||||
data: IntegrationHealth[];
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
let cache: CacheEntry | null = null;
|
||||
|
||||
function decodeJwt(token: string, envVar: string): TokenExpiry | null {
|
||||
if (!token || !token.startsWith('eyJ')) return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : '';
|
||||
const json = Buffer.from(b64 + pad, 'base64').toString('utf8');
|
||||
const claims = JSON.parse(json) as { exp?: number; sub?: string };
|
||||
if (!claims.exp) return null;
|
||||
const expiresMs = claims.exp * 1000;
|
||||
return {
|
||||
envVar,
|
||||
expiresAt: new Date(expiresMs).toISOString(),
|
||||
daysRemaining: (expiresMs - Date.now()) / 86400_000,
|
||||
subject: claims.sub ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function timed<T>(fn: () => Promise<T>): Promise<{ result: T; latencyMs: number }> {
|
||||
const start = Date.now();
|
||||
const result = await fn();
|
||||
return { result, latencyMs: Date.now() - start };
|
||||
}
|
||||
|
||||
async function liveCheck(opts: {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ status: HealthStatus; error: string | null; latencyMs: number; httpStatus: number | null }> {
|
||||
const ctrl = new AbortController();
|
||||
const timeout = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 8000);
|
||||
try {
|
||||
const { result, latencyMs } = await timed(() =>
|
||||
fetch(opts.url, { headers: { accept: 'application/json', ...opts.headers }, signal: ctrl.signal })
|
||||
);
|
||||
clearTimeout(timeout);
|
||||
if (result.ok) return { status: 'ok', error: null, latencyMs, httpStatus: result.status };
|
||||
if (result.status === 401 || result.status === 403) {
|
||||
const body = await result.text().catch(() => '');
|
||||
return {
|
||||
status: 'auth_failed',
|
||||
error: `${result.status}: ${body.slice(0, 200)}`,
|
||||
latencyMs,
|
||||
httpStatus: result.status,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'unknown',
|
||||
error: `${result.status} ${result.statusText}`,
|
||||
latencyMs,
|
||||
httpStatus: result.status,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
return {
|
||||
status: 'unreachable',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
latencyMs: opts.timeoutMs ?? 8000,
|
||||
httpStatus: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkS1(): Promise<IntegrationHealth> {
|
||||
const url = process.env.S1_API_URL?.replace(/\/$/, '');
|
||||
const token = process.env.S1_API_TOKEN;
|
||||
const checkedAt = new Date().toISOString();
|
||||
if (!url || !token) {
|
||||
return { key: 's1', name: 'SentinelOne', category: 'security', status: 'not_configured', configured: false, checkedAt };
|
||||
}
|
||||
const tokenExpiry = decodeJwt(token, 'S1_API_TOKEN');
|
||||
const live = await liveCheck({
|
||||
url: `${url}/web/api/v2.1/system/info`,
|
||||
headers: { Authorization: `ApiToken ${token}` },
|
||||
});
|
||||
return {
|
||||
key: 's1',
|
||||
name: 'SentinelOne',
|
||||
category: 'security',
|
||||
status: live.status,
|
||||
configured: true,
|
||||
latencyMs: live.latencyMs,
|
||||
error: live.error,
|
||||
tokenExpiry,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkDattoRmm(): Promise<IntegrationHealth> {
|
||||
const url = process.env.DATTO_RMM_API_URL?.replace(/\/$/, '');
|
||||
const key = process.env.DATTO_RMM_API_KEY;
|
||||
const secret = process.env.DATTO_RMM_API_SECRET;
|
||||
const checkedAt = new Date().toISOString();
|
||||
if (!url || !key || !secret) {
|
||||
return { key: 'datto_rmm', name: 'Datto RMM', category: 'rmm', status: 'not_configured', configured: false, checkedAt };
|
||||
}
|
||||
// OAuth password grant — same flow the client uses internally.
|
||||
const start = Date.now();
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 8000);
|
||||
const tokRes = await fetch(`${url}/auth/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
authorization: 'Basic ' + Buffer.from('public-client:public').toString('base64'),
|
||||
},
|
||||
body: `grant_type=password&username=${encodeURIComponent(key)}&password=${encodeURIComponent(secret)}`,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
clearTimeout(t);
|
||||
if (tokRes.ok) {
|
||||
return { key: 'datto_rmm', name: 'Datto RMM', category: 'rmm', status: 'ok', configured: true, latencyMs: Date.now() - start, checkedAt };
|
||||
}
|
||||
if (tokRes.status === 401 || tokRes.status === 403) {
|
||||
const body = await tokRes.text().catch(() => '');
|
||||
return {
|
||||
key: 'datto_rmm', name: 'Datto RMM', category: 'rmm', status: 'auth_failed',
|
||||
configured: true, latencyMs: Date.now() - start,
|
||||
error: `${tokRes.status}: ${body.slice(0, 200)}`, checkedAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'datto_rmm', name: 'Datto RMM', category: 'rmm', status: 'unknown',
|
||||
configured: true, latencyMs: Date.now() - start,
|
||||
error: `${tokRes.status} ${tokRes.statusText}`, checkedAt,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
key: 'datto_rmm', name: 'Datto RMM', category: 'rmm', status: 'unreachable',
|
||||
configured: true, latencyMs: Date.now() - start,
|
||||
error: err instanceof Error ? err.message : String(err), checkedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkItglue(): Promise<IntegrationHealth> {
|
||||
const apiKey = process.env.ITGLUE_API_KEY;
|
||||
const checkedAt = new Date().toISOString();
|
||||
if (!apiKey) {
|
||||
return { key: 'itglue', name: 'IT Glue', category: 'docs', status: 'not_configured', configured: false, checkedAt };
|
||||
}
|
||||
const live = await liveCheck({
|
||||
url: 'https://api.itglue.com/organizations?page[size]=1',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
});
|
||||
return {
|
||||
key: 'itglue', name: 'IT Glue', category: 'docs',
|
||||
status: live.status, configured: true,
|
||||
latencyMs: live.latencyMs, error: live.error,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkAutotask(): Promise<IntegrationHealth> {
|
||||
const url = process.env.AUTOTASK_API_URL?.replace(/\/$/, '');
|
||||
const user = process.env.AUTOTASK_USERNAME;
|
||||
const secret = process.env.AUTOTASK_SECRET;
|
||||
const code = process.env.AUTOTASK_API_INTEGRATION_CODE;
|
||||
const checkedAt = new Date().toISOString();
|
||||
if (!url || !user || !secret || !code) {
|
||||
return { key: 'autotask', name: 'Autotask', category: 'psa', status: 'not_configured', configured: false, checkedAt };
|
||||
}
|
||||
// Cheapest authenticated call — version endpoint (not behind auth at all
|
||||
// tenants, but failing here usually means URL/credential mismatch).
|
||||
const live = await liveCheck({
|
||||
url: `${url}/v1.0/Version`,
|
||||
headers: {
|
||||
ApiIntegrationCode: code,
|
||||
UserName: user,
|
||||
Secret: secret,
|
||||
},
|
||||
});
|
||||
return {
|
||||
key: 'autotask', name: 'Autotask', category: 'psa',
|
||||
status: live.status, configured: true,
|
||||
latencyMs: live.latencyMs, error: live.error,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function checkConfigOnly(
|
||||
key: string,
|
||||
name: string,
|
||||
category: IntegrationHealth['category'],
|
||||
envVars: string[]
|
||||
): IntegrationHealth {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const allSet = envVars.every((v) => !!process.env[v]);
|
||||
return {
|
||||
key, name, category,
|
||||
status: allSet ? 'unknown' : 'not_configured',
|
||||
configured: allSet,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Promise<IntegrationHealth[]> {
|
||||
if (!opts?.skipCache && cache && cache.expiresAt > Date.now()) {
|
||||
return cache.data;
|
||||
}
|
||||
const results = await Promise.all([
|
||||
checkAutotask(),
|
||||
checkDattoRmm(),
|
||||
checkItglue(),
|
||||
checkS1(),
|
||||
Promise.resolve(checkConfigOnly('veeam', 'Veeam VSPC', 'backup',
|
||||
['VEEAM_VSPC_URL', 'VEEAM_VSPC_API_KEY'])),
|
||||
Promise.resolve(checkConfigOnly('msgraph', 'Microsoft Graph', 'productivity',
|
||||
['MSGRAPH_CLIENT_ID', 'MSGRAPH_CLIENT_SECRET', 'MSGRAPH_TENANT_ID'])),
|
||||
Promise.resolve(checkConfigOnly('auvik', 'Auvik', 'network',
|
||||
['AUVIK_API_URL', 'AUVIK_API_USER', 'AUVIK_API_KEY'])),
|
||||
Promise.resolve(checkConfigOnly('addigy', 'Addigy', 'mdm',
|
||||
['ADDIGY_API_URL', 'ADDIGY_API_TOKEN', 'ADDIGY_ORG_ID'])),
|
||||
Promise.resolve(checkConfigOnly('mimecast', 'Mimecast', 'mail',
|
||||
['MIMECAST_CLIENT_ID', 'MIMECAST_CLIENT_SECRET'])),
|
||||
Promise.resolve(checkConfigOnly('duo', 'Duo', 'identity',
|
||||
['DUO_API_HOST', 'DUO_INTEGRATION_KEY', 'DUO_SECRET_KEY'])),
|
||||
Promise.resolve(checkConfigOnly('zabbix', 'Zabbix', 'network',
|
||||
['ZABBIX_API_URL', 'ZABBIX_API_TOKEN'])),
|
||||
Promise.resolve(checkConfigOnly('qbo', 'QuickBooks Online', 'finance',
|
||||
['QBO_CLIENT_ID', 'QBO_CLIENT_SECRET'])),
|
||||
Promise.resolve(checkConfigOnly('anthropic', 'Anthropic', 'llm',
|
||||
['ANTHROPIC_API_KEY'])),
|
||||
]);
|
||||
cache = { expiresAt: Date.now() + CACHE_TTL_MS, data: results };
|
||||
return results;
|
||||
}
|
||||
|
||||
export function clearIntegrationHealthCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
export interface HealthSummary {
|
||||
total: number;
|
||||
ok: number;
|
||||
failed: number;
|
||||
notConfigured: number;
|
||||
expiringWithin14Days: number;
|
||||
expired: number;
|
||||
hasIssues: boolean;
|
||||
}
|
||||
|
||||
export function summarize(items: IntegrationHealth[]): HealthSummary {
|
||||
let ok = 0, failed = 0, notConfigured = 0, expiringWithin14Days = 0, expired = 0;
|
||||
for (const i of items) {
|
||||
if (i.status === 'ok' || i.status === 'unknown') ok += 1;
|
||||
else if (i.status === 'auth_failed' || i.status === 'unreachable') failed += 1;
|
||||
else if (i.status === 'not_configured') notConfigured += 1;
|
||||
if (i.tokenExpiry) {
|
||||
if (i.tokenExpiry.daysRemaining <= 0) expired += 1;
|
||||
else if (i.tokenExpiry.daysRemaining <= 14) expiringWithin14Days += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: items.length,
|
||||
ok, failed, notConfigured,
|
||||
expiringWithin14Days, expired,
|
||||
hasIssues: failed > 0 || expired > 0 || expiringWithin14Days > 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -146,6 +146,26 @@ export class ITGlueClient {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal PATCH helper. Used by the audit feature to update flexible assets.
|
||||
* Body should be a JSON:API resource object (e.g. `{ data: { type, attributes } }`).
|
||||
* Returns the parsed JSON response (typically `{ data: {...} }`).
|
||||
*/
|
||||
private async patch<T = unknown>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`IT Glue PATCH ${path} → ${res.status} ${res.statusText}: ${text.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
private async fetchAllPages<T>(
|
||||
path: string,
|
||||
params: Record<string, string | number> = {},
|
||||
|
|
@ -175,6 +195,20 @@ export class ITGlueClient {
|
|||
return data.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw JSON:API resource for a single-resource endpoint
|
||||
* (`{ data: {...} }`). Used for per-record refresh after writes so callers
|
||||
* can read the un-mapped attributes (created-at, updated-at, etc.) for a
|
||||
* faithful upsert.
|
||||
*/
|
||||
async getRawSingle(
|
||||
path: string,
|
||||
params: Record<string, string | number> = {}
|
||||
): Promise<{ id: string; type: string; attributes: Record<string, unknown> } | null> {
|
||||
const data: any = await this.request(path, params);
|
||||
return data.data ?? null;
|
||||
}
|
||||
|
||||
/** Returns all raw JSON:API data items across all pages (used by sync service) */
|
||||
async getRawAllPages(path: string, params: Record<string, string | number> = {}): Promise<any[]> {
|
||||
const results: any[] = [];
|
||||
|
|
@ -274,6 +308,63 @@ export class ITGlueClient {
|
|||
return this.mapFlexibleAsset(data.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a flexible asset's traits. Sends PATCH /flexible_assets/:id with a
|
||||
* JSON:API body. `traits` is the merged trait map (IT Glue replaces the
|
||||
* trait set, so callers must include unchanged traits to preserve them; the
|
||||
* audit pipeline always reads then merges).
|
||||
*
|
||||
* Returns the updated asset as IT Glue returns it.
|
||||
*/
|
||||
async updateFlexibleAsset(
|
||||
id: string | number,
|
||||
traits: Record<string, unknown>
|
||||
): Promise<ITGlueFlexibleAsset> {
|
||||
const body = {
|
||||
data: {
|
||||
type: 'flexible_assets',
|
||||
id: String(id),
|
||||
attributes: { traits },
|
||||
},
|
||||
};
|
||||
const res = await this.patch<{ data: any }>(`/flexible_assets/${id}`, body);
|
||||
return this.mapFlexibleAsset(res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch a single flexible asset from IT Glue. Thin wrapper around
|
||||
* getFlexibleAsset; exists so callers naming "refresh" intent stays clear
|
||||
* separate from "read once".
|
||||
*/
|
||||
async refreshFlexibleAsset(id: string | number): Promise<ITGlueFlexibleAsset> {
|
||||
return this.getFlexibleAsset(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a configuration's editable attributes. Sends PATCH /configurations/:id
|
||||
* with a JSON:API body. Configurations have a flat attribute set (no traits
|
||||
* blob), so callers pass the partial map of fields to change — IT Glue
|
||||
* merges into the existing record.
|
||||
*/
|
||||
async updateConfiguration(
|
||||
id: string | number,
|
||||
attributes: Record<string, unknown>
|
||||
): Promise<ITGlueConfiguration> {
|
||||
const body = {
|
||||
data: {
|
||||
type: 'configurations',
|
||||
id: String(id),
|
||||
attributes,
|
||||
},
|
||||
};
|
||||
const res = await this.patch<{ data: any }>(`/configurations/${id}`, body);
|
||||
return this.mapConfiguration(res.data);
|
||||
}
|
||||
|
||||
async refreshConfiguration(id: string | number): Promise<ITGlueConfiguration> {
|
||||
return this.getConfiguration(id);
|
||||
}
|
||||
|
||||
async getFlexibleAssetTypes(): Promise<ITGlueFlexibleAssetType[]> {
|
||||
return this.fetchAllPages('/flexible_asset_types', {}, (item: any) => ({
|
||||
id: item.id,
|
||||
|
|
@ -474,3 +565,7 @@ export function getITGlueClient(): ITGlueClient {
|
|||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
export function isITGlueConfigured(): boolean {
|
||||
return !!process.env.ITGLUE_API_KEY;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -545,6 +545,115 @@ export class ITGlueSyncService {
|
|||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch a single configuration from IT Glue and upsert the mirror row.
|
||||
* Mirrors the bulk syncConfigurations upsert. Used after a write so the
|
||||
* UI sees the new value immediately.
|
||||
*/
|
||||
async refreshConfigurationById(id: string | number): Promise<void> {
|
||||
const client = getITGlueClient();
|
||||
const item = await client.getRawSingle(`/configurations/${id}`);
|
||||
if (!item) {
|
||||
throw new Error(`IT Glue refreshConfigurationById ${id}: empty response`);
|
||||
}
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_configurations
|
||||
(id, organization_id, organization_name, name, hostname, primary_ip,
|
||||
mac_address, serial_number, asset_tag, position, installed_by, purchased_by,
|
||||
notes, operating_system_notes, warranty_expires_at, installed_at, purchased_at,
|
||||
end_of_life_at, configuration_type_id, configuration_type_name,
|
||||
configuration_status_id, configuration_status_name,
|
||||
manufacturer_id, manufacturer_name, model_id, model_name,
|
||||
operating_system_id, operating_system_name, location_id, contact_id,
|
||||
rmm_id, rmm_integration_type, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, name=$4, hostname=$5, primary_ip=$6,
|
||||
mac_address=$7, serial_number=$8, asset_tag=$9, position=$10, installed_by=$11,
|
||||
purchased_by=$12, notes=$13, operating_system_notes=$14, warranty_expires_at=$15,
|
||||
installed_at=$16, purchased_at=$17, end_of_life_at=$18,
|
||||
configuration_type_id=$19, configuration_type_name=$20,
|
||||
configuration_status_id=$21, configuration_status_name=$22,
|
||||
manufacturer_id=$23, manufacturer_name=$24, model_id=$25, model_name=$26,
|
||||
operating_system_id=$27, operating_system_name=$28,
|
||||
location_id=$29, contact_id=$30, rmm_id=$31, rmm_integration_type=$32,
|
||||
updated_at=$34, synced_at=NOW()`,
|
||||
[
|
||||
item.id,
|
||||
a['organization-id'],
|
||||
a['organization-name'] || null,
|
||||
a.name,
|
||||
a.hostname || null,
|
||||
a['primary-ip'] || null,
|
||||
a['mac-address'] || null,
|
||||
a['serial-number'] || null,
|
||||
a['asset-tag'] || null,
|
||||
a.position || null,
|
||||
a['installed-by'] || null,
|
||||
a['purchased-by'] || null,
|
||||
a.notes || null,
|
||||
a['operating-system-notes'] || null,
|
||||
a['warranty-expires-at'] || null,
|
||||
a['installed-at'] || null,
|
||||
a['purchased-at'] || null,
|
||||
a['end-of-life-at'] || null,
|
||||
a['configuration-type-id'] || null,
|
||||
a['configuration-type-name'] || null,
|
||||
a['configuration-status-id'] || null,
|
||||
a['configuration-status-name'] || null,
|
||||
a['manufacturer-id'] || null,
|
||||
a['manufacturer-name'] || null,
|
||||
a['model-id'] || null,
|
||||
a['model-name'] || null,
|
||||
a['operating-system-id'] || null,
|
||||
a['operating-system-name'] || null,
|
||||
a['location-id'] || null,
|
||||
a['contact-id'] || null,
|
||||
a['rmm-id'] || null,
|
||||
a['rmm-integration-type'] || null,
|
||||
a['created-at'] || null,
|
||||
a['updated-at'] || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch a single flexible asset from IT Glue and upsert the mirror row.
|
||||
* Used after a write to keep itg_flexible_assets in sync without running
|
||||
* the full 27-entity sync.
|
||||
*/
|
||||
async refreshFlexibleAssetById(id: string | number): Promise<void> {
|
||||
const client = getITGlueClient();
|
||||
const item = await client.getRawSingle(`/flexible_assets/${id}`);
|
||||
if (!item) {
|
||||
throw new Error(`IT Glue refreshFlexibleAssetById ${id}: empty response`);
|
||||
}
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_flexible_assets
|
||||
(id, organization_id, organization_name, flexible_asset_type_id,
|
||||
flexible_asset_type_name, name, traits, archived, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, flexible_asset_type_id=$4,
|
||||
flexible_asset_type_name=$5, name=$6, traits=$7, archived=$8,
|
||||
updated_at=$10, synced_at=NOW()`,
|
||||
[
|
||||
item.id,
|
||||
a['organization-id'],
|
||||
a['organization-name'] || null,
|
||||
a['flexible-asset-type-id'],
|
||||
a['flexible-asset-type-name'] || null,
|
||||
a.name || null,
|
||||
JSON.stringify(a.traits || {}),
|
||||
a.archived ?? false,
|
||||
a['created-at'] || null,
|
||||
a['updated-at'] || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: ITGlueSyncService | null = null;
|
||||
|
|
|
|||
|
|
@ -2,24 +2,31 @@
|
|||
* Generic LLM caller for the analyzer pipeline.
|
||||
*
|
||||
* One round-trip is:
|
||||
* 1. Send (system, user) to the chosen model.
|
||||
* 1. Send (system, user) to the chosen model (Anthropic or OpenRouter).
|
||||
* 2. Extract the assistant's text content.
|
||||
* 3. JSON.parse + Zod-validate against the caller's schema.
|
||||
* 4. On failure: retry ONCE with the prior raw response + parse error in a
|
||||
* follow-up user turn, then validate again.
|
||||
* 5. After two failures: throw.
|
||||
*
|
||||
* The system prompt is marked with `cache_control: ephemeral`. Anthropic
|
||||
* silently no-ops caching when the prefix is below the model's minimum
|
||||
* (~2-4K tokens) — for our short stage prompts this often won't fire, which
|
||||
* is fine; cost is unaffected when caching is skipped.
|
||||
* Provider dispatch:
|
||||
* - claude-* → Anthropic SDK (with prompt-cache hint on the system prefix)
|
||||
* - <vendor>/<model> → OpenRouter chat-completions (OpenAI-compatible)
|
||||
*
|
||||
* The retry logic, schema validation, and result shape are identical across
|
||||
* providers so callers stay provider-agnostic.
|
||||
*/
|
||||
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
import type { ZodType } from 'zod';
|
||||
import { getAnthropicClient } from './client';
|
||||
import { callOpenRouterChat } from './openrouter-call';
|
||||
import { estimateCostUsd, type TokenUsage } from './pricing';
|
||||
import { type ModelId, OPUS } from './models';
|
||||
import {
|
||||
type ModelId,
|
||||
OPUS,
|
||||
providerForModel,
|
||||
} from './models';
|
||||
|
||||
export interface LLMCallOptions<T> {
|
||||
model: ModelId;
|
||||
|
|
@ -27,7 +34,7 @@ export interface LLMCallOptions<T> {
|
|||
user: string;
|
||||
schema: ZodType<T>;
|
||||
maxTokens: number;
|
||||
/** Override the singleton (test injection). */
|
||||
/** Override the Anthropic singleton (test injection). */
|
||||
client?: Anthropic;
|
||||
}
|
||||
|
||||
|
|
@ -41,10 +48,15 @@ export interface LLMCallResult<T> {
|
|||
raw_response: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate token usage from two calls (used to track total cost across
|
||||
* the original attempt + retry).
|
||||
*/
|
||||
interface RoundTripResult {
|
||||
text: string;
|
||||
usage: TokenUsage;
|
||||
}
|
||||
|
||||
type RoundTripFn = (
|
||||
history: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
) => Promise<RoundTripResult>;
|
||||
|
||||
function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
||||
return {
|
||||
input_tokens: a.input_tokens + b.input_tokens,
|
||||
|
|
@ -56,7 +68,7 @@ function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
|||
};
|
||||
}
|
||||
|
||||
interface MessagesCreateBody {
|
||||
interface AnthropicMessagesCreateBody {
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
system: Array<{
|
||||
|
|
@ -67,12 +79,12 @@ interface MessagesCreateBody {
|
|||
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}
|
||||
|
||||
function buildBody(opts: {
|
||||
function buildAnthropicBody(opts: {
|
||||
model: ModelId;
|
||||
system: string;
|
||||
history: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
maxTokens: number;
|
||||
}): MessagesCreateBody {
|
||||
}): AnthropicMessagesCreateBody {
|
||||
return {
|
||||
model: opts.model,
|
||||
max_tokens: opts.maxTokens,
|
||||
|
|
@ -87,7 +99,7 @@ function buildBody(opts: {
|
|||
};
|
||||
}
|
||||
|
||||
function extractText(response: Anthropic.Message): string {
|
||||
function extractAnthropicText(response: Anthropic.Message): string {
|
||||
const parts: string[] = [];
|
||||
for (const block of response.content) {
|
||||
if (block.type === 'text') parts.push(block.text);
|
||||
|
|
@ -135,42 +147,67 @@ function tryParseValidate<T>(
|
|||
return { ok: true, value: result.data };
|
||||
}
|
||||
|
||||
function makeAnthropicRoundTrip(
|
||||
opts: LLMCallOptions<unknown>,
|
||||
client: Anthropic
|
||||
): RoundTripFn {
|
||||
return async (history) => {
|
||||
const body = buildAnthropicBody({
|
||||
model: opts.model,
|
||||
system: opts.system,
|
||||
history,
|
||||
maxTokens: opts.maxTokens,
|
||||
});
|
||||
void (body satisfies Anthropic.MessageCreateParamsNonStreaming);
|
||||
const response = await client.messages.create(body);
|
||||
return {
|
||||
text: extractAnthropicText(response),
|
||||
usage: response.usage as TokenUsage,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function makeOpenRouterRoundTrip(opts: LLMCallOptions<unknown>): RoundTripFn {
|
||||
return async (history) => {
|
||||
const result = await callOpenRouterChat({
|
||||
model: opts.model,
|
||||
system: opts.system,
|
||||
history,
|
||||
maxTokens: opts.maxTokens,
|
||||
});
|
||||
return { text: result.text, usage: result.usage };
|
||||
};
|
||||
}
|
||||
|
||||
export async function callLLMStage<T>(
|
||||
opts: LLMCallOptions<T>
|
||||
): Promise<LLMCallResult<T>> {
|
||||
const client = opts.client ?? getAnthropicClient();
|
||||
const provider = providerForModel(opts.model);
|
||||
const roundTrip: RoundTripFn =
|
||||
provider === 'anthropic'
|
||||
? makeAnthropicRoundTrip(opts, opts.client ?? getAnthropicClient())
|
||||
: makeOpenRouterRoundTrip(opts);
|
||||
|
||||
const history: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
||||
{ role: 'user', content: opts.user },
|
||||
];
|
||||
|
||||
// Opus 4.7 rejects `temperature`, `top_p`, `top_k`. We don't pass any of
|
||||
// them, so the same body shape works on all three models.
|
||||
const firstBody = buildBody({
|
||||
model: opts.model,
|
||||
system: opts.system,
|
||||
history,
|
||||
maxTokens: opts.maxTokens,
|
||||
});
|
||||
void (firstBody satisfies Anthropic.MessageCreateParamsNonStreaming);
|
||||
|
||||
const first = await client.messages.create(firstBody);
|
||||
const firstText = extractText(first);
|
||||
const firstParse = tryParseValidate(firstText, opts.schema);
|
||||
const first = await roundTrip(history);
|
||||
const firstParse = tryParseValidate(first.text, opts.schema);
|
||||
|
||||
if (firstParse.ok) {
|
||||
const usage: TokenUsage = first.usage as TokenUsage;
|
||||
return {
|
||||
data: firstParse.value,
|
||||
usage,
|
||||
estimated_cost_usd: estimateCostUsd(opts.model, usage),
|
||||
usage: first.usage,
|
||||
estimated_cost_usd: estimateCostUsd(opts.model, first.usage),
|
||||
attempts: 1,
|
||||
raw_response: firstText,
|
||||
raw_response: first.text,
|
||||
};
|
||||
}
|
||||
|
||||
// Retry once. Append the model's previous (invalid) response and a follow-up
|
||||
// user turn explaining the parse error.
|
||||
history.push({ role: 'assistant', content: firstText });
|
||||
history.push({ role: 'assistant', content: first.text });
|
||||
history.push({
|
||||
role: 'user',
|
||||
content: [
|
||||
|
|
@ -182,26 +219,14 @@ export async function callLLMStage<T>(
|
|||
].join('\n'),
|
||||
});
|
||||
|
||||
const secondBody = buildBody({
|
||||
model: opts.model,
|
||||
system: opts.system,
|
||||
history,
|
||||
maxTokens: opts.maxTokens,
|
||||
});
|
||||
void (secondBody satisfies Anthropic.MessageCreateParamsNonStreaming);
|
||||
const second = await roundTrip(history);
|
||||
const secondParse = tryParseValidate(second.text, opts.schema);
|
||||
|
||||
const second = await client.messages.create(secondBody);
|
||||
const secondText = extractText(second);
|
||||
const secondParse = tryParseValidate(secondText, opts.schema);
|
||||
|
||||
const totalUsage = addUsage(
|
||||
first.usage as TokenUsage,
|
||||
second.usage as TokenUsage
|
||||
);
|
||||
const totalUsage = addUsage(first.usage, second.usage);
|
||||
|
||||
if (!secondParse.ok) {
|
||||
throw new Error(
|
||||
`LLM stage on ${opts.model} failed twice. First: ${firstParse.error}. Second: ${secondParse.error}. Last raw response: ${secondText.slice(0, 500)}`
|
||||
`LLM stage on ${opts.model} failed twice. First: ${firstParse.error}. Second: ${secondParse.error}. Last raw response: ${second.text.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +235,7 @@ export async function callLLMStage<T>(
|
|||
usage: totalUsage,
|
||||
estimated_cost_usd: estimateCostUsd(opts.model, totalUsage),
|
||||
attempts: 2,
|
||||
raw_response: secondText,
|
||||
raw_response: second.text,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,73 @@
|
|||
* Use these constants — never hardcode the strings elsewhere.
|
||||
*
|
||||
* Verify quarterly against https://docs.claude.com/en/docs/about-claude/models
|
||||
* Model IDs change rarely but pricing and capability tiers can shift.
|
||||
* and OpenRouter's model list. Model IDs change rarely but pricing and
|
||||
* capability tiers can shift.
|
||||
*/
|
||||
|
||||
// Anthropic
|
||||
export const HAIKU = 'claude-haiku-4-5' as const;
|
||||
export const SONNET = 'claude-sonnet-4-6' as const;
|
||||
export const OPUS = 'claude-opus-4-7' as const;
|
||||
|
||||
export type ModelId = typeof HAIKU | typeof SONNET | typeof OPUS;
|
||||
// OpenRouter / DeepSeek
|
||||
export const DEEPSEEK_V4_FLASH = 'deepseek/deepseek-v4-flash' as const;
|
||||
export const DEEPSEEK_V4_PRO = 'deepseek/deepseek-v4-pro' as const;
|
||||
export const DEEPSEEK_R1 = 'deepseek/deepseek-r1-0528' as const;
|
||||
|
||||
export type AnthropicModelId = typeof HAIKU | typeof SONNET | typeof OPUS;
|
||||
export type OpenRouterModelId =
|
||||
| typeof DEEPSEEK_V4_FLASH
|
||||
| typeof DEEPSEEK_V4_PRO
|
||||
| typeof DEEPSEEK_R1;
|
||||
export type ModelId = AnthropicModelId | OpenRouterModelId;
|
||||
|
||||
export type Provider = 'anthropic' | 'openrouter';
|
||||
|
||||
/**
|
||||
* Per-stage model picks for each provider. The pipeline reads from this when
|
||||
* the user picks a provider — every stage knows what model to call without
|
||||
* the caller having to wire up four constants.
|
||||
*/
|
||||
export interface StageModels {
|
||||
triage: ModelId;
|
||||
deep_analysis: ModelId;
|
||||
deep_reasoning: ModelId;
|
||||
fingerprint: ModelId;
|
||||
/** Aggregate-reduce step (cross-ticket bundle reports). */
|
||||
aggregate_reduce: ModelId;
|
||||
/** Haiku-suggested-links arm in link-discovery. */
|
||||
link_suggest: ModelId;
|
||||
}
|
||||
|
||||
export const ANTHROPIC_STAGE_MODELS: StageModels = {
|
||||
triage: HAIKU,
|
||||
deep_analysis: SONNET,
|
||||
deep_reasoning: OPUS,
|
||||
fingerprint: HAIKU,
|
||||
aggregate_reduce: SONNET,
|
||||
link_suggest: HAIKU,
|
||||
};
|
||||
|
||||
export const OPENROUTER_STAGE_MODELS: StageModels = {
|
||||
triage: DEEPSEEK_V4_FLASH,
|
||||
deep_analysis: DEEPSEEK_V4_PRO,
|
||||
deep_reasoning: DEEPSEEK_R1,
|
||||
fingerprint: DEEPSEEK_V4_FLASH,
|
||||
aggregate_reduce: DEEPSEEK_V4_PRO,
|
||||
link_suggest: DEEPSEEK_V4_FLASH,
|
||||
};
|
||||
|
||||
export function stageModelsFor(provider: Provider): StageModels {
|
||||
return provider === 'openrouter'
|
||||
? OPENROUTER_STAGE_MODELS
|
||||
: ANTHROPIC_STAGE_MODELS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect provider from a model id. Anthropic ids start with `claude-`;
|
||||
* OpenRouter ids contain a `/`. Used by the LLM call layer to dispatch.
|
||||
*/
|
||||
export function providerForModel(model: ModelId): Provider {
|
||||
return model.includes('/') ? 'openrouter' : 'anthropic';
|
||||
}
|
||||
|
|
|
|||
126
lib/services/llm/openrouter-call.ts
Normal file
126
lib/services/llm/openrouter-call.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* OpenRouter chat-completions caller.
|
||||
*
|
||||
* Talks to https://openrouter.ai/api/v1/chat/completions in OpenAI-compatible
|
||||
* format. Used as the OpenRouter side of `callLLMStage` so the existing
|
||||
* Anthropic call path stays untouched.
|
||||
*
|
||||
* JSON adherence: requests `response_format: { type: 'json_object' }`. DeepSeek
|
||||
* supports this (the API tolerates it as a hint when not natively supported,
|
||||
* per OpenAI-compat). The retry-on-parse-fail logic in call.ts is the safety
|
||||
* net for stragglers.
|
||||
*/
|
||||
|
||||
import type { TokenUsage } from './pricing';
|
||||
import type { ModelId } from './models';
|
||||
|
||||
const OPENROUTER_BASE = 'https://openrouter.ai/api/v1';
|
||||
|
||||
export interface OpenRouterChatResponse {
|
||||
text: string;
|
||||
usage: TokenUsage;
|
||||
}
|
||||
|
||||
interface RawChatResponse {
|
||||
id: string;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: { role: 'assistant'; content: string | null };
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
error?: { message: string; code?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat-completions request to OpenRouter and return the assistant's
|
||||
* text content + token usage. Throws on HTTP error or empty response.
|
||||
*/
|
||||
export async function callOpenRouterChat(opts: {
|
||||
model: ModelId;
|
||||
system: string;
|
||||
history: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
maxTokens: number;
|
||||
}): Promise<OpenRouterChatResponse> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'OPENROUTER_API_KEY is not set. The OpenRouter pipeline cannot run without it.'
|
||||
);
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: opts.system },
|
||||
...opts.history,
|
||||
];
|
||||
|
||||
const body = {
|
||||
model: opts.model,
|
||||
messages,
|
||||
max_tokens: opts.maxTokens,
|
||||
response_format: { type: 'json_object' as const },
|
||||
// Provider preferences:
|
||||
// - data_collection: 'deny' → refuse any inference provider whose
|
||||
// policy allows storing prompts/completions or training on them.
|
||||
// - sort: 'throughput' → among compliant providers, prefer the
|
||||
// fastest one. V4 Pro deep-analysis was ~3.5min without this hint;
|
||||
// with throughput sort it should land closer to ~1.5-2min.
|
||||
// - allow_fallbacks: true → still route across compliant providers
|
||||
// when the primary is down (default, made explicit).
|
||||
// OpenRouter publishes per-provider data policies; data_collection is
|
||||
// the documented way to enforce a privacy floor at the API call level.
|
||||
// The account-level "opt out of training" toggle is the belt; this is
|
||||
// the braces.
|
||||
provider: {
|
||||
data_collection: 'deny' as const,
|
||||
sort: 'throughput' as const,
|
||||
allow_fallbacks: true,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
// OpenRouter uses these for analytics + their leaderboard.
|
||||
'HTTP-Referer':
|
||||
process.env.BETTER_AUTH_URL || 'https://pulse.wulfconsulting.cloud',
|
||||
'X-Title': 'Pulse Ticket Analyzer',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`OpenRouter HTTP ${res.status}: ${text.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as RawChatResponse;
|
||||
if (json.error) {
|
||||
throw new Error(`OpenRouter error: ${json.error.message}`);
|
||||
}
|
||||
const choice = json.choices?.[0];
|
||||
if (!choice) {
|
||||
throw new Error('OpenRouter returned no choices');
|
||||
}
|
||||
const text = (choice.message.content ?? '').trim();
|
||||
|
||||
const usage: TokenUsage = {
|
||||
input_tokens: json.usage?.prompt_tokens ?? 0,
|
||||
output_tokens: json.usage?.completion_tokens ?? 0,
|
||||
};
|
||||
|
||||
return { text, usage };
|
||||
}
|
||||
|
||||
export function isOpenRouterConfigured(): boolean {
|
||||
return !!process.env.OPENROUTER_API_KEY;
|
||||
}
|
||||
|
|
@ -3,27 +3,46 @@
|
|||
*
|
||||
* Rates are USD per 1,000,000 tokens.
|
||||
*
|
||||
* VERIFY QUARTERLY against https://docs.claude.com/en/docs/about-claude/pricing
|
||||
* Last verified: 2026-04-15
|
||||
* VERIFY QUARTERLY against:
|
||||
* - https://docs.claude.com/en/docs/about-claude/pricing
|
||||
* - https://openrouter.ai/api/v1/models (deepseek/* entries)
|
||||
*
|
||||
* Last verified: 2026-05-02 (V4 Pro/Flash + R1-0528 added from OpenRouter live list)
|
||||
*/
|
||||
|
||||
import { HAIKU, SONNET, OPUS, type ModelId } from './models';
|
||||
import {
|
||||
HAIKU,
|
||||
SONNET,
|
||||
OPUS,
|
||||
DEEPSEEK_V4_FLASH,
|
||||
DEEPSEEK_V4_PRO,
|
||||
DEEPSEEK_R1,
|
||||
type ModelId,
|
||||
} from './models';
|
||||
|
||||
interface ModelRate {
|
||||
/** USD per 1M input tokens */
|
||||
input: number;
|
||||
/** USD per 1M output tokens */
|
||||
output: number;
|
||||
/** USD per 1M tokens read from prompt cache (~0.1× input) */
|
||||
/** USD per 1M tokens read from prompt cache (~0.1× input on Anthropic; not applicable on OpenRouter — set equal to input). */
|
||||
cacheRead: number;
|
||||
/** USD per 1M tokens written to 5-minute prompt cache (~1.25× input) */
|
||||
/** USD per 1M tokens written to 5-minute prompt cache (~1.25× input on Anthropic; not applicable on OpenRouter — set equal to input). */
|
||||
cacheWrite5m: number;
|
||||
}
|
||||
|
||||
export const PRICING: Record<ModelId, ModelRate> = {
|
||||
// Anthropic
|
||||
[HAIKU]: { input: 1.0, output: 5.0, cacheRead: 0.1, cacheWrite5m: 1.25 },
|
||||
[SONNET]: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite5m: 3.75 },
|
||||
[OPUS]: { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite5m: 6.25 },
|
||||
|
||||
// OpenRouter / DeepSeek (no prompt-cache discount surfaced via the OpenAI-
|
||||
// compatible API; we treat cacheRead/cacheWrite as the input rate so the
|
||||
// estimator stays additive even if those usage fields ever come back filled).
|
||||
[DEEPSEEK_V4_FLASH]: { input: 0.14, output: 0.28, cacheRead: 0.14, cacheWrite5m: 0.14 },
|
||||
[DEEPSEEK_V4_PRO]: { input: 0.435, output: 0.87, cacheRead: 0.435, cacheWrite5m: 0.435 },
|
||||
[DEEPSEEK_R1]: { input: 0.50, output: 2.15, cacheRead: 0.50, cacheWrite5m: 0.50 },
|
||||
};
|
||||
|
||||
export interface TokenUsage {
|
||||
|
|
|
|||
436
lib/services/rmm/executor.ts
Normal file
436
lib/services/rmm/executor.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
/**
|
||||
* Dispatch a single RMM Overshell execution.
|
||||
*
|
||||
* 1. Validate script_id against the in-code registry.
|
||||
* 2. Resolve the target device.
|
||||
* 3. Per-user 24h rate limit (50 executions). Records every decision in
|
||||
* analyzer_cost_audit so admins see RMM activity alongside LLM activity.
|
||||
* 4. Insert pending row in rmm_executions.
|
||||
* 5. Resolve Overshell component_uid (discover-on-demand if cache empty).
|
||||
* 6. Call client.runQuickJob — store the returned jobUid + flip to
|
||||
* 'running'. The worker takes over from there.
|
||||
* 7. Best-effort generic audit_log entry.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
import { presignUpload } from '@/lib/services/b2/client';
|
||||
import {
|
||||
evaluateCost,
|
||||
recordCostAuditDecision,
|
||||
} from '@/lib/services/analyzer/cost-guard';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
import {
|
||||
resolveOvershellComponent,
|
||||
resolveLogliftComponent,
|
||||
} from './settings';
|
||||
import { getScript } from './scripts';
|
||||
import {
|
||||
resolveAssetSelfTarget,
|
||||
resolveSiteAnchorTarget,
|
||||
} from './target-resolver';
|
||||
import {
|
||||
countUserExecutionsLast24h,
|
||||
createPendingExecution,
|
||||
markExecutionFailedToDispatch,
|
||||
markExecutionRunning,
|
||||
} from './persistence';
|
||||
|
||||
const RATE_LIMIT_PER_24H = 50;
|
||||
|
||||
export type ExecutionTarget =
|
||||
| { type: 'site_anchor'; companyId: number | string }
|
||||
| {
|
||||
type: 'asset_self';
|
||||
deviceUid: string;
|
||||
hostname?: string | null;
|
||||
companyId?: number | string | null;
|
||||
assetType?: 'flexible_asset' | 'configuration';
|
||||
assetId?: number | string;
|
||||
};
|
||||
|
||||
export interface QueueExecutionInput {
|
||||
scriptId: string;
|
||||
target: ExecutionTarget;
|
||||
performedByUserId: string | null;
|
||||
triggeredByAuditId?: string | null;
|
||||
}
|
||||
|
||||
export interface QueueExecutionResult {
|
||||
executionId: string;
|
||||
status: 'queued' | 'running' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function queueExecution(
|
||||
input: QueueExecutionInput
|
||||
): Promise<QueueExecutionResult> {
|
||||
const script = getScript(input.scriptId);
|
||||
if (!script) {
|
||||
throw new Error(`Unknown script_id: ${input.scriptId}`);
|
||||
}
|
||||
if (script.target_type !== input.target.type) {
|
||||
throw new Error(
|
||||
`Script "${script.id}" expects target_type=${script.target_type} but caller passed ${input.target.type}`
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the target device.
|
||||
let deviceUid: string;
|
||||
let hostname: string | null;
|
||||
let companyId: number | string | null;
|
||||
let assetType: 'flexible_asset' | 'configuration' | null = null;
|
||||
let assetId: number | string | null = null;
|
||||
|
||||
if (input.target.type === 'site_anchor') {
|
||||
const resolved = await resolveSiteAnchorTarget(input.target.companyId);
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
`No Wulf Nurse Production endpoint registered in Datto RMM for company ${input.target.companyId}.`
|
||||
);
|
||||
}
|
||||
deviceUid = resolved.device_uid;
|
||||
hostname = resolved.hostname;
|
||||
companyId = input.target.companyId;
|
||||
} else {
|
||||
const resolved = await resolveAssetSelfTarget(input.target.deviceUid);
|
||||
deviceUid = resolved.device_uid;
|
||||
hostname = resolved.hostname ?? input.target.hostname ?? null;
|
||||
companyId = input.target.companyId ?? null;
|
||||
assetType = input.target.assetType ?? null;
|
||||
assetId = input.target.assetId ?? null;
|
||||
}
|
||||
|
||||
// Rate-limit per user (24h rolling window).
|
||||
if (input.performedByUserId) {
|
||||
const recent = await countUserExecutionsLast24h(input.performedByUserId);
|
||||
if (recent >= RATE_LIMIT_PER_24H) {
|
||||
const evaluation = await evaluateCost({
|
||||
userId: input.performedByUserId,
|
||||
estimatedCost: 0,
|
||||
confirmedCost: false,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId: input.performedByUserId,
|
||||
action: 'rmm_execute',
|
||||
evaluation: { ...evaluation, decision: 'blocked', decisionReason: `Rate-limited: ${recent} executions in last 24h (limit ${RATE_LIMIT_PER_24H})` },
|
||||
context: { scriptId: input.scriptId, recent24h: recent, limit: RATE_LIMIT_PER_24H },
|
||||
});
|
||||
throw new Error(
|
||||
`RMM execution rate limit reached (${recent}/${RATE_LIMIT_PER_24H} in 24h).`
|
||||
);
|
||||
}
|
||||
// Approved decision logged for telemetry.
|
||||
const evaluation = await evaluateCost({
|
||||
userId: input.performedByUserId,
|
||||
estimatedCost: 0,
|
||||
confirmedCost: false,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId: input.performedByUserId,
|
||||
action: 'rmm_execute',
|
||||
evaluation,
|
||||
context: { scriptId: input.scriptId, recent24h: recent, deviceUid, hostname },
|
||||
});
|
||||
}
|
||||
|
||||
// Fork on transport — b2_upload uses the LogLift component + webhook flow.
|
||||
if (script.transport === 'b2_upload') {
|
||||
return dispatchB2Upload({
|
||||
script,
|
||||
deviceUid,
|
||||
hostname,
|
||||
companyId,
|
||||
assetType,
|
||||
assetId,
|
||||
performedByUserId: input.performedByUserId,
|
||||
triggeredByAuditId: input.triggeredByAuditId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the Overshell component (discover-on-demand if cache empty).
|
||||
let componentUid: string;
|
||||
let variableName: string;
|
||||
try {
|
||||
const resolved = await resolveOvershellComponent();
|
||||
componentUid = resolved.componentUid;
|
||||
variableName = resolved.variableName;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Could not resolve the Overshell component'
|
||||
);
|
||||
}
|
||||
|
||||
const jobName = `Pulse: ${script.name}`;
|
||||
const variables = [{ name: variableName, value: script.body }];
|
||||
|
||||
// Insert pending row first.
|
||||
const created = await createPendingExecution({
|
||||
scriptId: script.id,
|
||||
scriptVersion: script.version,
|
||||
targetType: input.target.type,
|
||||
targetDeviceUid: deviceUid,
|
||||
targetHostname: hostname,
|
||||
targetCompanyId: companyId,
|
||||
triggeredByAuditId: input.triggeredByAuditId ?? null,
|
||||
assetType,
|
||||
assetId,
|
||||
jobName,
|
||||
variables,
|
||||
performedByUserId: input.performedByUserId,
|
||||
});
|
||||
|
||||
// Dispatch.
|
||||
const client = getDattoRMMClient();
|
||||
let jobUid: string | null = null;
|
||||
try {
|
||||
const resp = await client.runQuickJob(deviceUid, {
|
||||
jobName,
|
||||
jobComponent: {
|
||||
componentUid,
|
||||
variables,
|
||||
},
|
||||
});
|
||||
// Datto's response is a {} on success per their convention; the job uid
|
||||
// sometimes comes back in different shapes depending on tenant config.
|
||||
// Accept either shape and persist whatever we can.
|
||||
jobUid =
|
||||
(resp?.uid as string | undefined) ??
|
||||
(resp?.jobUid as string | undefined) ??
|
||||
(resp?.id as string | undefined) ??
|
||||
(resp?.job?.uid as string | undefined) ??
|
||||
null;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markExecutionFailedToDispatch(created.id, message);
|
||||
return { executionId: created.id, status: 'failed', error: message };
|
||||
}
|
||||
|
||||
if (!jobUid) {
|
||||
// Datto accepted the request but didn't return an id we can poll. Mark
|
||||
// failed-to-dispatch so we don't leave the row dangling. The next run
|
||||
// can be resubmitted.
|
||||
const message =
|
||||
'Datto RMM accepted the runQuickJob request but did not return a job uid.';
|
||||
await markExecutionFailedToDispatch(created.id, message);
|
||||
return { executionId: created.id, status: 'failed', error: message };
|
||||
}
|
||||
|
||||
await markExecutionRunning(created.id, jobUid);
|
||||
|
||||
// Generic admin-visible audit entry.
|
||||
await audit.log({
|
||||
userId: input.performedByUserId ?? undefined,
|
||||
action: 'rmm.execute',
|
||||
resource: 'datto_device',
|
||||
resourceId: deviceUid,
|
||||
details: {
|
||||
execution_id: created.id,
|
||||
script_id: script.id,
|
||||
target_type: input.target.type,
|
||||
job_uid: jobUid,
|
||||
hostname,
|
||||
},
|
||||
});
|
||||
|
||||
return { executionId: created.id, status: 'running' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a LogLift-style execution: the Datto component uploads gzipped
|
||||
* evidence to B2 and POSTs a metadata webhook back to Pulse. The execution
|
||||
* row stays `running` until the webhook lands (or the 5-minute timeout fires).
|
||||
*
|
||||
* Variables passed to the Datto component:
|
||||
* RunId — correlation token; the webhook handler uses it to find
|
||||
* this row.
|
||||
* ClientId — Datto-side site uuid (informational; matches CS_PROFILE_UID).
|
||||
* ObjectKey — full B2 object key the collector will PUT to.
|
||||
* UploadUrl — pre-presigned PUT URL (30-min TTL); collector uploads
|
||||
* the gzip directly to B2 with no presign-fetch round-trip.
|
||||
* WebhookUrl — Pulse's /api/rmm/loglift/upload endpoint.
|
||||
* WebhookSecret — OPENCLAW_API_KEY; collector sends it as x-openclaw-key.
|
||||
*
|
||||
* Optional IssueDescription / TicketNumber can be added later by extending
|
||||
* QueueExecutionInput.
|
||||
*/
|
||||
async function dispatchB2Upload(args: {
|
||||
script: NonNullable<ReturnType<typeof getScript>>;
|
||||
deviceUid: string;
|
||||
hostname: string | null;
|
||||
companyId: number | string | null;
|
||||
assetType: 'flexible_asset' | 'configuration' | null;
|
||||
assetId: number | string | null;
|
||||
performedByUserId: string | null;
|
||||
triggeredByAuditId: string | null;
|
||||
}): Promise<QueueExecutionResult> {
|
||||
const {
|
||||
script,
|
||||
deviceUid,
|
||||
hostname,
|
||||
companyId,
|
||||
assetType,
|
||||
assetId,
|
||||
performedByUserId,
|
||||
triggeredByAuditId,
|
||||
} = args;
|
||||
|
||||
// The collector needs the Datto site uuid (datto_rmm_sites.uid) — that's
|
||||
// what folds into the B2 object key as ClientId.
|
||||
const siteRes = await postgresClient.query<{ site_uid: string }>(
|
||||
`SELECT s.uid AS site_uid
|
||||
FROM datto_rmm_devices d
|
||||
JOIN datto_rmm_sites s ON s.id = d.site_id
|
||||
WHERE d.uid = $1
|
||||
LIMIT 1`,
|
||||
[deviceUid]
|
||||
);
|
||||
if (siteRes.rowCount === 0) {
|
||||
throw new Error(
|
||||
`Datto device ${deviceUid} has no associated site — cannot derive ClientId for LogLift.`
|
||||
);
|
||||
}
|
||||
const clientId = siteRes.rows[0].site_uid;
|
||||
|
||||
// Resolve LogLift component (discover-on-demand if cache empty).
|
||||
let logliftUid: string;
|
||||
try {
|
||||
const resolved = await resolveLogliftComponent();
|
||||
logliftUid = resolved.componentUid;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
err instanceof Error ? err.message : 'Could not resolve the LogLift component'
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = (process.env.BETTER_AUTH_URL ?? '').replace(/\/$/, '');
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
'BETTER_AUTH_URL must be set for LogLift dispatch (used as the webhook URL the collector POSTs to).'
|
||||
);
|
||||
}
|
||||
const webhookUrl = `${baseUrl}/api/rmm/loglift/upload`;
|
||||
const webhookSecret = process.env.OPENCLAW_API_KEY;
|
||||
if (!webhookSecret) {
|
||||
throw new Error(
|
||||
'OPENCLAW_API_KEY must be set for LogLift dispatch (collector authenticates with it as x-openclaw-key).'
|
||||
);
|
||||
}
|
||||
|
||||
const runId = `pulse_${randomBytes(6).toString('hex')}_${Date.now()}`;
|
||||
const jobName = `Pulse: ${script.name}`;
|
||||
|
||||
// Build the B2 object key + presigned PUT URL up-front. The collector
|
||||
// uploads directly with no presign-fetch round-trip. Hostname must be
|
||||
// present (asset_self LogLift dispatch always knows the target hostname).
|
||||
if (!hostname) {
|
||||
throw new Error(
|
||||
`LogLift dispatch requires a hostname for device ${deviceUid} (used as the B2 object-key path component).`
|
||||
);
|
||||
}
|
||||
const safeHostname = hostname.replace(/[^A-Za-z0-9_.-]/g, '_');
|
||||
const ts = formatObjectKeyTimestamp(new Date());
|
||||
const objectKey = `${clientId}/${safeHostname}/eventlogs_${ts}.json.gz`;
|
||||
const uploadUrl = presignUpload(objectKey, 1800); // 30-min TTL
|
||||
|
||||
const variables = [
|
||||
{ name: 'RunId', value: runId },
|
||||
{ name: 'ClientId', value: clientId },
|
||||
{ name: 'ObjectKey', value: objectKey },
|
||||
{ name: 'UploadUrl', value: uploadUrl },
|
||||
{ name: 'WebhookUrl', value: webhookUrl },
|
||||
{ name: 'WebhookSecret', value: webhookSecret },
|
||||
];
|
||||
|
||||
// Strip secrets/signed URLs before persisting — the row's `variables`
|
||||
// column is admin-readable. ObjectKey is fine to keep; the presigned URL
|
||||
// contains a SigV4 signature that lets anyone PUT to that key for 30 min.
|
||||
const persistedVariables = variables.filter(
|
||||
(v) => v.name !== 'WebhookSecret' && v.name !== 'UploadUrl'
|
||||
);
|
||||
|
||||
const created = await createPendingExecution({
|
||||
scriptId: script.id,
|
||||
scriptVersion: script.version,
|
||||
targetType: 'asset_self',
|
||||
targetDeviceUid: deviceUid,
|
||||
targetHostname: hostname,
|
||||
targetCompanyId: companyId,
|
||||
triggeredByAuditId,
|
||||
assetType,
|
||||
assetId,
|
||||
jobName,
|
||||
variables: persistedVariables,
|
||||
performedByUserId,
|
||||
transport: 'b2_upload',
|
||||
runId,
|
||||
});
|
||||
|
||||
const client = getDattoRMMClient();
|
||||
let jobUid: string | null = null;
|
||||
try {
|
||||
const resp = await client.runQuickJob(deviceUid, {
|
||||
jobName,
|
||||
jobComponent: {
|
||||
componentUid: logliftUid,
|
||||
variables,
|
||||
},
|
||||
});
|
||||
jobUid =
|
||||
(resp?.uid as string | undefined) ??
|
||||
(resp?.jobUid as string | undefined) ??
|
||||
(resp?.id as string | undefined) ??
|
||||
(resp?.job?.uid as string | undefined) ??
|
||||
null;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markExecutionFailedToDispatch(created.id, message);
|
||||
return { executionId: created.id, status: 'failed', error: message };
|
||||
}
|
||||
|
||||
if (!jobUid) {
|
||||
const message =
|
||||
'Datto RMM accepted the runQuickJob request but did not return a job uid.';
|
||||
await markExecutionFailedToDispatch(created.id, message);
|
||||
return { executionId: created.id, status: 'failed', error: message };
|
||||
}
|
||||
|
||||
await markExecutionRunning(created.id, jobUid);
|
||||
|
||||
await audit.log({
|
||||
userId: performedByUserId ?? undefined,
|
||||
action: 'rmm.loglift.dispatched',
|
||||
resource: 'datto_device',
|
||||
resourceId: deviceUid,
|
||||
details: {
|
||||
execution_id: created.id,
|
||||
script_id: script.id,
|
||||
run_id: runId,
|
||||
client_id: clientId,
|
||||
object_key: objectKey,
|
||||
job_uid: jobUid,
|
||||
hostname,
|
||||
},
|
||||
});
|
||||
|
||||
return { executionId: created.id, status: 'running' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date as `YYYYMMDD_HHMMSS` (UTC). Matches the file portion of
|
||||
* `OBJECT_KEY_REGEX` (`eventlogs_[0-9_]+\.json\.gz`).
|
||||
*/
|
||||
function formatObjectKeyTimestamp(d: Date): string {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||
const h = String(d.getUTCHours()).padStart(2, '0');
|
||||
const mi = String(d.getUTCMinutes()).padStart(2, '0');
|
||||
const s = String(d.getUTCSeconds()).padStart(2, '0');
|
||||
return `${y}${m}${day}_${h}${mi}${s}`;
|
||||
}
|
||||
|
||||
export const _EXECUTOR_INTERNALS = { RATE_LIMIT_PER_24H, formatObjectKeyTimestamp };
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue