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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue