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:
lorentz 2026-05-03 07:13:18 -04:00
parent 378e68ad8a
commit 1112a06afe
132 changed files with 21352 additions and 743 deletions

View 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>
);
}

View 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&rsquo;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
View 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>
);
}

View 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&rsquo;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 &ldquo;loglift&rdquo; or &ldquo;eventlog&rdquo;.
</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>
);
}

View file

@ -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>
);
}

View 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&rsquo;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">
&ldquo;{p.quoted_note_text}&rdquo;
</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>
);
}

View 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&rsquo;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>
);
}

View 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&rsquo;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">
&ldquo;{p.quoted_note_text}&rdquo;
</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>
);
}

View 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&rsquo;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>
);
}

View 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&rsquo;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&rsquo;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>
);
}

View file

@ -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&rsquo;ll be navigated straight to it.
Otherwise the run takes ~1060 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>

View 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) });
});
}

View 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 });
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 });
}

View 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 });
}

View file

@ -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,
});

View 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 }
);
}
}

View 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 });
}

View file

@ -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 }
);
}
}

View 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,
});
}

View 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 });
}

View 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 });
}

View 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 });
}

View 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 }
);
}
}

View 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 });
}

View file

@ -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 }
);
}
}

View 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,
});
}

View 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 });
}

View 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 });
}

View 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 });
}

View 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,
},
});
}

View 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 });
}

View 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 });
}

View 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,
});
}

View file

@ -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({

View file

@ -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 });
}

View 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);
}

View 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) });
}

View 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),
},
},
});
}

View 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 });
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 });
}

View 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 }
);
}
}

View file

@ -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>
))
)}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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) {

View 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&rsquo;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">
&ldquo;{p.quoted_note_text}&rdquo;
</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);

View 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>
);
}

View 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&rsquo;t check for related tickets</AlertTitle>
<AlertDescription>{loadError}</AlertDescription>
</Alert>
);
}
if (links === null) {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
);
}
if (!hasContent) {
// Nothing to show — render nothing, the regular AnalyzeButton on the
// parent page is sufficient.
return null;
}
return (
<Card className={links.isProblemTicket ? 'border-primary' : ''}>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Network className="w-5 h-5" />
<CardTitle className="text-base">
Related tickets detected ({allRefs.length})
</CardTitle>
{links.isProblemTicket && (
<Badge variant="secondary">Problem ticket</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Switch
id="include-suggested"
checked={includeSuggested}
disabled={isRunning || suggestionsLoading}
onCheckedChange={(v) => {
const next = Boolean(v);
setIncludeSuggested(next);
if (next && links.suggested.length === 0) {
void loadSuggestions();
}
}}
/>
<Label htmlFor="include-suggested" className="text-xs">
{suggestionsLoading ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Asking AI
</span>
) : (
'AI-suggest more'
)}
</Label>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{links.isProblemTicket
? 'This looks like a problem/master ticket. Bundling will analyze every linked ticket and produce a cross-ticket report.'
: 'This ticket references other tickets. Bundle them to get a cross-ticket analysis.'}
</p>
<ul className="divide-y">
{allRefs.map((ref) => (
<li
key={ref.ticket_number + ':' + ref.source}
className="py-2 flex items-start gap-3"
>
<Checkbox
id={'rt-' + ref.ticket_number}
checked={selected.has(ref.ticket_number)}
onCheckedChange={() => toggleRef(ref)}
disabled={isRunning}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm">{ref.ticket_number}</span>
{ref.confidence === 'high' && (
<Badge variant="default" className="text-[10px] py-0">
explicit
</Badge>
)}
{ref.source === 'llm_suggested' && (
<Badge variant="outline" className="text-[10px] py-0">
AI-suggested
</Badge>
)}
{ref.status_label && (
<Badge variant="secondary" className="text-[10px] py-0">
{ref.status_label}
</Badge>
)}
</div>
{ref.title && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{ref.title}
</p>
)}
{ref.reason && (
<p className="text-xs text-muted-foreground italic mt-0.5">
{ref.reason}
</p>
)}
</div>
</li>
))}
</ul>
<div className="flex items-center gap-2 flex-wrap pt-2">
<Button
onClick={() => void submit()}
disabled={isRunning || selectedCount === 0}
variant={links.isProblemTicket ? 'default' : 'secondary'}
>
{isRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{statusLabel || 'Working…'}
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
Analyze with {selectedCount} linked ticket
{selectedCount === 1 ? '' : 's'}
</>
)}
</Button>
<span className="text-xs text-muted-foreground">
({selectedCount + 1} total master + linked)
</span>
</div>
</CardContent>
</Card>
);
}

View file

@ -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&rsquo;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"

View file

@ -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'
},
];

View 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>
);
}

View 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>
);
}

View 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

File diff suppressed because one or more lines are too long

View 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 | 3060s |
| OpenRouter (DeepSeek V4 Pro) | ~$0.01 | 60180s |
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), 35 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). 60180s 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

View 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.

View 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 30180 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.

View file

@ -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 |

View file

@ -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.

View file

@ -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

View file

@ -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({

View 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 };

View 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,
};

View 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 };

View 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,
};

View 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);
});
});

View 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,
};
}
}

View 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);
}

View file

@ -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),

View file

@ -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": [
{

View 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');
});
});

View 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,
};
}

View file

@ -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);

View file

@ -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,

View file

@ -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 {

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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(),
},
};

View file

@ -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) {

View 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
View 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,
};

View file

@ -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

View 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;
}

View file

@ -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, "&#39;");
}
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,

View 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,
};
}

View 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,
};
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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,
};
}

View file

@ -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';
}

View 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;
}

View file

@ -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 {

View 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