diff --git a/app/admin/device-link-conflicts/page.tsx b/app/admin/device-link-conflicts/page.tsx
new file mode 100644
index 0000000..3096370
--- /dev/null
+++ b/app/admin/device-link-conflicts/page.tsx
@@ -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(null);
+ const [total, setTotal] = useState(0);
+ const [error, setError] = useState(null);
+ const [source, setSource] = useState('all');
+ const [resolving, setResolving] = useState(null);
+
+ async function load(): Promise {
+ 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 {
+ 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 (
+
+
+
+
+
+ Device-link conflicts
+
+
+
+
+ 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.
+
+
+
+ Source:
+ setSource(v as SourceFilter)}>
+
+
+
+
+ {SOURCES.map((s) => (
+
+ {s}
+
+ ))}
+
+
+
+ {total} unresolved {total === 1 ? 'conflict' : 'conflicts'}
+
+
+
+ {error && (
+
+ Failed to load
+ {error}
+
+ )}
+
+ {items === null && !error && (
+
+
+
+
+
+ )}
+
+ {items !== null && items.length === 0 && !error && (
+
+
+ No conflicts
+
+ Nothing waiting for review on this filter.
+
+
+ )}
+
+ {items?.map((r) => (
+
+
+
+
+
+ {r.xref.source}:{r.xref.sourceId}
+
+
+ {r.xref.hostname ?? '(no hostname)'}
+ {r.xref.companyName && (
+
+ @ {r.xref.companyName}
+
+ )}
+
+
+
+ {r.candidates.length} candidates
+
+
+
+ {r.xref.serial && serial: {r.xref.serial} }
+ {r.xref.mac && mac: {r.xref.mac} }
+ {r.xref.lastSeenAt && (
+ last seen: {new Date(r.xref.lastSeenAt).toLocaleString()}
+ )}
+
+
+
+ {r.candidates.map((c) => {
+ const isResolving = resolving === `${r.id}:${c.ciId}`;
+ return (
+
+
+
+
+ {c.hostname ?? '(no hostname)'}
+
+ {c.isDeleted && (
+
+ deleted
+
+ )}
+ {c.confidence && (
+
+ {c.confidence}
+
+ )}
+
+
+ CI {c.ciId}
+ {c.serial && serial: {c.serial} }
+ {c.companyName && @ {c.companyName} }
+
+
+
void resolve(r.id, c.ciId)}
+ >
+ {isResolving ? (
+ <>
+
+ Linking…
+ >
+ ) : (
+ 'Link to this CI'
+ )}
+
+
+ );
+ })}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/admin/itglue-writes/page.tsx b/app/admin/itglue-writes/page.tsx
new file mode 100644
index 0000000..ddcc1e3
--- /dev/null
+++ b/app/admin/itglue-writes/page.tsx
@@ -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 = [
+ '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(null);
+ const [error, setError] = useState(null);
+ const [statusFilter, setStatusFilter] =
+ useState('all');
+
+ async function load(): Promise {
+ 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 (
+
+
+
+
+
+
IT Glue write log
+
+ Every PATCH to IT Glue from Pulse, with before/after diffs and
+ revert history.
+
+
+
+ {STATUSES.map((s) => (
+ setStatusFilter(s)}
+ >
+ {s}
+
+ ))}
+
+
+
+
+ {error && (
+
+ Couldn’t load writes
+ {error}
+
+ )}
+ {rows === null && !error ? (
+
+
+
+
+
+ ) : rows && rows.length === 0 ? (
+ No writes recorded yet.
+ ) : (
+
+ {(rows ?? []).map((w) => (
+
+
+
+
+
+ {w.asset_id}
+
+ {' · '}
+ {w.field_name}
+
+
+ {new Date(w.performed_at).toLocaleString()}
+
+
+ Before:
+
+ {w.before_value === null || w.before_value === undefined
+ ? '(empty)'
+ : JSON.stringify(w.before_value).slice(0, 200)}
+
+
+
+ After:
+
+ {JSON.stringify(w.after_value).slice(0, 200)}
+
+
+ {w.error_message && (
+
+ Error: {w.error_message}
+
+ )}
+
+
{w.status}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
new file mode 100644
index 0000000..3d452a1
--- /dev/null
+++ b/app/admin/page.tsx
@@ -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.label}
+
+ );
+}
+
+export default function AdminIndexPage() {
+ const [counts, setCounts] = useState(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 (
+
+
+
Admin
+
+ Sync, mappings, workflows, reporting, and tooling.
+
+
+
+
+ {sections.map((section) => (
+
+
+
+ {section.title}
+
+
+
+
+ {section.tiles.map((tile) => (
+
+
+
+
+ {tile.title}
+ {tone(tile.badge)}
+
+ {tile.description && (
+
+ {tile.description}
+
+ )}
+
+
+ ))}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/admin/rmm-overshell/page.tsx b/app/admin/rmm-overshell/page.tsx
new file mode 100644
index 0000000..be09b8a
--- /dev/null
+++ b/app/admin/rmm-overshell/page.tsx
@@ -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(null);
+ const [counts, setCounts] = useState<{ total: string; running: string; failed_24h: string } | null>(null);
+ const [executions, setExecutions] = useState(null);
+ const [error, setError] = useState(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 (
+
+
+
+
+
+ RMM Overshell
+
+
+ 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.
+
+
+
+ {error && (
+
+ Couldn’t load settings
+ {error}
+
+ )}
+
+ {settings === null && !error ? (
+
+ ) : settings ? (
+
+
+
Overshell component
+ {settings.overshellComponentUid ? (
+ <>
+
+ {settings.overshellComponentName}
+
+
+ {settings.overshellComponentUid}
+
+ {settings.discoveredAt && (
+
+ discovered {new Date(settings.discoveredAt).toLocaleString()}
+
+ )}
+ >
+ ) : (
+
+ No component cached. Click Discover to scan Datto RMM.
+
+ )}
+
+
+
Variable name
+
+ {settings.overshellVariableName}
+
+
+ Adjust if your component uses a different variable.
+
+
+
+
Activity (24h)
+
+ {counts?.total ?? '0'} total · {counts?.running ?? '0'} running ·
+
+ {' '}
+ {counts?.failed_24h ?? '0'} failed
+
+
+
+
+
+ {discovering ? (
+ <>
+
+ Discovering…
+ >
+ ) : (
+ <>
+
+ Re-discover Overshell
+ >
+ )}
+
+
+
+
LogLift component
+ {settings.logliftComponentUid ? (
+ <>
+
+ {settings.logliftComponentName}
+
+
+ {settings.logliftComponentUid}
+
+ {settings.logliftDiscoveredAt && (
+
+ discovered{' '}
+ {new Date(settings.logliftDiscoveredAt).toLocaleString()}
+
+ )}
+ >
+ ) : (
+
+ No LogLift component cached. Click below to scan Datto RMM
+ for one named “loglift” or “eventlog”.
+
+ )}
+
+
+ {discoveringLoglift ? (
+ <>
+
+ Discovering…
+ >
+ ) : (
+ <>
+
+ Re-discover LogLift
+ >
+ )}
+
+
+
+
+ ) : null}
+
+
+
+
+
+ Recent executions
+
+
+ {executions === null ? (
+
+ ) : executions.length === 0 ? (
+ No executions yet.
+ ) : (
+
+ {executions.map((e) => (
+
+ {e.scriptId}
+ {e.targetHostname ?? '—'}
+
+
+ {e.status}
+ {e.exitCode !== null ? ` · exit ${e.exitCode}` : ''}
+
+
+
+ {new Date(e.queuedAt).toLocaleString()}
+
+
+ {e.errorMessage && (
+ !
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/analyzer/analysis/[id]/page.tsx b/app/analyzer/analysis/[id]/page.tsx
index 18319f9..53dccb0 100644
--- a/app/analyzer/analysis/[id]/page.tsx
+++ b/app/analyzer/analysis/[id]/page.tsx
@@ -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({
)}
- {analysis && }
+ {analysis && (
+
+ )}
);
}
diff --git a/app/analyzer/itglue/applications/[id]/page.tsx b/app/analyzer/itglue/applications/[id]/page.tsx
new file mode 100644
index 0000000..4df64eb
--- /dev/null
+++ b/app/analyzer/itglue/applications/[id]/page.tsx
@@ -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;
+ 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 = {
+ 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(null);
+ const [audit, setAudit] = useState(null);
+ const [history, setHistory] = useState([]);
+ const [writes, setWrites] = useState([]);
+ const [xrefs, setXrefs] = useState([]);
+ const [error, setError] = useState(null);
+ const [provider, setProvider] = useState('anthropic');
+ const [running, setRunning] = useState(false);
+ const [busyKey, setBusyKey] = useState(null);
+
+ async function loadAll(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 (
+
+
+ Couldn’t load this asset
+ {error}
+
+
+ );
+ }
+ if (!detail) {
+ return (
+
+
+
+
+ );
+ }
+
+ const a = detail.asset;
+ const filledCount = orderedFields.filter((f) => f.populated).length;
+ const totalCount = orderedFields.length;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ Applications
+ {' '}
+ · {a.organizationName ?? 'Unknown org'}
+
+
{a.name ?? a.id}
+
+ {filledCount}/{totalCount} fields populated
+ {audit?.overall_score !== null && audit?.overall_score !== undefined ? (
+ <>
+ {' · '}
+ 0.8
+ ? 'default'
+ : (audit.overall_score ?? 0) > 0.5
+ ? 'secondary'
+ : 'destructive'
+ }
+ >
+ Audit score {Math.round((audit.overall_score ?? 0) * 100)}%
+
+ >
+ ) : null}
+
+
+
+
+
+ {running ? (
+ <>
+
+ Auditing…
+ >
+ ) : (
+ <>
+
+ {audit ? 'Re-audit' : 'Run audit'}
+ >
+ )}
+
+ {a.autotaskCompanyId && (
+
loadAll()}
+ />
+ )}
+
+
+ Open in IT Glue
+
+
+
+
+
+
+
+
+ {/* Audit findings */}
+ {audit && (
+
+
+
+ Audit findings
+
+ {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'}
+
+
+
+
+ {/* Field gaps */}
+
+
+ Field gaps ({audit.field_gaps.length})
+
+ {audit.field_gaps.length === 0 ? (
+ No field gaps detected.
+ ) : (
+
+ {audit.field_gaps.map((g) => {
+ const key = `field_gap:${g.field_name}`;
+ const busy = busyKey === key;
+ return (
+
+
+
+
{g.field_name}
+
{g.why_missing_matters}
+ {g.suggested_value !== null && (
+
+ Suggested:
+ {g.suggested_value}
+
+ )}
+ {g.evidence_ticket_numbers.length > 0 && (
+
+ Evidence:{' '}
+ {g.evidence_ticket_numbers.map((tn, i) => (
+
+ {i > 0 && ', '}
+
+ {tn}
+
+
+ ))}
+
+ )}
+
+
+
+ {g.confidence}
+
+ applyGap(g, 'field_gap')}
+ title={
+ !canWrite
+ ? 'Requires admin'
+ : g.suggested_value === null
+ ? 'No concrete suggestion'
+ : 'Apply to IT Glue'
+ }
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+ Apply
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Notes promotions */}
+ {audit.notes_promotions.length > 0 && (
+
+
+ Promote from Notes ({audit.notes_promotions.length})
+
+
+ {audit.notes_promotions.map((p, i) => {
+ const key = `note_promotion:${p.target_field}:${i}`;
+ const busy = busyKey === `note_promotion:${p.target_field}`;
+ return (
+
+
+
+
+ “{p.quoted_note_text}”
+
+
+ Belongs in{' '}
+ {p.target_field}
+ :{' '}
+ {p.suggested_value}
+
+
+
+
+ {p.confidence}
+
+ applyGap(p, 'note_promotion')}
+ title={!canWrite ? 'Requires admin' : 'Apply to IT Glue'}
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+ Apply
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Contradictions */}
+ {audit.contradictions.length > 0 && (
+
+
+ Contradictions ({audit.contradictions.length})
+
+
+ {audit.contradictions.map((c, i) => (
+
+
+
+
{c.description}
+
+ {c.evidence}
+
+
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {!audit && (
+
+
+ No audit yet. Click Run audit to analyze this asset.
+
+
+ )}
+
+ {/* Current fields */}
+
+
+ Current fields
+
+
+
+ {orderedFields.map((f) => (
+
+
+ {f.name}
+ {f.required && * }
+
+
+ {f.populated ? (
+ formatTraitValue(f.value)
+ ) : (
+ empty
+ )}
+ {f.hint && !f.populated && (
+ {f.hint}
+ )}
+
+
+ ))}
+
+
+
+
+ {/* Tickets that touched this asset */}
+ {(xrefs.filter((x) => x.relationship === 'referenced').length > 0 ||
+ xrefs.filter((x) => x.relationship === 'updated').length > 0) && (
+
+
+ Tickets that touched this asset
+
+
+ {xrefs.filter((x) => x.relationship === 'referenced').length > 0 && (
+
+
+ Referenced by ({xrefs.filter((x) => x.relationship === 'referenced').length})
+
+
+ {xrefs
+ .filter((x) => x.relationship === 'referenced')
+ .map((x) => (
+
+
+ {x.ticketNumber}
+
+ {x.details?.relevance_reason && (
+
+ — {x.details.relevance_reason}
+
+ )}
+
+ ))}
+
+
+ )}
+ {xrefs.filter((x) => x.relationship === 'updated').length > 0 && (
+
+
+ Updated by ({xrefs.filter((x) => x.relationship === 'updated').length})
+
+
+ {xrefs
+ .filter((x) => x.relationship === 'updated')
+ .map((x) => (
+
+
+ {x.ticketNumber}
+
+ {x.details?.field_name && (
+
+ — set {x.details.field_name}
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {/* Write history */}
+ {writes.length > 0 && (
+
+
+
+
+ Write history ({writes.length})
+
+
+
+
+ {writes.map((w) => (
+
+
+
+ {w.field_name}
+
+ {w.status}
+
+
+
+ {new Date(w.performed_at).toLocaleString()}
+
+
+ Before:
+
+ {w.before_value === null || w.before_value === undefined
+ ? '(empty)'
+ : JSON.stringify(w.before_value).slice(0, 200)}
+
+
+
+ After:
+
+ {JSON.stringify(w.after_value).slice(0, 200)}
+
+
+ {w.error_message && (
+
+ Error: {w.error_message}
+
+ )}
+
+ {w.status === 'committed' && (
+ revertWrite(w.id)}
+ title={!canWrite ? 'Requires admin' : 'Revert this write'}
+ >
+ {busyKey === `revert:${w.id}` ? (
+
+ ) : (
+
+ )}
+ Revert
+
+ )}
+
+ ))}
+
+
+
+ )}
+
+ {/* Audit history */}
+ {history.length > 1 && (
+
+
+ Audit history
+
+
+
+ {history.map((h) => (
+
+
+ {new Date(h.generated_at).toLocaleString()}
+ {' · '}
+ {h.provider === 'openrouter' ? 'DeepSeek' : 'Claude'}
+
+
+ Score{' '}
+
+ {h.overall_score !== null
+ ? Math.round(h.overall_score * 100) + '%'
+ : 'n/a'}
+
+
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/analyzer/itglue/applications/page.tsx b/app/analyzer/itglue/applications/page.tsx
new file mode 100644
index 0000000..08ac8f4
--- /dev/null
+++ b/app/analyzer/itglue/applications/page.tsx
@@ -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(null);
+ const [error, setError] = useState(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 (
+
+
+
+
+
+
IT Glue applications audit
+
+ {rows === null
+ ? 'Loading…'
+ : `${rows.length} application records · ${auditedCount} audited`}
+
+
+
setFilter(e.target.value)}
+ className="max-w-xs"
+ />
+
+
+
+ {error && (
+
+ Couldn’t load applications
+ {error}
+
+ )}
+ {rows === null && !error ? (
+
+
+
+
+
+ ) : (
+
+ {visible.map((r) => (
+
+
+
+ {r.name ?? r.id}
+
+
+ {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'}
+ )
+ >
+ )}
+
+
+
+ {r.latestAudit?.overallScore !== null &&
+ r.latestAudit?.overallScore !== undefined
+ ? Math.round(r.latestAudit.overallScore * 100) + '%'
+ : 'No audit'}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/analyzer/itglue/configurations/[id]/page.tsx b/app/analyzer/itglue/configurations/[id]/page.tsx
new file mode 100644
index 0000000..55c7ee1
--- /dev/null
+++ b/app/analyzer/itglue/configurations/[id]/page.tsx
@@ -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;
+ 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 = {
+ 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(null);
+ const [audit, setAudit] = useState(null);
+ const [history, setHistory] = useState([]);
+ const [writes, setWrites] = useState([]);
+ const [xrefs, setXrefs] = useState([]);
+ const [error, setError] = useState(null);
+ const [provider, setProvider] = useState('anthropic');
+ const [running, setRunning] = useState(false);
+ const [busyKey, setBusyKey] = useState(null);
+
+ async function loadAll(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 (
+
+
+ Couldn’t load this configuration
+ {error}
+
+
+ );
+ }
+ if (!detail) {
+ return (
+
+
+
+
+ );
+ }
+
+ 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 (
+
+
+
+
+
+
+
+ Configurations
+ {' '}
+ · {a.organizationName ?? 'Unknown org'}
+ {a.typeName && ` · ${a.typeName}`}
+
+
+
+ {a.name}
+
+
+ {filledCount}/{totalCount} fields populated
+ {a.statusName && ` · ${a.statusName}`}
+ {a.operatingSystemName && ` · ${a.operatingSystemName}`}
+ {audit?.overall_score !== null && audit?.overall_score !== undefined && (
+ <>
+ {' · '}
+ 0.8
+ ? 'default'
+ : (audit.overall_score ?? 0) > 0.5
+ ? 'secondary'
+ : 'destructive'
+ }
+ >
+ Audit score {Math.round((audit.overall_score ?? 0) * 100)}%
+
+ >
+ )}
+
+
+
+
+
+ {running ? (
+ <>
+
+ Auditing…
+ >
+ ) : (
+ <>
+
+ {audit ? 'Re-audit' : 'Run audit'}
+ >
+ )}
+
+ {a.dattoDeviceUid && (
+
loadAll()}
+ />
+ )}
+
+
+ Open in IT Glue
+
+
+
+
+
+
+
+
+ {audit && (
+
+
+
+ Audit findings
+
+ {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'}
+
+
+
+
+
+
+ Field gaps ({audit.field_gaps.length})
+
+ {audit.field_gaps.length === 0 ? (
+ No field gaps detected.
+ ) : (
+
+ {audit.field_gaps.map((g) => {
+ const key = `field_gap:${g.field_name}`;
+ const busy = busyKey === key;
+ return (
+
+
+
+
{g.field_name}
+
{g.why_missing_matters}
+ {g.suggested_value !== null && (
+
+ Suggested:
+ {g.suggested_value}
+
+ )}
+ {g.evidence_ticket_numbers.length > 0 && (
+
+ Evidence:{' '}
+ {g.evidence_ticket_numbers.map((tn, i) => (
+
+ {i > 0 && ', '}
+
+ {tn}
+
+
+ ))}
+
+ )}
+
+
+
+ {g.confidence}
+
+ applyGap(g, 'field_gap')}
+ title={
+ !canWrite
+ ? 'Requires admin'
+ : g.suggested_value === null
+ ? 'No concrete suggestion'
+ : 'Apply to IT Glue'
+ }
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+ Apply
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {audit.notes_promotions.length > 0 && (
+
+
+ Promote from Notes ({audit.notes_promotions.length})
+
+
+ {audit.notes_promotions.map((p, i) => {
+ const busy = busyKey === `note_promotion:${p.target_field}`;
+ return (
+
+
+
+
+ “{p.quoted_note_text}”
+
+
+ Belongs in{' '}
+ {p.target_field}
+ :{' '}
+ {p.suggested_value}
+
+
+
+
+ {p.confidence}
+
+ applyGap(p, 'note_promotion')}
+ title={!canWrite ? 'Requires admin' : 'Apply to IT Glue'}
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+ Apply
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {audit.contradictions.length > 0 && (
+
+
+ Contradictions ({audit.contradictions.length})
+
+
+ {audit.contradictions.map((c, i) => (
+
+
+
+
{c.description}
+
{c.evidence}
+
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {!audit && (
+
+
+ No audit yet. Click Run audit to analyze this configuration.
+
+
+ )}
+
+
+
+ Current fields
+
+
+
+ {orderedFields.map((f) => (
+
+
+ {f.name}
+ {f.required && * }
+
+
+ {f.populated ? (
+ formatValue(f.value)
+ ) : (
+ empty
+ )}
+ {f.hint && !f.populated && (
+ {f.hint}
+ )}
+
+
+ ))}
+
+
+
+
+ {(referencedXrefs.length > 0 || updatedXrefs.length > 0) && (
+
+
+ Tickets that touched this configuration
+
+
+ {referencedXrefs.length > 0 && (
+
+
+ Referenced by ({referencedXrefs.length})
+
+
+ {referencedXrefs.map((x) => (
+
+
+ {x.ticketNumber}
+
+ {x.details?.relevance_reason && (
+
+ — {x.details.relevance_reason}
+
+ )}
+
+ ))}
+
+
+ )}
+ {updatedXrefs.length > 0 && (
+
+
+ Updated by ({updatedXrefs.length})
+
+
+ {updatedXrefs.map((x) => (
+
+
+ {x.ticketNumber}
+
+ {x.details?.field_name && (
+
+ — set {x.details.field_name}
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {writes.length > 0 && (
+
+
+
+
+ Write history ({writes.length})
+
+
+
+
+ {writes.map((w) => (
+
+
+
+ {w.field_name}
+
+ {w.status}
+
+
+
+ {new Date(w.performed_at).toLocaleString()}
+
+
+ Before:
+
+ {w.before_value === null || w.before_value === undefined
+ ? '(empty)'
+ : JSON.stringify(w.before_value).slice(0, 200)}
+
+
+
+ After:
+
+ {JSON.stringify(w.after_value).slice(0, 200)}
+
+
+ {w.error_message && (
+
Error: {w.error_message}
+ )}
+
+ {w.status === 'committed' && (
+ revertWrite(w.id)}
+ title={!canWrite ? 'Requires admin' : 'Revert this write'}
+ >
+ {busyKey === `revert:${w.id}` ? (
+
+ ) : (
+
+ )}
+ Revert
+
+ )}
+
+ ))}
+
+
+
+ )}
+
+ {history.length > 1 && (
+
+
+ Audit history
+
+
+
+ {history.map((h) => (
+
+
+ {new Date(h.generated_at).toLocaleString()}
+ {' · '}
+ {h.provider === 'openrouter' ? 'DeepSeek' : 'Claude'}
+
+
+ Score{' '}
+
+ {h.overall_score !== null
+ ? Math.round(h.overall_score * 100) + '%'
+ : 'n/a'}
+
+
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/analyzer/itglue/configurations/page.tsx b/app/analyzer/itglue/configurations/page.tsx
new file mode 100644
index 0000000..f793e27
--- /dev/null
+++ b/app/analyzer/itglue/configurations/page.tsx
@@ -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(null);
+ const [error, setError] = useState(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 (
+
+
+
+
+
+
IT Glue configurations audit
+
+ {rows === null
+ ? 'Loading…'
+ : `${rows.length} configurations · ${auditedCount} audited`}
+
+
+
setFilter(e.target.value)}
+ className="max-w-sm"
+ />
+
+
+
+ {error && (
+
+ Couldn’t load configurations
+ {error}
+
+ )}
+ {rows === null && !error ? (
+
+
+
+
+
+ ) : (
+
+ {visible.map((r) => (
+
+
+
+ {r.name}
+
+
+ {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'}
+ )
+ >
+ )}
+
+
+
+ {r.latestAudit?.overallScore !== null &&
+ r.latestAudit?.overallScore !== undefined
+ ? Math.round(r.latestAudit.overallScore * 100) + '%'
+ : 'No audit'}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/analyzer/itglue/sites/[companyId]/page.tsx b/app/analyzer/itglue/sites/[companyId]/page.tsx
new file mode 100644
index 0000000..0b6c98b
--- /dev/null
+++ b/app/analyzer/itglue/sites/[companyId]/page.tsx
@@ -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(null);
+ const [executions, setExecutions] = useState(null);
+ const [error, setError] = useState(null);
+
+ async function loadAll(): Promise {
+ 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 (
+
+
+ Couldn’t load this site
+ {error}
+
+
+ );
+ }
+
+ if (!info) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
Site discovery
+
+ {info.companyName ?? `Company ${info.companyId}`}
+
+
+ {info.wnpHostname ? (
+ <>
+
+ Primary target:{' '}
+ {info.wnpHostname}
+ {info.wnpOnline === false && (
+
+ offline
+
+ )}
+ {info.sites.length > 1 && (
+
+ ({info.sites.length} WNP endpoints across this client’s sites)
+
+ )}
+ >
+ ) : (
+
+ No Wulf Nurse Production endpoint registered for this client.
+
+ )}
+
+
+
loadAll()}
+ />
+
+
+
+
+ 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.
+
+ {info.sites.length > 1 && (
+
+
+ All sites for this client
+
+
+ {info.sites.map((s) => (
+
+
+ {s.hostname}
+
+ {s.site_name} · {s.site_device_count} devices
+
+ {s.hostname === info.wnpHostname && (
+
+ primary
+
+ )}
+
+ {!s.online && (
+
+ offline
+
+ )}
+
+ ))}
+
+
+ v1 dispatches site-anchored scripts to the primary target only.
+ A future version will let you pick a specific site here.
+
+
+ )}
+
+
+
+
+
+ Recent runs ({recent.length})
+
+
+ {recent.length === 0 ? (
+ No runs yet.
+ ) : (
+
+ {recent.map((e) => (
+
+
+
+
+ {e.scriptId}{' '}
+
+ {e.status}
+
+
+
+ {e.targetHostname ?? '—'} ·{' '}
+ {new Date(e.queuedAt).toLocaleString()}
+
+
+
+ {e.parsedEvidence !== null && e.parsedEvidence !== undefined && (
+
+
+ Show parsed evidence
+
+
+ {JSON.stringify(e.parsedEvidence, null, 2)}
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/analyzer/ticket/[ticketNumber]/page.tsx b/app/analyzer/ticket/[ticketNumber]/page.tsx
index f3cfec9..864ae2d 100644
--- a/app/analyzer/ticket/[ticketNumber]/page.tsx
+++ b/app/analyzer/ticket/[ticketNumber]/page.tsx
@@ -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(null);
const [error, setError] = useState(null);
+ const [provider, setProvider] = useState('anthropic');
useEffect(() => {
let cancelled = false;
@@ -52,14 +58,18 @@ export default function TicketAnalyzerPage({
Ticket
{ticketNumber}
-
+
- Click Analyze to run the AI pipeline. If a current
- analysis already exists, you’ll be navigated straight to it.
- Otherwise the run takes ~10–60 seconds.
+ Click Analyze 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.
@@ -71,6 +81,9 @@ export default function TicketAnalyzerPage({
)}
+
+
+
Analysis history
@@ -87,40 +100,64 @@ export default function TicketAnalyzerPage({
) : (
- {(analyses ?? []).map((a) => (
-
-
-
-
- Version {a.analysisVersion}
- {latest?.id === a.id && (
-
- 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 (
+
+
+
+
+ Version {a.analysisVersion}
+
+ {isOpenRouter ? 'DeepSeek' : 'Claude'}
- )}
- {a.needsHumanReview && (
-
- Needs review
-
- )}
-
-
- {new Date(a.triggeredAt).toLocaleString()}
- {' · '}
- {a.opusUsed ? 'Haiku → Sonnet → Opus' : a.sonnetUsed ? 'Haiku → Sonnet' : 'Haiku'}
- {' · '}${a.estimatedCostUsd.toFixed(4)}
-
-
- {a.confidenceScore !== null && (
-
- {Math.round(a.confidenceScore * 100)}%
-
- )}
-
- ))}
+ {latest?.id === a.id && (
+
+ latest
+
+ )}
+ {a.needsHumanReview && (
+
+ Needs review
+
+ )}
+
+
+ {new Date(a.triggeredAt).toLocaleString()}
+ {' · '}
+ {tierLabel}
+ {' · '}${a.estimatedCostUsd.toFixed(4)}
+
+
+ {a.confidenceScore !== null && (
+
+ {Math.round(a.confidenceScore * 100)}%
+
+ )}
+
+ );
+ })}
)}
diff --git a/app/api/admin/device-link-conflicts/[id]/resolve/route.ts b/app/api/admin/device-link-conflicts/[id]/resolve/route.ts
new file mode 100644
index 0000000..9e1900c
--- /dev/null
+++ b/app/api/admin/device-link-conflicts/[id]/resolve/route.ts
@@ -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) });
+ });
+}
diff --git a/app/api/admin/device-link-conflicts/route.ts b/app/api/admin/device-link-conflicts/route.ts
new file mode 100644
index 0000000..530a0cd
--- /dev/null
+++ b/app/api/admin/device-link-conflicts/route.ts
@@ -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(
+ `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();
+ for (const r of reviews.rows) {
+ for (const id of r.candidate_ci_ids ?? []) allCiIds.add(String(id));
+ }
+ const ciDetails = new Map();
+ if (allCiIds.size > 0) {
+ const ciRes = await postgresClient.query(
+ `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 });
+}
diff --git a/app/api/admin/rmm/settings/discover-loglift/route.ts b/app/api/admin/rmm/settings/discover-loglift/route.ts
new file mode 100644
index 0000000..d5e9d33
--- /dev/null
+++ b/app/api/admin/rmm/settings/discover-loglift/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/api/admin/rmm/settings/discover/route.ts b/app/api/admin/rmm/settings/discover/route.ts
new file mode 100644
index 0000000..c5e9f51
--- /dev/null
+++ b/app/api/admin/rmm/settings/discover/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/api/admin/rmm/settings/route.ts b/app/api/admin/rmm/settings/route.ts
new file mode 100644
index 0000000..85fc908
--- /dev/null
+++ b/app/api/admin/rmm/settings/route.ts
@@ -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(
+ `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 });
+}
diff --git a/app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts b/app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts
new file mode 100644
index 0000000..9834ca0
--- /dev/null
+++ b/app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts
@@ -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 });
+}
diff --git a/app/api/analyzer/analyses/[id]/share/route.ts b/app/api/analyzer/analyses/[id]/share/route.ts
index ebc982a..725560c 100644
--- a/app/api/analyzer/analyses/[id]/share/route.ts
+++ b/app/api/analyzer/analyses/[id]/share/route.ts
@@ -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,
});
diff --git a/app/api/analyzer/itglue/applications/[id]/apply/route.ts b/app/api/analyzer/itglue/applications/[id]/apply/route.ts
new file mode 100644
index 0000000..b65999a
--- /dev/null
+++ b/app/api/analyzer/itglue/applications/[id]/apply/route.ts
@@ -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;
+}
+
+async function loadAssetRow(assetId: string): Promise {
+ const res = await postgresClient.query(
+ `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 = {
+ ...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 }
+ );
+ }
+}
diff --git a/app/api/analyzer/itglue/applications/[id]/audit/route.ts b/app/api/analyzer/itglue/applications/[id]/audit/route.ts
new file mode 100644
index 0000000..1b8a37c
--- /dev/null
+++ b/app/api/analyzer/itglue/applications/[id]/audit/route.ts
@@ -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 });
+}
diff --git a/app/api/analyzer/itglue/applications/[id]/revert/[writeId]/route.ts b/app/api/analyzer/itglue/applications/[id]/revert/[writeId]/route.ts
new file mode 100644
index 0000000..2814e14
--- /dev/null
+++ b/app/api/analyzer/itglue/applications/[id]/revert/[writeId]/route.ts
@@ -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;
+}
+
+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(
+ `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 = {
+ ...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 }
+ );
+ }
+}
diff --git a/app/api/analyzer/itglue/applications/[id]/route.ts b/app/api/analyzer/itglue/applications/[id]/route.ts
new file mode 100644
index 0000000..6d095de
--- /dev/null
+++ b/app/api/analyzer/itglue/applications/[id]/route.ts
@@ -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;
+ 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(
+ `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(
+ `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,
+ });
+}
diff --git a/app/api/analyzer/itglue/applications/[id]/writes/route.ts b/app/api/analyzer/itglue/applications/[id]/writes/route.ts
new file mode 100644
index 0000000..fe84d88
--- /dev/null
+++ b/app/api/analyzer/itglue/applications/[id]/writes/route.ts
@@ -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 });
+}
diff --git a/app/api/analyzer/itglue/applications/[id]/xrefs/route.ts b/app/api/analyzer/itglue/applications/[id]/xrefs/route.ts
new file mode 100644
index 0000000..37ad151
--- /dev/null
+++ b/app/api/analyzer/itglue/applications/[id]/xrefs/route.ts
@@ -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 });
+}
diff --git a/app/api/analyzer/itglue/applications/route.ts b/app/api/analyzer/itglue/applications/route.ts
new file mode 100644
index 0000000..ed10975
--- /dev/null
+++ b/app/api/analyzer/itglue/applications/route.ts
@@ -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(
+ `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 });
+}
diff --git a/app/api/analyzer/itglue/configurations/[id]/apply/route.ts b/app/api/analyzer/itglue/configurations/[id]/apply/route.ts
new file mode 100644
index 0000000..17377ed
--- /dev/null
+++ b/app/api/analyzer/itglue/configurations/[id]/apply/route.ts
@@ -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 = {
+ 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 {
+ const res = await postgresClient.query(
+ `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)[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 }
+ );
+ }
+}
diff --git a/app/api/analyzer/itglue/configurations/[id]/audit/route.ts b/app/api/analyzer/itglue/configurations/[id]/audit/route.ts
new file mode 100644
index 0000000..4042027
--- /dev/null
+++ b/app/api/analyzer/itglue/configurations/[id]/audit/route.ts
@@ -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 });
+}
diff --git a/app/api/analyzer/itglue/configurations/[id]/revert/[writeId]/route.ts b/app/api/analyzer/itglue/configurations/[id]/revert/[writeId]/route.ts
new file mode 100644
index 0000000..808390b
--- /dev/null
+++ b/app/api/analyzer/itglue/configurations/[id]/revert/[writeId]/route.ts
@@ -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 = {
+ 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 }
+ );
+ }
+}
diff --git a/app/api/analyzer/itglue/configurations/[id]/route.ts b/app/api/analyzer/itglue/configurations/[id]/route.ts
new file mode 100644
index 0000000..70ea864
--- /dev/null
+++ b/app/api/analyzer/itglue/configurations/[id]/route.ts
@@ -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(
+ `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,
+ });
+}
diff --git a/app/api/analyzer/itglue/configurations/[id]/writes/route.ts b/app/api/analyzer/itglue/configurations/[id]/writes/route.ts
new file mode 100644
index 0000000..05bf334
--- /dev/null
+++ b/app/api/analyzer/itglue/configurations/[id]/writes/route.ts
@@ -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 });
+}
diff --git a/app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts b/app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts
new file mode 100644
index 0000000..7e44ec0
--- /dev/null
+++ b/app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts
@@ -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 });
+}
diff --git a/app/api/analyzer/itglue/configurations/route.ts b/app/api/analyzer/itglue/configurations/route.ts
new file mode 100644
index 0000000..2a93d6a
--- /dev/null
+++ b/app/api/analyzer/itglue/configurations/route.ts
@@ -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(
+ `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 });
+}
diff --git a/app/api/analyzer/itglue/sites/[companyId]/route.ts b/app/api/analyzer/itglue/sites/[companyId]/route.ts
new file mode 100644
index 0000000..b10a839
--- /dev/null
+++ b/app/api/analyzer/itglue/sites/[companyId]/route.ts
@@ -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(
+ `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,
+ },
+ });
+}
diff --git a/app/api/analyzer/itglue/writes/route.ts b/app/api/analyzer/itglue/writes/route.ts
new file mode 100644
index 0000000..b12020c
--- /dev/null
+++ b/app/api/analyzer/itglue/writes/route.ts
@@ -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 = [
+ '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 });
+}
diff --git a/app/api/analyzer/share/recipients/route.ts b/app/api/analyzer/share/recipients/route.ts
new file mode 100644
index 0000000..a0a0cb2
--- /dev/null
+++ b/app/api/analyzer/share/recipients/route.ts
@@ -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(
+ `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(
+ `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 });
+}
diff --git a/app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts b/app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts
new file mode 100644
index 0000000..c961de7
--- /dev/null
+++ b/app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts
@@ -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,
+ });
+}
diff --git a/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts b/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts
index 0b7309d..39b7e6e 100644
--- a/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts
+++ b/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts
@@ -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({
diff --git a/app/api/analyzer/tickets/[ticketNumber]/itglue-xrefs/route.ts b/app/api/analyzer/tickets/[ticketNumber]/itglue-xrefs/route.ts
new file mode 100644
index 0000000..107f53c
--- /dev/null
+++ b/app/api/analyzer/tickets/[ticketNumber]/itglue-xrefs/route.ts
@@ -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 });
+}
diff --git a/app/api/analyzer/tickets/[ticketNumber]/links/route.ts b/app/api/analyzer/tickets/[ticketNumber]/links/route.ts
new file mode 100644
index 0000000..2888363
--- /dev/null
+++ b/app/api/analyzer/tickets/[ticketNumber]/links/route.ts
@@ -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);
+}
diff --git a/app/api/dashboard/integration-health/route.ts b/app/api/dashboard/integration-health/route.ts
new file mode 100644
index 0000000..07ef02a
--- /dev/null
+++ b/app/api/dashboard/integration-health/route.ts
@@ -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) });
+}
diff --git a/app/api/dashboard/overview/route.ts b/app/api/dashboard/overview/route.ts
new file mode 100644
index 0000000..4497da0
--- /dev/null
+++ b/app/api/dashboard/overview/route.ts
@@ -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(
+ `SELECT COUNT(*)::text AS count FROM device_link_review WHERE resolved_at IS NULL`
+ ),
+ postgresClient.query(
+ `SELECT COUNT(*)::text AS count FROM device_external_ids WHERE source = 'itglue' AND configuration_item_id IS NULL`
+ ),
+ postgresClient.query(
+ `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(
+ `SELECT COUNT(*)::text AS count FROM companies WHERE company_type = 1 AND is_active = true`
+ ),
+ postgresClient.query(
+ `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),
+ },
+ },
+ });
+}
diff --git a/app/api/rmm/executions/[id]/route.ts b/app/api/rmm/executions/[id]/route.ts
new file mode 100644
index 0000000..b520b85
--- /dev/null
+++ b/app/api/rmm/executions/[id]/route.ts
@@ -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 });
+}
diff --git a/app/api/rmm/executions/route.ts b/app/api/rmm/executions/route.ts
new file mode 100644
index 0000000..1826676
--- /dev/null
+++ b/app/api/rmm/executions/route.ts
@@ -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 = [
+ '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 }
+ );
+ }
+}
diff --git a/app/api/rmm/loglift/upload/route.ts b/app/api/rmm/loglift/upload/route.ts
new file mode 100644
index 0000000..9a83217
--- /dev/null
+++ b/app/api/rmm/loglift/upload/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/api/rmm/scripts/route.ts b/app/api/rmm/scripts/route.ts
new file mode 100644
index 0000000..072a1a6
--- /dev/null
+++ b/app/api/rmm/scripts/route.ts
@@ -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 });
+}
diff --git a/app/api/sync/schedules/reload/route.ts b/app/api/sync/schedules/reload/route.ts
new file mode 100644
index 0000000..1ab43ef
--- /dev/null
+++ b/app/api/sync/schedules/reload/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/configuration-items/page.tsx b/app/configuration-items/page.tsx
index 7bc1ead..4ac1b8b 100644
--- a/app/configuration-items/page.tsx
+++ b/app/configuration-items/page.tsx
@@ -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() {
RMM
NMS
ARMM
+ Actions
@@ -889,7 +891,7 @@ function ConfigurationItemsContent() {
setExpandedContacts(newExpanded);
}}
>
-
+
@@ -1024,6 +1026,17 @@ function ConfigurationItemsContent() {
)}
+
e.stopPropagation()}>
+ {item.rmmDevice?.uid ? (
+
+ ) : (
+ —
+ )}
+
))}
>
@@ -1150,6 +1163,17 @@ function ConfigurationItemsContent() {
)}
+
e.stopPropagation()}>
+ {item.rmmDevice?.uid ? (
+
+ ) : (
+ —
+ )}
+
))
)}
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 5797415..503a15a 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -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
off ;
+ if (s.lastStatus === 'failed')
+ return
;
+ if (isStale(s.lastRun))
+ return
;
+ if (s.lastStatus === 'success')
+ return
;
+ return
;
+}
+
export default function DashboardPage() {
- const [stats, setStats] = useState
({
- 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(null);
+ const [health, setHealth] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
- useEffect(() => {
- fetchStats();
- }, []);
-
- const fetchStats = async () => {
+ async function load(): Promise {
+ 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 (
-
- {/* Header */}
+
-
-
Dashboard
-
- Welcome to Pulse - Your PSA Management System
-
-
-
-
+ Dashboard
+
+
Refresh
- {/* Stats Overview */}
-
-
-
- Total Companies
-
-
-
- {stats.companies.total}
-
- {stats.companies.active} active
-
-
-
-
-
-
- NMS Coverage
-
-
-
-
- {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}%
-
-
- {stats.mappings.auvik.mapped} of {stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped} tenants
-
-
-
-
-
-
- RMM Coverage
-
-
-
-
- {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}%
-
-
- {stats.mappings.rmm.mapped} of {stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped} sites
-
-
-
-
-
-
-
- Open Quotes
-
-
-
- {stats.quotes.open}
-
- Pending approval
-
-
-
-
-
-
-
- System Status
-
-
-
-
-
- Online
-
-
- All systems operational
-
-
-
-
+ {error && (
+
+ Failed to load
+ {error}
+
+ )}
- {/* Quick Links */}
-
-
Quick Access
-
- {quickLinks.map((link) => (
-
-
-
-
- {link.title}
- {link.description}
-
-
- {link.stats}
-
-
-
- ))}
+ {/* NEEDS ATTENTION ----------------------------------------------------- */}
+
+
+ Needs attention
+
+
+
0 ? 'warn' : 'ok'}
+ />
+
+
+
-
+
- {/* Mapping Status */}
-
-
Integration Mappings
-
- {mappingCards.map((mapping) => (
-
- {mapping.comingSoon && (
-
- Coming Soon
-
- )}
-
-
-
- {!mapping.comingSoon && mapping.unmapped > 0 && (
-
-
- {mapping.unmapped} unmapped
-
- )}
-
- {mapping.title}
- {mapping.description}
-
-
- {!mapping.comingSoon ? (
- <>
-
-
-
Coverage
-
- {Math.round(getMappingProgress(mapping.mapped, mapping.total))}%
-
+ {/* RECENT OBSERVATIONS + AUDITS ---------------------------------------- */}
+
+
+
+
+
+ Recent device observations
+
+
+
+ {data === null && !error ? (
+
+ ) : data?.observations.length === 0 ? (
+ No observations recorded yet.
+ ) : (
+
+ {data?.observations.map((o) => (
+
+
+
{o.hostname ?? '(unanchored)'}
+
+ {o.kind}
+ {o.companyName && · {o.companyName} }
-
-
-
-
- {mapping.mapped} mapped
-
-
-
- {mapping.unmapped} unmapped
-
+
+ {relTime(o.collectedAt)}
-
-
- Manage Mappings
-
-
-
- >
- ) : (
-
- Integration under development
-
- )}
-
-
- ))}
-
-
-
- {/* Recent Activity - Placeholder */}
-
-
Recent Activity
-
-
-
-
-
-
-
Data sync completed
-
Companies synchronized successfully - 5 minutes ago
-
+
+ ))}
-
-
-
-
New RMM site mapped
-
Site "Acme Corp - Dallas" mapped to Acme Corp - 2 hours ago
-
-
-
-
-
-
Configuration items updated
-
247 devices synchronized from RMM - 1 day ago
-
-
-
+ )}
+
+
+
+
+
+ Recent audits
+
+
+
+ {data === null && !error ? (
+
+ ) : data?.audits.length === 0 ? (
+ No endpoint audits yet.
+ ) : (
+
+ {data?.audits.map((a) => (
+
+
+
{a.hostname ?? '(unanchored)'}
+
+ score {a.overallScore?.toFixed(2) ?? '—'} · {a.fieldGapsCount} gaps
+ {a.companyName && · {a.companyName} }
+
+
+
+ {relTime(a.generatedAt)}
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* INTEGRATION HEALTH -------------------------------------------------- */}
+
+
+
+
+ Integration health
+ {health?.summary.hasIssues && (
+ issues
+ )}
+
+
+
+ {!health ? (
+
+ ) : (
+
+ {health.items
+ .slice()
+ .sort((a, b) => statusOrder(a.status) - statusOrder(b.status))
+ .map((i) => (
+
+ ))}
+
+ )}
+
+
+
+ {/* SYNC HEALTH --------------------------------------------------------- */}
+
+
+ Sync health
+
+
+ {data === null && !error ? (
+
+ ) : (
+
+ {data?.syncHealth.map((s) => (
+
+
{s.name}
+
+ {relTime(s.lastRun)}
+ {syncStatusIcon(s)}
+
+
+ ))}
+
+ )}
+
+
+
+ {/* STATS FOOTER -------------------------------------------------------- */}
+ {data && (
+
+ {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)
+
+ )}
+
+ );
+}
+
+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 (
+
+
+
+
+
+ {value === undefined ? '—' : value.toLocaleString()}
+
+ {label}
+ {sub && {sub}
}
+
+
+
+ );
+}
+
+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
;
+ if (expired)
+ return
;
+ if (expiringSoon)
+ return
;
+ if (item.status === 'ok')
+ return
;
+ if (item.status === 'unknown')
+ return
;
+ return
off ;
+}
+
+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 (
+
+
{item.name}
+
+ {detail && {detail} }
+ {statusBadge(item)}
);
}
+
+function RowSkeletons() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/components/admin/SyncScheduler.tsx b/components/admin/SyncScheduler.tsx
index 6360555..7143f87 100644
--- a/components/admin/SyncScheduler.tsx
+++ b/components/admin/SyncScheduler.tsx
@@ -10,7 +10,7 @@ import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
-import { Clock, Play, Pause, Trash2, Plus, Calendar, AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
+import { Clock, Play, Pause, Trash2, Plus, Calendar, AlertCircle, CheckCircle2, XCircle, RefreshCw } from 'lucide-react';
interface ScheduleConfig {
id: string;
@@ -37,6 +37,7 @@ interface ScheduleStatus {
export default function SyncScheduler() {
const [schedules, setSchedules] = useState
([]);
+ const [reloading, setReloading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editingSchedule, setEditingSchedule] = useState(null);
@@ -90,6 +91,20 @@ export default function SyncScheduler() {
}
};
+ const reloadSchedules = async () => {
+ setReloading(true);
+ try {
+ const res = await fetch('/api/sync/schedules/reload', { method: 'POST' });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
+ await fetchSchedules();
+ } catch (err) {
+ alert(`Reload failed: ${err instanceof Error ? err.message : err}`);
+ } finally {
+ setReloading(false);
+ }
+ };
+
const toggleSchedule = async (scheduleId: string, currentState: boolean) => {
try {
const response = await fetch(`/api/sync/schedules/${scheduleId}`, {
@@ -259,10 +274,16 @@ export default function SyncScheduler() {
Manage automatic sync schedules
-
-
- New Schedule
-
+
+
+
+ {reloading ? 'Reloading…' : 'Reload from DB'}
+
+
+
+ New Schedule
+
+
diff --git a/components/analyzer/analyze-button.tsx b/components/analyzer/analyze-button.tsx
index a462cb3..45d9b62 100644
--- a/components/analyzer/analyze-button.tsx
+++ b/components/analyzer/analyze-button.tsx
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Sparkles, Loader2 } from 'lucide-react';
import type { JobStatus } from '@/lib/types/analyzer';
+import type { AnalyzerProvider } from './provider-toggle';
interface AnalyzeButtonProps {
ticketNumber: string;
@@ -13,6 +14,8 @@ interface AnalyzeButtonProps {
force?: boolean;
variant?: 'default' | 'outline' | 'secondary';
label?: string;
+ /** LLM provider (anthropic = Claude default; openrouter = DeepSeek). */
+ provider?: AnalyzerProvider;
}
const STAGE_LABEL: Record = {
@@ -31,13 +34,16 @@ export function AnalyzeButton({
force = false,
variant = 'default',
label = 'Analyze',
+ provider = 'anthropic',
}: AnalyzeButtonProps) {
const router = useRouter();
const [status, setStatus] = useState('idle');
async function pollJob(jobId: string) {
const start = Date.now();
- const TIMEOUT_MS = 5 * 60 * 1000;
+ // DeepSeek runs (especially V4 Pro deep analysis) take 4-6× longer than
+ // Claude — observed ~5min on a typical ticket. 12min keeps headroom.
+ const TIMEOUT_MS = 12 * 60 * 1000;
while (Date.now() - start < TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, 2000));
const res = await fetch(`/api/analyzer/jobs/${jobId}`);
@@ -64,7 +70,7 @@ export function AnalyzeButton({
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ force }),
+ body: JSON.stringify({ force, provider }),
}
);
if (!res.ok) {
diff --git a/components/analyzer/itglue-suggestions-panel.tsx b/components/analyzer/itglue-suggestions-panel.tsx
new file mode 100644
index 0000000..abc4cd1
--- /dev/null
+++ b/components/analyzer/itglue-suggestions-panel.tsx
@@ -0,0 +1,493 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import Link from 'next/link';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import {
+ ProviderToggle,
+ type AnalyzerProvider,
+} from '@/components/analyzer/provider-toggle';
+import {
+ Sparkles,
+ Loader2,
+ ExternalLink,
+ CheckCircle2,
+ Server,
+ Layers,
+ Database,
+} from 'lucide-react';
+import { useSession } from '@/lib/auth-client';
+import { toast } from 'sonner';
+
+interface FieldGap {
+ field_name: string;
+ why_missing_matters: string;
+ suggested_value: string | null;
+ evidence_ticket_numbers: string[];
+ confidence: 'high' | 'medium' | 'low';
+}
+
+interface NotePromotion {
+ quoted_note_text: string;
+ target_field: string;
+ suggested_value: string;
+ confidence: 'high' | 'medium' | 'low';
+}
+
+interface AuditRow {
+ id: string;
+ generated_at: string;
+ provider: 'anthropic' | 'openrouter';
+ ticket_count: number;
+ field_gaps: FieldGap[];
+ notes_promotions: NotePromotion[];
+ contradictions: { description: string; evidence: string }[];
+ overall_score: number | null;
+ estimated_cost_usd: number | null;
+}
+
+interface MatchedAsset {
+ id: string;
+ name: string | null;
+ hostname?: string | null;
+ type_name: string | null;
+ score: number;
+ matched_term: string;
+ latestAudit: AuditRow | null;
+}
+
+interface SuggestionsResponse {
+ ticketNumber: string;
+ organizationId: string | null;
+ organizationName: string | null;
+ flexibleAssets: MatchedAsset[];
+ configurations: MatchedAsset[];
+}
+
+const CONFIDENCE_TONE: Record = {
+ high: 'border-red-500 bg-red-500/10',
+ medium: 'border-amber-500 bg-amber-500/10',
+ low: 'border-blue-500 bg-blue-500/10',
+};
+
+interface ItglueSuggestionsPanelProps {
+ analysisId: string;
+}
+
+export function ItglueSuggestionsPanel({ analysisId }: ItglueSuggestionsPanelProps) {
+ const { data: session } = useSession();
+ const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
+ const canWrite = role === 'admin' || role === 'super-admin';
+
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [loadError, setLoadError] = useState(null);
+ const [provider, setProvider] = useState('anthropic');
+ const [auditing, setAuditing] = useState(null);
+ const [busyKey, setBusyKey] = useState(null);
+
+ async function loadSuggestions(): Promise {
+ setLoading(true);
+ setLoadError(null);
+ try {
+ const res = await fetch(
+ `/api/analyzer/analyses/${analysisId}/itglue-suggestions`
+ );
+ if (!res.ok) {
+ const err = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(err.error ?? `Request failed: ${res.status}`);
+ }
+ const d = (await res.json()) as SuggestionsResponse;
+ setData(d);
+ } catch (err) {
+ setLoadError(err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function runAudit(
+ assetType: 'flexible_asset' | 'configuration',
+ assetId: string
+ ): Promise {
+ const key = `${assetType}:${assetId}`;
+ setAuditing(key);
+ try {
+ const res = await fetch(
+ `/api/analyzer/analyses/${analysisId}/itglue-suggestions`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ assetType, assetId, provider }),
+ }
+ );
+ const d = await res.json();
+ if (!res.ok) throw new Error(d.message || d.error || 'Audit failed');
+ toast.success('Audit complete');
+ // Refresh the suggestions to pick up the new audit row.
+ await loadSuggestions();
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Audit failed');
+ } finally {
+ setAuditing(null);
+ }
+ }
+
+ async function applyGap(
+ assetType: 'flexible_asset' | 'configuration',
+ assetId: string,
+ auditId: string,
+ gap: FieldGap | NotePromotion,
+ kind: 'field_gap' | 'note_promotion'
+ ): Promise {
+ if (!canWrite) return;
+ const fieldName =
+ kind === 'field_gap' ? (gap as FieldGap).field_name : (gap as NotePromotion).target_field;
+ const suggested =
+ kind === 'field_gap'
+ ? (gap as FieldGap).suggested_value
+ : (gap as NotePromotion).suggested_value;
+ if (suggested === null || suggested === undefined || suggested === '') {
+ toast.error('No suggested value to apply');
+ return;
+ }
+ const evidence =
+ kind === 'field_gap'
+ ? {
+ ticket_numbers: (gap as FieldGap).evidence_ticket_numbers,
+ gap_description: (gap as FieldGap).why_missing_matters,
+ }
+ : {
+ ticket_numbers: [],
+ gap_description: `Promoted from Notes: "${(gap as NotePromotion).quoted_note_text}"`,
+ };
+ const key = `${assetType}:${assetId}:${kind}:${fieldName}`;
+ setBusyKey(key);
+ try {
+ const path =
+ assetType === 'flexible_asset'
+ ? `/api/analyzer/itglue/applications/${assetId}/apply`
+ : `/api/analyzer/itglue/configurations/${assetId}/apply`;
+ const res = await fetch(path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ auditId,
+ fieldName,
+ suggestedValue: suggested,
+ sourceEvidence: evidence,
+ }),
+ });
+ const d = await res.json();
+ if (!res.ok) throw new Error(d.message || d.error || 'Apply failed');
+ toast.success(`Applied: ${fieldName}`);
+ await loadSuggestions();
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Apply failed');
+ } finally {
+ setBusyKey(null);
+ }
+ }
+
+ const totalMatches = useMemo(() => {
+ if (!data) return 0;
+ return data.flexibleAssets.length + data.configurations.length;
+ }, [data]);
+
+ return (
+
+
+
+
+
+ IT Glue documentation
+
+
+
+
+ {loading ? (
+ <>
+
+ Checking…
+ >
+ ) : data ? (
+ <>
+
+ Re-check
+ >
+ ) : (
+ <>
+
+ Check IT Glue documentation
+ >
+ )}
+
+
+
+
+
+ {loadError && (
+
+ Couldn’t load suggestions
+ {loadError}
+
+ )}
+
+ {!data && !loading && !loadError && (
+
+ Click Check IT Glue documentation to find IT Glue
+ records this ticket touched and surface what should be documented.
+
+ )}
+
+ {data && (
+ <>
+
+ Matched {totalMatches} IT Glue record
+ {totalMatches === 1 ? '' : 's'} for{' '}
+ {data.organizationName ?? 'this client'} .
+ {totalMatches === 0 &&
+ ' (No matches — ticket fingerprint did not mention any IT Glue assets we could find.)'}
+
+
+ {data.flexibleAssets.length > 0 && (
+
+
+ Applications ({data.flexibleAssets.length})
+
+ {data.flexibleAssets.map((m) =>
+ renderAssetMatch(
+ m,
+ 'flexible_asset',
+ auditing === `flexible_asset:${m.id}`,
+ busyKey,
+ canWrite,
+ () => runAudit('flexible_asset', m.id),
+ (g, k, auditId) => applyGap('flexible_asset', m.id, auditId, g, k)
+ )
+ )}
+
+ )}
+
+ {data.configurations.length > 0 && (
+
+
+ Configurations ({data.configurations.length})
+
+ {data.configurations.map((m) =>
+ renderAssetMatch(
+ m,
+ 'configuration',
+ auditing === `configuration:${m.id}`,
+ busyKey,
+ canWrite,
+ () => runAudit('configuration', m.id),
+ (g, k, auditId) => applyGap('configuration', m.id, auditId, g, k)
+ )
+ )}
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
+function renderAssetMatch(
+ m: MatchedAsset,
+ assetType: 'flexible_asset' | 'configuration',
+ auditing: boolean,
+ busyKey: string | null,
+ canWrite: boolean,
+ onRunAudit: () => void,
+ onApply: (
+ gap: FieldGap | NotePromotion,
+ kind: 'field_gap' | 'note_promotion',
+ auditId: string
+ ) => void
+) {
+ const detailHref =
+ assetType === 'flexible_asset'
+ ? `/analyzer/itglue/applications/${m.id}`
+ : `/analyzer/itglue/configurations/${m.id}`;
+ const audit = m.latestAudit;
+ return (
+
+
+
+
+ {m.name ?? m.id}
+
+
+ {m.type_name ?? '—'}
+ {m.hostname && ` · ${m.hostname}`}
+ {' · '}match: {m.matched_term}
+ {audit?.overall_score !== null && audit?.overall_score !== undefined && (
+ <>
+ {' · '}score{' '}
+ 0.8
+ ? 'default'
+ : (audit.overall_score ?? 0) > 0.5
+ ? 'secondary'
+ : 'destructive'
+ }
+ className="text-[10px]"
+ >
+ {Math.round((audit.overall_score ?? 0) * 100)}%
+
+ >
+ )}
+
+
+
+
+
+ Open
+
+
+
+
+ {auditing ? (
+ <>
+
+ Auditing…
+ >
+ ) : (
+ <>
+
+ {audit ? 'Re-audit for this ticket' : 'Audit for this ticket'}
+ >
+ )}
+
+
+
+
+ {audit && (
+
+ {audit.field_gaps.length === 0 && audit.notes_promotions.length === 0 && (
+
+ No new gaps surfaced from this ticket. Existing record looks
+ sufficient for what was learned.
+
+ )}
+ {audit.field_gaps.map((g) => {
+ const k = `${assetType}:${m.id}:field_gap:${g.field_name}`;
+ const busy = busyKey === k;
+ return (
+
+
+
+
{g.field_name}
+
{g.why_missing_matters}
+ {g.suggested_value !== null && (
+
+ Suggested:
+
+ {g.suggested_value}
+
+
+ )}
+
+
+
+ {g.confidence}
+
+ onApply(g, 'field_gap', audit.id)}
+ title={
+ !canWrite
+ ? 'Requires admin'
+ : g.suggested_value === null
+ ? 'No concrete suggestion'
+ : 'Apply to IT Glue'
+ }
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+ Apply
+
+
+
+
+ );
+ })}
+ {audit.notes_promotions.map((p, i) => {
+ const k = `${assetType}:${m.id}:note_promotion:${p.target_field}:${i}`;
+ const busy = busyKey === `${assetType}:${m.id}:note_promotion:${p.target_field}`;
+ return (
+
+
+
+
+ “{p.quoted_note_text}”
+
+
+ → {p.target_field} :{' '}
+ {p.suggested_value}
+
+
+
+
+ {p.confidence}
+
+ onApply(p, 'note_promotion', audit.id)}
+ title={!canWrite ? 'Requires admin' : 'Apply to IT Glue'}
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+ Apply
+
+
+
+
+ );
+ })}
+ {audit.contradictions.length > 0 && (
+
+ {audit.contradictions.map((c, i) => (
+
+ ⚠ {c.description}
+ — {c.evidence}
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
+
+interface MatchedAssetExt extends MatchedAsset {
+ hostname?: string | null;
+}
+void ({} as MatchedAssetExt);
diff --git a/components/analyzer/provider-toggle.tsx b/components/analyzer/provider-toggle.tsx
new file mode 100644
index 0000000..ae60968
--- /dev/null
+++ b/components/analyzer/provider-toggle.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import { Sparkles, Zap } from 'lucide-react';
+
+export type AnalyzerProvider = 'anthropic' | 'openrouter';
+
+interface ProviderToggleProps {
+ value: AnalyzerProvider;
+ onChange: (next: AnalyzerProvider) => void;
+ disabled?: boolean;
+ size?: 'sm' | 'md';
+}
+
+const OPTIONS: Array<{
+ value: AnalyzerProvider;
+ label: string;
+ hint: string;
+ icon: typeof Sparkles;
+}> = [
+ {
+ value: 'anthropic',
+ label: 'Claude',
+ hint: 'Haiku → Sonnet → Opus',
+ icon: Sparkles,
+ },
+ {
+ value: 'openrouter',
+ label: 'DeepSeek',
+ hint: 'V4 Flash → V4 Pro → R1',
+ icon: Zap,
+ },
+];
+
+export function ProviderToggle({
+ value,
+ onChange,
+ disabled = false,
+ size = 'md',
+}: ProviderToggleProps) {
+ const padding = size === 'sm' ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm';
+ return (
+
+ {OPTIONS.map((opt) => {
+ const Icon = opt.icon;
+ const active = opt.value === value;
+ return (
+ onChange(opt.value)}
+ title={opt.hint}
+ className={[
+ padding,
+ 'rounded-sm flex items-center gap-1.5 transition-colors',
+ active
+ ? 'bg-background shadow-sm font-medium'
+ : 'text-muted-foreground hover:text-foreground',
+ disabled ? 'opacity-50 cursor-not-allowed' : '',
+ ].join(' ')}
+ >
+
+ {opt.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/components/analyzer/related-tickets-panel.tsx b/components/analyzer/related-tickets-panel.tsx
new file mode 100644
index 0000000..3a17df8
--- /dev/null
+++ b/components/analyzer/related-tickets-panel.tsx
@@ -0,0 +1,368 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Badge } from '@/components/ui/badge';
+import { Switch } from '@/components/ui/switch';
+import { Label } from '@/components/ui/label';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { toast } from 'sonner';
+import { Sparkles, Loader2, Network } from 'lucide-react';
+import type {
+ AggregateReportStatus,
+ DiscoveredLinks,
+ TicketRef,
+} from '@/lib/types/analyzer';
+
+interface RelatedTicketsPanelProps {
+ ticketNumber: string;
+ /** LLM provider for the bundle run. Defaults to 'anthropic'. */
+ provider?: 'anthropic' | 'openrouter';
+}
+
+type Phase =
+ | 'idle'
+ | 'starting'
+ | 'pending_analyses'
+ | 'pending'
+ | 'running'
+ | 'complete'
+ | 'failed';
+
+const REPORT_POLL_TIMEOUT_MS = 10 * 60 * 1000;
+
+export function RelatedTicketsPanel({
+ ticketNumber,
+ provider = 'anthropic',
+}: RelatedTicketsPanelProps) {
+ const router = useRouter();
+ const [links, setLinks] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const [selected, setSelected] = useState>(new Set());
+ const [includeSuggested, setIncludeSuggested] = useState(false);
+ const [suggestionsLoading, setSuggestionsLoading] = useState(false);
+ const [phase, setPhase] = useState('idle');
+ const [statusLabel, setStatusLabel] = useState('');
+
+ // Initial cheap fetch.
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const res = await fetch(
+ `/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/links`
+ );
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(data.error ?? `Request failed: ${res.status}`);
+ }
+ const data = (await res.json()) as DiscoveredLinks;
+ if (cancelled) return;
+ setLinks(data);
+ // Pre-check all explicit refs.
+ setSelected(new Set(data.explicit.map((r) => r.ticket_number)));
+ } catch (err) {
+ if (!cancelled)
+ setLoadError(err instanceof Error ? err.message : 'Unknown error');
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [ticketNumber]);
+
+ async function loadSuggestions(): Promise {
+ if (!links) return;
+ setSuggestionsLoading(true);
+ try {
+ const res = await fetch(
+ `/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/links`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ includeSuggested: true }),
+ }
+ );
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(data.error ?? `Request failed: ${res.status}`);
+ }
+ const data = (await res.json()) as DiscoveredLinks;
+ setLinks(data);
+ } catch (err) {
+ toast.error(
+ err instanceof Error
+ ? `Suggestion failed: ${err.message}`
+ : 'Suggestion failed'
+ );
+ setIncludeSuggested(false);
+ } finally {
+ setSuggestionsLoading(false);
+ }
+ }
+
+ function toggleRef(ref: TicketRef): void {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(ref.ticket_number)) next.delete(ref.ticket_number);
+ else next.add(ref.ticket_number);
+ return next;
+ });
+ }
+
+ async function pollReport(reportId: string): Promise {
+ const start = Date.now();
+ while (Date.now() - start < REPORT_POLL_TIMEOUT_MS) {
+ await new Promise((r) => setTimeout(r, 3000));
+ const res = await fetch(`/api/analyzer/aggregate-reports/${reportId}`);
+ if (!res.ok) throw new Error(`Report poll failed: ${res.status}`);
+ const data = (await res.json()) as {
+ report: { status: AggregateReportStatus; errorMessage: string | null };
+ };
+ const status = data.report.status;
+ setPhase(status as Phase);
+ setStatusLabel(
+ status === 'pending_analyses'
+ ? 'Analyzing linked tickets…'
+ : status === 'pending' || status === 'running'
+ ? 'Building bundle report…'
+ : status === 'complete'
+ ? 'Done'
+ : status === 'failed'
+ ? 'Failed'
+ : ''
+ );
+ if (status === 'complete') {
+ router.push(`/analyzer/reports/${reportId}`);
+ return;
+ }
+ if (status === 'failed') {
+ throw new Error(data.report.errorMessage ?? 'Bundle report failed');
+ }
+ }
+ throw new Error('Bundle report timed out after 10 minutes');
+ }
+
+ async function submit(opts: { confirmedCost?: boolean } = {}): Promise {
+ if (selected.size === 0) {
+ toast.error('Select at least one linked ticket');
+ return;
+ }
+ setPhase('starting');
+ setStatusLabel('Queueing analyses…');
+ try {
+ const res = await fetch(
+ `/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/analyze-bundle`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ linkedTicketNumbers: Array.from(selected),
+ includeItglueContext: true,
+ confirmedCost: opts.confirmedCost ?? false,
+ provider,
+ }),
+ }
+ );
+ if (res.status === 400) {
+ const data = (await res.json().catch(() => ({}))) as {
+ requiresConfirmation?: boolean;
+ message?: string;
+ estimatedCost?: number;
+ };
+ if (data.requiresConfirmation) {
+ const ok = window.confirm(
+ `${data.message ?? 'Confirmation required'}.\n\nEstimated cost: $${data.estimatedCost?.toFixed(2) ?? '?'}\n\nProceed?`
+ );
+ if (ok) {
+ await submit({ confirmedCost: true });
+ return;
+ }
+ setPhase('idle');
+ setStatusLabel('');
+ return;
+ }
+ throw new Error(data.message ?? 'Bundle request rejected');
+ }
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(data.error ?? `Request failed: ${res.status}`);
+ }
+ const data = (await res.json()) as {
+ aggregateReportId: string;
+ status: AggregateReportStatus;
+ };
+ setPhase(data.status as Phase);
+ setStatusLabel(
+ data.status === 'pending_analyses'
+ ? 'Analyzing linked tickets…'
+ : 'Building bundle report…'
+ );
+ await pollReport(data.aggregateReportId);
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Bundle failed');
+ setPhase('idle');
+ setStatusLabel('');
+ }
+ }
+
+ const allRefs = useMemo(
+ () => (links ? [...links.explicit, ...links.suggested] : []),
+ [links]
+ );
+ const isRunning = phase !== 'idle' && phase !== 'failed' && phase !== 'complete';
+ const selectedCount = selected.size;
+ const hasContent = links && (links.explicit.length > 0 || links.isProblemTicket);
+
+ if (loadError) {
+ return (
+
+ Couldn’t check for related tickets
+ {loadError}
+
+ );
+ }
+
+ if (links === null) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (!hasContent) {
+ // Nothing to show — render nothing, the regular AnalyzeButton on the
+ // parent page is sufficient.
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ Related tickets detected ({allRefs.length})
+
+ {links.isProblemTicket && (
+ Problem ticket
+ )}
+
+
+ {
+ const next = Boolean(v);
+ setIncludeSuggested(next);
+ if (next && links.suggested.length === 0) {
+ void loadSuggestions();
+ }
+ }}
+ />
+
+ {suggestionsLoading ? (
+
+
+ Asking AI…
+
+ ) : (
+ 'AI-suggest more'
+ )}
+
+
+
+
+
+
+ {links.isProblemTicket
+ ? 'This looks like a problem/master ticket. Bundling will analyze every linked ticket and produce a cross-ticket report.'
+ : 'This ticket references other tickets. Bundle them to get a cross-ticket analysis.'}
+
+
+
+ {allRefs.map((ref) => (
+
+ toggleRef(ref)}
+ disabled={isRunning}
+ className="mt-0.5"
+ />
+
+
+ {ref.ticket_number}
+ {ref.confidence === 'high' && (
+
+ explicit
+
+ )}
+ {ref.source === 'llm_suggested' && (
+
+ AI-suggested
+
+ )}
+ {ref.status_label && (
+
+ {ref.status_label}
+
+ )}
+
+ {ref.title && (
+
+ {ref.title}
+
+ )}
+ {ref.reason && (
+
+ {ref.reason}
+
+ )}
+
+
+ ))}
+
+
+
+ void submit()}
+ disabled={isRunning || selectedCount === 0}
+ variant={links.isProblemTicket ? 'default' : 'secondary'}
+ >
+ {isRunning ? (
+ <>
+
+ {statusLabel || 'Working…'}
+ >
+ ) : (
+ <>
+
+ Analyze with {selectedCount} linked ticket
+ {selectedCount === 1 ? '' : 's'}
+ >
+ )}
+
+
+ ({selectedCount + 1} total — master + linked)
+
+
+
+
+ );
+}
diff --git a/components/analyzer/share-modal.tsx b/components/analyzer/share-modal.tsx
index d7e0124..fa82e90 100644
--- a/components/analyzer/share-modal.tsx
+++ b/components/analyzer/share-modal.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
@@ -14,20 +14,104 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
-import { Share2 } from 'lucide-react';
+import { Share2, Clock, Users, Check } from 'lucide-react';
import { toast } from 'sonner';
interface ShareModalProps {
analysisId: string;
}
+interface DirectoryEntry {
+ email: string;
+ displayName: string | null;
+ jobTitle: string | null;
+ department: string | null;
+}
+
+interface RecentEntry {
+ email: string;
+ lastSharedAt: string;
+}
+
+interface RecipientsResponse {
+ recent: RecentEntry[];
+ directory: DirectoryEntry[];
+ allowedDomains: string[];
+}
+
+const MAX_DIRECTORY_VISIBLE = 12;
+
+function timeAgo(iso: string): string {
+ const ms = Date.now() - new Date(iso).getTime();
+ const minute = 60_000;
+ const hour = 60 * minute;
+ const day = 24 * hour;
+ if (ms < hour) return `${Math.max(1, Math.round(ms / minute))}m ago`;
+ if (ms < day) return `${Math.round(ms / hour)}h ago`;
+ return `${Math.round(ms / day)}d ago`;
+}
+
export function ShareModal({ analysisId }: ShareModalProps) {
const [open, setOpen] = useState(false);
const [recipientEmail, setRecipientEmail] = useState('');
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
- async function handleSubmit(e: React.FormEvent) {
+ const [recipients, setRecipients] = useState(null);
+ const [recipientsError, setRecipientsError] = useState(null);
+ const [showSuggestions, setShowSuggestions] = useState(false);
+
+ const inputRef = useRef(null);
+
+ // Fetch recipients lazily on first dialog open.
+ useEffect(() => {
+ if (!open) return;
+ if (recipients !== null) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ const res = await fetch('/api/analyzer/share/recipients');
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(data.error ?? `Request failed: ${res.status}`);
+ }
+ const data = (await res.json()) as RecipientsResponse;
+ if (!cancelled) setRecipients(data);
+ } catch (err) {
+ if (!cancelled) {
+ setRecipientsError(
+ err instanceof Error ? err.message : 'Unknown error'
+ );
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [open, recipients]);
+
+ const filteredDirectory = useMemo(() => {
+ if (!recipients) return [];
+ const q = recipientEmail.trim().toLowerCase();
+ if (!q) return recipients.directory.slice(0, MAX_DIRECTORY_VISIBLE);
+ return recipients.directory
+ .filter(
+ (d) =>
+ d.email.toLowerCase().includes(q) ||
+ (d.displayName ?? '').toLowerCase().includes(q)
+ )
+ .slice(0, MAX_DIRECTORY_VISIBLE);
+ }, [recipients, recipientEmail]);
+
+ const hasRecent = (recipients?.recent.length ?? 0) > 0;
+
+ function pick(email: string): void {
+ setRecipientEmail(email);
+ setShowSuggestions(false);
+ inputRef.current?.blur();
+ }
+
+ async function handleSubmit(e: React.FormEvent): Promise {
e.preventDefault();
setSubmitting(true);
try {
@@ -66,8 +150,16 @@ export function ShareModal({ analysisId }: ShareModalProps) {
}
}
+ // Reset transient state when the dialog closes.
+ function onOpenChange(next: boolean): void {
+ setOpen(next);
+ if (!next) {
+ setShowSuggestions(false);
+ }
+ }
+
return (
-
+
@@ -78,22 +170,107 @@ export function ShareModal({ analysisId }: ShareModalProps) {
Share this analysis
- Recipient must be on an allowed domain (set via
- ALLOWED_SHARE_DOMAINS).
+ {recipients?.allowedDomains.length
+ ? `Allowed domains: ${recipients.allowedDomains.join(', ')}`
+ : 'Recipient must be on an allowed domain.'}
+