feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul

- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target
  resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift)
- LogLift evidence pipeline (migration 078): upload webhook, B2 storage client,
  receiver/matcher, EventLogCollector PowerShell script
- IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket
  xrefs, applications/configurations browse pages + apply/revert/audit endpoints
- Link-aware analyzer bundles (migration 073) + provider toggle (migration 074):
  link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion
  panels, analyze-bundle endpoint
- Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts
  admin page, reconciler service, resolve endpoints
- Dashboard overhaul: integration-health service + alerts, overview/health endpoints
- Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-05-03 07:13:18 -04:00
parent 378e68ad8a
commit 1112a06afe
132 changed files with 21352 additions and 743 deletions

View file

@ -0,0 +1,253 @@
'use client';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
interface Candidate {
ciId: string;
confidence: string | null;
hostname: string | null;
serial: string | null;
mac: string | null;
companyId: string | null;
companyName: string | null;
isDeleted: boolean;
}
interface Review {
id: string;
detectedAt: string;
xref: {
id: string;
source: string;
sourceId: string;
hostname: string | null;
serial: string | null;
mac: string | null;
companyId: string | null;
companyName: string | null;
lastSeenAt: string | null;
};
candidates: Candidate[];
}
const SOURCES = ['all', 'datto_rmm', 'itglue', 's1', 'veeam'] as const;
type SourceFilter = (typeof SOURCES)[number];
function confidenceColor(c: string | null): 'default' | 'secondary' | 'outline' {
if (c === 'exact_serial') return 'default';
if (c === 'mac') return 'default';
if (c === 'hostname_in_company') return 'secondary';
return 'outline';
}
export default function DeviceLinkConflictsPage() {
const [items, setItems] = useState<Review[] | null>(null);
const [total, setTotal] = useState(0);
const [error, setError] = useState<string | null>(null);
const [source, setSource] = useState<SourceFilter>('all');
const [resolving, setResolving] = useState<string | null>(null);
async function load(): Promise<void> {
setError(null);
setItems(null);
try {
const params = new URLSearchParams({ limit: '100' });
if (source !== 'all') params.set('source', source);
const res = await fetch(`/api/admin/device-link-conflicts?${params}`);
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const data = (await res.json()) as { items: Review[]; total: number };
setItems(data.items);
setTotal(data.total);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
}
useEffect(() => {
void load();
}, [source]);
async function resolve(reviewId: string, ciId: string): Promise<void> {
setResolving(`${reviewId}:${ciId}`);
try {
const res = await fetch(`/api/admin/device-link-conflicts/${reviewId}/resolve`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ciId }),
});
const data = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new Error(data.error ?? `Request failed: ${res.status}`);
toast.success(`Linked to CI ${ciId}`);
setItems((prev) => prev?.filter((r) => r.id !== reviewId) ?? null);
setTotal((t) => Math.max(0, t - 1));
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Resolve failed');
} finally {
setResolving(null);
}
}
return (
<div className="container mx-auto px-6 py-6 max-w-6xl space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-amber-500" />
Device-link conflicts
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Cases where the reconciler found two or more configuration_items
matching one external device record. Pick the right CI to break the
tie. Skipped rows stay unlinked until resolved.
</p>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Source:</span>
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOURCES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{total} unresolved {total === 1 ? 'conflict' : 'conflicts'}
</span>
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to load</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{items === null && !error && (
<div className="space-y-2">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)}
{items !== null && items.length === 0 && !error && (
<Alert>
<CheckCircle2 className="size-4" />
<AlertTitle>No conflicts</AlertTitle>
<AlertDescription>
Nothing waiting for review on this filter.
</AlertDescription>
</Alert>
)}
{items?.map((r) => (
<Card key={r.id} className="border-amber-200">
<CardHeader className="pb-3">
<div className="flex items-baseline justify-between gap-3">
<div className="space-y-0.5">
<div className="text-sm font-mono">
{r.xref.source}:{r.xref.sourceId}
</div>
<div className="text-base font-medium">
{r.xref.hostname ?? '(no hostname)'}
{r.xref.companyName && (
<span className="text-sm text-muted-foreground ml-2">
@ {r.xref.companyName}
</span>
)}
</div>
</div>
<Badge variant="outline" className="text-xs">
{r.candidates.length} candidates
</Badge>
</div>
<div className="text-xs text-muted-foreground space-x-3">
{r.xref.serial && <span>serial: {r.xref.serial}</span>}
{r.xref.mac && <span>mac: {r.xref.mac}</span>}
{r.xref.lastSeenAt && (
<span>last seen: {new Date(r.xref.lastSeenAt).toLocaleString()}</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-2">
{r.candidates.map((c) => {
const isResolving = resolving === `${r.id}:${c.ciId}`;
return (
<div
key={c.ciId}
className="flex items-center justify-between gap-3 rounded-md border p-3"
>
<div className="space-y-0.5 min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium truncate">
{c.hostname ?? '(no hostname)'}
</span>
{c.isDeleted && (
<Badge variant="destructive" className="text-xs">
deleted
</Badge>
)}
{c.confidence && (
<Badge
variant={confidenceColor(c.confidence)}
className="text-xs"
>
{c.confidence}
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground space-x-3">
<span className="font-mono">CI {c.ciId}</span>
{c.serial && <span>serial: {c.serial}</span>}
{c.companyName && <span>@ {c.companyName}</span>}
</div>
</div>
<Button
size="sm"
disabled={isResolving || c.isDeleted}
onClick={() => void resolve(r.id, c.ciId)}
>
{isResolving ? (
<>
<Loader2 className="size-3 animate-spin mr-1" />
Linking
</>
) : (
'Link to this CI'
)}
</Button>
</div>
);
})}
</CardContent>
</Card>
))}
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,168 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface WriteRow {
id: string;
audit_id: string | null;
asset_type: 'flexible_asset';
asset_id: string;
field_name: string;
before_value: unknown;
after_value: unknown;
performed_by_user_id: string | null;
performed_at: string;
status: 'pending' | 'committed' | 'failed' | 'reverted';
error_message: string | null;
}
const STATUSES: Array<WriteRow['status'] | 'all'> = [
'all',
'committed',
'reverted',
'failed',
'pending',
];
function statusVariant(
s: WriteRow['status']
): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (s) {
case 'committed':
return 'default';
case 'reverted':
return 'secondary';
case 'failed':
return 'destructive';
default:
return 'outline';
}
}
export default function ItglueWritesPage() {
const [rows, setRows] = useState<WriteRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] =
useState<WriteRow['status'] | 'all'>('all');
async function load(): Promise<void> {
try {
const url =
statusFilter === 'all'
? '/api/analyzer/itglue/writes'
: `/api/analyzer/itglue/writes?status=${statusFilter}`;
const res = await fetch(url);
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const data = (await res.json()) as { writes: WriteRow[] };
setRows(data.writes);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter]);
return (
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<CardTitle>IT Glue write log</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Every PATCH to IT Glue from Pulse, with before/after diffs and
revert history.
</p>
</div>
<div className="flex items-center gap-1">
{STATUSES.map((s) => (
<Button
key={s}
variant={statusFilter === s ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setStatusFilter(s)}
>
{s}
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Couldn&rsquo;t load writes</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{rows === null && !error ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : rows && rows.length === 0 ? (
<p className="text-sm text-muted-foreground">No writes recorded yet.</p>
) : (
<ul className="divide-y">
{(rows ?? []).map((w) => (
<li key={w.id} className="py-3">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-sm">
<Link
href={`/analyzer/itglue/applications/${w.asset_id}`}
className="font-mono hover:underline"
>
{w.asset_id}
</Link>
{' · '}
<span className="font-medium">{w.field_name}</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(w.performed_at).toLocaleString()}
</p>
<p className="text-xs mt-1 break-words">
<span className="text-muted-foreground">Before: </span>
<span className="font-mono">
{w.before_value === null || w.before_value === undefined
? '(empty)'
: JSON.stringify(w.before_value).slice(0, 200)}
</span>
</p>
<p className="text-xs mt-0.5 break-words">
<span className="text-muted-foreground">After: </span>
<span className="font-mono">
{JSON.stringify(w.after_value).slice(0, 200)}
</span>
</p>
{w.error_message && (
<p className="text-xs mt-1 text-destructive">
Error: {w.error_message}
</p>
)}
</div>
<Badge variant={statusVariant(w.status)}>{w.status}</Badge>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

321
app/admin/page.tsx Normal file
View file

@ -0,0 +1,321 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
RefreshCw,
Network,
Globe,
Smartphone,
Radio,
AlertTriangle,
Workflow,
GitBranch,
Sparkles,
Zap,
Bell,
Sun,
BarChart3,
ScrollText,
Database,
SlidersHorizontal,
Activity,
Tv,
Users,
ShieldCheck,
Settings as SettingsIcon,
Shield,
DollarSign,
CalendarClock,
} from 'lucide-react';
interface AdminCounts {
linkConflicts: number;
schedules: { enabled: number; total: number };
unmappedAuvik: number;
unmappedRmm: number;
unmappedAddigy: number;
}
interface NavTile {
title: string;
href: string;
icon: React.ElementType;
description?: string;
badge?: { label: string; tone: 'warn' | 'info' | 'muted' };
}
interface Section {
title: string;
tiles: NavTile[];
}
function tone(badge?: NavTile['badge']) {
if (!badge) return null;
const variant: 'destructive' | 'secondary' | 'outline' =
badge.tone === 'warn' ? 'destructive' : badge.tone === 'info' ? 'secondary' : 'outline';
return (
<Badge variant={variant} className="text-[10px] font-mono ml-2 shrink-0">
{badge.label}
</Badge>
);
}
export default function AdminIndexPage() {
const [counts, setCounts] = useState<AdminCounts | null>(null);
useEffect(() => {
void (async () => {
try {
const [overviewRes, mappingsRes] = await Promise.all([
fetch('/api/dashboard/overview'),
fetch('/api/dashboard/stats'),
]);
const overview = overviewRes.ok ? await overviewRes.json() : null;
const mappings = mappingsRes.ok ? await mappingsRes.json() : null;
setCounts({
linkConflicts: overview?.attention?.linkConflicts ?? 0,
schedules: overview?.attention?.schedules ?? { enabled: 0, total: 0 },
unmappedAuvik: mappings?.mappings?.auvik?.unmapped ?? 0,
unmappedRmm: mappings?.mappings?.rmm?.unmapped ?? 0,
unmappedAddigy: 0,
});
} catch {
// Counts are decorative — fail quiet.
}
})();
}, []);
const sections: Section[] = [
{
title: 'Sync',
tiles: [
{
title: 'Integrations & Sync',
href: '/admin/sync',
icon: RefreshCw,
description: 'Overview of sync status across all integrations',
badge: counts
? {
label: `${counts.schedules.enabled}/${counts.schedules.total} schedules on`,
tone: 'muted',
}
: undefined,
},
{ title: 'Autotask', href: '/admin/sync/autotask', icon: RefreshCw },
{ title: 'Datto RMM', href: '/admin/sync/datto-rmm', icon: Globe },
{ title: 'IT Glue', href: '/admin/sync/itglue', icon: Shield },
{ title: 'SentinelOne', href: '/admin/sync/sentinelone', icon: Shield },
{ title: 'Veeam', href: '/admin/sync/veeam', icon: Activity },
{ title: 'Auvik', href: '/admin/sync/auvik', icon: Network },
{ title: 'Addigy', href: '/admin/sync/addigy', icon: Smartphone },
{ title: 'Mimecast', href: '/admin/sync/mimecast', icon: Shield },
{ title: 'Duo', href: '/admin/sync/duo', icon: Shield },
{ title: 'QuickBooks Online', href: '/admin/qbo', icon: DollarSign },
],
},
{
title: 'Mappings',
tiles: [
{
title: 'Device-Link Conflicts',
href: '/admin/device-link-conflicts',
icon: AlertTriangle,
description: 'Resolve cases where one external device matches multiple Autotask CIs',
badge:
counts && counts.linkConflicts > 0
? { label: counts.linkConflicts.toLocaleString(), tone: 'warn' }
: undefined,
},
{
title: 'NMS Mapping (Auvik)',
href: '/auvik-mappings',
icon: Network,
description: 'Map Auvik tenants to companies',
badge:
counts && counts.unmappedAuvik > 0
? { label: `${counts.unmappedAuvik} unmapped`, tone: 'info' }
: undefined,
},
{
title: 'RMM Mapping (Datto)',
href: '/rmm-mappings',
icon: Globe,
description: 'Map RMM sites to companies',
badge:
counts && counts.unmappedRmm > 0
? { label: `${counts.unmappedRmm} unmapped`, tone: 'info' }
: undefined,
},
{
title: 'Apple RMM (Addigy)',
href: '/addigy-mappings',
icon: Smartphone,
description: 'Map Addigy devices to companies',
},
{
title: 'SentinelOne Mappings',
href: '/sentinelone/mappings',
icon: Shield,
description: 'Map S1 sites to companies (gap blocks reconciler)',
},
{
title: 'Zabbix WAN Monitor',
href: '/admin/zabbix-wan',
icon: Radio,
description: 'Sync RMM site WAN IPs to Zabbix with Autotask routing',
},
],
},
{
title: 'Workflow',
tiles: [
{
title: 'Ticket Workflows',
href: '/admin/workflow',
icon: Workflow,
description: 'Automated ticket triage and classification',
},
{
title: 'Classification Rules',
href: '/admin/workflow/classification-rules',
icon: GitBranch,
description: 'Keyword-based classification rules',
},
{
title: 'AI Templates',
href: '/admin/workflow/ai-templates',
icon: Sparkles,
description: 'AI prompt templates for enhancement',
},
{
title: 'Webhook Pipelines',
href: '/admin/workflow/pipelines',
icon: Zap,
description: 'Automated webhook processing workflows',
},
{
title: 'Notification Channels',
href: '/admin/workflow/channels',
icon: Bell,
description: 'Teams, Telegram, and webhook notifications',
},
],
},
{
title: 'Reports',
tiles: [
{
title: 'Morning NOC Summary',
href: '/admin/morning-summary',
icon: Sun,
description: 'Daily Zabbix overnight summary posted to Teams',
},
{
title: 'Ticket Digest Reports',
href: '/admin/ticket-digest',
icon: BarChart3,
description: 'LLM-analyzed ticket reports — daily/weekly/monthly',
},
{
title: 'IT Glue Writes',
href: '/admin/itglue-writes',
icon: ScrollText,
description: 'History of audit-driven IT Glue field writes',
},
{
title: 'Audit Log',
href: '/admin/audit-log',
icon: ScrollText,
description: 'System audit trail',
},
],
},
{
title: 'Tools & Data',
tiles: [
{
title: 'RMM Overshell',
href: '/admin/rmm-overshell',
icon: Database,
description: 'Datto RMM PowerShell discovery — settings, executions, evidence pipeline',
},
{
title: 'Data Browser',
href: '/admin/data-browser',
icon: Database,
description: 'Browse and query system data',
},
{
title: 'Display Settings',
href: '/admin/display-settings',
icon: SlidersHorizontal,
description: 'Configure company filters for Kiosk and Mobile dashboards',
},
{
title: 'Kiosk Settings',
href: '/kiosk/settings',
icon: Tv,
description: 'Configure executive dashboard for TV display',
},
],
},
{
title: 'Access',
tiles: [
{ title: 'Users', href: '/admin/users', icon: Users },
{ title: 'Roles', href: '/admin/roles', icon: ShieldCheck },
{ title: 'Settings', href: '/admin/settings', icon: SettingsIcon },
],
},
];
return (
<div className="container mx-auto px-6 py-6 max-w-7xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Admin</h1>
<p className="text-sm text-muted-foreground mt-1">
Sync, mappings, workflows, reporting, and tooling.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{sections.map((section) => (
<Card key={section.title}>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{section.title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-1">
{section.tiles.map((tile) => (
<Link
key={tile.href}
href={tile.href}
className="flex items-start gap-3 rounded-md p-2 -mx-2 hover:bg-muted/60 transition-colors"
>
<tile.icon className="size-4 mt-0.5 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium truncate">{tile.title}</span>
{tone(tile.badge)}
</div>
{tile.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{tile.description}
</p>
)}
</div>
</Link>
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,272 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Loader2, RefreshCw, Terminal } from 'lucide-react';
import { toast } from 'sonner';
interface Settings {
overshellComponentUid: string | null;
overshellComponentName: string | null;
overshellVariableName: string;
discoveredAt: string | null;
logliftComponentUid: string | null;
logliftComponentName: string | null;
logliftDiscoveredAt: string | null;
updatedAt: string;
}
interface ExecRow {
id: string;
scriptId: string;
jobName: string;
targetHostname: string | null;
status: 'queued' | 'running' | 'complete' | 'failed' | 'timeout';
exitCode: number | null;
errorMessage: string | null;
performedByUserId: string | null;
queuedAt: string;
completedAt: string | null;
}
export default function RmmOvershellAdminPage() {
const [settings, setSettings] = useState<Settings | null>(null);
const [counts, setCounts] = useState<{ total: string; running: string; failed_24h: string } | null>(null);
const [executions, setExecutions] = useState<ExecRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [discovering, setDiscovering] = useState(false);
const [discoveringLoglift, setDiscoveringLoglift] = useState(false);
async function loadAll() {
try {
const [s, e] = await Promise.all([
fetch('/api/admin/rmm/settings').then((r) => r.json()),
fetch('/api/rmm/executions?limit=50').then((r) => r.json()),
]);
if (s.error) throw new Error(s.error);
setSettings(s.settings);
setCounts(s.counts);
setExecutions(e.executions ?? []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
}
useEffect(() => {
void loadAll();
}, []);
async function discover() {
setDiscovering(true);
try {
const res = await fetch('/api/admin/rmm/settings/discover', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Discovery failed');
toast.success(`Found component: ${data.discovered?.name ?? 'unknown'}`);
void loadAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Discovery failed');
} finally {
setDiscovering(false);
}
}
async function discoverLoglift() {
setDiscoveringLoglift(true);
try {
const res = await fetch('/api/admin/rmm/settings/discover-loglift', {
method: 'POST',
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Discovery failed');
toast.success(`Found LogLift component: ${data.discovered?.name ?? 'unknown'}`);
void loadAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Discovery failed');
} finally {
setDiscoveringLoglift(false);
}
}
return (
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5" />
RMM Overshell
</CardTitle>
<p className="text-sm text-muted-foreground">
Datto RMM PowerShell evidence pipeline. Pulse dispatches scripts via
the configured Overshell component; the worker polls for results
and the audit pipeline pulls them in as live evidence.
</p>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t load settings</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{settings === null && !error ? (
<Skeleton className="h-32 w-full" />
) : settings ? (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="font-medium">Overshell component</p>
{settings.overshellComponentUid ? (
<>
<p className="text-xs text-muted-foreground mt-0.5">
{settings.overshellComponentName}
</p>
<p className="text-[10px] font-mono text-muted-foreground">
{settings.overshellComponentUid}
</p>
{settings.discoveredAt && (
<p className="text-[10px] text-muted-foreground mt-1">
discovered {new Date(settings.discoveredAt).toLocaleString()}
</p>
)}
</>
) : (
<p className="text-xs text-amber-600 mt-0.5">
No component cached. Click Discover to scan Datto RMM.
</p>
)}
</div>
<div>
<p className="font-medium">Variable name</p>
<p className="text-xs font-mono text-muted-foreground mt-0.5">
{settings.overshellVariableName}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
Adjust if your component uses a different variable.
</p>
</div>
<div>
<p className="font-medium">Activity (24h)</p>
<p className="text-xs text-muted-foreground mt-0.5">
{counts?.total ?? '0'} total · {counts?.running ?? '0'} running ·
<span className="text-destructive">
{' '}
{counts?.failed_24h ?? '0'} failed
</span>
</p>
</div>
<div className="flex items-end gap-2">
<Button onClick={discover} disabled={discovering}>
{discovering ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Discovering
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Re-discover Overshell
</>
)}
</Button>
</div>
<div className="col-span-2 border-t pt-4">
<p className="font-medium">LogLift component</p>
{settings.logliftComponentUid ? (
<>
<p className="text-xs text-muted-foreground mt-0.5">
{settings.logliftComponentName}
</p>
<p className="text-[10px] font-mono text-muted-foreground">
{settings.logliftComponentUid}
</p>
{settings.logliftDiscoveredAt && (
<p className="text-[10px] text-muted-foreground mt-1">
discovered{' '}
{new Date(settings.logliftDiscoveredAt).toLocaleString()}
</p>
)}
</>
) : (
<p className="text-xs text-amber-600 mt-0.5">
No LogLift component cached. Click below to scan Datto RMM
for one named &ldquo;loglift&rdquo; or &ldquo;eventlog&rdquo;.
</p>
)}
<div className="mt-3">
<Button
variant="outline"
onClick={discoverLoglift}
disabled={discoveringLoglift}
>
{discoveringLoglift ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Discovering
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Re-discover LogLift
</>
)}
</Button>
</div>
</div>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Recent executions</CardTitle>
</CardHeader>
<CardContent>
{executions === null ? (
<Skeleton className="h-24 w-full" />
) : executions.length === 0 ? (
<p className="text-sm text-muted-foreground">No executions yet.</p>
) : (
<ul className="divide-y">
{executions.map((e) => (
<li key={e.id} className="py-2 grid grid-cols-12 gap-2 text-sm">
<span className="col-span-3 font-mono truncate">{e.scriptId}</span>
<span className="col-span-3 truncate">{e.targetHostname ?? '—'}</span>
<span className="col-span-2">
<Badge
variant={
e.status === 'complete'
? 'default'
: e.status === 'failed' || e.status === 'timeout'
? 'destructive'
: 'outline'
}
className="text-[10px]"
>
{e.status}
{e.exitCode !== null ? ` · exit ${e.exitCode}` : ''}
</Badge>
</span>
<span className="col-span-3 text-xs text-muted-foreground truncate">
{new Date(e.queuedAt).toLocaleString()}
</span>
<span className="col-span-1 text-right">
{e.errorMessage && (
<span className="text-xs text-destructive truncate">!</span>
)}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}