feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
378e68ad8a
commit
1112a06afe
132 changed files with 21352 additions and 743 deletions
253
app/admin/device-link-conflicts/page.tsx
Normal file
253
app/admin/device-link-conflicts/page.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Candidate {
|
||||
ciId: string;
|
||||
confidence: string | null;
|
||||
hostname: string | null;
|
||||
serial: string | null;
|
||||
mac: string | null;
|
||||
companyId: string | null;
|
||||
companyName: string | null;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
|
||||
interface Review {
|
||||
id: string;
|
||||
detectedAt: string;
|
||||
xref: {
|
||||
id: string;
|
||||
source: string;
|
||||
sourceId: string;
|
||||
hostname: string | null;
|
||||
serial: string | null;
|
||||
mac: string | null;
|
||||
companyId: string | null;
|
||||
companyName: string | null;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
candidates: Candidate[];
|
||||
}
|
||||
|
||||
const SOURCES = ['all', 'datto_rmm', 'itglue', 's1', 'veeam'] as const;
|
||||
type SourceFilter = (typeof SOURCES)[number];
|
||||
|
||||
function confidenceColor(c: string | null): 'default' | 'secondary' | 'outline' {
|
||||
if (c === 'exact_serial') return 'default';
|
||||
if (c === 'mac') return 'default';
|
||||
if (c === 'hostname_in_company') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
export default function DeviceLinkConflictsPage() {
|
||||
const [items, setItems] = useState<Review[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<SourceFilter>('all');
|
||||
const [resolving, setResolving] = useState<string | null>(null);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
setError(null);
|
||||
setItems(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '100' });
|
||||
if (source !== 'all') params.set('source', source);
|
||||
const res = await fetch(`/api/admin/device-link-conflicts?${params}`);
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { items: Review[]; total: number };
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [source]);
|
||||
|
||||
async function resolve(reviewId: string, ciId: string): Promise<void> {
|
||||
setResolving(`${reviewId}:${ciId}`);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/device-link-conflicts/${reviewId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ciId }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
toast.success(`Linked to CI ${ciId}`);
|
||||
setItems((prev) => prev?.filter((r) => r.id !== reviewId) ?? null);
|
||||
setTotal((t) => Math.max(0, t - 1));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Resolve failed');
|
||||
} finally {
|
||||
setResolving(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-6xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-amber-500" />
|
||||
Device-link conflicts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cases where the reconciler found two or more configuration_items
|
||||
matching one external device record. Pick the right CI to break the
|
||||
tie. Skipped rows stay unlinked until resolved.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">Source:</span>
|
||||
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOURCES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{total} unresolved {total === 1 ? 'conflict' : 'conflicts'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{items === null && !error && (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items !== null && items.length === 0 && !error && (
|
||||
<Alert>
|
||||
<CheckCircle2 className="size-4" />
|
||||
<AlertTitle>No conflicts</AlertTitle>
|
||||
<AlertDescription>
|
||||
Nothing waiting for review on this filter.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{items?.map((r) => (
|
||||
<Card key={r.id} className="border-amber-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-mono">
|
||||
{r.xref.source}:{r.xref.sourceId}
|
||||
</div>
|
||||
<div className="text-base font-medium">
|
||||
{r.xref.hostname ?? '(no hostname)'}
|
||||
{r.xref.companyName && (
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
@ {r.xref.companyName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{r.candidates.length} candidates
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-x-3">
|
||||
{r.xref.serial && <span>serial: {r.xref.serial}</span>}
|
||||
{r.xref.mac && <span>mac: {r.xref.mac}</span>}
|
||||
{r.xref.lastSeenAt && (
|
||||
<span>last seen: {new Date(r.xref.lastSeenAt).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{r.candidates.map((c) => {
|
||||
const isResolving = resolving === `${r.id}:${c.ciId}`;
|
||||
return (
|
||||
<div
|
||||
key={c.ciId}
|
||||
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
||||
>
|
||||
<div className="space-y-0.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium truncate">
|
||||
{c.hostname ?? '(no hostname)'}
|
||||
</span>
|
||||
{c.isDeleted && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
deleted
|
||||
</Badge>
|
||||
)}
|
||||
{c.confidence && (
|
||||
<Badge
|
||||
variant={confidenceColor(c.confidence)}
|
||||
className="text-xs"
|
||||
>
|
||||
{c.confidence}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-x-3">
|
||||
<span className="font-mono">CI {c.ciId}</span>
|
||||
{c.serial && <span>serial: {c.serial}</span>}
|
||||
{c.companyName && <span>@ {c.companyName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isResolving || c.isDeleted}
|
||||
onClick={() => void resolve(r.id, c.ciId)}
|
||||
>
|
||||
{isResolving ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin mr-1" />
|
||||
Linking…
|
||||
</>
|
||||
) : (
|
||||
'Link to this CI'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
app/admin/itglue-writes/page.tsx
Normal file
168
app/admin/itglue-writes/page.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
interface WriteRow {
|
||||
id: string;
|
||||
audit_id: string | null;
|
||||
asset_type: 'flexible_asset';
|
||||
asset_id: string;
|
||||
field_name: string;
|
||||
before_value: unknown;
|
||||
after_value: unknown;
|
||||
performed_by_user_id: string | null;
|
||||
performed_at: string;
|
||||
status: 'pending' | 'committed' | 'failed' | 'reverted';
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
const STATUSES: Array<WriteRow['status'] | 'all'> = [
|
||||
'all',
|
||||
'committed',
|
||||
'reverted',
|
||||
'failed',
|
||||
'pending',
|
||||
];
|
||||
|
||||
function statusVariant(
|
||||
s: WriteRow['status']
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (s) {
|
||||
case 'committed':
|
||||
return 'default';
|
||||
case 'reverted':
|
||||
return 'secondary';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ItglueWritesPage() {
|
||||
const [rows, setRows] = useState<WriteRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<WriteRow['status'] | 'all'>('all');
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const url =
|
||||
statusFilter === 'all'
|
||||
? '/api/analyzer/itglue/writes'
|
||||
: `/api/analyzer/itglue/writes?status=${statusFilter}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { writes: WriteRow[] };
|
||||
setRows(data.writes);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statusFilter]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<CardTitle>IT Glue write log</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Every PATCH to IT Glue from Pulse, with before/after diffs and
|
||||
revert history.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{STATUSES.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant={statusFilter === s ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter(s)}
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Couldn’t load writes</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{rows === null && !error ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : rows && rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No writes recorded yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{(rows ?? []).map((w) => (
|
||||
<li key={w.id} className="py-3">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm">
|
||||
<Link
|
||||
href={`/analyzer/itglue/applications/${w.asset_id}`}
|
||||
className="font-mono hover:underline"
|
||||
>
|
||||
{w.asset_id}
|
||||
</Link>
|
||||
{' · '}
|
||||
<span className="font-medium">{w.field_name}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(w.performed_at).toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs mt-1 break-words">
|
||||
<span className="text-muted-foreground">Before: </span>
|
||||
<span className="font-mono">
|
||||
{w.before_value === null || w.before_value === undefined
|
||||
? '(empty)'
|
||||
: JSON.stringify(w.before_value).slice(0, 200)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs mt-0.5 break-words">
|
||||
<span className="text-muted-foreground">After: </span>
|
||||
<span className="font-mono">
|
||||
{JSON.stringify(w.after_value).slice(0, 200)}
|
||||
</span>
|
||||
</p>
|
||||
{w.error_message && (
|
||||
<p className="text-xs mt-1 text-destructive">
|
||||
Error: {w.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={statusVariant(w.status)}>{w.status}</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
321
app/admin/page.tsx
Normal file
321
app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
RefreshCw,
|
||||
Network,
|
||||
Globe,
|
||||
Smartphone,
|
||||
Radio,
|
||||
AlertTriangle,
|
||||
Workflow,
|
||||
GitBranch,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Bell,
|
||||
Sun,
|
||||
BarChart3,
|
||||
ScrollText,
|
||||
Database,
|
||||
SlidersHorizontal,
|
||||
Activity,
|
||||
Tv,
|
||||
Users,
|
||||
ShieldCheck,
|
||||
Settings as SettingsIcon,
|
||||
Shield,
|
||||
DollarSign,
|
||||
CalendarClock,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AdminCounts {
|
||||
linkConflicts: number;
|
||||
schedules: { enabled: number; total: number };
|
||||
unmappedAuvik: number;
|
||||
unmappedRmm: number;
|
||||
unmappedAddigy: number;
|
||||
}
|
||||
|
||||
interface NavTile {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
description?: string;
|
||||
badge?: { label: string; tone: 'warn' | 'info' | 'muted' };
|
||||
}
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
tiles: NavTile[];
|
||||
}
|
||||
|
||||
function tone(badge?: NavTile['badge']) {
|
||||
if (!badge) return null;
|
||||
const variant: 'destructive' | 'secondary' | 'outline' =
|
||||
badge.tone === 'warn' ? 'destructive' : badge.tone === 'info' ? 'secondary' : 'outline';
|
||||
return (
|
||||
<Badge variant={variant} className="text-[10px] font-mono ml-2 shrink-0">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminIndexPage() {
|
||||
const [counts, setCounts] = useState<AdminCounts | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const [overviewRes, mappingsRes] = await Promise.all([
|
||||
fetch('/api/dashboard/overview'),
|
||||
fetch('/api/dashboard/stats'),
|
||||
]);
|
||||
const overview = overviewRes.ok ? await overviewRes.json() : null;
|
||||
const mappings = mappingsRes.ok ? await mappingsRes.json() : null;
|
||||
setCounts({
|
||||
linkConflicts: overview?.attention?.linkConflicts ?? 0,
|
||||
schedules: overview?.attention?.schedules ?? { enabled: 0, total: 0 },
|
||||
unmappedAuvik: mappings?.mappings?.auvik?.unmapped ?? 0,
|
||||
unmappedRmm: mappings?.mappings?.rmm?.unmapped ?? 0,
|
||||
unmappedAddigy: 0,
|
||||
});
|
||||
} catch {
|
||||
// Counts are decorative — fail quiet.
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: 'Sync',
|
||||
tiles: [
|
||||
{
|
||||
title: 'Integrations & Sync',
|
||||
href: '/admin/sync',
|
||||
icon: RefreshCw,
|
||||
description: 'Overview of sync status across all integrations',
|
||||
badge: counts
|
||||
? {
|
||||
label: `${counts.schedules.enabled}/${counts.schedules.total} schedules on`,
|
||||
tone: 'muted',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{ title: 'Autotask', href: '/admin/sync/autotask', icon: RefreshCw },
|
||||
{ title: 'Datto RMM', href: '/admin/sync/datto-rmm', icon: Globe },
|
||||
{ title: 'IT Glue', href: '/admin/sync/itglue', icon: Shield },
|
||||
{ title: 'SentinelOne', href: '/admin/sync/sentinelone', icon: Shield },
|
||||
{ title: 'Veeam', href: '/admin/sync/veeam', icon: Activity },
|
||||
{ title: 'Auvik', href: '/admin/sync/auvik', icon: Network },
|
||||
{ title: 'Addigy', href: '/admin/sync/addigy', icon: Smartphone },
|
||||
{ title: 'Mimecast', href: '/admin/sync/mimecast', icon: Shield },
|
||||
{ title: 'Duo', href: '/admin/sync/duo', icon: Shield },
|
||||
{ title: 'QuickBooks Online', href: '/admin/qbo', icon: DollarSign },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Mappings',
|
||||
tiles: [
|
||||
{
|
||||
title: 'Device-Link Conflicts',
|
||||
href: '/admin/device-link-conflicts',
|
||||
icon: AlertTriangle,
|
||||
description: 'Resolve cases where one external device matches multiple Autotask CIs',
|
||||
badge:
|
||||
counts && counts.linkConflicts > 0
|
||||
? { label: counts.linkConflicts.toLocaleString(), tone: 'warn' }
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: 'NMS Mapping (Auvik)',
|
||||
href: '/auvik-mappings',
|
||||
icon: Network,
|
||||
description: 'Map Auvik tenants to companies',
|
||||
badge:
|
||||
counts && counts.unmappedAuvik > 0
|
||||
? { label: `${counts.unmappedAuvik} unmapped`, tone: 'info' }
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: 'RMM Mapping (Datto)',
|
||||
href: '/rmm-mappings',
|
||||
icon: Globe,
|
||||
description: 'Map RMM sites to companies',
|
||||
badge:
|
||||
counts && counts.unmappedRmm > 0
|
||||
? { label: `${counts.unmappedRmm} unmapped`, tone: 'info' }
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: 'Apple RMM (Addigy)',
|
||||
href: '/addigy-mappings',
|
||||
icon: Smartphone,
|
||||
description: 'Map Addigy devices to companies',
|
||||
},
|
||||
{
|
||||
title: 'SentinelOne Mappings',
|
||||
href: '/sentinelone/mappings',
|
||||
icon: Shield,
|
||||
description: 'Map S1 sites to companies (gap blocks reconciler)',
|
||||
},
|
||||
{
|
||||
title: 'Zabbix WAN Monitor',
|
||||
href: '/admin/zabbix-wan',
|
||||
icon: Radio,
|
||||
description: 'Sync RMM site WAN IPs to Zabbix with Autotask routing',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Workflow',
|
||||
tiles: [
|
||||
{
|
||||
title: 'Ticket Workflows',
|
||||
href: '/admin/workflow',
|
||||
icon: Workflow,
|
||||
description: 'Automated ticket triage and classification',
|
||||
},
|
||||
{
|
||||
title: 'Classification Rules',
|
||||
href: '/admin/workflow/classification-rules',
|
||||
icon: GitBranch,
|
||||
description: 'Keyword-based classification rules',
|
||||
},
|
||||
{
|
||||
title: 'AI Templates',
|
||||
href: '/admin/workflow/ai-templates',
|
||||
icon: Sparkles,
|
||||
description: 'AI prompt templates for enhancement',
|
||||
},
|
||||
{
|
||||
title: 'Webhook Pipelines',
|
||||
href: '/admin/workflow/pipelines',
|
||||
icon: Zap,
|
||||
description: 'Automated webhook processing workflows',
|
||||
},
|
||||
{
|
||||
title: 'Notification Channels',
|
||||
href: '/admin/workflow/channels',
|
||||
icon: Bell,
|
||||
description: 'Teams, Telegram, and webhook notifications',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Reports',
|
||||
tiles: [
|
||||
{
|
||||
title: 'Morning NOC Summary',
|
||||
href: '/admin/morning-summary',
|
||||
icon: Sun,
|
||||
description: 'Daily Zabbix overnight summary posted to Teams',
|
||||
},
|
||||
{
|
||||
title: 'Ticket Digest Reports',
|
||||
href: '/admin/ticket-digest',
|
||||
icon: BarChart3,
|
||||
description: 'LLM-analyzed ticket reports — daily/weekly/monthly',
|
||||
},
|
||||
{
|
||||
title: 'IT Glue Writes',
|
||||
href: '/admin/itglue-writes',
|
||||
icon: ScrollText,
|
||||
description: 'History of audit-driven IT Glue field writes',
|
||||
},
|
||||
{
|
||||
title: 'Audit Log',
|
||||
href: '/admin/audit-log',
|
||||
icon: ScrollText,
|
||||
description: 'System audit trail',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tools & Data',
|
||||
tiles: [
|
||||
{
|
||||
title: 'RMM Overshell',
|
||||
href: '/admin/rmm-overshell',
|
||||
icon: Database,
|
||||
description: 'Datto RMM PowerShell discovery — settings, executions, evidence pipeline',
|
||||
},
|
||||
{
|
||||
title: 'Data Browser',
|
||||
href: '/admin/data-browser',
|
||||
icon: Database,
|
||||
description: 'Browse and query system data',
|
||||
},
|
||||
{
|
||||
title: 'Display Settings',
|
||||
href: '/admin/display-settings',
|
||||
icon: SlidersHorizontal,
|
||||
description: 'Configure company filters for Kiosk and Mobile dashboards',
|
||||
},
|
||||
{
|
||||
title: 'Kiosk Settings',
|
||||
href: '/kiosk/settings',
|
||||
icon: Tv,
|
||||
description: 'Configure executive dashboard for TV display',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Access',
|
||||
tiles: [
|
||||
{ title: 'Users', href: '/admin/users', icon: Users },
|
||||
{ title: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
||||
{ title: 'Settings', href: '/admin/settings', icon: SettingsIcon },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-7xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Admin</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Sync, mappings, workflows, reporting, and tooling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{sections.map((section) => (
|
||||
<Card key={section.title}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{section.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{section.tiles.map((tile) => (
|
||||
<Link
|
||||
key={tile.href}
|
||||
href={tile.href}
|
||||
className="flex items-start gap-3 rounded-md p-2 -mx-2 hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<tile.icon className="size-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm font-medium truncate">{tile.title}</span>
|
||||
{tone(tile.badge)}
|
||||
</div>
|
||||
{tile.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tile.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
app/admin/rmm-overshell/page.tsx
Normal file
272
app/admin/rmm-overshell/page.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Loader2, RefreshCw, Terminal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Settings {
|
||||
overshellComponentUid: string | null;
|
||||
overshellComponentName: string | null;
|
||||
overshellVariableName: string;
|
||||
discoveredAt: string | null;
|
||||
logliftComponentUid: string | null;
|
||||
logliftComponentName: string | null;
|
||||
logliftDiscoveredAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ExecRow {
|
||||
id: string;
|
||||
scriptId: string;
|
||||
jobName: string;
|
||||
targetHostname: string | null;
|
||||
status: 'queued' | 'running' | 'complete' | 'failed' | 'timeout';
|
||||
exitCode: number | null;
|
||||
errorMessage: string | null;
|
||||
performedByUserId: string | null;
|
||||
queuedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export default function RmmOvershellAdminPage() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [counts, setCounts] = useState<{ total: string; running: string; failed_24h: string } | null>(null);
|
||||
const [executions, setExecutions] = useState<ExecRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [discoveringLoglift, setDiscoveringLoglift] = useState(false);
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [s, e] = await Promise.all([
|
||||
fetch('/api/admin/rmm/settings').then((r) => r.json()),
|
||||
fetch('/api/rmm/executions?limit=50').then((r) => r.json()),
|
||||
]);
|
||||
if (s.error) throw new Error(s.error);
|
||||
setSettings(s.settings);
|
||||
setCounts(s.counts);
|
||||
setExecutions(e.executions ?? []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
async function discover() {
|
||||
setDiscovering(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/rmm/settings/discover', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Discovery failed');
|
||||
toast.success(`Found component: ${data.discovered?.name ?? 'unknown'}`);
|
||||
void loadAll();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Discovery failed');
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverLoglift() {
|
||||
setDiscoveringLoglift(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/rmm/settings/discover-loglift', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Discovery failed');
|
||||
toast.success(`Found LogLift component: ${data.discovered?.name ?? 'unknown'}`);
|
||||
void loadAll();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Discovery failed');
|
||||
} finally {
|
||||
setDiscoveringLoglift(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5" />
|
||||
RMM Overshell
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Datto RMM PowerShell evidence pipeline. Pulse dispatches scripts via
|
||||
the configured Overshell component; the worker polls for results
|
||||
and the audit pipeline pulls them in as live evidence.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load settings</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{settings === null && !error ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : settings ? (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">Overshell component</p>
|
||||
{settings.overshellComponentUid ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{settings.overshellComponentName}
|
||||
</p>
|
||||
<p className="text-[10px] font-mono text-muted-foreground">
|
||||
{settings.overshellComponentUid}
|
||||
</p>
|
||||
{settings.discoveredAt && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
discovered {new Date(settings.discoveredAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-amber-600 mt-0.5">
|
||||
No component cached. Click Discover to scan Datto RMM.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Variable name</p>
|
||||
<p className="text-xs font-mono text-muted-foreground mt-0.5">
|
||||
{settings.overshellVariableName}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Adjust if your component uses a different variable.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Activity (24h)</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{counts?.total ?? '0'} total · {counts?.running ?? '0'} running ·
|
||||
<span className="text-destructive">
|
||||
{' '}
|
||||
{counts?.failed_24h ?? '0'} failed
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button onClick={discover} disabled={discovering}>
|
||||
{discovering ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Discovering…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Re-discover Overshell
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="col-span-2 border-t pt-4">
|
||||
<p className="font-medium">LogLift component</p>
|
||||
{settings.logliftComponentUid ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{settings.logliftComponentName}
|
||||
</p>
|
||||
<p className="text-[10px] font-mono text-muted-foreground">
|
||||
{settings.logliftComponentUid}
|
||||
</p>
|
||||
{settings.logliftDiscoveredAt && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
discovered{' '}
|
||||
{new Date(settings.logliftDiscoveredAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-amber-600 mt-0.5">
|
||||
No LogLift component cached. Click below to scan Datto RMM
|
||||
for one named “loglift” or “eventlog”.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={discoverLoglift}
|
||||
disabled={discoveringLoglift}
|
||||
>
|
||||
{discoveringLoglift ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Discovering…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Re-discover LogLift
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Recent executions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{executions === null ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : executions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No executions yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{executions.map((e) => (
|
||||
<li key={e.id} className="py-2 grid grid-cols-12 gap-2 text-sm">
|
||||
<span className="col-span-3 font-mono truncate">{e.scriptId}</span>
|
||||
<span className="col-span-3 truncate">{e.targetHostname ?? '—'}</span>
|
||||
<span className="col-span-2">
|
||||
<Badge
|
||||
variant={
|
||||
e.status === 'complete'
|
||||
? 'default'
|
||||
: e.status === 'failed' || e.status === 'timeout'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{e.status}
|
||||
{e.exitCode !== null ? ` · exit ${e.exitCode}` : ''}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="col-span-3 text-xs text-muted-foreground truncate">
|
||||
{new Date(e.queuedAt).toLocaleString()}
|
||||
</span>
|
||||
<span className="col-span-1 text-right">
|
||||
{e.errorMessage && (
|
||||
<span className="text-xs text-destructive truncate">!</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useEffect, useState, use } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AnalysisView } from '@/components/analyzer/analysis-view';
|
||||
import { ItglueSuggestionsPanel } from '@/components/analyzer/itglue-suggestions-panel';
|
||||
import type { PersistedAnalysis } from '@/lib/types/analyzer';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
|
|
@ -50,7 +51,12 @@ export default function AnalysisDetailPage({
|
|||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
)}
|
||||
{analysis && <AnalysisView analysis={analysis} />}
|
||||
{analysis && (
|
||||
<div className="space-y-6">
|
||||
<AnalysisView analysis={analysis} />
|
||||
<ItglueSuggestionsPanel analysisId={analysis.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
800
app/analyzer/itglue/applications/[id]/page.tsx
Normal file
800
app/analyzer/itglue/applications/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,800 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, use } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
ProviderToggle,
|
||||
type AnalyzerProvider,
|
||||
} from '@/components/analyzer/provider-toggle';
|
||||
import { RmmScriptPicker } from '@/components/rmm/rmm-script-picker';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Sparkles,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
ArrowLeftRight,
|
||||
CheckCircle2,
|
||||
Undo2,
|
||||
} from 'lucide-react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface FieldRow {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string | null;
|
||||
hint: string | null;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface AssetDetail {
|
||||
asset: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
organizationId: string | null;
|
||||
organizationName: string | null;
|
||||
flexibleAssetTypeId: string;
|
||||
flexibleAssetTypeName: string | null;
|
||||
autotaskCompanyId: string | null;
|
||||
traits: Record<string, unknown>;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
fields: FieldRow[];
|
||||
}
|
||||
|
||||
interface FieldGap {
|
||||
field_name: string;
|
||||
why_missing_matters: string;
|
||||
suggested_value: string | null;
|
||||
evidence_ticket_numbers: string[];
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
interface NotePromotion {
|
||||
quoted_note_text: string;
|
||||
target_field: string;
|
||||
suggested_value: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
interface Contradiction {
|
||||
description: string;
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
interface AuditRow {
|
||||
id: string;
|
||||
generated_at: string;
|
||||
provider: 'anthropic' | 'openrouter';
|
||||
model_used: string | null;
|
||||
ticket_count: number;
|
||||
field_gaps: FieldGap[];
|
||||
notes_promotions: NotePromotion[];
|
||||
contradictions: Contradiction[];
|
||||
overall_score: number | null;
|
||||
estimated_cost_usd: number | null;
|
||||
}
|
||||
|
||||
interface WriteRow {
|
||||
id: string;
|
||||
audit_id: string | null;
|
||||
field_name: string;
|
||||
before_value: unknown;
|
||||
after_value: unknown;
|
||||
performed_by_user_id: string | null;
|
||||
performed_at: string;
|
||||
status: 'pending' | 'committed' | 'failed' | 'reverted';
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
interface XrefRow {
|
||||
id: string;
|
||||
ticketNumber: string;
|
||||
analysisId: string | null;
|
||||
relationship: 'referenced' | 'updated' | 'should_have_referenced';
|
||||
source: string;
|
||||
details: { write_id?: string; field_name?: string; relevance_reason?: string } | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const CONFIDENCE_TONE: Record<FieldGap['confidence'], string> = {
|
||||
high: 'border-red-500 bg-red-500/10 text-red-700 dark:text-red-300',
|
||||
medium: 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300',
|
||||
low: 'border-blue-500 bg-blue-500/10 text-blue-700 dark:text-blue-300',
|
||||
};
|
||||
|
||||
function fieldNameToTraitKey(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function formatTraitValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
if (Array.isArray(v)) return v.length === 0 ? '' : JSON.stringify(v);
|
||||
if (typeof v === 'object') {
|
||||
const obj = v as { values?: unknown[] };
|
||||
if (Array.isArray(obj.values)) {
|
||||
return obj.values
|
||||
.map((it) => {
|
||||
const o = it as { name?: string; 'first-name'?: string; 'last-name'?: string };
|
||||
if (o.name) return o.name;
|
||||
if (o['first-name'] || o['last-name'])
|
||||
return [o['first-name'], o['last-name']].filter(Boolean).join(' ');
|
||||
return JSON.stringify(it);
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
return JSON.stringify(v).slice(0, 200);
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function isPopulated(v: unknown): boolean {
|
||||
if (v === null || v === undefined) return false;
|
||||
if (typeof v === 'string') return v.trim().length > 0;
|
||||
if (Array.isArray(v)) return v.length > 0;
|
||||
if (typeof v === 'object') {
|
||||
const obj = v as { values?: unknown[] };
|
||||
if (Array.isArray(obj.values)) return obj.values.length > 0;
|
||||
return Object.keys(v).length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function ApplicationAuditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const { data: session } = useSession();
|
||||
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
|
||||
const canWrite = role === 'admin' || role === 'super-admin';
|
||||
|
||||
const [detail, setDetail] = useState<AssetDetail | null>(null);
|
||||
const [audit, setAudit] = useState<AuditRow | null>(null);
|
||||
const [history, setHistory] = useState<AuditRow[]>([]);
|
||||
const [writes, setWrites] = useState<WriteRow[]>([]);
|
||||
const [xrefs, setXrefs] = useState<XrefRow[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [provider, setProvider] = useState<AnalyzerProvider>('anthropic');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
|
||||
async function loadAll(): Promise<void> {
|
||||
try {
|
||||
const [d, a, w, x] = await Promise.all([
|
||||
fetch(`/api/analyzer/itglue/applications/${id}`).then((r) => r.json()),
|
||||
fetch(`/api/analyzer/itglue/applications/${id}/audit?history=1`).then(
|
||||
(r) => r.json()
|
||||
),
|
||||
fetch(`/api/analyzer/itglue/applications/${id}/writes`).then((r) =>
|
||||
r.json()
|
||||
),
|
||||
fetch(`/api/analyzer/itglue/applications/${id}/xrefs`).then((r) =>
|
||||
r.json()
|
||||
),
|
||||
]);
|
||||
if (d.error) throw new Error(d.error);
|
||||
setDetail(d as AssetDetail);
|
||||
setAudit(a.audit ?? null);
|
||||
setHistory(a.history ?? []);
|
||||
setWrites(w.writes ?? []);
|
||||
setXrefs(x.xrefs ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
async function runAudit(): Promise<void> {
|
||||
setRunning(true);
|
||||
try {
|
||||
const res = await fetch(`/api/analyzer/itglue/applications/${id}/audit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || data.error || 'Audit failed');
|
||||
setAudit(data.audit);
|
||||
// Refresh history.
|
||||
void loadAll();
|
||||
toast.success('Audit complete');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Audit failed');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyGap(
|
||||
gap: FieldGap | NotePromotion,
|
||||
kind: 'field_gap' | 'note_promotion'
|
||||
): Promise<void> {
|
||||
if (!canWrite) return;
|
||||
if (!audit) return;
|
||||
const fieldName =
|
||||
kind === 'field_gap' ? (gap as FieldGap).field_name : (gap as NotePromotion).target_field;
|
||||
const suggested =
|
||||
kind === 'field_gap'
|
||||
? (gap as FieldGap).suggested_value
|
||||
: (gap as NotePromotion).suggested_value;
|
||||
if (suggested === null || suggested === undefined || suggested === '') {
|
||||
toast.error('No suggested value to apply');
|
||||
return;
|
||||
}
|
||||
const evidence =
|
||||
kind === 'field_gap'
|
||||
? {
|
||||
ticket_numbers: (gap as FieldGap).evidence_ticket_numbers,
|
||||
gap_description: (gap as FieldGap).why_missing_matters,
|
||||
}
|
||||
: {
|
||||
ticket_numbers: [],
|
||||
gap_description: `Promoted from Notes: "${(gap as NotePromotion).quoted_note_text}"`,
|
||||
};
|
||||
const key = `${kind}:${fieldName}`;
|
||||
setBusyKey(key);
|
||||
try {
|
||||
const res = await fetch(`/api/analyzer/itglue/applications/${id}/apply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
auditId: audit.id,
|
||||
fieldName,
|
||||
suggestedValue: suggested,
|
||||
sourceEvidence: evidence,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || data.error || 'Apply failed');
|
||||
}
|
||||
toast.success(`Applied: ${fieldName}`);
|
||||
void loadAll();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Apply failed');
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function revertWrite(writeId: string): Promise<void> {
|
||||
if (!canWrite) return;
|
||||
setBusyKey(`revert:${writeId}`);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/analyzer/itglue/applications/${id}/revert/${writeId}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok)
|
||||
throw new Error(data.message || data.error || 'Revert failed');
|
||||
toast.success('Reverted');
|
||||
void loadAll();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Revert failed');
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
const orderedFields = useMemo(() => {
|
||||
if (!detail) return [];
|
||||
return detail.fields.map((f) => {
|
||||
const traitKey = fieldNameToTraitKey(f.name);
|
||||
const value = detail.asset.traits[traitKey];
|
||||
return { ...f, traitKey, value, populated: isPopulated(value) };
|
||||
});
|
||||
}, [detail]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load this asset</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const a = detail.asset;
|
||||
const filledCount = orderedFields.filter((f) => f.populated).length;
|
||||
const totalCount = orderedFields.length;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Link
|
||||
href="/analyzer/itglue/applications"
|
||||
className="hover:underline"
|
||||
>
|
||||
Applications
|
||||
</Link>{' '}
|
||||
· {a.organizationName ?? 'Unknown org'}
|
||||
</p>
|
||||
<CardTitle className="text-2xl truncate">{a.name ?? a.id}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{filledCount}/{totalCount} fields populated
|
||||
{audit?.overall_score !== null && audit?.overall_score !== undefined ? (
|
||||
<>
|
||||
{' · '}
|
||||
<Badge
|
||||
variant={
|
||||
(audit.overall_score ?? 0) > 0.8
|
||||
? 'default'
|
||||
: (audit.overall_score ?? 0) > 0.5
|
||||
? 'secondary'
|
||||
: 'destructive'
|
||||
}
|
||||
>
|
||||
Audit score {Math.round((audit.overall_score ?? 0) * 100)}%
|
||||
</Badge>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
|
||||
<Button onClick={runAudit} disabled={running}>
|
||||
{running ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Auditing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
{audit ? 'Re-audit' : 'Run audit'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{a.autotaskCompanyId && (
|
||||
<RmmScriptPicker
|
||||
filter="site_anchor"
|
||||
companyId={a.autotaskCompanyId}
|
||||
onComplete={() => loadAll()}
|
||||
/>
|
||||
)}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
href={`https://wulf.itglue.com/${a.organizationId}/assets/${a.flexibleAssetTypeId}/records/${a.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open in IT Glue
|
||||
<ExternalLink className="w-3.5 h-3.5 ml-1.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Audit findings */}
|
||||
{audit && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Audit findings
|
||||
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
||||
{new Date(audit.generated_at).toLocaleString()}
|
||||
{' · '}
|
||||
{audit.provider === 'openrouter' ? 'DeepSeek' : 'Claude'}
|
||||
{audit.estimated_cost_usd !== null
|
||||
? ` · $${audit.estimated_cost_usd.toFixed(4)}`
|
||||
: ''}
|
||||
{' · '}
|
||||
{audit.ticket_count} ticket{audit.ticket_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Field gaps */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Field gaps ({audit.field_gaps.length})
|
||||
</h3>
|
||||
{audit.field_gaps.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No field gaps detected.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{audit.field_gaps.map((g) => {
|
||||
const key = `field_gap:${g.field_name}`;
|
||||
const busy = busyKey === key;
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className={`border-l-4 rounded p-3 ${CONFIDENCE_TONE[g.confidence]}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-foreground">{g.field_name}</p>
|
||||
<p className="text-sm mt-1">{g.why_missing_matters}</p>
|
||||
{g.suggested_value !== null && (
|
||||
<p className="text-sm mt-2">
|
||||
<span className="font-medium">Suggested: </span>
|
||||
<span className="font-mono">{g.suggested_value}</span>
|
||||
</p>
|
||||
)}
|
||||
{g.evidence_ticket_numbers.length > 0 && (
|
||||
<p className="text-xs mt-2 text-muted-foreground">
|
||||
Evidence:{' '}
|
||||
{g.evidence_ticket_numbers.map((tn, i) => (
|
||||
<span key={tn}>
|
||||
{i > 0 && ', '}
|
||||
<Link
|
||||
href={`/analyzer/ticket/${tn}`}
|
||||
className="font-mono hover:underline"
|
||||
>
|
||||
{tn}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{g.confidence}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
!canWrite ||
|
||||
g.suggested_value === null ||
|
||||
g.suggested_value === '' ||
|
||||
busy
|
||||
}
|
||||
onClick={() => applyGap(g, 'field_gap')}
|
||||
title={
|
||||
!canWrite
|
||||
? 'Requires admin'
|
||||
: g.suggested_value === null
|
||||
? 'No concrete suggestion'
|
||||
: 'Apply to IT Glue'
|
||||
}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Notes promotions */}
|
||||
{audit.notes_promotions.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Promote from Notes ({audit.notes_promotions.length})
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{audit.notes_promotions.map((p, i) => {
|
||||
const key = `note_promotion:${p.target_field}:${i}`;
|
||||
const busy = busyKey === `note_promotion:${p.target_field}`;
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className="border-l-4 border-primary/40 bg-primary/5 rounded p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-mono italic text-muted-foreground">
|
||||
“{p.quoted_note_text}”
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
Belongs in{' '}
|
||||
<span className="font-medium">{p.target_field}</span>
|
||||
:{' '}
|
||||
<span className="font-mono">{p.suggested_value}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{p.confidence}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canWrite || busy}
|
||||
onClick={() => applyGap(p, 'note_promotion')}
|
||||
title={!canWrite ? 'Requires admin' : 'Apply to IT Glue'}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Contradictions */}
|
||||
{audit.contradictions.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Contradictions ({audit.contradictions.length})
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{audit.contradictions.map((c, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm p-3 rounded bg-muted/40"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<p>{c.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{c.evidence}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!audit && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No audit yet. Click <strong>Run audit</strong> to analyze this asset.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current fields */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Current fields</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="divide-y">
|
||||
{orderedFields.map((f) => (
|
||||
<div key={f.id} className="py-2 grid grid-cols-3 gap-3 text-sm">
|
||||
<dt
|
||||
className={`font-medium ${f.populated ? '' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{f.name}
|
||||
{f.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</dt>
|
||||
<dd className="col-span-2 break-words">
|
||||
{f.populated ? (
|
||||
formatTraitValue(f.value)
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">empty</span>
|
||||
)}
|
||||
{f.hint && !f.populated && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{f.hint}</p>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tickets that touched this asset */}
|
||||
{(xrefs.filter((x) => x.relationship === 'referenced').length > 0 ||
|
||||
xrefs.filter((x) => x.relationship === 'updated').length > 0) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Tickets that touched this asset</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{xrefs.filter((x) => x.relationship === 'referenced').length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Referenced by ({xrefs.filter((x) => x.relationship === 'referenced').length})
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{xrefs
|
||||
.filter((x) => x.relationship === 'referenced')
|
||||
.map((x) => (
|
||||
<li key={x.id}>
|
||||
<Link
|
||||
href={`/analyzer/ticket/${x.ticketNumber}`}
|
||||
className="font-mono hover:underline"
|
||||
>
|
||||
{x.ticketNumber}
|
||||
</Link>
|
||||
{x.details?.relevance_reason && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
— {x.details.relevance_reason}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{xrefs.filter((x) => x.relationship === 'updated').length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Updated by ({xrefs.filter((x) => x.relationship === 'updated').length})
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{xrefs
|
||||
.filter((x) => x.relationship === 'updated')
|
||||
.map((x) => (
|
||||
<li key={x.id}>
|
||||
<Link
|
||||
href={`/analyzer/ticket/${x.ticketNumber}`}
|
||||
className="font-mono hover:underline"
|
||||
>
|
||||
{x.ticketNumber}
|
||||
</Link>
|
||||
{x.details?.field_name && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
— set <span className="font-mono">{x.details.field_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Write history */}
|
||||
{writes.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ArrowLeftRight className="w-4 h-4" />
|
||||
Write history ({writes.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{writes.map((w) => (
|
||||
<li key={w.id} className="py-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{w.field_name}</span>
|
||||
<Badge
|
||||
variant={
|
||||
w.status === 'committed'
|
||||
? 'default'
|
||||
: w.status === 'reverted'
|
||||
? 'secondary'
|
||||
: w.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="ml-2 text-[10px]"
|
||||
>
|
||||
{w.status}
|
||||
</Badge>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(w.performed_at).toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs mt-1 break-words">
|
||||
<span className="text-muted-foreground">Before: </span>
|
||||
<span className="font-mono">
|
||||
{w.before_value === null || w.before_value === undefined
|
||||
? '(empty)'
|
||||
: JSON.stringify(w.before_value).slice(0, 200)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs mt-0.5 break-words">
|
||||
<span className="text-muted-foreground">After: </span>
|
||||
<span className="font-mono">
|
||||
{JSON.stringify(w.after_value).slice(0, 200)}
|
||||
</span>
|
||||
</p>
|
||||
{w.error_message && (
|
||||
<p className="text-xs mt-1 text-destructive">
|
||||
Error: {w.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{w.status === 'committed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canWrite || busyKey === `revert:${w.id}`}
|
||||
onClick={() => revertWrite(w.id)}
|
||||
title={!canWrite ? 'Requires admin' : 'Revert this write'}
|
||||
>
|
||||
{busyKey === `revert:${w.id}` ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Undo2 className="w-3.5 h-3.5 mr-1" />
|
||||
)}
|
||||
Revert
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Audit history */}
|
||||
{history.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Audit history</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{history.map((h) => (
|
||||
<li
|
||||
key={h.id}
|
||||
className="py-2 flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(h.generated_at).toLocaleString()}
|
||||
{' · '}
|
||||
{h.provider === 'openrouter' ? 'DeepSeek' : 'Claude'}
|
||||
</span>
|
||||
<span>
|
||||
Score{' '}
|
||||
<Badge variant="outline">
|
||||
{h.overall_score !== null
|
||||
? Math.round(h.overall_score * 100) + '%'
|
||||
: 'n/a'}
|
||||
</Badge>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<Separator />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
app/analyzer/itglue/applications/page.tsx
Normal file
155
app/analyzer/itglue/applications/page.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface ApplicationRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
organizationId: string | null;
|
||||
organizationName: string | null;
|
||||
traitCount: number;
|
||||
latestAudit: {
|
||||
id: string;
|
||||
generatedAt: string | null;
|
||||
overallScore: number | null;
|
||||
provider: 'anthropic' | 'openrouter' | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function scoreBadgeVariant(
|
||||
score: number | null
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (score === null) return 'outline';
|
||||
if (score > 0.8) return 'default';
|
||||
if (score > 0.5) return 'secondary';
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
export default function ApplicationsListPage() {
|
||||
const [rows, setRows] = useState<ApplicationRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/analyzer/itglue/applications');
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
const data = (await res.json()) as { applications: ApplicationRow[] };
|
||||
if (!cancelled) setRows(data.applications);
|
||||
} catch (err) {
|
||||
if (!cancelled)
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visible = rows
|
||||
? rows.filter((r) => {
|
||||
if (!filter.trim()) return true;
|
||||
const q = filter.toLowerCase();
|
||||
return (
|
||||
(r.name ?? '').toLowerCase().includes(q) ||
|
||||
(r.organizationName ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
|
||||
const auditedCount = rows
|
||||
? rows.filter((r) => r.latestAudit !== null).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<CardTitle>IT Glue applications audit</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{rows === null
|
||||
? 'Loading…'
|
||||
: `${rows.length} application records · ${auditedCount} audited`}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Filter by name or client…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load applications</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{rows === null && !error ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{visible.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="py-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/analyzer/itglue/applications/${r.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{r.name ?? r.id}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{r.organizationName ?? '—'}
|
||||
{' · '}
|
||||
{r.traitCount} field{r.traitCount === 1 ? '' : 's'} populated
|
||||
{r.latestAudit && (
|
||||
<>
|
||||
{' · '}
|
||||
last audited{' '}
|
||||
{r.latestAudit.generatedAt
|
||||
? new Date(r.latestAudit.generatedAt).toLocaleDateString()
|
||||
: 'unknown'}
|
||||
{' '}
|
||||
(
|
||||
{r.latestAudit.provider === 'openrouter'
|
||||
? 'DeepSeek'
|
||||
: 'Claude'}
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={scoreBadgeVariant(r.latestAudit?.overallScore ?? null)}>
|
||||
{r.latestAudit?.overallScore !== null &&
|
||||
r.latestAudit?.overallScore !== undefined
|
||||
? Math.round(r.latestAudit.overallScore * 100) + '%'
|
||||
: 'No audit'}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
762
app/analyzer/itglue/configurations/[id]/page.tsx
Normal file
762
app/analyzer/itglue/configurations/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,762 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, use } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
ProviderToggle,
|
||||
type AnalyzerProvider,
|
||||
} from '@/components/analyzer/provider-toggle';
|
||||
import { RmmScriptPicker } from '@/components/rmm/rmm-script-picker';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Sparkles,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
ArrowLeftRight,
|
||||
CheckCircle2,
|
||||
Undo2,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface FieldRow {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string | null;
|
||||
hint: string | null;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface AssetDetail {
|
||||
asset: {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
organizationId: string | null;
|
||||
organizationName: string | null;
|
||||
typeId: string | null;
|
||||
typeName: string | null;
|
||||
statusName: string | null;
|
||||
operatingSystemName: string | null;
|
||||
dattoDeviceUid: string | null;
|
||||
autotaskCompanyId: string | null;
|
||||
traits: Record<string, unknown>;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
fields: FieldRow[];
|
||||
}
|
||||
|
||||
interface FieldGap {
|
||||
field_name: string;
|
||||
why_missing_matters: string;
|
||||
suggested_value: string | null;
|
||||
evidence_ticket_numbers: string[];
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
interface NotePromotion {
|
||||
quoted_note_text: string;
|
||||
target_field: string;
|
||||
suggested_value: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
interface Contradiction {
|
||||
description: string;
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
interface AuditRow {
|
||||
id: string;
|
||||
generated_at: string;
|
||||
provider: 'anthropic' | 'openrouter';
|
||||
model_used: string | null;
|
||||
ticket_count: number;
|
||||
field_gaps: FieldGap[];
|
||||
notes_promotions: NotePromotion[];
|
||||
contradictions: Contradiction[];
|
||||
overall_score: number | null;
|
||||
estimated_cost_usd: number | null;
|
||||
}
|
||||
|
||||
interface WriteRow {
|
||||
id: string;
|
||||
audit_id: string | null;
|
||||
field_name: string;
|
||||
before_value: unknown;
|
||||
after_value: unknown;
|
||||
performed_by_user_id: string | null;
|
||||
performed_at: string;
|
||||
status: 'pending' | 'committed' | 'failed' | 'reverted';
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
interface XrefRow {
|
||||
id: string;
|
||||
ticketNumber: string;
|
||||
analysisId: string | null;
|
||||
relationship: 'referenced' | 'updated' | 'should_have_referenced';
|
||||
source: string;
|
||||
details: { write_id?: string; field_name?: string; relevance_reason?: string } | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const CONFIDENCE_TONE: Record<FieldGap['confidence'], string> = {
|
||||
high: 'border-red-500 bg-red-500/10 text-red-700 dark:text-red-300',
|
||||
medium: 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300',
|
||||
low: 'border-blue-500 bg-blue-500/10 text-blue-700 dark:text-blue-300',
|
||||
};
|
||||
|
||||
function isPopulated(v: unknown): boolean {
|
||||
if (v === null || v === undefined) return false;
|
||||
if (typeof v === 'string') return v.trim().length > 0;
|
||||
if (Array.isArray(v)) return v.length > 0;
|
||||
if (typeof v === 'object') return Object.keys(v).length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
return JSON.stringify(v).slice(0, 200);
|
||||
}
|
||||
|
||||
export default function ConfigurationAuditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const { data: session } = useSession();
|
||||
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
|
||||
const canWrite = role === 'admin' || role === 'super-admin';
|
||||
|
||||
const [detail, setDetail] = useState<AssetDetail | null>(null);
|
||||
const [audit, setAudit] = useState<AuditRow | null>(null);
|
||||
const [history, setHistory] = useState<AuditRow[]>([]);
|
||||
const [writes, setWrites] = useState<WriteRow[]>([]);
|
||||
const [xrefs, setXrefs] = useState<XrefRow[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [provider, setProvider] = useState<AnalyzerProvider>('anthropic');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
|
||||
async function loadAll(): Promise<void> {
|
||||
try {
|
||||
const [d, a, w, x] = await Promise.all([
|
||||
fetch(`/api/analyzer/itglue/configurations/${id}`).then((r) => r.json()),
|
||||
fetch(`/api/analyzer/itglue/configurations/${id}/audit?history=1`).then(
|
||||
(r) => r.json()
|
||||
),
|
||||
fetch(`/api/analyzer/itglue/configurations/${id}/writes`).then((r) =>
|
||||
r.json()
|
||||
),
|
||||
fetch(`/api/analyzer/itglue/configurations/${id}/xrefs`).then((r) =>
|
||||
r.json()
|
||||
),
|
||||
]);
|
||||
if (d.error) throw new Error(d.error);
|
||||
setDetail(d as AssetDetail);
|
||||
setAudit(a.audit ?? null);
|
||||
setHistory(a.history ?? []);
|
||||
setWrites(w.writes ?? []);
|
||||
setXrefs(x.xrefs ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
async function runAudit(): Promise<void> {
|
||||
setRunning(true);
|
||||
try {
|
||||
const res = await fetch(`/api/analyzer/itglue/configurations/${id}/audit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || data.error || 'Audit failed');
|
||||
setAudit(data.audit);
|
||||
void loadAll();
|
||||
toast.success('Audit complete');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Audit failed');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyGap(
|
||||
gap: FieldGap | NotePromotion,
|
||||
kind: 'field_gap' | 'note_promotion'
|
||||
): Promise<void> {
|
||||
if (!canWrite || !audit) return;
|
||||
const fieldName =
|
||||
kind === 'field_gap' ? (gap as FieldGap).field_name : (gap as NotePromotion).target_field;
|
||||
const suggested =
|
||||
kind === 'field_gap'
|
||||
? (gap as FieldGap).suggested_value
|
||||
: (gap as NotePromotion).suggested_value;
|
||||
if (suggested === null || suggested === undefined || suggested === '') {
|
||||
toast.error('No suggested value to apply');
|
||||
return;
|
||||
}
|
||||
const evidence =
|
||||
kind === 'field_gap'
|
||||
? {
|
||||
ticket_numbers: (gap as FieldGap).evidence_ticket_numbers,
|
||||
gap_description: (gap as FieldGap).why_missing_matters,
|
||||
}
|
||||
: {
|
||||
ticket_numbers: [],
|
||||
gap_description: `Promoted from Notes: "${(gap as NotePromotion).quoted_note_text}"`,
|
||||
};
|
||||
const key = `${kind}:${fieldName}`;
|
||||
setBusyKey(key);
|
||||
try {
|
||||
const res = await fetch(`/api/analyzer/itglue/configurations/${id}/apply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
auditId: audit.id,
|
||||
fieldName,
|
||||
suggestedValue: suggested,
|
||||
sourceEvidence: evidence,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || data.error || 'Apply failed');
|
||||
toast.success(`Applied: ${fieldName}`);
|
||||
void loadAll();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Apply failed');
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function revertWrite(writeId: string): Promise<void> {
|
||||
if (!canWrite) return;
|
||||
setBusyKey(`revert:${writeId}`);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/analyzer/itglue/configurations/${id}/revert/${writeId}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || data.error || 'Revert failed');
|
||||
toast.success('Reverted');
|
||||
void loadAll();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Revert failed');
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
const orderedFields = useMemo(() => {
|
||||
if (!detail) return [];
|
||||
return detail.fields.map((f) => {
|
||||
const value = detail.asset.traits[f.name];
|
||||
return { ...f, value, populated: isPopulated(value) };
|
||||
});
|
||||
}, [detail]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load this configuration</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const a = detail.asset;
|
||||
const filledCount = orderedFields.filter((f) => f.populated).length;
|
||||
const totalCount = orderedFields.length;
|
||||
const referencedXrefs = xrefs.filter((x) => x.relationship === 'referenced');
|
||||
const updatedXrefs = xrefs.filter((x) => x.relationship === 'updated');
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Link
|
||||
href="/analyzer/itglue/configurations"
|
||||
className="hover:underline"
|
||||
>
|
||||
Configurations
|
||||
</Link>{' '}
|
||||
· {a.organizationName ?? 'Unknown org'}
|
||||
{a.typeName && ` · ${a.typeName}`}
|
||||
</p>
|
||||
<CardTitle className="text-2xl truncate flex items-center gap-2">
|
||||
<Server className="w-6 h-6 text-muted-foreground" />
|
||||
{a.name}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{filledCount}/{totalCount} fields populated
|
||||
{a.statusName && ` · ${a.statusName}`}
|
||||
{a.operatingSystemName && ` · ${a.operatingSystemName}`}
|
||||
{audit?.overall_score !== null && audit?.overall_score !== undefined && (
|
||||
<>
|
||||
{' · '}
|
||||
<Badge
|
||||
variant={
|
||||
(audit.overall_score ?? 0) > 0.8
|
||||
? 'default'
|
||||
: (audit.overall_score ?? 0) > 0.5
|
||||
? 'secondary'
|
||||
: 'destructive'
|
||||
}
|
||||
>
|
||||
Audit score {Math.round((audit.overall_score ?? 0) * 100)}%
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
|
||||
<Button onClick={runAudit} disabled={running}>
|
||||
{running ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Auditing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
{audit ? 'Re-audit' : 'Run audit'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{a.dattoDeviceUid && (
|
||||
<RmmScriptPicker
|
||||
filter="asset_self"
|
||||
deviceUid={a.dattoDeviceUid}
|
||||
hostname={a.hostname}
|
||||
companyId={a.autotaskCompanyId ?? undefined}
|
||||
assetType="configuration"
|
||||
assetId={a.id}
|
||||
onComplete={() => loadAll()}
|
||||
/>
|
||||
)}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
href={`https://wulf.itglue.com/${a.organizationId}/configurations/${a.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open in IT Glue
|
||||
<ExternalLink className="w-3.5 h-3.5 ml-1.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{audit && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Audit findings
|
||||
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
||||
{new Date(audit.generated_at).toLocaleString()}
|
||||
{' · '}
|
||||
{audit.provider === 'openrouter' ? 'DeepSeek' : 'Claude'}
|
||||
{audit.estimated_cost_usd !== null
|
||||
? ` · $${audit.estimated_cost_usd.toFixed(4)}`
|
||||
: ''}
|
||||
{' · '}
|
||||
{audit.ticket_count} ticket{audit.ticket_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Field gaps ({audit.field_gaps.length})
|
||||
</h3>
|
||||
{audit.field_gaps.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No field gaps detected.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{audit.field_gaps.map((g) => {
|
||||
const key = `field_gap:${g.field_name}`;
|
||||
const busy = busyKey === key;
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className={`border-l-4 rounded p-3 ${CONFIDENCE_TONE[g.confidence]}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-foreground">{g.field_name}</p>
|
||||
<p className="text-sm mt-1">{g.why_missing_matters}</p>
|
||||
{g.suggested_value !== null && (
|
||||
<p className="text-sm mt-2">
|
||||
<span className="font-medium">Suggested: </span>
|
||||
<span className="font-mono break-all">{g.suggested_value}</span>
|
||||
</p>
|
||||
)}
|
||||
{g.evidence_ticket_numbers.length > 0 && (
|
||||
<p className="text-xs mt-2 text-muted-foreground">
|
||||
Evidence:{' '}
|
||||
{g.evidence_ticket_numbers.map((tn, i) => (
|
||||
<span key={tn}>
|
||||
{i > 0 && ', '}
|
||||
<Link
|
||||
href={`/analyzer/ticket/${tn}`}
|
||||
className="font-mono hover:underline"
|
||||
>
|
||||
{tn}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{g.confidence}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
!canWrite ||
|
||||
g.suggested_value === null ||
|
||||
g.suggested_value === '' ||
|
||||
busy
|
||||
}
|
||||
onClick={() => applyGap(g, 'field_gap')}
|
||||
title={
|
||||
!canWrite
|
||||
? 'Requires admin'
|
||||
: g.suggested_value === null
|
||||
? 'No concrete suggestion'
|
||||
: 'Apply to IT Glue'
|
||||
}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{audit.notes_promotions.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Promote from Notes ({audit.notes_promotions.length})
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{audit.notes_promotions.map((p, i) => {
|
||||
const busy = busyKey === `note_promotion:${p.target_field}`;
|
||||
return (
|
||||
<li
|
||||
key={`np-${i}`}
|
||||
className="border-l-4 border-primary/40 bg-primary/5 rounded p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-mono italic text-muted-foreground break-words">
|
||||
“{p.quoted_note_text}”
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
Belongs in{' '}
|
||||
<span className="font-medium">{p.target_field}</span>
|
||||
:{' '}
|
||||
<span className="font-mono break-all">{p.suggested_value}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{p.confidence}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canWrite || busy}
|
||||
onClick={() => applyGap(p, 'note_promotion')}
|
||||
title={!canWrite ? 'Requires admin' : 'Apply to IT Glue'}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{audit.contradictions.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Contradictions ({audit.contradictions.length})
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{audit.contradictions.map((c, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm p-3 rounded bg-muted/40"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<p>{c.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{c.evidence}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!audit && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No audit yet. Click <strong>Run audit</strong> to analyze this configuration.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Current fields</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="divide-y">
|
||||
{orderedFields.map((f) => (
|
||||
<div key={f.id} className="py-2 grid grid-cols-3 gap-3 text-sm">
|
||||
<dt
|
||||
className={`font-medium ${f.populated ? '' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{f.name}
|
||||
{f.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</dt>
|
||||
<dd className="col-span-2 break-words">
|
||||
{f.populated ? (
|
||||
formatValue(f.value)
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">empty</span>
|
||||
)}
|
||||
{f.hint && !f.populated && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{f.hint}</p>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{(referencedXrefs.length > 0 || updatedXrefs.length > 0) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Tickets that touched this configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{referencedXrefs.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Referenced by ({referencedXrefs.length})
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{referencedXrefs.map((x) => (
|
||||
<li key={x.id}>
|
||||
<Link
|
||||
href={`/analyzer/ticket/${x.ticketNumber}`}
|
||||
className="font-mono hover:underline"
|
||||
>
|
||||
{x.ticketNumber}
|
||||
</Link>
|
||||
{x.details?.relevance_reason && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
— {x.details.relevance_reason}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{updatedXrefs.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Updated by ({updatedXrefs.length})
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{updatedXrefs.map((x) => (
|
||||
<li key={x.id}>
|
||||
<Link
|
||||
href={`/analyzer/ticket/${x.ticketNumber}`}
|
||||
className="font-mono hover:underline"
|
||||
>
|
||||
{x.ticketNumber}
|
||||
</Link>
|
||||
{x.details?.field_name && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
— set <span className="font-mono">{x.details.field_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{writes.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ArrowLeftRight className="w-4 h-4" />
|
||||
Write history ({writes.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{writes.map((w) => (
|
||||
<li key={w.id} className="py-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{w.field_name}</span>
|
||||
<Badge
|
||||
variant={
|
||||
w.status === 'committed'
|
||||
? 'default'
|
||||
: w.status === 'reverted'
|
||||
? 'secondary'
|
||||
: w.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="ml-2 text-[10px]"
|
||||
>
|
||||
{w.status}
|
||||
</Badge>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(w.performed_at).toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs mt-1 break-words">
|
||||
<span className="text-muted-foreground">Before: </span>
|
||||
<span className="font-mono">
|
||||
{w.before_value === null || w.before_value === undefined
|
||||
? '(empty)'
|
||||
: JSON.stringify(w.before_value).slice(0, 200)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs mt-0.5 break-words">
|
||||
<span className="text-muted-foreground">After: </span>
|
||||
<span className="font-mono">
|
||||
{JSON.stringify(w.after_value).slice(0, 200)}
|
||||
</span>
|
||||
</p>
|
||||
{w.error_message && (
|
||||
<p className="text-xs mt-1 text-destructive">Error: {w.error_message}</p>
|
||||
)}
|
||||
</div>
|
||||
{w.status === 'committed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canWrite || busyKey === `revert:${w.id}`}
|
||||
onClick={() => revertWrite(w.id)}
|
||||
title={!canWrite ? 'Requires admin' : 'Revert this write'}
|
||||
>
|
||||
{busyKey === `revert:${w.id}` ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Undo2 className="w-3.5 h-3.5 mr-1" />
|
||||
)}
|
||||
Revert
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{history.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Audit history</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{history.map((h) => (
|
||||
<li key={h.id} className="py-2 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(h.generated_at).toLocaleString()}
|
||||
{' · '}
|
||||
{h.provider === 'openrouter' ? 'DeepSeek' : 'Claude'}
|
||||
</span>
|
||||
<span>
|
||||
Score{' '}
|
||||
<Badge variant="outline">
|
||||
{h.overall_score !== null
|
||||
? Math.round(h.overall_score * 100) + '%'
|
||||
: 'n/a'}
|
||||
</Badge>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<Separator />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
app/analyzer/itglue/configurations/page.tsx
Normal file
156
app/analyzer/itglue/configurations/page.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface ConfigurationRow {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
typeName: string | null;
|
||||
statusName: string | null;
|
||||
organizationId: string | null;
|
||||
organizationName: string | null;
|
||||
latestAudit: {
|
||||
id: string;
|
||||
generatedAt: string | null;
|
||||
overallScore: number | null;
|
||||
provider: 'anthropic' | 'openrouter' | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function scoreBadgeVariant(
|
||||
score: number | null
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (score === null) return 'outline';
|
||||
if (score > 0.8) return 'default';
|
||||
if (score > 0.5) return 'secondary';
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
export default function ConfigurationsListPage() {
|
||||
const [rows, setRows] = useState<ConfigurationRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/analyzer/itglue/configurations');
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
const data = (await res.json()) as { configurations: ConfigurationRow[] };
|
||||
if (!cancelled) setRows(data.configurations);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visible = rows
|
||||
? rows.filter((r) => {
|
||||
if (!filter.trim()) return true;
|
||||
const q = filter.toLowerCase();
|
||||
return (
|
||||
(r.name ?? '').toLowerCase().includes(q) ||
|
||||
(r.hostname ?? '').toLowerCase().includes(q) ||
|
||||
(r.organizationName ?? '').toLowerCase().includes(q) ||
|
||||
(r.typeName ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
|
||||
const auditedCount = rows ? rows.filter((r) => r.latestAudit !== null).length : 0;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<CardTitle>IT Glue configurations audit</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{rows === null
|
||||
? 'Loading…'
|
||||
: `${rows.length} configurations · ${auditedCount} audited`}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Filter by name, hostname, type, or client…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load configurations</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{rows === null && !error ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{visible.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="py-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/analyzer/itglue/configurations/${r.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{r.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{r.organizationName ?? '—'}
|
||||
{r.typeName && ` · ${r.typeName}`}
|
||||
{r.statusName && ` · ${r.statusName}`}
|
||||
{r.hostname && ` · ${r.hostname}`}
|
||||
{r.latestAudit && (
|
||||
<>
|
||||
{' · '}last audited{' '}
|
||||
{r.latestAudit.generatedAt
|
||||
? new Date(r.latestAudit.generatedAt).toLocaleDateString()
|
||||
: 'unknown'}
|
||||
{' '}
|
||||
(
|
||||
{r.latestAudit.provider === 'openrouter'
|
||||
? 'DeepSeek'
|
||||
: 'Claude'}
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={scoreBadgeVariant(r.latestAudit?.overallScore ?? null)}>
|
||||
{r.latestAudit?.overallScore !== null &&
|
||||
r.latestAudit?.overallScore !== undefined
|
||||
? Math.round(r.latestAudit.overallScore * 100) + '%'
|
||||
: 'No audit'}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
app/analyzer/itglue/sites/[companyId]/page.tsx
Normal file
234
app/analyzer/itglue/sites/[companyId]/page.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, use } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { RmmScriptPicker } from '@/components/rmm/rmm-script-picker';
|
||||
import { Server } from 'lucide-react';
|
||||
|
||||
interface SiteInfo {
|
||||
companyId: string;
|
||||
companyName: string | null;
|
||||
itglueOrgId: string | null;
|
||||
itglueOrgName: string | null;
|
||||
dattoSiteId: number | null;
|
||||
wnpHostname: string | null;
|
||||
wnpDeviceUid: string | null;
|
||||
wnpOnline: boolean | null;
|
||||
sites: Array<{
|
||||
device_uid: string;
|
||||
hostname: string | null;
|
||||
online: boolean;
|
||||
site_id: number;
|
||||
site_name: string;
|
||||
site_device_count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ExecRow {
|
||||
id: string;
|
||||
scriptId: string;
|
||||
jobName: string;
|
||||
targetHostname: string | null;
|
||||
status: 'queued' | 'running' | 'complete' | 'failed' | 'timeout';
|
||||
exitCode: number | null;
|
||||
parsedEvidence: unknown;
|
||||
queuedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export default function SiteDiscoveryPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ companyId: string }>;
|
||||
}) {
|
||||
const { companyId } = use(params);
|
||||
const [info, setInfo] = useState<SiteInfo | null>(null);
|
||||
const [executions, setExecutions] = useState<ExecRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadAll(): Promise<void> {
|
||||
try {
|
||||
const [s, e] = await Promise.all([
|
||||
fetch(`/api/analyzer/itglue/sites/${companyId}`).then((r) => r.json()),
|
||||
fetch(`/api/rmm/executions?companyId=${companyId}&limit=50`).then((r) =>
|
||||
r.json()
|
||||
),
|
||||
]);
|
||||
if (s.error) throw new Error(s.error);
|
||||
setInfo(s.site);
|
||||
setExecutions(e.executions ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [companyId]);
|
||||
|
||||
const recent = useMemo(() => executions ?? [], [executions]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load this site</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">Site discovery</p>
|
||||
<CardTitle className="text-2xl truncate">
|
||||
{info.companyName ?? `Company ${info.companyId}`}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{info.wnpHostname ? (
|
||||
<>
|
||||
<Server className="inline w-3 h-3 mr-1" />
|
||||
Primary target:{' '}
|
||||
<span className="font-mono">{info.wnpHostname}</span>
|
||||
{info.wnpOnline === false && (
|
||||
<Badge variant="destructive" className="ml-2 text-[10px]">
|
||||
offline
|
||||
</Badge>
|
||||
)}
|
||||
{info.sites.length > 1 && (
|
||||
<span className="ml-2">
|
||||
({info.sites.length} WNP endpoints across this client’s sites)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-amber-600">
|
||||
No Wulf Nurse Production endpoint registered for this client.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<RmmScriptPicker
|
||||
filter="site_anchor"
|
||||
companyId={info.companyId}
|
||||
onComplete={() => loadAll()}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Site-anchored discovery scripts run from the Wulf Nurse Production
|
||||
endpoint and use native PowerShell + AD/DHCP/DNS cmdlets to gather
|
||||
facts about the environment. Output is captured and made available
|
||||
to subsequent IT Glue audits as live evidence.
|
||||
</p>
|
||||
{info.sites.length > 1 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">
|
||||
All sites for this client
|
||||
</p>
|
||||
<ul className="text-xs space-y-1">
|
||||
{info.sites.map((s) => (
|
||||
<li
|
||||
key={s.device_uid}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span>
|
||||
<span className="font-mono">{s.hostname}</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{s.site_name} · {s.site_device_count} devices
|
||||
</span>
|
||||
{s.hostname === info.wnpHostname && (
|
||||
<Badge variant="secondary" className="ml-2 text-[10px]">
|
||||
primary
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
{!s.online && (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
offline
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
v1 dispatches site-anchored scripts to the primary target only.
|
||||
A future version will let you pick a specific site here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Recent runs ({recent.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recent.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No runs yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{recent.map((e) => (
|
||||
<li key={e.id} className="py-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{e.scriptId}{' '}
|
||||
<Badge
|
||||
variant={
|
||||
e.status === 'complete'
|
||||
? 'default'
|
||||
: e.status === 'failed' || e.status === 'timeout'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="ml-1 text-[10px]"
|
||||
>
|
||||
{e.status}
|
||||
</Badge>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{e.targetHostname ?? '—'} ·{' '}
|
||||
{new Date(e.queuedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{e.parsedEvidence !== null && e.parsedEvidence !== undefined && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-muted-foreground cursor-pointer">
|
||||
Show parsed evidence
|
||||
</summary>
|
||||
<pre className="text-[11px] bg-muted/50 rounded p-2 max-h-72 overflow-auto whitespace-pre-wrap mt-1">
|
||||
{JSON.stringify(e.parsedEvidence, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,12 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { AnalyzeButton } from '@/components/analyzer/analyze-button';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { RelatedTicketsPanel } from '@/components/analyzer/related-tickets-panel';
|
||||
import {
|
||||
ProviderToggle,
|
||||
type AnalyzerProvider,
|
||||
} from '@/components/analyzer/provider-toggle';
|
||||
import { Sparkles, Zap } from 'lucide-react';
|
||||
import type { PersistedAnalysis } from '@/lib/types/analyzer';
|
||||
|
||||
export default function TicketAnalyzerPage({
|
||||
|
|
@ -18,6 +23,7 @@ export default function TicketAnalyzerPage({
|
|||
const { ticketNumber } = use(params);
|
||||
const [analyses, setAnalyses] = useState<PersistedAnalysis[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [provider, setProvider] = useState<AnalyzerProvider>('anthropic');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -52,14 +58,18 @@ export default function TicketAnalyzerPage({
|
|||
<p className="text-sm text-muted-foreground">Ticket</p>
|
||||
<CardTitle className="font-mono">{ticketNumber}</CardTitle>
|
||||
</div>
|
||||
<AnalyzeButton ticketNumber={ticketNumber} />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
|
||||
<AnalyzeButton ticketNumber={ticketNumber} provider={provider} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click <strong>Analyze</strong> to run the AI pipeline. If a current
|
||||
analysis already exists, you’ll be navigated straight to it.
|
||||
Otherwise the run takes ~10–60 seconds.
|
||||
Click <strong>Analyze</strong> to run the AI pipeline using the
|
||||
selected provider. Each provider keeps its own analysis history,
|
||||
so you can compare Claude and DeepSeek output side-by-side. A run
|
||||
with the same content hash on the same provider returns instantly.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -71,6 +81,9 @@ export default function TicketAnalyzerPage({
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<RelatedTicketsPanel ticketNumber={ticketNumber} provider={provider} />
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Analysis history</CardTitle>
|
||||
|
|
@ -87,40 +100,64 @@ export default function TicketAnalyzerPage({
|
|||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{(analyses ?? []).map((a) => (
|
||||
<li key={a.id} className="py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/analyzer/analysis/${a.id}`}
|
||||
className="font-medium hover:underline flex items-center gap-2"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Version {a.analysisVersion}
|
||||
{latest?.id === a.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
latest
|
||||
{(analyses ?? []).map((a) => {
|
||||
const isOpenRouter = a.provider === 'openrouter';
|
||||
const tierLabel = isOpenRouter
|
||||
? a.opusUsed
|
||||
? 'V4 Flash → V4 Pro → R1'
|
||||
: a.sonnetUsed
|
||||
? 'V4 Flash → V4 Pro'
|
||||
: 'V4 Flash'
|
||||
: a.opusUsed
|
||||
? 'Haiku → Sonnet → Opus'
|
||||
: a.sonnetUsed
|
||||
? 'Haiku → Sonnet'
|
||||
: 'Haiku';
|
||||
const ProviderIcon = isOpenRouter ? Zap : Sparkles;
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className="py-3 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/analyzer/analysis/${a.id}`}
|
||||
className="font-medium hover:underline flex items-center gap-2 flex-wrap"
|
||||
>
|
||||
<ProviderIcon className="w-4 h-4" />
|
||||
Version {a.analysisVersion}
|
||||
<Badge
|
||||
variant={isOpenRouter ? 'default' : 'secondary'}
|
||||
className="text-[10px] py-0"
|
||||
>
|
||||
{isOpenRouter ? 'DeepSeek' : 'Claude'}
|
||||
</Badge>
|
||||
)}
|
||||
{a.needsHumanReview && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Needs review
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(a.triggeredAt).toLocaleString()}
|
||||
{' · '}
|
||||
{a.opusUsed ? 'Haiku → Sonnet → Opus' : a.sonnetUsed ? 'Haiku → Sonnet' : 'Haiku'}
|
||||
{' · '}${a.estimatedCostUsd.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
{a.confidenceScore !== null && (
|
||||
<Badge variant="outline">
|
||||
{Math.round(a.confidenceScore * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{latest?.id === a.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
latest
|
||||
</Badge>
|
||||
)}
|
||||
{a.needsHumanReview && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Needs review
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(a.triggeredAt).toLocaleString()}
|
||||
{' · '}
|
||||
{tierLabel}
|
||||
{' · '}${a.estimatedCostUsd.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
{a.confidenceScore !== null && (
|
||||
<Badge variant="outline">
|
||||
{Math.round(a.confidenceScore * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
98
app/api/admin/device-link-conflicts/[id]/resolve/route.ts
Normal file
98
app/api/admin/device-link-conflicts/[id]/resolve/route.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* POST /api/admin/device-link-conflicts/[id]/resolve
|
||||
* Body: { ciId: string, note?: string }
|
||||
*
|
||||
* Resolves a conflict by manually picking a configuration_item to link the
|
||||
* underlying device_external_ids row to. Sets link_confidence='manual' and
|
||||
* marks the review row resolved.
|
||||
*
|
||||
* Validates that ciId is in candidate_ci_ids — admins can't pick an arbitrary
|
||||
* CI here. (For an arbitrary-CI override, separate flow.)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const ResolveBody = z.object({
|
||||
ciId: z.string().regex(/^\d+$/, 'ciId must be numeric'),
|
||||
note: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
if (!/^[0-9a-f-]{36}$/i.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid review id' }, { status: 400 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
const parsed = ResolveBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid payload', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const ciIdNum = Number(parsed.data.ciId);
|
||||
|
||||
return postgresClient.transaction(async (tx) => {
|
||||
const reviewRes = await tx.query<{
|
||||
device_external_id: string;
|
||||
candidate_ci_ids: string[];
|
||||
resolved_at: string | null;
|
||||
}>(
|
||||
`SELECT device_external_id::text, candidate_ci_ids::text[], resolved_at::text
|
||||
FROM device_link_review
|
||||
WHERE id = $1
|
||||
FOR UPDATE`,
|
||||
[id]
|
||||
);
|
||||
if (reviewRes.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Review not found' }, { status: 404 });
|
||||
}
|
||||
const review = reviewRes.rows[0];
|
||||
if (review.resolved_at) {
|
||||
return NextResponse.json({ error: 'Already resolved' }, { status: 409 });
|
||||
}
|
||||
if (!review.candidate_ci_ids.map(String).includes(String(ciIdNum))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ciId must be one of the conflict candidates' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await tx.query(
|
||||
`UPDATE device_external_ids
|
||||
SET configuration_item_id = $2,
|
||||
link_confidence = 'manual',
|
||||
linked_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[review.device_external_id, ciIdNum]
|
||||
);
|
||||
|
||||
await tx.query(
|
||||
`UPDATE device_link_review
|
||||
SET resolved_at = NOW(),
|
||||
resolved_by_user_id = $2,
|
||||
resolved_to_ci_id = $3,
|
||||
resolution_note = $4
|
||||
WHERE id = $1`,
|
||||
[id, session?.user?.id ?? null, ciIdNum, parsed.data.note ?? null]
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, resolvedToCiId: String(ciIdNum) });
|
||||
});
|
||||
}
|
||||
133
app/api/admin/device-link-conflicts/route.ts
Normal file
133
app/api/admin/device-link-conflicts/route.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* GET /api/admin/device-link-conflicts
|
||||
* Returns unresolved device-link reviews with candidate CI details, paged.
|
||||
* Query params:
|
||||
* limit (default 50, max 200)
|
||||
* offset (default 0)
|
||||
* source (filter: 'datto_rmm' | 'itglue' | ...)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
interface ReviewRow {
|
||||
id: string;
|
||||
detected_at: string;
|
||||
xref_id: string;
|
||||
source: string;
|
||||
source_id: string;
|
||||
hostname: string | null;
|
||||
serial: string | null;
|
||||
mac: string | null;
|
||||
xref_company_id: string | null;
|
||||
xref_company_name: string | null;
|
||||
last_seen_at: string | null;
|
||||
candidate_ci_ids: string[];
|
||||
match_confidences: string[];
|
||||
}
|
||||
|
||||
interface CiRow {
|
||||
id: string;
|
||||
reference_title: string | null;
|
||||
serial_number: string | null;
|
||||
rmm_device_audit_mac_address: string | null;
|
||||
company_id: string | null;
|
||||
company_name: string | null;
|
||||
is_deleted: boolean;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
|
||||
const url = request.nextUrl;
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10) || 50, 200);
|
||||
const offset = Math.max(parseInt(url.searchParams.get('offset') ?? '0', 10) || 0, 0);
|
||||
const source = url.searchParams.get('source');
|
||||
|
||||
const params: unknown[] = [limit, offset];
|
||||
let sourceFilter = '';
|
||||
if (source) {
|
||||
params.push(source);
|
||||
sourceFilter = `AND dx.source = $${params.length}`;
|
||||
}
|
||||
|
||||
const reviews = await postgresClient.query<ReviewRow>(
|
||||
`SELECT r.id::text, r.detected_at::text, r.candidate_ci_ids::text[],
|
||||
r.match_confidences,
|
||||
dx.id::text AS xref_id, dx.source, dx.source_id,
|
||||
dx.hostname, dx.serial, dx.mac,
|
||||
dx.company_id::text AS xref_company_id,
|
||||
c.company_name AS xref_company_name,
|
||||
dx.last_seen_at::text
|
||||
FROM device_link_review r
|
||||
JOIN device_external_ids dx ON dx.id = r.device_external_id
|
||||
LEFT JOIN companies c ON c.id = dx.company_id
|
||||
WHERE r.resolved_at IS NULL
|
||||
${sourceFilter}
|
||||
ORDER BY r.detected_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
params
|
||||
);
|
||||
|
||||
// Bulk-fetch all candidate CI details in one query.
|
||||
const allCiIds = new Set<string>();
|
||||
for (const r of reviews.rows) {
|
||||
for (const id of r.candidate_ci_ids ?? []) allCiIds.add(String(id));
|
||||
}
|
||||
const ciDetails = new Map<string, CiRow>();
|
||||
if (allCiIds.size > 0) {
|
||||
const ciRes = await postgresClient.query<CiRow>(
|
||||
`SELECT ci.id::text, ci.reference_title, ci.serial_number,
|
||||
ci.rmm_device_audit_mac_address,
|
||||
ci.company_id::text AS company_id,
|
||||
c.company_name, COALESCE(ci.is_deleted, false) AS is_deleted
|
||||
FROM configuration_items ci
|
||||
LEFT JOIN companies c ON c.id = ci.company_id
|
||||
WHERE ci.id = ANY($1::bigint[])`,
|
||||
[Array.from(allCiIds)]
|
||||
);
|
||||
for (const ci of ciRes.rows) ciDetails.set(ci.id, ci);
|
||||
}
|
||||
|
||||
const totalRes = await postgresClient.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM device_link_review r
|
||||
JOIN device_external_ids dx ON dx.id = r.device_external_id
|
||||
WHERE r.resolved_at IS NULL ${sourceFilter}`,
|
||||
source ? [source] : []
|
||||
);
|
||||
const total = parseInt(totalRes.rows[0]?.count ?? '0', 10);
|
||||
|
||||
const items = reviews.rows.map((r) => ({
|
||||
id: r.id,
|
||||
detectedAt: r.detected_at,
|
||||
xref: {
|
||||
id: r.xref_id,
|
||||
source: r.source,
|
||||
sourceId: r.source_id,
|
||||
hostname: r.hostname,
|
||||
serial: r.serial,
|
||||
mac: r.mac,
|
||||
companyId: r.xref_company_id,
|
||||
companyName: r.xref_company_name,
|
||||
lastSeenAt: r.last_seen_at,
|
||||
},
|
||||
candidates: (r.candidate_ci_ids ?? []).map((ciId, i) => {
|
||||
const ci = ciDetails.get(String(ciId));
|
||||
return {
|
||||
ciId: String(ciId),
|
||||
confidence: r.match_confidences?.[i] ?? null,
|
||||
hostname: ci?.reference_title ?? null,
|
||||
serial: ci?.serial_number ?? null,
|
||||
mac: ci?.rmm_device_audit_mac_address ?? null,
|
||||
companyId: ci?.company_id ?? null,
|
||||
companyName: ci?.company_name ?? null,
|
||||
isDeleted: ci?.is_deleted ?? false,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ items, total, limit, offset });
|
||||
}
|
||||
41
app/api/admin/rmm/settings/discover-loglift/route.ts
Normal file
41
app/api/admin/rmm/settings/discover-loglift/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* POST /api/admin/rmm/settings/discover-loglift
|
||||
*
|
||||
* Force a re-scan of Datto RMM components, find the LogLift / event-log
|
||||
* collector component, and update rmm_settings. Returns the discovered
|
||||
* { uid, name } or 404 if nothing matches the discovery pattern.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import { discoverLogliftComponent } from '@/lib/services/rmm/settings';
|
||||
|
||||
export async function POST(_request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
try {
|
||||
const result = await discoverLogliftComponent();
|
||||
if (!result.discovered) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'No matching component found',
|
||||
message:
|
||||
'Datto RMM did not return any component whose name matches /loglift|eventlog/i. Register the LogLift collector component and retry.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
settings: result.settings,
|
||||
discovered: result.discovered,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Discovery failed',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
app/api/admin/rmm/settings/discover/route.ts
Normal file
38
app/api/admin/rmm/settings/discover/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* POST /api/admin/rmm/settings/discover
|
||||
*
|
||||
* Force a re-scan of Datto RMM components, find the Overshell, and update
|
||||
* rmm_settings. Returns the discovered { uid, name } or 404 if nothing
|
||||
* matches the discovery pattern.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import { discoverOvershellComponent } from '@/lib/services/rmm/settings';
|
||||
|
||||
export async function POST(_request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
try {
|
||||
const result = await discoverOvershellComponent();
|
||||
if (!result.discovered) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'No matching component found',
|
||||
message:
|
||||
'Datto RMM did not return any component whose name matches /overshell/i. Register a component (Account → ComStore → Run Command or similar) and retry.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ settings: result.settings, discovered: result.discovered });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Discovery failed',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
61
app/api/admin/rmm/settings/route.ts
Normal file
61
app/api/admin/rmm/settings/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* GET /api/admin/rmm/settings
|
||||
* Returns the cached Overshell config + recent execution counts.
|
||||
*
|
||||
* PATCH /api/admin/rmm/settings
|
||||
* Body: { overshellVariableName: string }
|
||||
* Updates the variable name the Overshell component expects.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import {
|
||||
getRmmSettings,
|
||||
updateOvershellVariableName,
|
||||
} from '@/lib/services/rmm/settings';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const PatchBody = z.object({
|
||||
overshellVariableName: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
interface CountsRow {
|
||||
total: string;
|
||||
running: string;
|
||||
failed_24h: string;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
const settings = await getRmmSettings();
|
||||
const counts = await postgresClient.query<CountsRow>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*)::text FROM rmm_executions) AS total,
|
||||
(SELECT COUNT(*)::text FROM rmm_executions WHERE status = 'running') AS running,
|
||||
(SELECT COUNT(*)::text FROM rmm_executions
|
||||
WHERE status = 'failed' AND queued_at >= NOW() - INTERVAL '24 hours') AS failed_24h`
|
||||
);
|
||||
return NextResponse.json({
|
||||
settings,
|
||||
counts: counts.rows[0] ?? { total: '0', running: '0', failed_24h: '0' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = PatchBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const settings = await updateOvershellVariableName(
|
||||
parsed.data.overshellVariableName
|
||||
);
|
||||
return NextResponse.json({ settings });
|
||||
}
|
||||
144
app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts
Normal file
144
app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* GET /api/analyzer/analyses/:id/itglue-suggestions
|
||||
* Returns matched IT Glue assets (flexible_asset + configuration) for the
|
||||
* analysis, plus any existing ticket-scoped audits keyed by (assetType, assetId).
|
||||
*
|
||||
* POST /api/analyzer/analyses/:id/itglue-suggestions
|
||||
* Body: { assetType: 'flexible_asset' | 'configuration', assetId, provider? }
|
||||
* Runs a ticket-scoped audit (evidence = just this analysis). Returns the
|
||||
* audit row.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { matchAssetsForAnalysis } from '@/lib/services/analyzer/asset-audit/asset-matcher';
|
||||
import { runAssetAudit } from '@/lib/services/analyzer/asset-audit/runner';
|
||||
import {
|
||||
getAssetAuditById,
|
||||
getLatestTicketScopedAudit,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import {
|
||||
evaluateCost,
|
||||
recordCostAuditDecision,
|
||||
} from '@/lib/services/analyzer/cost-guard';
|
||||
import { ProviderEnum } from '@/lib/types/analyzer';
|
||||
|
||||
const PER_AUDIT_COST_USD: Record<'anthropic' | 'openrouter', number> = {
|
||||
anthropic: 0.05,
|
||||
openrouter: 0.005,
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
const matched = await matchAssetsForAnalysis(id);
|
||||
if (!matched) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Analysis not found or not yet complete' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// For each match, look up an existing ticket-scoped audit if any.
|
||||
const flexWithAudits = await Promise.all(
|
||||
matched.flexibleAssets.map(async (m) => ({
|
||||
...m,
|
||||
latestAudit: await getLatestTicketScopedAudit(id, 'flexible_asset', m.id),
|
||||
}))
|
||||
);
|
||||
const configWithAudits = await Promise.all(
|
||||
matched.configurations.map(async (m) => ({
|
||||
...m,
|
||||
latestAudit: await getLatestTicketScopedAudit(id, 'configuration', m.id),
|
||||
}))
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
ticketNumber: matched.ticketNumber,
|
||||
organizationId: matched.organizationId,
|
||||
organizationName: matched.organizationName,
|
||||
flexibleAssets: flexWithAudits,
|
||||
configurations: configWithAudits,
|
||||
});
|
||||
}
|
||||
|
||||
const PostBody = z.object({
|
||||
assetType: z.enum(['flexible_asset', 'configuration']),
|
||||
assetId: z.union([z.string(), z.number()]),
|
||||
provider: ProviderEnum.optional().default('anthropic'),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id: analysisId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = PostBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { assetType, assetId, provider } = parsed.data;
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
|
||||
const evaluation = await evaluateCost({
|
||||
userId,
|
||||
estimatedCost: PER_AUDIT_COST_USD[provider],
|
||||
confirmedCost: false,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId,
|
||||
action: 'itglue_audit',
|
||||
evaluation,
|
||||
context: {
|
||||
mode: 'ticket_scoped',
|
||||
analysisId,
|
||||
assetType,
|
||||
assetId,
|
||||
provider,
|
||||
},
|
||||
});
|
||||
if (evaluation.decision === 'blocked') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Daily cost limit reached',
|
||||
message: evaluation.decisionReason,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await runAssetAudit({
|
||||
assetType,
|
||||
assetId,
|
||||
generatedByUserId: userId,
|
||||
provider,
|
||||
ticketScopeAnalysisId: analysisId,
|
||||
});
|
||||
|
||||
if (result.status === 'failed') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Audit failed',
|
||||
message: result.errorMessage,
|
||||
auditId: result.auditId,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const audit = await getAssetAuditById(result.auditId);
|
||||
return NextResponse.json({ audit });
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
createShare,
|
||||
getAnalysisById,
|
||||
} from '@/lib/services/analyzer/persistence';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { sendAnalysisShareEmail } from '@/lib/services/email';
|
||||
|
||||
function getAllowedDomains(): string[] {
|
||||
|
|
@ -92,6 +93,32 @@ export async function POST(
|
|||
note,
|
||||
});
|
||||
|
||||
// Fetch the ticket title for the email subtitle. Best-effort: a missing
|
||||
// ticket (analyzer mirror skew) shouldn't fail the share.
|
||||
let ticketTitle: string | null = null;
|
||||
try {
|
||||
const titleRes = await postgresClient.query<{ title: string | null }>(
|
||||
`SELECT title FROM tickets
|
||||
WHERE ticket_number = $1
|
||||
AND COALESCE(is_deleted, false) = false
|
||||
LIMIT 1`,
|
||||
[analysis.ticketNumber]
|
||||
);
|
||||
if (titleRes.rowCount && titleRes.rowCount > 0) {
|
||||
ticketTitle = titleRes.rows[0].title;
|
||||
}
|
||||
} catch {
|
||||
// Title is decorative — proceed without it.
|
||||
}
|
||||
|
||||
const modelTier: 'haiku' | 'sonnet' | 'opus' | null = analysis.opusUsed
|
||||
? 'opus'
|
||||
: analysis.sonnetUsed
|
||||
? 'sonnet'
|
||||
: analysis.haikuUsed
|
||||
? 'haiku'
|
||||
: null;
|
||||
|
||||
const analysisUrl = `${getAppBaseUrl().replace(/\/$/, '')}/analyzer/analysis/${id}`;
|
||||
let emailSent = true;
|
||||
let emailError: string | null = null;
|
||||
|
|
@ -101,9 +128,16 @@ export async function POST(
|
|||
senderName: sessionUser.name || sessionUser.email,
|
||||
senderEmail: sessionUser.email,
|
||||
ticketNumber: analysis.ticketNumber,
|
||||
ticketTitle,
|
||||
analysisVersion: analysis.analysisVersion,
|
||||
summary: analysis.summary,
|
||||
nextStep: analysis.nextStep,
|
||||
nextStepRationale: analysis.nextStepRationale,
|
||||
whatWasDone: analysis.whatWasDone,
|
||||
whatShouldHaveBeenDone: analysis.whatShouldHaveBeenDone,
|
||||
gaps: analysis.gaps,
|
||||
confidenceScore: analysis.confidenceScore,
|
||||
modelTier,
|
||||
analysisUrl,
|
||||
note,
|
||||
});
|
||||
|
|
|
|||
201
app/api/analyzer/itglue/applications/[id]/apply/route.ts
Normal file
201
app/api/analyzer/itglue/applications/[id]/apply/route.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* POST /api/analyzer/itglue/applications/:id/apply
|
||||
*
|
||||
* Body: ApplyAssetSuggestionRequest = { auditId, fieldName, suggestedValue, sourceEvidence? }
|
||||
*
|
||||
* Flow:
|
||||
* 1. Verify auth + itglue.write permission.
|
||||
* 2. Read the asset's current traits (the source of truth for before_value).
|
||||
* 3. Insert pending row in itglue_writes capturing before/after.
|
||||
* 4. Call IT Glue PATCH /flexible_assets/:id (merged trait map).
|
||||
* 5. On success: mark write committed, refresh the local mirror row, write
|
||||
* a generic audit_log entry.
|
||||
* 6. On failure: mark write failed, return 502.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { ApplyAssetSuggestionRequest } from '@/lib/types/analyzer';
|
||||
import {
|
||||
createPendingWrite,
|
||||
fieldNameToTraitKey,
|
||||
getAssetAuditById,
|
||||
markWriteCommitted,
|
||||
markWriteFailed,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import { insertUpdatedXref } from '@/lib/services/analyzer/asset-audit/xrefs';
|
||||
import { getITGlueClient } from '@/lib/services/itglue-client';
|
||||
import { getITGlueSyncService } from '@/lib/services/itglue-sync-service';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
organization_name: string | null;
|
||||
flexible_asset_type_id: string;
|
||||
traits: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function loadAssetRow(assetId: string): Promise<AssetRow | null> {
|
||||
const res = await postgresClient.query<AssetRow>(
|
||||
`SELECT id::text AS id,
|
||||
organization_name,
|
||||
flexible_asset_type_id::text AS flexible_asset_type_id,
|
||||
traits
|
||||
FROM itg_flexible_assets
|
||||
WHERE id = $1
|
||||
LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
return res.rowCount === 0 ? null : res.rows[0];
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requirePermission('itglue', 'write');
|
||||
if (error) return error;
|
||||
|
||||
const { id: assetId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = ApplyAssetSuggestionRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { auditId, fieldName, suggestedValue, sourceEvidence } = parsed.data;
|
||||
|
||||
// Sanity-check: the audit must exist and reference this asset.
|
||||
const auditRow = await getAssetAuditById(auditId);
|
||||
if (!auditRow) {
|
||||
return NextResponse.json({ error: 'Audit not found' }, { status: 404 });
|
||||
}
|
||||
if (auditRow.asset_id !== assetId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audit does not reference this asset' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Refuse to write to credential-shaped fields. Belt for the prompt's
|
||||
// braces; the prompt already tells the LLM not to suggest these, but
|
||||
// re-block here so a malicious-looking payload can't sneak through.
|
||||
const lowerField = fieldName.toLowerCase();
|
||||
if (
|
||||
/(password|secret|key|token|credential)/.test(lowerField)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Refusing to write to credential-shaped field' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read current traits.
|
||||
const asset = await loadAssetRow(assetId);
|
||||
if (!asset) {
|
||||
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
|
||||
}
|
||||
const traitKey = fieldNameToTraitKey(fieldName);
|
||||
const beforeValue = asset.traits[traitKey] ?? null;
|
||||
const userId =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.id ?? null;
|
||||
const userEmail =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.email ??
|
||||
undefined;
|
||||
|
||||
// Insert pending row first so we never write to IT Glue without an audit
|
||||
// row in flight.
|
||||
const writeRow = await createPendingWrite({
|
||||
audit_id: auditId,
|
||||
asset_type: 'flexible_asset',
|
||||
asset_id: assetId,
|
||||
field_name: fieldName,
|
||||
before_value: beforeValue,
|
||||
after_value: suggestedValue,
|
||||
performed_by_user_id: userId,
|
||||
source_evidence: sourceEvidence ?? null,
|
||||
triggered_by_ticket_number: auditRow.triggered_by_ticket_number ?? null,
|
||||
});
|
||||
|
||||
// Build merged trait map. IT Glue replaces the trait set on PATCH, so we
|
||||
// must include unchanged traits.
|
||||
const merged: Record<string, unknown> = {
|
||||
...asset.traits,
|
||||
[traitKey]: suggestedValue,
|
||||
};
|
||||
|
||||
try {
|
||||
const client = getITGlueClient();
|
||||
const updated = await client.updateFlexibleAsset(assetId, merged);
|
||||
await markWriteCommitted(writeRow.id, updated);
|
||||
|
||||
// Best-effort: refresh the mirror so subsequent reads see the new value
|
||||
// without waiting for the next fullSync.
|
||||
try {
|
||||
await getITGlueSyncService().refreshFlexibleAssetById(assetId);
|
||||
} catch (refreshErr) {
|
||||
console.warn(
|
||||
`[itglue-apply] mirror refresh failed for asset ${assetId}:`,
|
||||
refreshErr instanceof Error ? refreshErr.message : refreshErr
|
||||
);
|
||||
}
|
||||
|
||||
// Generic admin-visible audit log row.
|
||||
await audit.log({
|
||||
userId: userId ?? undefined,
|
||||
userEmail,
|
||||
action: 'itglue.write',
|
||||
resource: 'flexible_asset',
|
||||
resourceId: assetId,
|
||||
details: {
|
||||
write_id: writeRow.id,
|
||||
audit_id: auditId,
|
||||
field_name: fieldName,
|
||||
trait_key: traitKey,
|
||||
before: beforeValue,
|
||||
after: suggestedValue,
|
||||
},
|
||||
});
|
||||
|
||||
// Phase 4.1: cross-reference row when this write was triggered by a
|
||||
// ticket-scoped audit. Best-effort.
|
||||
if (auditRow.triggered_by_ticket_number) {
|
||||
try {
|
||||
await insertUpdatedXref({
|
||||
ticketNumber: auditRow.triggered_by_ticket_number,
|
||||
analysisId: auditRow.triggered_by_analysis_id ?? null,
|
||||
assetType: 'flexible_asset',
|
||||
assetId,
|
||||
writeId: writeRow.id,
|
||||
fieldName,
|
||||
});
|
||||
} catch (xrefErr) {
|
||||
console.warn(
|
||||
`[itglue-apply] xref insert failed for write ${writeRow.id}:`,
|
||||
xrefErr instanceof Error ? xrefErr.message : xrefErr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
writeId: writeRow.id,
|
||||
status: 'committed',
|
||||
asset: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markWriteFailed(writeRow.id, message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
writeId: writeRow.id,
|
||||
status: 'failed',
|
||||
error: 'IT Glue write failed',
|
||||
message,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
113
app/api/analyzer/itglue/applications/[id]/audit/route.ts
Normal file
113
app/api/analyzer/itglue/applications/[id]/audit/route.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/applications/:id/audit
|
||||
* Returns the latest audit row for the asset (or null).
|
||||
*
|
||||
* POST /api/analyzer/itglue/applications/:id/audit
|
||||
* Body: { provider?: 'anthropic' | 'openrouter' }
|
||||
* Runs a fresh audit. Cost-guarded the same way single-ticket runs are.
|
||||
* Returns the new audit row.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { RunAssetAuditRequest } from '@/lib/types/analyzer';
|
||||
import { runAssetAudit } from '@/lib/services/analyzer/asset-audit/runner';
|
||||
import {
|
||||
getAssetAuditById,
|
||||
getLatestAssetAudit,
|
||||
listAssetAudits,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import {
|
||||
evaluateCost,
|
||||
recordCostAuditDecision,
|
||||
} from '@/lib/services/analyzer/cost-guard';
|
||||
|
||||
const PER_AUDIT_COST_USD: Record<'anthropic' | 'openrouter', number> = {
|
||||
anthropic: 0.1,
|
||||
openrouter: 0.01,
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
const url = new URL(request.url);
|
||||
const includeHistory = url.searchParams.get('history') === '1';
|
||||
|
||||
const latest = await getLatestAssetAudit(id);
|
||||
if (!includeHistory) {
|
||||
return NextResponse.json({ audit: latest });
|
||||
}
|
||||
const history = await listAssetAudits(id, 'flexible_asset', 20);
|
||||
return NextResponse.json({ audit: latest, history });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = RunAssetAuditRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const provider = parsed.data.provider;
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
|
||||
const evaluation = await evaluateCost({
|
||||
userId,
|
||||
estimatedCost: PER_AUDIT_COST_USD[provider],
|
||||
confirmedCost: false,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId,
|
||||
action: 'itglue_audit',
|
||||
evaluation,
|
||||
context: { assetId: id, provider },
|
||||
});
|
||||
if (evaluation.decision === 'blocked') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Daily cost limit reached',
|
||||
message: evaluation.decisionReason,
|
||||
estimatedCost: evaluation.estimatedCost,
|
||||
dailySpendBefore: evaluation.dailySpendBefore,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
// Audits are cheap enough that requires_confirmation should never trip
|
||||
// the per-request threshold — but let it fall through anyway.
|
||||
|
||||
const result = await runAssetAudit({
|
||||
assetType: 'flexible_asset',
|
||||
assetId: id,
|
||||
generatedByUserId: userId,
|
||||
provider,
|
||||
});
|
||||
|
||||
if (result.status === 'failed') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Audit failed',
|
||||
message: result.errorMessage,
|
||||
auditId: result.auditId,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const audit = await getAssetAuditById(result.auditId);
|
||||
return NextResponse.json({ audit });
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* POST /api/analyzer/itglue/applications/:id/revert/:writeId
|
||||
*
|
||||
* Reverts a previously committed write by re-applying its before_value.
|
||||
* Implementation: insert a NEW itglue_writes row with reversed before/after,
|
||||
* call IT Glue PATCH with the original before_value, then mark the original
|
||||
* row status='reverted'. Audit log captures both rows.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
createPendingWrite,
|
||||
fieldNameToTraitKey,
|
||||
getWriteById,
|
||||
markWriteCommitted,
|
||||
markWriteFailed,
|
||||
markWriteReverted,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import { getITGlueClient } from '@/lib/services/itglue-client';
|
||||
import { getITGlueSyncService } from '@/lib/services/itglue-sync-service';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
traits: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; writeId: string }> }
|
||||
) {
|
||||
const { session, error } = await requirePermission('itglue', 'write');
|
||||
if (error) return error;
|
||||
|
||||
const { id: assetId, writeId } = await params;
|
||||
const userId =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.id ?? null;
|
||||
const userEmail =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.email ??
|
||||
undefined;
|
||||
|
||||
const original = await getWriteById(writeId);
|
||||
if (!original) {
|
||||
return NextResponse.json({ error: 'Write not found' }, { status: 404 });
|
||||
}
|
||||
if (original.asset_id !== assetId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write does not reference this asset' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (original.status === 'reverted') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write already reverted' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (original.status !== 'committed') {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot revert a write in status '${original.status}'` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read current asset traits to merge cleanly.
|
||||
const assetRes = await postgresClient.query<AssetRow>(
|
||||
`SELECT id::text AS id, traits FROM itg_flexible_assets WHERE id = $1 LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
if (assetRes.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
|
||||
}
|
||||
const traits = assetRes.rows[0].traits;
|
||||
const traitKey = fieldNameToTraitKey(original.field_name);
|
||||
|
||||
// Build the revert: swap original's before/after; the new "after" value is
|
||||
// the original's "before".
|
||||
const revertRow = await createPendingWrite({
|
||||
audit_id: original.audit_id,
|
||||
asset_type: 'flexible_asset',
|
||||
asset_id: assetId,
|
||||
field_name: original.field_name,
|
||||
before_value: original.after_value,
|
||||
after_value: original.before_value,
|
||||
performed_by_user_id: userId,
|
||||
source_evidence: { reverts_write_id: original.id },
|
||||
});
|
||||
|
||||
const merged: Record<string, unknown> = {
|
||||
...traits,
|
||||
[traitKey]: original.before_value,
|
||||
};
|
||||
|
||||
try {
|
||||
const client = getITGlueClient();
|
||||
const updated = await client.updateFlexibleAsset(assetId, merged);
|
||||
|
||||
await markWriteCommitted(revertRow.id, updated);
|
||||
await markWriteReverted(original.id);
|
||||
|
||||
try {
|
||||
await getITGlueSyncService().refreshFlexibleAssetById(assetId);
|
||||
} catch (refreshErr) {
|
||||
console.warn(
|
||||
`[itglue-revert] mirror refresh failed for asset ${assetId}:`,
|
||||
refreshErr instanceof Error ? refreshErr.message : refreshErr
|
||||
);
|
||||
}
|
||||
|
||||
await audit.log({
|
||||
userId: userId ?? undefined,
|
||||
userEmail,
|
||||
action: 'itglue.revert',
|
||||
resource: 'flexible_asset',
|
||||
resourceId: assetId,
|
||||
details: {
|
||||
revert_write_id: revertRow.id,
|
||||
original_write_id: original.id,
|
||||
field_name: original.field_name,
|
||||
before: original.after_value,
|
||||
after: original.before_value,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
writeId: revertRow.id,
|
||||
revertedWriteId: original.id,
|
||||
status: 'committed',
|
||||
asset: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markWriteFailed(revertRow.id, message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
writeId: revertRow.id,
|
||||
status: 'failed',
|
||||
error: 'IT Glue revert failed',
|
||||
message,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
88
app/api/analyzer/itglue/applications/[id]/route.ts
Normal file
88
app/api/analyzer/itglue/applications/[id]/route.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/applications/:id
|
||||
*
|
||||
* Returns the asset row + its type's field schema (with hints) so the
|
||||
* detail page can render fields in IT Glue's order with empty fields shown
|
||||
* muted. Read-only.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { redact } from '@/lib/services/analyzer/itglue-redact';
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
flexible_asset_type_id: string;
|
||||
flexible_asset_type_name: string | null;
|
||||
traits: Record<string, unknown>;
|
||||
created_at: Date | null;
|
||||
updated_at: Date | null;
|
||||
}
|
||||
|
||||
interface FieldRow {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string | null;
|
||||
hint: string | null;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const assetRes = await postgresClient.query<AssetRow & { autotask_company_id: string | null }>(
|
||||
`SELECT a.id::text AS id,
|
||||
a.name,
|
||||
a.organization_id::text AS organization_id,
|
||||
a.organization_name,
|
||||
a.flexible_asset_type_id::text AS flexible_asset_type_id,
|
||||
a.flexible_asset_type_name,
|
||||
a.traits,
|
||||
a.created_at,
|
||||
a.updated_at,
|
||||
comp.id::text AS autotask_company_id
|
||||
FROM itg_flexible_assets a
|
||||
LEFT JOIN companies comp ON LOWER(comp.company_name) = LOWER(a.organization_name)
|
||||
WHERE a.id = $1
|
||||
LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
if (assetRes.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
|
||||
}
|
||||
const a = assetRes.rows[0];
|
||||
|
||||
const fieldsRes = await postgresClient.query<FieldRow>(
|
||||
`SELECT id::text AS id, name, kind, hint, required
|
||||
FROM itg_flexible_asset_fields
|
||||
WHERE flexible_asset_type_id = $1
|
||||
ORDER BY id`,
|
||||
[a.flexible_asset_type_id]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
asset: {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
organizationId: a.organization_id,
|
||||
organizationName: a.organization_name,
|
||||
flexibleAssetTypeId: a.flexible_asset_type_id,
|
||||
flexibleAssetTypeName: a.flexible_asset_type_name,
|
||||
autotaskCompanyId: a.autotask_company_id,
|
||||
traits: redact(a.traits ?? {}),
|
||||
createdAt: a.created_at?.toISOString() ?? null,
|
||||
updatedAt: a.updated_at?.toISOString() ?? null,
|
||||
},
|
||||
fields: fieldsRes.rows,
|
||||
});
|
||||
}
|
||||
22
app/api/analyzer/itglue/applications/[id]/writes/route.ts
Normal file
22
app/api/analyzer/itglue/applications/[id]/writes/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/applications/:id/writes
|
||||
*
|
||||
* Returns the write history for a single asset (for the asset detail page).
|
||||
* Auth-only — admins use the same data plus the dedicated /admin/itglue-writes
|
||||
* page for cross-asset views.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listWritesForAsset } from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { id } = await params;
|
||||
const writes = await listWritesForAsset(id, 50);
|
||||
return NextResponse.json({ writes });
|
||||
}
|
||||
21
app/api/analyzer/itglue/applications/[id]/xrefs/route.ts
Normal file
21
app/api/analyzer/itglue/applications/[id]/xrefs/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/applications/:id/xrefs
|
||||
*
|
||||
* Returns the xref rows for this flexible asset — "tickets that referenced
|
||||
* me" + "tickets that updated me" — for the asset detail page.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listXrefsForAsset } from '@/lib/services/analyzer/asset-audit/xrefs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { id } = await params;
|
||||
const xrefs = await listXrefsForAsset('flexible_asset', id, 100);
|
||||
return NextResponse.json({ xrefs });
|
||||
}
|
||||
76
app/api/analyzer/itglue/applications/route.ts
Normal file
76
app/api/analyzer/itglue/applications/route.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/applications
|
||||
*
|
||||
* Returns all Application flexible-asset records, joined to their latest
|
||||
* audit (if any). Powers /analyzer/itglue/applications listing page.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const APPLICATION_TYPE_ID = 3790;
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string | null;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
trait_count: number;
|
||||
latest_audit_id: string | null;
|
||||
latest_audit_at: Date | null;
|
||||
latest_audit_score: number | null;
|
||||
latest_audit_provider: 'anthropic' | 'openrouter' | null;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const res = await postgresClient.query<Row>(
|
||||
`SELECT a.id::text AS id,
|
||||
a.name,
|
||||
a.organization_id::text AS organization_id,
|
||||
a.organization_name,
|
||||
(SELECT COUNT(*)::int
|
||||
FROM jsonb_object_keys(a.traits) k) AS trait_count,
|
||||
la.id::text AS latest_audit_id,
|
||||
la.generated_at AS latest_audit_at,
|
||||
la.overall_score::float8 AS latest_audit_score,
|
||||
la.provider AS latest_audit_provider
|
||||
FROM itg_flexible_assets a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, generated_at, overall_score, provider
|
||||
FROM itglue_asset_audits
|
||||
WHERE asset_type = 'flexible_asset'
|
||||
AND asset_id = a.id
|
||||
AND status = 'complete'
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1
|
||||
) la ON true
|
||||
WHERE a.flexible_asset_type_id = $1
|
||||
AND COALESCE(a.archived, false) = false
|
||||
ORDER BY
|
||||
la.overall_score ASC NULLS FIRST,
|
||||
a.organization_name,
|
||||
a.name`,
|
||||
[APPLICATION_TYPE_ID]
|
||||
);
|
||||
|
||||
const applications = res.rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
organizationId: r.organization_id,
|
||||
organizationName: r.organization_name,
|
||||
traitCount: r.trait_count,
|
||||
latestAudit: r.latest_audit_id
|
||||
? {
|
||||
id: r.latest_audit_id,
|
||||
generatedAt: r.latest_audit_at?.toISOString() ?? null,
|
||||
overallScore: r.latest_audit_score,
|
||||
provider: r.latest_audit_provider,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
return NextResponse.json({ applications });
|
||||
}
|
||||
215
app/api/analyzer/itglue/configurations/[id]/apply/route.ts
Normal file
215
app/api/analyzer/itglue/configurations/[id]/apply/route.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* POST /api/analyzer/itglue/configurations/:id/apply
|
||||
*
|
||||
* Same shape as the Applications apply route but PATCHes /configurations/:id
|
||||
* with flat attributes (no traits blob). All audit-trail layers identical.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { ApplyAssetSuggestionRequest } from '@/lib/types/analyzer';
|
||||
import {
|
||||
createPendingWrite,
|
||||
getAssetAuditById,
|
||||
markWriteCommitted,
|
||||
markWriteFailed,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import { insertUpdatedXref } from '@/lib/services/analyzer/asset-audit/xrefs';
|
||||
import { getITGlueClient } from '@/lib/services/itglue-client';
|
||||
import { getITGlueSyncService } from '@/lib/services/itglue-sync-service';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
|
||||
interface ConfigRow {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
primary_ip: string | null;
|
||||
mac_address: string | null;
|
||||
serial_number: string | null;
|
||||
asset_tag: string | null;
|
||||
position: string | null;
|
||||
notes: string | null;
|
||||
operating_system_notes: string | null;
|
||||
}
|
||||
|
||||
const CONFIG_EDITABLE_COLUMNS = [
|
||||
'name',
|
||||
'hostname',
|
||||
'primary_ip',
|
||||
'mac_address',
|
||||
'serial_number',
|
||||
'asset_tag',
|
||||
'position',
|
||||
'notes',
|
||||
'operating_system_notes',
|
||||
] as const;
|
||||
|
||||
const FIELD_TO_ITGLUE_ATTR: Record<string, string> = {
|
||||
name: 'name',
|
||||
hostname: 'hostname',
|
||||
primary_ip: 'primary-ip',
|
||||
mac_address: 'mac-address',
|
||||
serial_number: 'serial-number',
|
||||
asset_tag: 'asset-tag',
|
||||
position: 'position',
|
||||
notes: 'notes',
|
||||
operating_system_notes: 'operating-system-notes',
|
||||
};
|
||||
|
||||
async function loadConfigurationRow(assetId: string): Promise<ConfigRow | null> {
|
||||
const res = await postgresClient.query<ConfigRow>(
|
||||
`SELECT id::text AS id,
|
||||
name, hostname, primary_ip, mac_address, serial_number,
|
||||
asset_tag, position, notes, operating_system_notes
|
||||
FROM itg_configurations
|
||||
WHERE id = $1
|
||||
LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
return res.rowCount === 0 ? null : res.rows[0];
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requirePermission('itglue', 'write');
|
||||
if (error) return error;
|
||||
|
||||
const { id: assetId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = ApplyAssetSuggestionRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { auditId, fieldName, suggestedValue, sourceEvidence } = parsed.data;
|
||||
|
||||
const auditRow = await getAssetAuditById(auditId);
|
||||
if (!auditRow) {
|
||||
return NextResponse.json({ error: 'Audit not found' }, { status: 404 });
|
||||
}
|
||||
if (auditRow.asset_id !== assetId || auditRow.asset_type !== 'configuration') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audit does not reference this configuration' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const lowerField = fieldName.toLowerCase();
|
||||
if (/(password|secret|key|token|credential)/.test(lowerField)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Refusing to write to credential-shaped field' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Refuse to write fields the audit pipeline shouldn't be touching on a
|
||||
// Configuration. We only let it edit the columns flagged editable.
|
||||
if (!CONFIG_EDITABLE_COLUMNS.includes(fieldName as (typeof CONFIG_EDITABLE_COLUMNS)[number])) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Field '${fieldName}' is not editable via the audit pipeline`,
|
||||
editableFields: CONFIG_EDITABLE_COLUMNS,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await loadConfigurationRow(assetId);
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: 'Configuration not found' }, { status: 404 });
|
||||
}
|
||||
const beforeValue =
|
||||
(config as unknown as Record<string, unknown>)[fieldName] ?? null;
|
||||
const userId =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.id ?? null;
|
||||
const userEmail =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.email ??
|
||||
undefined;
|
||||
|
||||
const writeRow = await createPendingWrite({
|
||||
audit_id: auditId,
|
||||
asset_type: 'configuration',
|
||||
asset_id: assetId,
|
||||
field_name: fieldName,
|
||||
before_value: beforeValue,
|
||||
after_value: suggestedValue,
|
||||
performed_by_user_id: userId,
|
||||
source_evidence: sourceEvidence ?? null,
|
||||
triggered_by_ticket_number: auditRow.triggered_by_ticket_number ?? null,
|
||||
});
|
||||
|
||||
// IT Glue PATCH attribute — convert snake to dash form.
|
||||
const attrKey = FIELD_TO_ITGLUE_ATTR[fieldName];
|
||||
const attributes = { [attrKey]: suggestedValue };
|
||||
|
||||
try {
|
||||
const client = getITGlueClient();
|
||||
const updated = await client.updateConfiguration(assetId, attributes);
|
||||
await markWriteCommitted(writeRow.id, updated);
|
||||
|
||||
try {
|
||||
await getITGlueSyncService().refreshConfigurationById(assetId);
|
||||
} catch (refreshErr) {
|
||||
console.warn(
|
||||
`[itglue-config-apply] mirror refresh failed for ${assetId}:`,
|
||||
refreshErr instanceof Error ? refreshErr.message : refreshErr
|
||||
);
|
||||
}
|
||||
|
||||
await audit.log({
|
||||
userId: userId ?? undefined,
|
||||
userEmail,
|
||||
action: 'itglue.write',
|
||||
resource: 'configuration',
|
||||
resourceId: assetId,
|
||||
details: {
|
||||
write_id: writeRow.id,
|
||||
audit_id: auditId,
|
||||
field_name: fieldName,
|
||||
before: beforeValue,
|
||||
after: suggestedValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (auditRow.triggered_by_ticket_number) {
|
||||
try {
|
||||
await insertUpdatedXref({
|
||||
ticketNumber: auditRow.triggered_by_ticket_number,
|
||||
analysisId: auditRow.triggered_by_analysis_id ?? null,
|
||||
assetType: 'configuration',
|
||||
assetId,
|
||||
writeId: writeRow.id,
|
||||
fieldName,
|
||||
});
|
||||
} catch (xrefErr) {
|
||||
console.warn(
|
||||
`[itglue-config-apply] xref insert failed for write ${writeRow.id}:`,
|
||||
xrefErr instanceof Error ? xrefErr.message : xrefErr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
writeId: writeRow.id,
|
||||
status: 'committed',
|
||||
asset: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markWriteFailed(writeRow.id, message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
writeId: writeRow.id,
|
||||
status: 'failed',
|
||||
error: 'IT Glue write failed',
|
||||
message,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
104
app/api/analyzer/itglue/configurations/[id]/audit/route.ts
Normal file
104
app/api/analyzer/itglue/configurations/[id]/audit/route.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/configurations/:id/audit
|
||||
* POST /api/analyzer/itglue/configurations/:id/audit
|
||||
*
|
||||
* Mirrors the Applications endpoint but for Configurations.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { RunAssetAuditRequest } from '@/lib/types/analyzer';
|
||||
import { runAssetAudit } from '@/lib/services/analyzer/asset-audit/runner';
|
||||
import {
|
||||
getAssetAuditById,
|
||||
getLatestAssetAudit,
|
||||
listAssetAudits,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import {
|
||||
evaluateCost,
|
||||
recordCostAuditDecision,
|
||||
} from '@/lib/services/analyzer/cost-guard';
|
||||
|
||||
const PER_AUDIT_COST_USD: Record<'anthropic' | 'openrouter', number> = {
|
||||
anthropic: 0.1,
|
||||
openrouter: 0.01,
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
const url = new URL(request.url);
|
||||
const includeHistory = url.searchParams.get('history') === '1';
|
||||
|
||||
const latest = await getLatestAssetAudit(id, 'configuration');
|
||||
if (!includeHistory) return NextResponse.json({ audit: latest });
|
||||
const history = await listAssetAudits(id, 'configuration', 20);
|
||||
return NextResponse.json({ audit: latest, history });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = RunAssetAuditRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const provider = parsed.data.provider;
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
|
||||
const evaluation = await evaluateCost({
|
||||
userId,
|
||||
estimatedCost: PER_AUDIT_COST_USD[provider],
|
||||
confirmedCost: false,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId,
|
||||
action: 'itglue_audit',
|
||||
evaluation,
|
||||
context: { assetId: id, assetType: 'configuration', provider },
|
||||
});
|
||||
if (evaluation.decision === 'blocked') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Daily cost limit reached',
|
||||
message: evaluation.decisionReason,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await runAssetAudit({
|
||||
assetType: 'configuration',
|
||||
assetId: id,
|
||||
generatedByUserId: userId,
|
||||
provider,
|
||||
});
|
||||
|
||||
if (result.status === 'failed') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Audit failed',
|
||||
message: result.errorMessage,
|
||||
auditId: result.auditId,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const audit = await getAssetAuditById(result.auditId);
|
||||
return NextResponse.json({ audit });
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* POST /api/analyzer/itglue/configurations/:id/revert/:writeId
|
||||
*
|
||||
* Mirrors the Applications revert path but PATCHes /configurations/:id.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
createPendingWrite,
|
||||
getWriteById,
|
||||
markWriteCommitted,
|
||||
markWriteFailed,
|
||||
markWriteReverted,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
import { getITGlueClient } from '@/lib/services/itglue-client';
|
||||
import { getITGlueSyncService } from '@/lib/services/itglue-sync-service';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
|
||||
const FIELD_TO_ITGLUE_ATTR: Record<string, string> = {
|
||||
name: 'name',
|
||||
hostname: 'hostname',
|
||||
primary_ip: 'primary-ip',
|
||||
mac_address: 'mac-address',
|
||||
serial_number: 'serial-number',
|
||||
asset_tag: 'asset-tag',
|
||||
position: 'position',
|
||||
notes: 'notes',
|
||||
operating_system_notes: 'operating-system-notes',
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; writeId: string }> }
|
||||
) {
|
||||
const { session, error } = await requirePermission('itglue', 'write');
|
||||
if (error) return error;
|
||||
|
||||
const { id: assetId, writeId } = await params;
|
||||
const userId =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.id ?? null;
|
||||
const userEmail =
|
||||
(session?.user as { id: string; email?: string } | undefined)?.email ??
|
||||
undefined;
|
||||
|
||||
const original = await getWriteById(writeId);
|
||||
if (!original) return NextResponse.json({ error: 'Write not found' }, { status: 404 });
|
||||
if (original.asset_id !== assetId || original.asset_type !== 'configuration') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write does not reference this configuration' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (original.status === 'reverted') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write already reverted' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (original.status !== 'committed') {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot revert a write in status '${original.status}'` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const exists = await postgresClient.query(
|
||||
`SELECT 1 FROM itg_configurations WHERE id = $1 LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
if (exists.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Configuration not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const attrKey = FIELD_TO_ITGLUE_ATTR[original.field_name];
|
||||
if (!attrKey) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot map field '${original.field_name}' to IT Glue attribute` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const revertRow = await createPendingWrite({
|
||||
audit_id: original.audit_id,
|
||||
asset_type: 'configuration',
|
||||
asset_id: assetId,
|
||||
field_name: original.field_name,
|
||||
before_value: original.after_value,
|
||||
after_value: original.before_value,
|
||||
performed_by_user_id: userId,
|
||||
source_evidence: { reverts_write_id: original.id },
|
||||
});
|
||||
|
||||
try {
|
||||
const client = getITGlueClient();
|
||||
const updated = await client.updateConfiguration(assetId, {
|
||||
[attrKey]: original.before_value,
|
||||
});
|
||||
|
||||
await markWriteCommitted(revertRow.id, updated);
|
||||
await markWriteReverted(original.id);
|
||||
|
||||
try {
|
||||
await getITGlueSyncService().refreshConfigurationById(assetId);
|
||||
} catch (refreshErr) {
|
||||
console.warn(
|
||||
`[itglue-config-revert] mirror refresh failed for ${assetId}:`,
|
||||
refreshErr instanceof Error ? refreshErr.message : refreshErr
|
||||
);
|
||||
}
|
||||
|
||||
await audit.log({
|
||||
userId: userId ?? undefined,
|
||||
userEmail,
|
||||
action: 'itglue.revert',
|
||||
resource: 'configuration',
|
||||
resourceId: assetId,
|
||||
details: {
|
||||
revert_write_id: revertRow.id,
|
||||
original_write_id: original.id,
|
||||
field_name: original.field_name,
|
||||
before: original.after_value,
|
||||
after: original.before_value,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
writeId: revertRow.id,
|
||||
revertedWriteId: original.id,
|
||||
status: 'committed',
|
||||
asset: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markWriteFailed(revertRow.id, message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
writeId: revertRow.id,
|
||||
status: 'failed',
|
||||
error: 'IT Glue revert failed',
|
||||
message,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
135
app/api/analyzer/itglue/configurations/[id]/route.ts
Normal file
135
app/api/analyzer/itglue/configurations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/configurations/:id
|
||||
*
|
||||
* Returns one Configuration row + the curated field schema (with hints) so
|
||||
* the detail page renders fields in a stable order.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { _ASSET_AUDIT_INTERNALS } from '@/lib/services/analyzer/asset-audit/data-builder';
|
||||
|
||||
interface ConfigRow {
|
||||
id: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
configuration_type_id: string | null;
|
||||
configuration_type_name: string | null;
|
||||
configuration_status_id: string | null;
|
||||
configuration_status_name: string | null;
|
||||
manufacturer_name: string | null;
|
||||
model_name: string | null;
|
||||
operating_system_name: string | null;
|
||||
contact_id: string | null;
|
||||
location_id: string | null;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
primary_ip: string | null;
|
||||
mac_address: string | null;
|
||||
serial_number: string | null;
|
||||
asset_tag: string | null;
|
||||
position: string | null;
|
||||
notes: string | null;
|
||||
operating_system_notes: string | null;
|
||||
created_at: Date | null;
|
||||
updated_at: Date | null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const res = await postgresClient.query<ConfigRow & { rmm_id: string | null; autotask_company_id: string | null }>(
|
||||
`SELECT c.id::text AS id,
|
||||
c.organization_id::text AS organization_id,
|
||||
c.organization_name,
|
||||
c.configuration_type_id::text AS configuration_type_id,
|
||||
c.configuration_type_name,
|
||||
c.configuration_status_id::text AS configuration_status_id,
|
||||
c.configuration_status_name,
|
||||
c.manufacturer_name, c.model_name,
|
||||
c.operating_system_name,
|
||||
c.contact_id::text AS contact_id,
|
||||
c.location_id::text AS location_id,
|
||||
c.name, c.hostname, c.primary_ip, c.mac_address, c.serial_number, c.asset_tag,
|
||||
c.position, c.notes, c.operating_system_notes,
|
||||
c.created_at, c.updated_at,
|
||||
c.rmm_id,
|
||||
comp.id::text AS autotask_company_id
|
||||
FROM itg_configurations c
|
||||
LEFT JOIN companies comp ON LOWER(comp.company_name) = LOWER(c.organization_name)
|
||||
WHERE c.id = $1
|
||||
LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Configuration not found' }, { status: 404 });
|
||||
}
|
||||
const c = res.rows[0];
|
||||
|
||||
// If we have an rmm_id, look up the Datto device uid for the picker.
|
||||
let dattoDeviceUid: string | null = null;
|
||||
if (c.rmm_id) {
|
||||
const drmm = await postgresClient.query<{ uid: string }>(
|
||||
`SELECT uid FROM datto_rmm_devices WHERE id::text = $1 OR uid = $1 LIMIT 1`,
|
||||
[c.rmm_id]
|
||||
);
|
||||
dattoDeviceUid = drmm.rows[0]?.uid ?? null;
|
||||
}
|
||||
// Fallback: hostname-based device lookup.
|
||||
if (!dattoDeviceUid && c.hostname) {
|
||||
const drmm = await postgresClient.query<{ uid: string }>(
|
||||
`SELECT uid FROM datto_rmm_devices WHERE LOWER(hostname) = LOWER($1) LIMIT 1`,
|
||||
[c.hostname]
|
||||
);
|
||||
dattoDeviceUid = drmm.rows[0]?.uid ?? null;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
asset: {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
hostname: c.hostname,
|
||||
organizationId: c.organization_id,
|
||||
organizationName: c.organization_name,
|
||||
typeId: c.configuration_type_id,
|
||||
typeName: c.configuration_type_name,
|
||||
statusName: c.configuration_status_name,
|
||||
manufacturerName: c.manufacturer_name,
|
||||
modelName: c.model_name,
|
||||
operatingSystemName: c.operating_system_name,
|
||||
contactId: c.contact_id,
|
||||
locationId: c.location_id,
|
||||
dattoDeviceUid,
|
||||
autotaskCompanyId: c.autotask_company_id,
|
||||
// Synthesized "fields" map keyed by the audit field names.
|
||||
traits: {
|
||||
name: c.name,
|
||||
hostname: c.hostname,
|
||||
primary_ip: c.primary_ip,
|
||||
mac_address: c.mac_address,
|
||||
serial_number: c.serial_number,
|
||||
asset_tag: c.asset_tag,
|
||||
position: c.position,
|
||||
configuration_type_name: c.configuration_type_name,
|
||||
configuration_status_name: c.configuration_status_name,
|
||||
manufacturer_name: c.manufacturer_name,
|
||||
model_name: c.model_name,
|
||||
operating_system_name: c.operating_system_name,
|
||||
operating_system_notes: c.operating_system_notes,
|
||||
notes: c.notes,
|
||||
contact_id: c.contact_id,
|
||||
location_id: c.location_id,
|
||||
},
|
||||
createdAt: c.created_at?.toISOString() ?? null,
|
||||
updatedAt: c.updated_at?.toISOString() ?? null,
|
||||
},
|
||||
fields: _ASSET_AUDIT_INTERNALS.CONFIGURATION_FIELDS,
|
||||
});
|
||||
}
|
||||
24
app/api/analyzer/itglue/configurations/[id]/writes/route.ts
Normal file
24
app/api/analyzer/itglue/configurations/[id]/writes/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/configurations/:id/writes
|
||||
*
|
||||
* Per-configuration write history.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listWritesForAsset } from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { id } = await params;
|
||||
const writes = await listWritesForAsset(id, 50);
|
||||
// Filter to configuration writes — listWritesForAsset doesn't filter by
|
||||
// asset_type, but a given asset_id only ever exists under one type so this
|
||||
// is functionally equivalent. Defensive filter:
|
||||
const filtered = writes.filter((w) => w.asset_type === 'configuration');
|
||||
return NextResponse.json({ writes: filtered });
|
||||
}
|
||||
18
app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts
Normal file
18
app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/configurations/:id/xrefs
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listXrefsForAsset } from '@/lib/services/analyzer/asset-audit/xrefs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { id } = await params;
|
||||
const xrefs = await listXrefsForAsset('configuration', id, 100);
|
||||
return NextResponse.json({ xrefs });
|
||||
}
|
||||
76
app/api/analyzer/itglue/configurations/route.ts
Normal file
76
app/api/analyzer/itglue/configurations/route.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/configurations
|
||||
*
|
||||
* Returns Configuration records joined to their latest audit. Powers the
|
||||
* /analyzer/itglue/configurations listing page.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
configuration_type_name: string | null;
|
||||
configuration_status_name: string | null;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
latest_audit_id: string | null;
|
||||
latest_audit_at: Date | null;
|
||||
latest_audit_score: number | null;
|
||||
latest_audit_provider: 'anthropic' | 'openrouter' | null;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const res = await postgresClient.query<Row>(
|
||||
`SELECT c.id::text AS id,
|
||||
c.name,
|
||||
c.hostname,
|
||||
c.configuration_type_name,
|
||||
c.configuration_status_name,
|
||||
c.organization_id::text AS organization_id,
|
||||
c.organization_name,
|
||||
la.id::text AS latest_audit_id,
|
||||
la.generated_at AS latest_audit_at,
|
||||
la.overall_score::float8 AS latest_audit_score,
|
||||
la.provider AS latest_audit_provider
|
||||
FROM itg_configurations c
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, generated_at, overall_score, provider
|
||||
FROM itglue_asset_audits
|
||||
WHERE asset_type = 'configuration'
|
||||
AND asset_id = c.id
|
||||
AND status = 'complete'
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1
|
||||
) la ON true
|
||||
ORDER BY
|
||||
la.overall_score ASC NULLS FIRST,
|
||||
c.organization_name,
|
||||
c.name`
|
||||
);
|
||||
|
||||
const configurations = res.rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
hostname: r.hostname,
|
||||
typeName: r.configuration_type_name,
|
||||
statusName: r.configuration_status_name,
|
||||
organizationId: r.organization_id,
|
||||
organizationName: r.organization_name,
|
||||
latestAudit: r.latest_audit_id
|
||||
? {
|
||||
id: r.latest_audit_id,
|
||||
generatedAt: r.latest_audit_at?.toISOString() ?? null,
|
||||
overallScore: r.latest_audit_score,
|
||||
provider: r.latest_audit_provider,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
return NextResponse.json({ configurations });
|
||||
}
|
||||
67
app/api/analyzer/itglue/sites/[companyId]/route.ts
Normal file
67
app/api/analyzer/itglue/sites/[companyId]/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/sites/:companyId
|
||||
*
|
||||
* Returns the site-discovery summary for a client: company identity,
|
||||
* IT Glue org name, Datto site mapping, and the resolved Wulf Nurse
|
||||
* Production endpoint (hostname + uid + online state).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
listSiteAnchorTargets,
|
||||
resolveSiteAnchorTarget,
|
||||
} from '@/lib/services/rmm/target-resolver';
|
||||
|
||||
interface CompanyRow {
|
||||
id: string;
|
||||
company_name: string | null;
|
||||
itglue_org_id: string | null;
|
||||
itglue_org_name: string | null;
|
||||
datto_site_id: number | null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ companyId: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { companyId } = await params;
|
||||
const res = await postgresClient.query<CompanyRow>(
|
||||
`SELECT c.id::text AS id,
|
||||
c.company_name,
|
||||
o.id::text AS itglue_org_id,
|
||||
o.name AS itglue_org_name,
|
||||
ds.id AS datto_site_id
|
||||
FROM companies c
|
||||
LEFT JOIN itg_organizations o ON LOWER(o.name) = LOWER(c.company_name)
|
||||
LEFT JOIN datto_rmm_sites ds ON ds.autotask_company_id = c.id
|
||||
WHERE c.id = $1
|
||||
LIMIT 1`,
|
||||
[companyId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
return NextResponse.json({ error: 'Company not found' }, { status: 404 });
|
||||
}
|
||||
const c = res.rows[0];
|
||||
const target = await resolveSiteAnchorTarget(companyId);
|
||||
const allTargets = await listSiteAnchorTargets(companyId);
|
||||
return NextResponse.json({
|
||||
site: {
|
||||
companyId: c.id,
|
||||
companyName: c.company_name,
|
||||
itglueOrgId: c.itglue_org_id,
|
||||
itglueOrgName: c.itglue_org_name,
|
||||
dattoSiteId: c.datto_site_id,
|
||||
// The chosen primary target — what the executor picks by default.
|
||||
wnpHostname: target?.hostname ?? null,
|
||||
wnpDeviceUid: target?.device_uid ?? null,
|
||||
wnpOnline: target?.online ?? null,
|
||||
// Every available WNP across all sites for the client.
|
||||
sites: allTargets,
|
||||
},
|
||||
});
|
||||
}
|
||||
39
app/api/analyzer/itglue/writes/route.ts
Normal file
39
app/api/analyzer/itglue/writes/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* GET /api/analyzer/itglue/writes
|
||||
*
|
||||
* Cross-asset write history — admin only. Powers /admin/itglue-writes.
|
||||
*
|
||||
* Query params: ?status=...&limit=...&offset=...
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import {
|
||||
listAllWrites,
|
||||
type WriteStatus,
|
||||
} from '@/lib/services/analyzer/asset-audit/persistence';
|
||||
|
||||
const ALLOWED_STATUSES: ReadonlyArray<WriteStatus> = [
|
||||
'pending',
|
||||
'committed',
|
||||
'failed',
|
||||
'reverted',
|
||||
];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get('limit') ?? 100);
|
||||
const offset = Number(url.searchParams.get('offset') ?? 0);
|
||||
const statusParam = url.searchParams.get('status');
|
||||
const status = (
|
||||
statusParam && ALLOWED_STATUSES.includes(statusParam as WriteStatus)
|
||||
? (statusParam as WriteStatus)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const writes = await listAllWrites({ limit, offset, status });
|
||||
return NextResponse.json({ writes });
|
||||
}
|
||||
105
app/api/analyzer/share/recipients/route.ts
Normal file
105
app/api/analyzer/share/recipients/route.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* GET /api/analyzer/share/recipients
|
||||
*
|
||||
* Returns suggestions for the share modal:
|
||||
* - recent: the last few unique recipient emails the calling user has
|
||||
* shared with (from analyzer_shares).
|
||||
* - directory: the AD/Microsoft Graph user directory, filtered to
|
||||
* ALLOWED_SHARE_DOMAINS and active accounts only.
|
||||
*
|
||||
* The share endpoint itself still validates the domain server-side, so
|
||||
* the directory list is a UX nicety, not a security boundary.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const RECENT_LIMIT = 5;
|
||||
|
||||
function getAllowedDomains(): string[] {
|
||||
const raw = process.env.ALLOWED_SHARE_DOMAINS ?? '';
|
||||
return raw
|
||||
.split(',')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter((d) => d.length > 0);
|
||||
}
|
||||
|
||||
interface RecentRow {
|
||||
shared_with_email: string;
|
||||
last_shared_at: Date | string;
|
||||
}
|
||||
|
||||
interface DirectoryRow {
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
job_title: string | null;
|
||||
department: string | null;
|
||||
}
|
||||
|
||||
function toIso(d: Date | string): string {
|
||||
return d instanceof Date ? d.toISOString() : new Date(d).toISOString();
|
||||
}
|
||||
|
||||
function emailDomain(email: string): string {
|
||||
return email.split('@')[1]?.toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
const allowedDomains = getAllowedDomains();
|
||||
|
||||
if (allowedDomains.length === 0) {
|
||||
return NextResponse.json({
|
||||
recent: [],
|
||||
directory: [],
|
||||
allowedDomains: [],
|
||||
});
|
||||
}
|
||||
|
||||
const recentRows = userId
|
||||
? (
|
||||
await postgresClient.query<RecentRow>(
|
||||
`SELECT shared_with_email,
|
||||
MAX(shared_at) AS last_shared_at
|
||||
FROM analyzer_shares
|
||||
WHERE shared_by_user_id = $1
|
||||
GROUP BY shared_with_email
|
||||
ORDER BY MAX(shared_at) DESC
|
||||
LIMIT $2`,
|
||||
[userId, RECENT_LIMIT]
|
||||
)
|
||||
).rows
|
||||
: [];
|
||||
|
||||
const directoryRes = await postgresClient.query<DirectoryRow>(
|
||||
`SELECT email, display_name, job_title, department
|
||||
FROM graph_users
|
||||
WHERE account_enabled = true
|
||||
AND email IS NOT NULL
|
||||
AND lower(split_part(email, '@', 2)) = ANY($1::text[])
|
||||
ORDER BY COALESCE(display_name, email)`,
|
||||
[allowedDomains]
|
||||
);
|
||||
|
||||
// Drop recents whose domain is no longer allowed (defensive — share endpoint
|
||||
// would reject them anyway, but the picker shouldn't dangle).
|
||||
const recent = recentRows
|
||||
.filter((r) => allowedDomains.includes(emailDomain(r.shared_with_email)))
|
||||
.map((r) => ({
|
||||
email: r.shared_with_email,
|
||||
lastSharedAt: toIso(r.last_shared_at),
|
||||
}));
|
||||
|
||||
const directory = directoryRes.rows.map((d) => ({
|
||||
email: d.email,
|
||||
displayName: d.display_name,
|
||||
jobTitle: d.job_title,
|
||||
department: d.department,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ recent, directory, allowedDomains });
|
||||
}
|
||||
257
app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts
Normal file
257
app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* POST /api/analyzer/tickets/:ticketNumber/analyze-bundle
|
||||
*
|
||||
* Body: { linkedTicketNumbers: string[], includeItglueContext?, reportTitle?, confirmedCost? }
|
||||
*
|
||||
* Behavior:
|
||||
* 1. Validate the master + each linked ticket exists in the local mirror.
|
||||
* 2. For each ticket: idempotency-check via content hash; if a fresh
|
||||
* analysis exists, collect its id; otherwise queue an analyzer job.
|
||||
* 3. Cost-guard the *new* work only (already-complete analyses don't add
|
||||
* cost).
|
||||
* 4. Create an analyzer_aggregate_reports row populated with
|
||||
* expected_ticket_numbers + triggered_by_ticket_number. If everything
|
||||
* was already complete, transition straight to 'pending' and fire
|
||||
* runAggregateReport. Otherwise the row sits in 'pending_analyses'
|
||||
* until the worker chain-trigger flips it once all jobs land.
|
||||
*
|
||||
* Response:
|
||||
* { aggregateReportId, ticketCount, queuedJobIds, alreadyCompleteAnalysisIds, status }
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
loadTicketBundle,
|
||||
TicketNotFoundError,
|
||||
} from '@/lib/services/analyzer/data-access';
|
||||
import { preprocessTicket } from '@/lib/services/analyzer/preprocessor';
|
||||
import {
|
||||
findExistingAnalysisByContentHash,
|
||||
queueJob,
|
||||
} from '@/lib/services/analyzer/persistence';
|
||||
import {
|
||||
createAggregateReport,
|
||||
runAggregateReport,
|
||||
} from '@/lib/services/analyzer/aggregate-persistence';
|
||||
import {
|
||||
estimateAggregateReportCost,
|
||||
evaluateCost,
|
||||
recordCostAuditDecision,
|
||||
} from '@/lib/services/analyzer/cost-guard';
|
||||
import { BundleAnalyzeRequest } from '@/lib/types/analyzer';
|
||||
// Side-effect import: ensure the worker self-starts so queued jobs run.
|
||||
import '@/lib/services/analyzer/worker';
|
||||
|
||||
const MAX_BUNDLE_SIZE = 25;
|
||||
|
||||
/**
|
||||
* Pessimistic per-ticket cost. Anthropic path runs Sonnet (+ optional Opus);
|
||||
* OpenRouter path runs DeepSeek V4 Pro (+ optional R1) at ~7-10× lower rate.
|
||||
*/
|
||||
const PER_TICKET_COST_USD: Record<'anthropic' | 'openrouter', number> = {
|
||||
anthropic: 0.15,
|
||||
openrouter: 0.02,
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ ticketNumber: string }> }
|
||||
) {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { ticketNumber: masterTicketNumber } = await params;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = BundleAnalyzeRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const {
|
||||
linkedTicketNumbers,
|
||||
includeItglueContext,
|
||||
reportTitle,
|
||||
confirmedCost,
|
||||
provider,
|
||||
} = parsed.data;
|
||||
|
||||
// Build the unique ordered set: master first, then linked (deduped, master removed).
|
||||
const linkedSet = new Set(linkedTicketNumbers);
|
||||
linkedSet.delete(masterTicketNumber);
|
||||
const allTicketNumbers = [masterTicketNumber, ...Array.from(linkedSet)];
|
||||
|
||||
if (allTicketNumbers.length < 2) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Bundle requires at least one linked ticket besides the master. Use /analyze for single-ticket runs.',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (allTicketNumbers.length > MAX_BUNDLE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Bundle size ${allTicketNumbers.length} exceeds cap ${MAX_BUNDLE_SIZE}.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify every ticket exists locally in one query.
|
||||
const lookup = await postgresClient.query<{ ticket_number: string }>(
|
||||
`SELECT ticket_number FROM tickets
|
||||
WHERE ticket_number = ANY($1::text[])
|
||||
AND COALESCE(is_deleted, false) = false`,
|
||||
[allTicketNumbers]
|
||||
);
|
||||
const present = new Set(lookup.rows.map((r) => r.ticket_number));
|
||||
const missing = allTicketNumbers.filter((n) => !present.has(n));
|
||||
if (missing.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Some tickets not found in local mirror', missing },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
|
||||
// Per-ticket idempotency check + queue plan.
|
||||
const queuedJobIds: string[] = [];
|
||||
const alreadyCompleteAnalysisIds: string[] = [];
|
||||
const ticketsNeedingAnalysis: string[] = [];
|
||||
|
||||
for (const tn of allTicketNumbers) {
|
||||
let bundle;
|
||||
try {
|
||||
bundle = await loadTicketBundle(tn);
|
||||
} catch (err) {
|
||||
if (err instanceof TicketNotFoundError) {
|
||||
// Shouldn't happen — we just verified existence — but be defensive.
|
||||
return NextResponse.json(
|
||||
{ error: `Ticket ${tn} disappeared between checks` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
console.error(`[analyze-bundle] data-access error for ${tn}:`, err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load ticket', message: tn },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const pre = preprocessTicket(bundle);
|
||||
const existing = await findExistingAnalysisByContentHash(
|
||||
tn,
|
||||
pre.content_hash,
|
||||
provider
|
||||
);
|
||||
if (existing) {
|
||||
alreadyCompleteAnalysisIds.push(existing.id);
|
||||
} else {
|
||||
ticketsNeedingAnalysis.push(tn);
|
||||
}
|
||||
}
|
||||
|
||||
// Cost-guard: only the new per-ticket work + the aggregate-reduce step.
|
||||
const aggregateCost = estimateAggregateReportCost({
|
||||
ticketCount: allTicketNumbers.length,
|
||||
includeItglueContext,
|
||||
});
|
||||
const newPerTicketCost =
|
||||
ticketsNeedingAnalysis.length * PER_TICKET_COST_USD[provider];
|
||||
const estimatedCost =
|
||||
Math.round((newPerTicketCost + aggregateCost) * 10_000) / 10_000;
|
||||
|
||||
const evaluation = await evaluateCost({
|
||||
userId,
|
||||
estimatedCost,
|
||||
confirmedCost,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId,
|
||||
action: 'analyze_bundle',
|
||||
evaluation,
|
||||
context: {
|
||||
masterTicketNumber,
|
||||
ticketCount: allTicketNumbers.length,
|
||||
newPerTicketCount: ticketsNeedingAnalysis.length,
|
||||
includeItglueContext,
|
||||
},
|
||||
});
|
||||
if (evaluation.decision === 'blocked') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Daily cost limit reached',
|
||||
message: evaluation.decisionReason,
|
||||
estimatedCost: evaluation.estimatedCost,
|
||||
dailySpendBefore: evaluation.dailySpendBefore,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
if (evaluation.decision === 'requires_confirmation') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Confirmation required',
|
||||
message: evaluation.decisionReason,
|
||||
estimatedCost: evaluation.estimatedCost,
|
||||
dailySpendBefore: evaluation.dailySpendBefore,
|
||||
requiresConfirmation: true,
|
||||
retryWith: { confirmedCost: true },
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Queue jobs for the missing tickets.
|
||||
for (const tn of ticketsNeedingAnalysis) {
|
||||
const job = await queueJob({
|
||||
ticket_number: tn,
|
||||
queued_by_user_id: userId,
|
||||
provider,
|
||||
});
|
||||
queuedJobIds.push(job.id);
|
||||
}
|
||||
|
||||
// Create the aggregate report row. Bundle mode (expectedTicketNumbers set)
|
||||
// means status starts as 'pending_analyses' if any jobs were queued, or as
|
||||
// 'pending' if everything was already complete and we can run immediately.
|
||||
const allAlreadyComplete = ticketsNeedingAnalysis.length === 0;
|
||||
const created = await createAggregateReport({
|
||||
generatedByUserId: userId,
|
||||
filterCriteria: {
|
||||
mode: 'bundle',
|
||||
masterTicketNumber,
|
||||
linkedTicketNumbers: Array.from(linkedSet),
|
||||
provider,
|
||||
},
|
||||
analysisIds: alreadyCompleteAnalysisIds,
|
||||
ticketCount: allTicketNumbers.length,
|
||||
includeItglueContext,
|
||||
reportTitle: reportTitle ?? null,
|
||||
expectedTicketNumbers: allAlreadyComplete ? undefined : allTicketNumbers,
|
||||
triggeredByTicketNumber: masterTicketNumber,
|
||||
});
|
||||
|
||||
if (allAlreadyComplete) {
|
||||
void runAggregateReport(created.id).catch((err) => {
|
||||
console.error('[analyze-bundle] background runner threw:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
aggregateReportId: created.id,
|
||||
ticketCount: allTicketNumbers.length,
|
||||
queuedJobIds,
|
||||
alreadyCompleteAnalysisIds,
|
||||
status: allAlreadyComplete ? 'pending' : 'pending_analyses',
|
||||
estimatedCost: evaluation.estimatedCost,
|
||||
softWarn: evaluation.softWarn,
|
||||
});
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export async function POST(
|
|||
|
||||
const { ticketNumber } = await params;
|
||||
|
||||
let parsedBody: { force?: boolean };
|
||||
let parsedBody: { force?: boolean; provider?: 'anthropic' | 'openrouter' };
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const result = AnalyzeTicketRequest.safeParse(body);
|
||||
|
|
@ -51,6 +51,7 @@ export async function POST(
|
|||
} catch {
|
||||
parsedBody = {};
|
||||
}
|
||||
const provider = parsedBody.provider ?? 'anthropic';
|
||||
|
||||
// Verify the ticket exists and load its data for the idempotency check.
|
||||
let bundle;
|
||||
|
|
@ -74,12 +75,14 @@ export async function POST(
|
|||
}
|
||||
|
||||
// Idempotency short-circuit: when force=false, return the existing analysis
|
||||
// without queueing a job if the source data hasn't changed since.
|
||||
// without queueing a job if the source data hasn't changed since. Provider-
|
||||
// scoped so a Claude run never short-circuits a DeepSeek request.
|
||||
if (!parsedBody.force) {
|
||||
const pre = preprocessTicket(bundle);
|
||||
const existing = await findExistingAnalysisByContentHash(
|
||||
ticketNumber,
|
||||
pre.content_hash
|
||||
pre.content_hash,
|
||||
provider
|
||||
);
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
|
|
@ -96,6 +99,7 @@ export async function POST(
|
|||
const job = await queueJob({
|
||||
ticket_number: ticketNumber,
|
||||
queued_by_user_id: userId,
|
||||
provider,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* GET /api/analyzer/tickets/:ticketNumber/itglue-xrefs
|
||||
*
|
||||
* Returns the xrefs for one ticket — "every IT Glue asset this ticket
|
||||
* touched" — for use on the analysis page or future ticket views.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listXrefsForTicket } from '@/lib/services/analyzer/asset-audit/xrefs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ ticketNumber: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { ticketNumber } = await params;
|
||||
const xrefs = await listXrefsForTicket(ticketNumber, 100);
|
||||
return NextResponse.json({ xrefs });
|
||||
}
|
||||
97
app/api/analyzer/tickets/[ticketNumber]/links/route.ts
Normal file
97
app/api/analyzer/tickets/[ticketNumber]/links/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* GET /api/analyzer/tickets/:ticketNumber/links
|
||||
* Cheap explicit-only discovery (regex + RELATED TICKETS section + problem_ticket_id).
|
||||
* No LLM cost. Use this on page load to render the Related Tickets panel.
|
||||
*
|
||||
* POST /api/analyzer/tickets/:ticketNumber/links
|
||||
* Body: { includeSuggested?: boolean }
|
||||
* Runs the Haiku-suggested arm in addition to the explicit arm.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import {
|
||||
loadTicketBundle,
|
||||
TicketNotFoundError,
|
||||
} from '@/lib/services/analyzer/data-access';
|
||||
import {
|
||||
discoverLinks,
|
||||
discoverExplicitLinks,
|
||||
} from '@/lib/services/analyzer/link-discovery';
|
||||
import { SuggestLinksRequest } from '@/lib/types/analyzer';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ ticketNumber: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { ticketNumber } = await params;
|
||||
|
||||
let bundle;
|
||||
try {
|
||||
bundle = await loadTicketBundle(ticketNumber);
|
||||
} catch (err) {
|
||||
if (err instanceof TicketNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: `Ticket ${ticketNumber} not found in local mirror` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
console.error('[analyzer/links] data-access error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load ticket' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await discoverExplicitLinks(bundle);
|
||||
return NextResponse.json({
|
||||
explicit: result.explicit,
|
||||
suggested: [],
|
||||
isProblemTicket: result.isProblemTicket,
|
||||
problemTicketSignals: result.problemTicketSignals,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ ticketNumber: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { ticketNumber } = await params;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = SuggestLinksRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let bundle;
|
||||
try {
|
||||
bundle = await loadTicketBundle(ticketNumber);
|
||||
} catch (err) {
|
||||
if (err instanceof TicketNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: `Ticket ${ticketNumber} not found in local mirror` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
console.error('[analyzer/links] data-access error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load ticket' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await discoverLinks(bundle, {
|
||||
includeSuggested: parsed.data.includeSuggested,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
31
app/api/dashboard/integration-health/route.ts
Normal file
31
app/api/dashboard/integration-health/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* GET /api/dashboard/integration-health
|
||||
* Live auth check + JWT expiry decode for each configured integration.
|
||||
* Cached in-process for 5 minutes.
|
||||
*
|
||||
* ?refresh=1 forces re-check (admin only).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requirePermission } from '@/lib/auth-utils';
|
||||
import {
|
||||
checkIntegrationHealth,
|
||||
clearIntegrationHealthCache,
|
||||
summarize,
|
||||
} from '@/lib/services/integration-health';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const refresh = request.nextUrl.searchParams.get('refresh') === '1';
|
||||
|
||||
if (refresh) {
|
||||
const adminCheck = await requirePermission('admin', 'access');
|
||||
if (adminCheck.error) return adminCheck.error;
|
||||
clearIntegrationHealthCache();
|
||||
} else {
|
||||
const authCheck = await requireAuth();
|
||||
if (authCheck.error) return authCheck.error;
|
||||
}
|
||||
|
||||
const items = await checkIntegrationHealth({ skipCache: refresh });
|
||||
return NextResponse.json({ items, summary: summarize(items) });
|
||||
}
|
||||
168
app/api/dashboard/overview/route.ts
Normal file
168
app/api/dashboard/overview/route.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* GET /api/dashboard/overview
|
||||
* Single round-trip backing the new dashboard. All queries run in parallel.
|
||||
*
|
||||
* attention — counts that should pull a human's eyes
|
||||
* observations — recent device_observations (loglift et al.)
|
||||
* audits — recent endpoint_audits
|
||||
* syncHealth — per-schedule last_run / last_status from sync_schedules
|
||||
* stats — small footer: companies, CIs, xref linkage
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
type Counts = { count: string };
|
||||
|
||||
const [
|
||||
linkConflictsRes,
|
||||
itglueUnlinkedRes,
|
||||
s1UnmappedRes,
|
||||
schedulesRes,
|
||||
observationsRes,
|
||||
auditsRes,
|
||||
syncHealthRes,
|
||||
companiesRes,
|
||||
ciRes,
|
||||
xrefRes,
|
||||
] = await Promise.all([
|
||||
postgresClient.query<Counts>(
|
||||
`SELECT COUNT(*)::text AS count FROM device_link_review WHERE resolved_at IS NULL`
|
||||
),
|
||||
postgresClient.query<Counts>(
|
||||
`SELECT COUNT(*)::text AS count FROM device_external_ids WHERE source = 'itglue' AND configuration_item_id IS NULL`
|
||||
),
|
||||
postgresClient.query<Counts>(
|
||||
`SELECT COUNT(*)::text AS count FROM device_external_ids WHERE source = 's1' AND configuration_item_id IS NULL`
|
||||
),
|
||||
postgresClient.query<{ enabled: string; total: string }>(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE is_enabled)::text AS enabled,
|
||||
COUNT(*)::text AS total
|
||||
FROM sync_schedules`
|
||||
),
|
||||
postgresClient.query<{
|
||||
id: string;
|
||||
kind: string;
|
||||
source: string;
|
||||
collected_at: string;
|
||||
hostname: string | null;
|
||||
company_name: string | null;
|
||||
run_id: string | null;
|
||||
}>(
|
||||
`SELECT o.id::text,
|
||||
o.kind, o.source,
|
||||
o.collected_at::text,
|
||||
ci.reference_title AS hostname,
|
||||
c.company_name,
|
||||
o.run_id
|
||||
FROM device_observations o
|
||||
LEFT JOIN configuration_items ci ON ci.id = o.configuration_item_id
|
||||
LEFT JOIN companies c ON c.id = ci.company_id
|
||||
ORDER BY o.collected_at DESC
|
||||
LIMIT 10`
|
||||
),
|
||||
postgresClient.query<{
|
||||
id: string;
|
||||
generated_at: string;
|
||||
hostname: string | null;
|
||||
company_name: string | null;
|
||||
overall_score: string | null;
|
||||
field_gaps_count: string;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT a.id::text,
|
||||
a.generated_at::text,
|
||||
ci.reference_title AS hostname,
|
||||
c.company_name,
|
||||
a.overall_score::text,
|
||||
jsonb_array_length(COALESCE(a.field_gaps, '[]'::jsonb))::text AS field_gaps_count,
|
||||
a.status
|
||||
FROM endpoint_audits a
|
||||
LEFT JOIN configuration_items ci ON ci.id = a.configuration_item_id
|
||||
LEFT JOIN companies c ON c.id = ci.company_id
|
||||
ORDER BY a.generated_at DESC
|
||||
LIMIT 10`
|
||||
),
|
||||
postgresClient.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
sync_type: string;
|
||||
is_enabled: boolean;
|
||||
last_run: string | null;
|
||||
last_status: string | null;
|
||||
last_error: string | null;
|
||||
next_run: string | null;
|
||||
}>(
|
||||
`SELECT id, name, sync_type, is_enabled,
|
||||
last_run::text, last_status, last_error,
|
||||
next_run::text
|
||||
FROM sync_schedules
|
||||
ORDER BY name`
|
||||
),
|
||||
postgresClient.query<Counts>(
|
||||
`SELECT COUNT(*)::text AS count FROM companies WHERE company_type = 1 AND is_active = true`
|
||||
),
|
||||
postgresClient.query<Counts>(
|
||||
`SELECT COUNT(*)::text AS count FROM configuration_items WHERE is_deleted = false OR is_deleted IS NULL`
|
||||
),
|
||||
postgresClient.query<{ total: string; linked: string }>(
|
||||
`SELECT COUNT(*)::text AS total,
|
||||
COUNT(*) FILTER (WHERE configuration_item_id IS NOT NULL)::text AS linked
|
||||
FROM device_external_ids`
|
||||
),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
attention: {
|
||||
linkConflicts: parseInt(linkConflictsRes.rows[0]?.count ?? '0', 10),
|
||||
itglueUnlinked: parseInt(itglueUnlinkedRes.rows[0]?.count ?? '0', 10),
|
||||
s1Unmapped: parseInt(s1UnmappedRes.rows[0]?.count ?? '0', 10),
|
||||
schedules: {
|
||||
enabled: parseInt(schedulesRes.rows[0]?.enabled ?? '0', 10),
|
||||
total: parseInt(schedulesRes.rows[0]?.total ?? '0', 10),
|
||||
},
|
||||
},
|
||||
observations: observationsRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
source: r.source,
|
||||
collectedAt: r.collected_at,
|
||||
hostname: r.hostname,
|
||||
companyName: r.company_name,
|
||||
runId: r.run_id,
|
||||
})),
|
||||
audits: auditsRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
generatedAt: r.generated_at,
|
||||
hostname: r.hostname,
|
||||
companyName: r.company_name,
|
||||
overallScore: r.overall_score === null ? null : Number(r.overall_score),
|
||||
fieldGapsCount: parseInt(r.field_gaps_count, 10),
|
||||
status: r.status,
|
||||
})),
|
||||
syncHealth: syncHealthRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
syncType: r.sync_type,
|
||||
isEnabled: r.is_enabled,
|
||||
lastRun: r.last_run,
|
||||
lastStatus: r.last_status,
|
||||
lastError: r.last_error,
|
||||
nextRun: r.next_run,
|
||||
})),
|
||||
stats: {
|
||||
activeCompanies: parseInt(companiesRes.rows[0]?.count ?? '0', 10),
|
||||
configurationItems: parseInt(ciRes.rows[0]?.count ?? '0', 10),
|
||||
xref: {
|
||||
total: parseInt(xrefRes.rows[0]?.total ?? '0', 10),
|
||||
linked: parseInt(xrefRes.rows[0]?.linked ?? '0', 10),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
24
app/api/rmm/executions/[id]/route.ts
Normal file
24
app/api/rmm/executions/[id]/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* GET /api/rmm/executions/:id
|
||||
*
|
||||
* Returns one execution row including stdout / stderr / parsed_evidence.
|
||||
* Used by the live-stream UI to poll status.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { getExecutionById } from '@/lib/services/rmm/persistence';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const { id } = await params;
|
||||
const execution = await getExecutionById(id);
|
||||
if (!execution) {
|
||||
return NextResponse.json({ error: 'Execution not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ execution });
|
||||
}
|
||||
118
app/api/rmm/executions/route.ts
Normal file
118
app/api/rmm/executions/route.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* GET /api/rmm/executions
|
||||
* List recent executions. Filters: companyId, scriptId, status,
|
||||
* assetType, assetId. requireAuth() — admin sees all by default.
|
||||
*
|
||||
* POST /api/rmm/executions
|
||||
* Body: { scriptId, target: { type: 'site_anchor', companyId } |
|
||||
* { type: 'asset_self', deviceUid, hostname?, companyId?, assetType?, assetId? },
|
||||
* triggeredByAuditId? }
|
||||
* Requires rmm.execute. Queues a fresh execution.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requireAuth, requirePermission } from '@/lib/auth-utils';
|
||||
import {
|
||||
listExecutions,
|
||||
type RmmExecutionStatus,
|
||||
} from '@/lib/services/rmm/persistence';
|
||||
import { queueExecution } from '@/lib/services/rmm/executor';
|
||||
// Side-effect import: starts the worker once per process.
|
||||
import '@/lib/services/rmm/worker';
|
||||
|
||||
const PostBody = z.object({
|
||||
scriptId: z.string().min(1),
|
||||
target: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('site_anchor'),
|
||||
companyId: z.union([z.string(), z.number()]),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('asset_self'),
|
||||
deviceUid: z.string().min(1),
|
||||
hostname: z.string().nullable().optional(),
|
||||
companyId: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
assetType: z.enum(['flexible_asset', 'configuration']).optional(),
|
||||
assetId: z.union([z.string(), z.number()]).optional(),
|
||||
}),
|
||||
]),
|
||||
triggeredByAuditId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
const ALLOWED_STATUSES: ReadonlyArray<RmmExecutionStatus> = [
|
||||
'queued',
|
||||
'running',
|
||||
'complete',
|
||||
'failed',
|
||||
'timeout',
|
||||
];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get('limit') ?? 100);
|
||||
const offset = Number(url.searchParams.get('offset') ?? 0);
|
||||
const companyIdParam = url.searchParams.get('companyId');
|
||||
const scriptIdParam = url.searchParams.get('scriptId');
|
||||
const statusParam = url.searchParams.get('status');
|
||||
const assetTypeParam = url.searchParams.get('assetType');
|
||||
const assetIdParam = url.searchParams.get('assetId');
|
||||
const status =
|
||||
statusParam && ALLOWED_STATUSES.includes(statusParam as RmmExecutionStatus)
|
||||
? (statusParam as RmmExecutionStatus)
|
||||
: undefined;
|
||||
const assetType =
|
||||
assetTypeParam === 'flexible_asset' || assetTypeParam === 'configuration'
|
||||
? assetTypeParam
|
||||
: undefined;
|
||||
|
||||
const executions = await listExecutions({
|
||||
limit,
|
||||
offset,
|
||||
companyId: companyIdParam ?? undefined,
|
||||
scriptId: scriptIdParam ?? undefined,
|
||||
status,
|
||||
assetType,
|
||||
assetId: assetIdParam ?? undefined,
|
||||
});
|
||||
return NextResponse.json({ executions });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { session, error } = await requirePermission('rmm', 'execute');
|
||||
if (error) return error;
|
||||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = PostBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid body', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await queueExecution({
|
||||
scriptId: parsed.data.scriptId,
|
||||
target: parsed.data.target,
|
||||
performedByUserId: userId,
|
||||
triggeredByAuditId: parsed.data.triggeredByAuditId ?? null,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Rate-limit failures + bad targets read as 400; everything else 500.
|
||||
const isClient =
|
||||
message.includes('rate limit') ||
|
||||
message.includes('No Wulf Nurse') ||
|
||||
message.includes('Unknown script') ||
|
||||
message.includes('expects target_type');
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not queue execution', message },
|
||||
{ status: isClient ? 400 : 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
99
app/api/rmm/loglift/upload/route.ts
Normal file
99
app/api/rmm/loglift/upload/route.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* LogLift evidence webhook.
|
||||
*
|
||||
* The Datto RMM collector component uploads gzipped event-log JSON to B2,
|
||||
* then POSTs the metadata here. Pulse downloads the gzip, slims it, persists
|
||||
* it as RMM evidence, and (when the hostname matches a single IT Glue
|
||||
* Configuration) fires an asset-first audit on the side.
|
||||
*
|
||||
* Auth: `x-openclaw-key` header — same shared secret the collector already
|
||||
* carries for OpenClaw API calls.
|
||||
*
|
||||
* Public route per `middleware.ts` (`/api/rmm/loglift` exclusion).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { validateOpenClawKey } from '@/lib/utils/openclaw-auth';
|
||||
import { OBJECT_KEY_REGEX } from '@/lib/services/b2/client';
|
||||
import { processLogliftWebhook } from '@/lib/services/rmm/loglift-receiver';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const WebhookSchema = z.object({
|
||||
runId: z.string().min(1).max(200),
|
||||
clientId: z.string().min(1).max(200),
|
||||
computerName: z.string().min(1).max(200),
|
||||
deviceUid: z.string().max(200).nullable().optional(),
|
||||
summary: z.object({
|
||||
totalEvents: z.number().int().nonnegative().nullable().optional(),
|
||||
criticalEvents: z.number().int().nonnegative().nullable().optional(),
|
||||
errorCount: z.number().int().nonnegative().nullable().optional(),
|
||||
warningCount: z.number().int().nonnegative().nullable().optional(),
|
||||
timeRange: z.string().max(200).nullable().optional(),
|
||||
}),
|
||||
objectKey: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.refine((s) => OBJECT_KEY_REGEX.test(s), {
|
||||
message: 'objectKey does not match the required eventlogs path shape',
|
||||
}),
|
||||
collectedAt: z.string().min(1).max(64),
|
||||
rmmContext: z
|
||||
.object({
|
||||
siteName: z.string().max(200).nullable().optional(),
|
||||
siteUid: z.string().max(200).nullable().optional(),
|
||||
accountUid: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
.partial()
|
||||
.nullable()
|
||||
.optional(),
|
||||
issueDescription: z.string().max(4000).nullable().optional(),
|
||||
ticketNumber: z.string().max(64).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const auth = validateOpenClawKey(req);
|
||||
if (auth) return auth;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = WebhookSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid payload',
|
||||
details: parsed.error.flatten(),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processLogliftWebhook(parsed.data);
|
||||
return NextResponse.json(
|
||||
{
|
||||
executionId: result.executionId,
|
||||
matched: result.matched,
|
||||
parsed: result.parsed,
|
||||
auditId: result.audit_id,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('[LOGLIFT-WEBHOOK]', message, err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process LogLift webhook', message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
25
app/api/rmm/scripts/route.ts
Normal file
25
app/api/rmm/scripts/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* GET /api/rmm/scripts
|
||||
*
|
||||
* Returns the script library catalog (id, name, description, target_type,
|
||||
* expected_runtime_seconds, version). Bodies are not returned — they live
|
||||
* in the repo and are only sent to Datto RMM, never to clients.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listScripts } from '@/lib/services/rmm/scripts';
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const scripts = listScripts().map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
target_type: s.target_type,
|
||||
expected_runtime_seconds: s.expected_runtime_seconds,
|
||||
version: s.version,
|
||||
}));
|
||||
return NextResponse.json({ scripts });
|
||||
}
|
||||
27
app/api/sync/schedules/reload/route.ts
Normal file
27
app/api/sync/schedules/reload/route.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* POST /api/sync/schedules/reload
|
||||
* Stops every running cron task and re-loads from the DB. Use after seeding
|
||||
* new schedule rows directly via SQL (the scheduler only seeds defaults on a
|
||||
* virgin table).
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import { syncScheduler } from '@/lib/services/sync-scheduler';
|
||||
|
||||
export async function POST() {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const result = await syncScheduler.reloadAllSchedules();
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('[SCHEDULE API] reload failed:', message);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reload schedules', details: message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,7 @@ import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
|||
import { AuvikDevice } from '@/lib/types/auvik';
|
||||
import { AddigyDevice } from '@/lib/types/addigy';
|
||||
import { useApi } from '@/lib/hooks/use-api';
|
||||
import { RmmDispatchDialog } from '@/components/rmm/rmm-dispatch-dialog';
|
||||
|
||||
interface DeviceComparison {
|
||||
autotaskDevice?: ConfigurationItem;
|
||||
|
|
@ -864,6 +865,7 @@ function ConfigurationItemsContent() {
|
|||
<TableHead>RMM</TableHead>
|
||||
<TableHead>NMS</TableHead>
|
||||
<TableHead>ARMM</TableHead>
|
||||
<TableHead className="w-32">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -889,7 +891,7 @@ function ConfigurationItemsContent() {
|
|||
setExpandedContacts(newExpanded);
|
||||
}}
|
||||
>
|
||||
<TableCell colSpan={11}>
|
||||
<TableCell colSpan={12}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
|
||||
<Users className="h-4 w-4" />
|
||||
|
|
@ -1024,6 +1026,17 @@ function ConfigurationItemsContent() {
|
|||
<XCircle className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-32" onClick={(e) => e.stopPropagation()}>
|
||||
{item.rmmDevice?.uid ? (
|
||||
<RmmDispatchDialog
|
||||
deviceUid={item.rmmDevice.uid}
|
||||
hostname={item.rmmDevice.hostname ?? null}
|
||||
companyId={selectedCompany ?? null}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
|
|
@ -1150,6 +1163,17 @@ function ConfigurationItemsContent() {
|
|||
<XCircle className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-32" onClick={(e) => e.stopPropagation()}>
|
||||
{item.rmmDevice?.uid ? (
|
||||
<RmmDispatchDialog
|
||||
deviceUid={item.rmmDevice.uid}
|
||||
hostname={item.rmmDevice.hostname ?? null}
|
||||
companyId={selectedCompany ?? null}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,386 +2,453 @@
|
|||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Server,
|
||||
Building2,
|
||||
Network,
|
||||
Globe,
|
||||
Smartphone,
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Database,
|
||||
Shield,
|
||||
CalendarClock,
|
||||
RefreshCw,
|
||||
ArrowRight,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Users,
|
||||
HardDrive,
|
||||
Wifi,
|
||||
FileText
|
||||
Clock,
|
||||
Activity,
|
||||
Sparkles,
|
||||
Plug,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DashboardStats {
|
||||
companies: {
|
||||
total: number;
|
||||
active: number;
|
||||
};
|
||||
configurationItems: {
|
||||
total: number;
|
||||
active: number;
|
||||
};
|
||||
mappings: {
|
||||
auvik: {
|
||||
mapped: number;
|
||||
unmapped: number;
|
||||
};
|
||||
rmm: {
|
||||
mapped: number;
|
||||
unmapped: number;
|
||||
};
|
||||
};
|
||||
quotes: {
|
||||
open: number;
|
||||
interface IntegrationHealthItem {
|
||||
key: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown';
|
||||
configured: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string | null;
|
||||
tokenExpiry?: {
|
||||
envVar: string;
|
||||
expiresAt: string;
|
||||
daysRemaining: number;
|
||||
subject?: string | null;
|
||||
} | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface IntegrationHealthResponse {
|
||||
items: IntegrationHealthItem[];
|
||||
summary: {
|
||||
total: number;
|
||||
ok: number;
|
||||
failed: number;
|
||||
notConfigured: number;
|
||||
expiringWithin14Days: number;
|
||||
expired: number;
|
||||
hasIssues: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface Overview {
|
||||
attention: {
|
||||
linkConflicts: number;
|
||||
itglueUnlinked: number;
|
||||
s1Unmapped: number;
|
||||
schedules: { enabled: number; total: number };
|
||||
};
|
||||
observations: Array<{
|
||||
id: string;
|
||||
kind: string;
|
||||
source: string;
|
||||
collectedAt: string;
|
||||
hostname: string | null;
|
||||
companyName: string | null;
|
||||
runId: string | null;
|
||||
}>;
|
||||
audits: Array<{
|
||||
id: string;
|
||||
generatedAt: string;
|
||||
hostname: string | null;
|
||||
companyName: string | null;
|
||||
overallScore: number | null;
|
||||
fieldGapsCount: number;
|
||||
status: string;
|
||||
}>;
|
||||
syncHealth: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
syncType: string;
|
||||
isEnabled: boolean;
|
||||
lastRun: string | null;
|
||||
lastStatus: string | null;
|
||||
lastError: string | null;
|
||||
nextRun: string | null;
|
||||
}>;
|
||||
stats: {
|
||||
activeCompanies: number;
|
||||
configurationItems: number;
|
||||
xref: { total: number; linked: number };
|
||||
};
|
||||
}
|
||||
|
||||
const STALE_HOURS = 24;
|
||||
|
||||
function relTime(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (ms < 0) return 'in the future';
|
||||
const min = Math.floor(ms / 60000);
|
||||
if (min < 1) return 'just now';
|
||||
if (min < 60) return `${min} min ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 48) return `${hr} h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return `${day} d ago`;
|
||||
}
|
||||
|
||||
function isStale(iso: string | null): boolean {
|
||||
if (!iso) return true;
|
||||
return Date.now() - new Date(iso).getTime() > STALE_HOURS * 3600_000;
|
||||
}
|
||||
|
||||
function syncStatusIcon(s: { lastStatus: string | null; lastRun: string | null; isEnabled: boolean }) {
|
||||
if (!s.isEnabled) return <span className="text-muted-foreground text-xs">off</span>;
|
||||
if (s.lastStatus === 'failed')
|
||||
return <XCircle className="size-4 text-destructive" aria-label="failed" />;
|
||||
if (isStale(s.lastRun))
|
||||
return <Clock className="size-4 text-amber-500" aria-label="stale" />;
|
||||
if (s.lastStatus === 'success')
|
||||
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />;
|
||||
return <Clock className="size-4 text-muted-foreground" aria-label="never run" />;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
companies: { total: 0, active: 0 },
|
||||
configurationItems: { total: 0, active: 0 },
|
||||
mappings: {
|
||||
auvik: { mapped: 0, unmapped: 0 },
|
||||
rmm: { mapped: 0, unmapped: 0 }
|
||||
},
|
||||
quotes: { open: 0, total: 0 }
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<Overview | null>(null);
|
||||
const [health, setHealth] = useState<IntegrationHealthResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const fetchStats = async () => {
|
||||
async function load(): Promise<void> {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch cached stats from database (fast, no external API calls)
|
||||
const statsRes = await fetch('/api/dashboard/stats');
|
||||
const statsData = await statsRes.json();
|
||||
|
||||
setStats({
|
||||
companies: statsData.companies || { total: 0, active: 0 },
|
||||
configurationItems: {
|
||||
total: 0,
|
||||
active: 0
|
||||
},
|
||||
mappings: statsData.mappings || {
|
||||
auvik: { mapped: 0, unmapped: 0 },
|
||||
rmm: { mapped: 0, unmapped: 0 }
|
||||
},
|
||||
quotes: statsData.quotes || { open: 0, total: 0 }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard stats:', error);
|
||||
const [overviewRes, healthRes] = await Promise.all([
|
||||
fetch('/api/dashboard/overview'),
|
||||
fetch('/api/dashboard/integration-health'),
|
||||
]);
|
||||
if (!overviewRes.ok) {
|
||||
const body = (await overviewRes.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${overviewRes.status}`);
|
||||
}
|
||||
setData((await overviewRes.json()) as Overview);
|
||||
if (healthRes.ok) {
|
||||
setHealth((await healthRes.json()) as IntegrationHealthResponse);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
title: 'Configuration Items',
|
||||
description: 'View and manage IT assets and devices',
|
||||
href: '/configuration-items',
|
||||
icon: Server,
|
||||
color: 'blue',
|
||||
stats: `${stats.configurationItems.active} active items`
|
||||
},
|
||||
{
|
||||
title: 'Kiosk Display',
|
||||
description: 'Configure and view executive dashboard for TV',
|
||||
href: '/kiosk/settings',
|
||||
icon: Activity,
|
||||
color: 'blue',
|
||||
stats: 'Settings & display'
|
||||
},
|
||||
{
|
||||
title: 'Sync Management',
|
||||
description: 'Synchronize data from external systems',
|
||||
href: '/admin/sync',
|
||||
icon: RefreshCw,
|
||||
color: 'green',
|
||||
stats: 'Run data synchronization'
|
||||
},
|
||||
{
|
||||
title: 'Data Browser',
|
||||
description: 'Browse and query system data',
|
||||
href: '/admin/data-browser',
|
||||
icon: Database,
|
||||
color: 'purple',
|
||||
stats: 'Explore database tables'
|
||||
}
|
||||
];
|
||||
|
||||
const mappingCards = [
|
||||
{
|
||||
title: 'NMS Mapping (Auvik)',
|
||||
description: 'Network Management System integration',
|
||||
href: '/auvik-mappings',
|
||||
icon: Network,
|
||||
color: 'blue',
|
||||
mapped: stats.mappings.auvik.mapped,
|
||||
unmapped: stats.mappings.auvik.unmapped,
|
||||
total: stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped
|
||||
},
|
||||
{
|
||||
title: 'RMM Mapping (Datto)',
|
||||
description: 'Remote Monitoring & Management',
|
||||
href: '/rmm-mappings',
|
||||
icon: Globe,
|
||||
color: 'purple',
|
||||
mapped: stats.mappings.rmm.mapped,
|
||||
unmapped: stats.mappings.rmm.unmapped,
|
||||
total: stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped
|
||||
},
|
||||
{
|
||||
title: 'Apple RMM (Addigy)',
|
||||
description: 'Apple device management',
|
||||
href: '/addigy-mappings',
|
||||
icon: Smartphone,
|
||||
color: 'orange',
|
||||
mapped: 0,
|
||||
unmapped: 0,
|
||||
total: 0,
|
||||
comingSoon: true
|
||||
}
|
||||
];
|
||||
|
||||
const getMappingProgress = (mapped: number, total: number) => {
|
||||
if (total === 0) return 0;
|
||||
return (mapped / total) * 100;
|
||||
};
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="container mx-auto px-6 py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Welcome to Pulse - Your PSA Management System
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={fetchStats} variant="outline" size="sm" disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<Button onClick={load} variant="outline" size="sm" disabled={loading}>
|
||||
<RefreshCw className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Companies</CardTitle>
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.companies.total}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.companies.active} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">NMS Coverage</CardTitle>
|
||||
<Wifi className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped > 0
|
||||
? Math.round(getMappingProgress(stats.mappings.auvik.mapped, stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped))
|
||||
: 0}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.mappings.auvik.mapped} of {stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped} tenants
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">RMM Coverage</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped > 0
|
||||
? Math.round(getMappingProgress(stats.mappings.rmm.mapped, stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped))
|
||||
: 0}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.mappings.rmm.mapped} of {stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped} sites
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Link href="/quotes">
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Open Quotes</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.quotes.open}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pending approval
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">System Status</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
Online
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All systems operational
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Quick Access</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{quickLinks.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<link.icon className={`h-8 w-8 text-${link.color}-600`} />
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="mt-4">{link.title}</CardTitle>
|
||||
<CardDescription>{link.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{link.stats}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
{/* NEEDS ATTENTION ----------------------------------------------------- */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
||||
Needs attention
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<AttentionCard
|
||||
icon={AlertTriangle}
|
||||
value={data?.attention.linkConflicts}
|
||||
label="Device-link conflicts"
|
||||
href="/admin/device-link-conflicts"
|
||||
tone={data && data.attention.linkConflicts > 0 ? 'warn' : 'ok'}
|
||||
/>
|
||||
<AttentionCard
|
||||
icon={Database}
|
||||
value={data?.attention.itglueUnlinked}
|
||||
label="IT Glue ↛ Autotask"
|
||||
sub="unlinked configurations"
|
||||
href="/admin/device-link-conflicts"
|
||||
tone="info"
|
||||
/>
|
||||
<AttentionCard
|
||||
icon={Shield}
|
||||
value={data?.attention.s1Unmapped}
|
||||
label="S1 unmapped"
|
||||
sub="missing site → company mapping"
|
||||
href="/sentinelone/mappings"
|
||||
tone="info"
|
||||
/>
|
||||
<AttentionCard
|
||||
icon={CalendarClock}
|
||||
value={data?.attention.schedules.enabled}
|
||||
label={`Schedules on / ${data?.attention.schedules.total ?? '—'}`}
|
||||
href="/admin/sync/autotask"
|
||||
tone="info"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mapping Status */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Integration Mappings</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{mappingCards.map((mapping) => (
|
||||
<Card key={mapping.href} className="relative">
|
||||
{mapping.comingSoon && (
|
||||
<Badge className="absolute top-4 right-4" variant="secondary">
|
||||
Coming Soon
|
||||
</Badge>
|
||||
)}
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<mapping.icon className={`h-8 w-8 text-${mapping.color}-600`} />
|
||||
{!mapping.comingSoon && mapping.unmapped > 0 && (
|
||||
<Badge variant="outline" className="bg-orange-50 border-orange-200 text-orange-700">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{mapping.unmapped} unmapped
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="mt-4">{mapping.title}</CardTitle>
|
||||
<CardDescription>{mapping.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!mapping.comingSoon ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Coverage</span>
|
||||
<span className="font-medium">
|
||||
{Math.round(getMappingProgress(mapping.mapped, mapping.total))}%
|
||||
</span>
|
||||
{/* RECENT OBSERVATIONS + AUDITS ---------------------------------------- */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Activity className="size-4" />
|
||||
Recent device observations
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data === null && !error ? (
|
||||
<RowSkeletons />
|
||||
) : data?.observations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No observations recorded yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{data?.observations.map((o) => (
|
||||
<div
|
||||
key={o.id}
|
||||
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{o.hostname ?? '(unanchored)'}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-mono">{o.kind}</span>
|
||||
{o.companyName && <span> · {o.companyName}</span>}
|
||||
</div>
|
||||
<Progress value={getMappingProgress(mapping.mapped, mapping.total)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||
{mapping.mapped} mapped
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3 text-gray-400" />
|
||||
{mapping.unmapped} unmapped
|
||||
</span>
|
||||
<div className="text-xs text-muted-foreground shrink-0 ml-3">
|
||||
{relTime(o.collectedAt)}
|
||||
</div>
|
||||
<Link href={mapping.href}>
|
||||
<Button className="w-full" variant="outline" size="sm">
|
||||
Manage Mappings
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Integration under development
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity - Placeholder */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Recent Activity</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2 w-2 bg-green-600 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Data sync completed</p>
|
||||
<p className="text-xs text-muted-foreground">Companies synchronized successfully - 5 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2 w-2 bg-blue-600 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">New RMM site mapped</p>
|
||||
<p className="text-xs text-muted-foreground">Site "Acme Corp - Dallas" mapped to Acme Corp - 2 hours ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2 w-2 bg-purple-600 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Configuration items updated</p>
|
||||
<p className="text-xs text-muted-foreground">247 devices synchronized from RMM - 1 day ago</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="size-4" />
|
||||
Recent audits
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data === null && !error ? (
|
||||
<RowSkeletons />
|
||||
) : data?.audits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No endpoint audits yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{data?.audits.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{a.hostname ?? '(unanchored)'}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
score {a.overallScore?.toFixed(2) ?? '—'} · {a.fieldGapsCount} gaps
|
||||
{a.companyName && <span> · {a.companyName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0 ml-3">
|
||||
{relTime(a.generatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* INTEGRATION HEALTH -------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Plug className="size-4" />
|
||||
Integration health
|
||||
{health?.summary.hasIssues && (
|
||||
<Badge variant="destructive" className="text-[10px]">issues</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!health ? (
|
||||
<RowSkeletons />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{health.items
|
||||
.slice()
|
||||
.sort((a, b) => statusOrder(a.status) - statusOrder(b.status))
|
||||
.map((i) => (
|
||||
<IntegrationRow key={i.key} item={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SYNC HEALTH --------------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Sync health</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data === null && !error ? (
|
||||
<RowSkeletons />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{data?.syncHealth.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between py-1.5 text-sm border-b last:border-0">
|
||||
<div className="min-w-0 flex-1 truncate">{s.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0 ml-3">
|
||||
<span>{relTime(s.lastRun)}</span>
|
||||
{syncStatusIcon(s)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* STATS FOOTER -------------------------------------------------------- */}
|
||||
{data && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.stats.activeCompanies} companies · {data.stats.configurationItems.toLocaleString()} CIs ·{' '}
|
||||
{data.stats.xref.total.toLocaleString()} xref rows (
|
||||
{data.stats.xref.total > 0
|
||||
? Math.round((data.stats.xref.linked / data.stats.xref.total) * 100)
|
||||
: 0}
|
||||
% linked)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttentionCard(props: {
|
||||
icon: React.ElementType;
|
||||
value: number | undefined;
|
||||
label: string;
|
||||
sub?: string;
|
||||
href: string;
|
||||
tone: 'ok' | 'warn' | 'info';
|
||||
}) {
|
||||
const { icon: Icon, value, label, sub, href, tone } = props;
|
||||
const valueColor =
|
||||
tone === 'warn' && value && value > 0
|
||||
? 'text-amber-600 dark:text-amber-500'
|
||||
: tone === 'ok'
|
||||
? 'text-foreground'
|
||||
: 'text-foreground';
|
||||
return (
|
||||
<Link href={href} className="block">
|
||||
<Card className="hover:shadow-md transition-shadow h-full">
|
||||
<CardContent className="pt-4 pb-3 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<ArrowRight className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className={`text-2xl font-semibold tabular-nums ${valueColor}`}>
|
||||
{value === undefined ? '—' : value.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm font-medium leading-tight">{label}</div>
|
||||
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function statusOrder(s: IntegrationHealthItem['status']): number {
|
||||
switch (s) {
|
||||
case 'auth_failed': return 0;
|
||||
case 'unreachable': return 1;
|
||||
case 'unknown': return 2;
|
||||
case 'ok': return 3;
|
||||
case 'not_configured': return 4;
|
||||
default: return 5;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(item: IntegrationHealthItem) {
|
||||
const expiringSoon =
|
||||
item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14;
|
||||
const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
|
||||
if (item.status === 'auth_failed' || item.status === 'unreachable')
|
||||
return <XCircle className="size-4 text-destructive" aria-label={item.status} />;
|
||||
if (expired)
|
||||
return <KeyRound className="size-4 text-destructive" aria-label="token expired" />;
|
||||
if (expiringSoon)
|
||||
return <KeyRound className="size-4 text-amber-500" aria-label="token expires soon" />;
|
||||
if (item.status === 'ok')
|
||||
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />;
|
||||
if (item.status === 'unknown')
|
||||
return <CheckCircle2 className="size-4 text-muted-foreground" aria-label="configured" />;
|
||||
return <span className="text-xs text-muted-foreground">off</span>;
|
||||
}
|
||||
|
||||
function IntegrationRow({ item }: { item: IntegrationHealthItem }) {
|
||||
const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
|
||||
const expiringSoon =
|
||||
item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14;
|
||||
const detail =
|
||||
item.status === 'auth_failed' || item.status === 'unreachable'
|
||||
? item.error?.slice(0, 80)
|
||||
: expired
|
||||
? `token expired ${Math.abs(item.tokenExpiry!.daysRemaining).toFixed(0)} d ago`
|
||||
: expiringSoon
|
||||
? `token expires in ${item.tokenExpiry!.daysRemaining.toFixed(0)} d`
|
||||
: item.latencyMs !== undefined
|
||||
? `${item.latencyMs} ms`
|
||||
: null;
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5 text-sm border-b last:border-0">
|
||||
<div className="min-w-0 flex-1 truncate">{item.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0 ml-3">
|
||||
{detail && <span className="truncate max-w-[20ch]">{detail}</span>}
|
||||
{statusBadge(item)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowSkeletons() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue