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,189 @@
'use client';
/**
* Per-row "Run RMM" dialog. Lists asset-self scripts from /api/rmm/scripts,
* dispatches against a known Datto deviceUid, and surfaces live execution
* status via the existing RmmExecutionStream without leaving the page.
*
* Used from /configuration-items so admins don't have to construct hidden
* /analyzer/itglue/configurations/<id> URLs by hand.
*/
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Loader2, Terminal } from 'lucide-react';
import { toast } from 'sonner';
import { useSession } from '@/lib/auth-client';
import { RmmExecutionStream } from './rmm-execution-stream';
interface Script {
id: string;
name: string;
description: string;
target_type: 'site_anchor' | 'asset_self';
expected_runtime_seconds: number;
version: number;
}
interface RmmDispatchDialogProps {
deviceUid: string;
hostname?: string | null;
companyId?: number | string | null;
triggerLabel?: string;
}
export function RmmDispatchDialog({
deviceUid,
hostname,
companyId,
triggerLabel = 'Run RMM',
}: RmmDispatchDialogProps) {
const { data: session } = useSession();
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
const canExecute = role === 'admin' || role === 'super-admin';
const [scripts, setScripts] = useState<Script[] | null>(null);
const [open, setOpen] = useState(false);
const [running, setRunning] = useState<string | null>(null);
const [activeExecutionId, setActiveExecutionId] = useState<string | null>(null);
useEffect(() => {
if (!open || scripts !== null) return;
let cancelled = false;
(async () => {
try {
const res = await fetch('/api/rmm/scripts');
if (!res.ok) return;
const data = (await res.json()) as { scripts: Script[] };
if (!cancelled) setScripts(data.scripts);
} catch {
// Silent — the dialog just won't populate.
}
})();
return () => {
cancelled = true;
};
}, [open, scripts]);
async function dispatch(s: Script): Promise<void> {
if (!canExecute) return;
setRunning(s.id);
try {
const res = await fetch('/api/rmm/executions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scriptId: s.id,
target: {
type: 'asset_self',
deviceUid,
hostname: hostname ?? null,
companyId: companyId ?? null,
},
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message ?? data.error ?? 'Execution failed');
}
setActiveExecutionId(data.executionId);
toast.success(`${s.name}: queued`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Could not dispatch');
} finally {
setRunning(null);
}
}
const visible = scripts?.filter((s) => s.target_type === 'asset_self') ?? [];
const disabledReason = !canExecute
? 'Requires admin'
: !deviceUid
? 'No Datto device id'
: null;
return (
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
// Reset active execution when the dialog is closed so the next open
// starts fresh. Status stays visible until the user closes.
if (!o) setActiveExecutionId(null);
}}
>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!!disabledReason}
title={disabledReason ?? 'Dispatch a Datto RMM script for this device'}
onClick={(e) => e.stopPropagation()}
>
<Terminal className="w-3.5 h-3.5 mr-1.5" />
{triggerLabel}
</Button>
</DialogTrigger>
<DialogContent
className="max-w-2xl"
onClick={(e) => e.stopPropagation()}
>
<DialogHeader>
<DialogTitle>Dispatch RMM Script</DialogTitle>
<DialogDescription>
Target: <span className="font-mono">{hostname ?? deviceUid}</span>
</DialogDescription>
</DialogHeader>
{visible.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{scripts === null ? 'Loading…' : 'No asset-targeted scripts in the registry.'}
</p>
) : (
<ul className="divide-y border rounded-md max-h-[40vh] overflow-auto">
{visible.map((s) => (
<li key={s.id}>
<button
type="button"
className="w-full text-left px-3 py-2 hover:bg-accent flex items-start gap-2 disabled:opacity-50"
onClick={() => dispatch(s)}
disabled={running !== null || activeExecutionId !== null}
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium flex items-center gap-2">
{s.name}
<Badge variant="outline" className="text-[10px] py-0">
~{s.expected_runtime_seconds}s
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{s.description}
</p>
</div>
{running === s.id && (
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
)}
</button>
</li>
))}
</ul>
)}
{activeExecutionId && (
<div className="mt-2">
<RmmExecutionStream executionId={activeExecutionId} />
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,183 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Loader2, CheckCircle2, AlertTriangle, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ExecutionRow {
id: string;
scriptId: string;
jobName: string;
targetHostname: string | null;
status: 'queued' | 'running' | 'complete' | 'failed' | 'timeout';
exitCode: number | null;
rawStdout: string | null;
rawStderr: string | null;
parsedEvidence: unknown;
parseError: string | null;
errorMessage: string | null;
queuedAt: string;
completedAt: string | null;
}
const POLL_MS = 3000;
const POLL_TIMEOUT_MS = 6 * 60 * 1000; // 6 min — slightly longer than the server-side hard cap.
export function RmmExecutionStream({
executionId,
onComplete,
}: {
executionId: string;
onComplete?: () => void;
}) {
const [exec, setExec] = useState<ExecutionRow | null>(null);
const [error, setError] = useState<string | null>(null);
const [closed, setClosed] = useState(false);
useEffect(() => {
if (closed) return;
let cancelled = false;
const start = Date.now();
async function tick() {
if (cancelled) return;
try {
const res = await fetch(`/api/rmm/executions/${executionId}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = (await res.json()) as { execution: ExecutionRow };
if (cancelled) return;
setExec(data.execution);
if (
data.execution.status === 'complete' ||
data.execution.status === 'failed' ||
data.execution.status === 'timeout'
) {
onComplete?.();
return;
}
if (Date.now() - start > POLL_TIMEOUT_MS) {
setError('Polling timed out — check execution status manually.');
return;
}
setTimeout(tick, POLL_MS);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'Unknown error');
}
}
void tick();
return () => {
cancelled = true;
};
}, [executionId, closed, onComplete]);
if (closed) return null;
const status = exec?.status ?? 'queued';
const isDone =
status === 'complete' || status === 'failed' || status === 'timeout';
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<CardTitle className="text-base flex items-center gap-2">
{!isDone ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : status === 'complete' ? (
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
) : (
<AlertTriangle className="w-4 h-4 text-amber-600" />
)}
{exec?.jobName ?? 'Discovery script'}
<Badge
variant={
status === 'complete'
? 'default'
: status === 'failed' || status === 'timeout'
? 'destructive'
: 'outline'
}
className="text-[10px]"
>
{status}
</Badge>
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
{exec?.targetHostname ? `target: ${exec.targetHostname} · ` : ''}
execution {executionId}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setClosed(true)}
title="Hide"
>
<X className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{error && <p className="text-sm text-destructive">{error}</p>}
{isDone && exec?.parseError && (
<p className="text-xs text-amber-600">
Output parser failed: {exec.parseError}
</p>
)}
{isDone && exec?.errorMessage && (
<p className="text-xs text-destructive">{exec.errorMessage}</p>
)}
{isDone && exec?.parsedEvidence !== undefined && exec.parsedEvidence !== null && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">
Parsed evidence
</p>
<pre className="text-[11px] bg-muted/50 rounded p-2 max-h-72 overflow-auto whitespace-pre-wrap">
{JSON.stringify(exec.parsedEvidence, null, 2)}
</pre>
</div>
)}
{isDone && exec?.rawStdout && (
<details>
<summary className="text-xs font-semibold uppercase tracking-wide text-muted-foreground cursor-pointer">
Raw stdout ({exec.rawStdout.length} chars)
</summary>
<pre className="text-[11px] bg-muted/50 rounded p-2 max-h-72 overflow-auto whitespace-pre-wrap mt-1">
{exec.rawStdout.slice(0, 50000)}
</pre>
</details>
)}
{isDone && exec?.rawStderr && (
<details>
<summary className="text-xs font-semibold uppercase tracking-wide text-muted-foreground cursor-pointer">
Raw stderr
</summary>
<pre className="text-[11px] bg-muted/50 rounded p-2 max-h-40 overflow-auto whitespace-pre-wrap mt-1">
{exec.rawStderr.slice(0, 20000)}
</pre>
</details>
)}
{!isDone && (
<p className="text-xs text-muted-foreground">
Polling every {POLL_MS / 1000}s Datto typically returns within
~30-90s for asset-self scripts and ~60-180s for site-anchored.
</p>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,198 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Loader2, Terminal, Server, Layers } from 'lucide-react';
import { toast } from 'sonner';
import { useSession } from '@/lib/auth-client';
import { RmmExecutionStream } from './rmm-execution-stream';
export interface RmmScriptCatalogEntry {
id: string;
name: string;
description: string;
target_type: 'site_anchor' | 'asset_self';
expected_runtime_seconds: number;
version: number;
}
interface RmmScriptPickerProps {
/**
* Filter the picker to scripts compatible with this target.
* - 'site_anchor': site-wide scripts (DC, AD, DHCP, DNS).
* - 'asset_self': scripts that target a specific device (the audited Configuration).
*/
filter: 'site_anchor' | 'asset_self';
/** Used for site_anchor scripts. */
companyId?: number | string;
/** Used for asset_self scripts. */
deviceUid?: string;
hostname?: string | null;
/** Optional bookkeeping. */
assetType?: 'flexible_asset' | 'configuration';
assetId?: number | string;
/** Refresh callback when an execution completes (so the parent re-fetches). */
onComplete?: (executionId: string) => void;
}
export function RmmScriptPicker({
filter,
companyId,
deviceUid,
hostname,
assetType,
assetId,
onComplete,
}: RmmScriptPickerProps) {
const { data: session } = useSession();
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
const canExecute = role === 'admin' || role === 'super-admin';
const [scripts, setScripts] = useState<RmmScriptCatalogEntry[] | null>(null);
const [open, setOpen] = useState(false);
const [running, setRunning] = useState<string | null>(null);
const [activeExecutionId, setActiveExecutionId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch('/api/rmm/scripts');
if (!res.ok) return;
const data = (await res.json()) as { scripts: RmmScriptCatalogEntry[] };
if (!cancelled) setScripts(data.scripts);
} catch {
// Silent — picker just won't populate.
}
})();
return () => {
cancelled = true;
};
}, []);
async function dispatch(script: RmmScriptCatalogEntry): Promise<void> {
if (!canExecute) return;
setRunning(script.id);
try {
const target =
script.target_type === 'site_anchor'
? { type: 'site_anchor' as const, companyId: companyId! }
: {
type: 'asset_self' as const,
deviceUid: deviceUid!,
hostname: hostname ?? null,
companyId: companyId ?? null,
assetType,
assetId,
};
const res = await fetch('/api/rmm/executions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scriptId: script.id,
target,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message ?? data.error ?? 'Execution failed');
}
setActiveExecutionId(data.executionId);
toast.success(`${script.name}: queued`);
setOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Could not dispatch');
} finally {
setRunning(null);
}
}
const visible = scripts?.filter((s) => s.target_type === filter) ?? [];
const disabledReason = !canExecute
? 'Requires admin'
: filter === 'site_anchor' && !companyId
? 'No client mapped'
: filter === 'asset_self' && !deviceUid
? 'No Datto device id'
: null;
return (
<div className="space-y-3">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!!disabledReason || scripts === null}
title={disabledReason ?? 'Run a discovery script via Datto RMM Overshell'}
>
<Terminal className="w-3.5 h-3.5 mr-1.5" />
Run discovery
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end">
<div className="px-3 py-2 border-b text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1">
{filter === 'site_anchor' ? (
<>
<Layers className="w-3.5 h-3.5" /> Site-anchored discovery
</>
) : (
<>
<Server className="w-3.5 h-3.5" /> Asset-specific discovery
</>
)}
</div>
{visible.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">
{scripts === null ? 'Loading…' : 'No scripts in the registry for this target.'}
</div>
) : (
<ul className="divide-y max-h-80 overflow-auto">
{visible.map((s) => (
<li key={s.id}>
<button
type="button"
className="w-full text-left px-3 py-2 hover:bg-accent flex items-start gap-2 disabled:opacity-50"
onClick={() => dispatch(s)}
disabled={running !== null}
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium flex items-center gap-2">
{s.name}
<Badge variant="outline" className="text-[10px] py-0">
~{s.expected_runtime_seconds}s
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{s.description}
</p>
</div>
{running === s.id && (
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
)}
</button>
</li>
))}
</ul>
)}
</PopoverContent>
</Popover>
{activeExecutionId && (
<RmmExecutionStream
executionId={activeExecutionId}
onComplete={() => {
setActiveExecutionId(null);
onComplete?.(activeExecutionId);
}}
/>
)}
</div>
);
}