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