- 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>
198 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|