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: + + + {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}} +
+
+ +
+ ); + })} +
+
+ ))} +
+
+
+ ); +} 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) => ( + + ))} +
+
+
+ + {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 + +

+
+
+ +
+
+

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”. +

+ )} +
+ +
+
+
+ ) : 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} +

+
+
+ + + {a.autotaskCompanyId && ( + loadAll()} + /> + )} + +
+
+
+
+ + {/* 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} + + +
    +
    +
  • + ); + })} +
+ )} +
+ + {/* 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} + + +
    +
    +
  • + ); + })} +
+
+ )} + + {/* 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' && ( + + )} +
  • + ))} +
+
+
+ )} + + {/* 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)}% + + + )} +

+
+
+ + + {a.dattoDeviceUid && ( + loadAll()} + /> + )} + +
+
+
+
+ + {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} + + +
    +
    +
  • + ); + })} +
+ )} +
+ + {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} + + +
    +
    +
  • + ); + })} +
+
+ )} + + {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' && ( + + )} +
  • + ))} +
+
+
+ )} + + {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 -

-
-
- {/* 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)}
- - - - - ) : ( -

- 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
- +
+ + +
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 +
+
+ + +
+
+
+ + {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)}% + + + )} +

+
+
+ + +
+
+ + {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} + + +
+
+
+ ); + })} + {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} + + +
+
+
+ ); + })} + {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 ( + + ); + })} +
+ ); +} 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(); + } + }} + /> + +
+
+
+ +

+ {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} +

    + )} +
    +
  • + ))} +
+ +
+ + + ({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 ( - + + ))} + +
+ Directory +
+ {filteredDirectory.length === 0 ? ( +
+ No matching directory users. +
+ ) : ( + filteredDirectory.map((d) => ( + + )) + )} +
+ )} +
+ {recipientsError && ( +

+ Couldn’t load directory ({recipientsError}). Type any + allowed-domain email to share. +

+ )}
+