- 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>
183 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|