wulf-pulse/components/rmm/rmm-execution-stream.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

183 lines
5.8 KiB
TypeScript

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