wulf-pulse/components/rmm/rmm-dispatch-dialog.tsx
lorentz 1112a06afe 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>
2026-05-03 07:13:18 -04:00

189 lines
5.8 KiB
TypeScript

'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>
);
}