wulf-pulse/components/rmm/rmm-script-picker.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

198 lines
6.4 KiB
TypeScript

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