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
189
components/rmm/rmm-dispatch-dialog.tsx
Normal file
189
components/rmm/rmm-dispatch-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
components/rmm/rmm-execution-stream.tsx
Normal file
183
components/rmm/rmm-execution-stream.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
components/rmm/rmm-script-picker.tsx
Normal file
198
components/rmm/rmm-script-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue