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>
This commit is contained in:
lorentz 2026-05-03 07:13:18 -04:00
parent 378e68ad8a
commit 1112a06afe
132 changed files with 21352 additions and 743 deletions

View file

@ -10,7 +10,7 @@ import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Clock, Play, Pause, Trash2, Plus, Calendar, AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
import { Clock, Play, Pause, Trash2, Plus, Calendar, AlertCircle, CheckCircle2, XCircle, RefreshCw } from 'lucide-react';
interface ScheduleConfig {
id: string;
@ -37,6 +37,7 @@ interface ScheduleStatus {
export default function SyncScheduler() {
const [schedules, setSchedules] = useState<ScheduleStatus[]>([]);
const [reloading, setReloading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingSchedule, setEditingSchedule] = useState<ScheduleConfig | null>(null);
@ -90,6 +91,20 @@ export default function SyncScheduler() {
}
};
const reloadSchedules = async () => {
setReloading(true);
try {
const res = await fetch('/api/sync/schedules/reload', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
await fetchSchedules();
} catch (err) {
alert(`Reload failed: ${err instanceof Error ? err.message : err}`);
} finally {
setReloading(false);
}
};
const toggleSchedule = async (scheduleId: string, currentState: boolean) => {
try {
const response = await fetch(`/api/sync/schedules/${scheduleId}`, {
@ -259,10 +274,16 @@ export default function SyncScheduler() {
Manage automatic sync schedules
</CardDescription>
</div>
<Button onClick={openCreateDialog}>
<Plus className="h-4 w-4 mr-2" />
New Schedule
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={reloadSchedules} disabled={reloading}>
<RefreshCw className={`h-4 w-4 mr-2 ${reloading ? 'animate-spin' : ''}`} />
{reloading ? 'Reloading…' : 'Reload from DB'}
</Button>
<Button onClick={openCreateDialog}>
<Plus className="h-4 w-4 mr-2" />
New Schedule
</Button>
</div>
</div>
</CardHeader>
<CardContent>

View file

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Sparkles, Loader2 } from 'lucide-react';
import type { JobStatus } from '@/lib/types/analyzer';
import type { AnalyzerProvider } from './provider-toggle';
interface AnalyzeButtonProps {
ticketNumber: string;
@ -13,6 +14,8 @@ interface AnalyzeButtonProps {
force?: boolean;
variant?: 'default' | 'outline' | 'secondary';
label?: string;
/** LLM provider (anthropic = Claude default; openrouter = DeepSeek). */
provider?: AnalyzerProvider;
}
const STAGE_LABEL: Record<JobStatus, string> = {
@ -31,13 +34,16 @@ export function AnalyzeButton({
force = false,
variant = 'default',
label = 'Analyze',
provider = 'anthropic',
}: AnalyzeButtonProps) {
const router = useRouter();
const [status, setStatus] = useState<JobStatus | 'idle'>('idle');
async function pollJob(jobId: string) {
const start = Date.now();
const TIMEOUT_MS = 5 * 60 * 1000;
// DeepSeek runs (especially V4 Pro deep analysis) take 4-6× longer than
// Claude — observed ~5min on a typical ticket. 12min keeps headroom.
const TIMEOUT_MS = 12 * 60 * 1000;
while (Date.now() - start < TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, 2000));
const res = await fetch(`/api/analyzer/jobs/${jobId}`);
@ -64,7 +70,7 @@ export function AnalyzeButton({
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force }),
body: JSON.stringify({ force, provider }),
}
);
if (!res.ok) {

View file

@ -0,0 +1,493 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
ProviderToggle,
type AnalyzerProvider,
} from '@/components/analyzer/provider-toggle';
import {
Sparkles,
Loader2,
ExternalLink,
CheckCircle2,
Server,
Layers,
Database,
} from 'lucide-react';
import { useSession } from '@/lib/auth-client';
import { toast } from 'sonner';
interface FieldGap {
field_name: string;
why_missing_matters: string;
suggested_value: string | null;
evidence_ticket_numbers: string[];
confidence: 'high' | 'medium' | 'low';
}
interface NotePromotion {
quoted_note_text: string;
target_field: string;
suggested_value: string;
confidence: 'high' | 'medium' | 'low';
}
interface AuditRow {
id: string;
generated_at: string;
provider: 'anthropic' | 'openrouter';
ticket_count: number;
field_gaps: FieldGap[];
notes_promotions: NotePromotion[];
contradictions: { description: string; evidence: string }[];
overall_score: number | null;
estimated_cost_usd: number | null;
}
interface MatchedAsset {
id: string;
name: string | null;
hostname?: string | null;
type_name: string | null;
score: number;
matched_term: string;
latestAudit: AuditRow | null;
}
interface SuggestionsResponse {
ticketNumber: string;
organizationId: string | null;
organizationName: string | null;
flexibleAssets: MatchedAsset[];
configurations: MatchedAsset[];
}
const CONFIDENCE_TONE: Record<FieldGap['confidence'], string> = {
high: 'border-red-500 bg-red-500/10',
medium: 'border-amber-500 bg-amber-500/10',
low: 'border-blue-500 bg-blue-500/10',
};
interface ItglueSuggestionsPanelProps {
analysisId: string;
}
export function ItglueSuggestionsPanel({ analysisId }: ItglueSuggestionsPanelProps) {
const { data: session } = useSession();
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
const canWrite = role === 'admin' || role === 'super-admin';
const [data, setData] = useState<SuggestionsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [provider, setProvider] = useState<AnalyzerProvider>('anthropic');
const [auditing, setAuditing] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
async function loadSuggestions(): Promise<void> {
setLoading(true);
setLoadError(null);
try {
const res = await fetch(
`/api/analyzer/analyses/${analysisId}/itglue-suggestions`
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(err.error ?? `Request failed: ${res.status}`);
}
const d = (await res.json()) as SuggestionsResponse;
setData(d);
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}
async function runAudit(
assetType: 'flexible_asset' | 'configuration',
assetId: string
): Promise<void> {
const key = `${assetType}:${assetId}`;
setAuditing(key);
try {
const res = await fetch(
`/api/analyzer/analyses/${analysisId}/itglue-suggestions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assetType, assetId, provider }),
}
);
const d = await res.json();
if (!res.ok) throw new Error(d.message || d.error || 'Audit failed');
toast.success('Audit complete');
// Refresh the suggestions to pick up the new audit row.
await loadSuggestions();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Audit failed');
} finally {
setAuditing(null);
}
}
async function applyGap(
assetType: 'flexible_asset' | 'configuration',
assetId: string,
auditId: string,
gap: FieldGap | NotePromotion,
kind: 'field_gap' | 'note_promotion'
): Promise<void> {
if (!canWrite) return;
const fieldName =
kind === 'field_gap' ? (gap as FieldGap).field_name : (gap as NotePromotion).target_field;
const suggested =
kind === 'field_gap'
? (gap as FieldGap).suggested_value
: (gap as NotePromotion).suggested_value;
if (suggested === null || suggested === undefined || suggested === '') {
toast.error('No suggested value to apply');
return;
}
const evidence =
kind === 'field_gap'
? {
ticket_numbers: (gap as FieldGap).evidence_ticket_numbers,
gap_description: (gap as FieldGap).why_missing_matters,
}
: {
ticket_numbers: [],
gap_description: `Promoted from Notes: "${(gap as NotePromotion).quoted_note_text}"`,
};
const key = `${assetType}:${assetId}:${kind}:${fieldName}`;
setBusyKey(key);
try {
const path =
assetType === 'flexible_asset'
? `/api/analyzer/itglue/applications/${assetId}/apply`
: `/api/analyzer/itglue/configurations/${assetId}/apply`;
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
auditId,
fieldName,
suggestedValue: suggested,
sourceEvidence: evidence,
}),
});
const d = await res.json();
if (!res.ok) throw new Error(d.message || d.error || 'Apply failed');
toast.success(`Applied: ${fieldName}`);
await loadSuggestions();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Apply failed');
} finally {
setBusyKey(null);
}
}
const totalMatches = useMemo(() => {
if (!data) return 0;
return data.flexibleAssets.length + data.configurations.length;
}, [data]);
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Database className="w-5 h-5" />
<CardTitle className="text-base">IT Glue documentation</CardTitle>
</div>
<div className="flex items-center gap-2 flex-wrap">
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
<Button onClick={loadSuggestions} disabled={loading} size="sm">
{loading ? (
<>
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
Checking
</>
) : data ? (
<>
<Sparkles className="w-3.5 h-3.5 mr-1.5" />
Re-check
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5 mr-1.5" />
Check IT Glue documentation
</>
)}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{loadError && (
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t load suggestions</AlertTitle>
<AlertDescription>{loadError}</AlertDescription>
</Alert>
)}
{!data && !loading && !loadError && (
<p className="text-sm text-muted-foreground">
Click <strong>Check IT Glue documentation</strong> to find IT Glue
records this ticket touched and surface what should be documented.
</p>
)}
{data && (
<>
<p className="text-sm text-muted-foreground">
Matched <strong>{totalMatches}</strong> IT Glue record
{totalMatches === 1 ? '' : 's'} for{' '}
<strong>{data.organizationName ?? 'this client'}</strong>.
{totalMatches === 0 &&
' (No matches — ticket fingerprint did not mention any IT Glue assets we could find.)'}
</p>
{data.flexibleAssets.length > 0 && (
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1">
<Layers className="w-3.5 h-3.5" /> Applications ({data.flexibleAssets.length})
</h3>
{data.flexibleAssets.map((m) =>
renderAssetMatch(
m,
'flexible_asset',
auditing === `flexible_asset:${m.id}`,
busyKey,
canWrite,
() => runAudit('flexible_asset', m.id),
(g, k, auditId) => applyGap('flexible_asset', m.id, auditId, g, k)
)
)}
</section>
)}
{data.configurations.length > 0 && (
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1">
<Server className="w-3.5 h-3.5" /> Configurations ({data.configurations.length})
</h3>
{data.configurations.map((m) =>
renderAssetMatch(
m,
'configuration',
auditing === `configuration:${m.id}`,
busyKey,
canWrite,
() => runAudit('configuration', m.id),
(g, k, auditId) => applyGap('configuration', m.id, auditId, g, k)
)
)}
</section>
)}
</>
)}
</CardContent>
</Card>
);
}
function renderAssetMatch(
m: MatchedAsset,
assetType: 'flexible_asset' | 'configuration',
auditing: boolean,
busyKey: string | null,
canWrite: boolean,
onRunAudit: () => void,
onApply: (
gap: FieldGap | NotePromotion,
kind: 'field_gap' | 'note_promotion',
auditId: string
) => void
) {
const detailHref =
assetType === 'flexible_asset'
? `/analyzer/itglue/applications/${m.id}`
: `/analyzer/itglue/configurations/${m.id}`;
const audit = m.latestAudit;
return (
<div key={`${assetType}:${m.id}`} className="rounded-md border p-3 space-y-3">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<Link href={detailHref} className="font-medium hover:underline">
{m.name ?? m.id}
</Link>
<p className="text-xs text-muted-foreground mt-0.5">
{m.type_name ?? '—'}
{m.hostname && ` · ${m.hostname}`}
{' · '}match: <span className="font-mono">{m.matched_term}</span>
{audit?.overall_score !== null && audit?.overall_score !== undefined && (
<>
{' · '}score{' '}
<Badge
variant={
(audit.overall_score ?? 0) > 0.8
? 'default'
: (audit.overall_score ?? 0) > 0.5
? 'secondary'
: 'destructive'
}
className="text-[10px]"
>
{Math.round((audit.overall_score ?? 0) * 100)}%
</Badge>
</>
)}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button asChild variant="outline" size="sm">
<Link href={detailHref}>
Open
<ExternalLink className="w-3 h-3 ml-1" />
</Link>
</Button>
<Button onClick={onRunAudit} disabled={auditing} size="sm">
{auditing ? (
<>
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
Auditing
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5 mr-1.5" />
{audit ? 'Re-audit for this ticket' : 'Audit for this ticket'}
</>
)}
</Button>
</div>
</div>
{audit && (
<div className="space-y-2">
{audit.field_gaps.length === 0 && audit.notes_promotions.length === 0 && (
<p className="text-xs text-muted-foreground">
No new gaps surfaced from this ticket. Existing record looks
sufficient for what was learned.
</p>
)}
{audit.field_gaps.map((g) => {
const k = `${assetType}:${m.id}:field_gap:${g.field_name}`;
const busy = busyKey === k;
return (
<div
key={k}
className={`border-l-4 rounded p-2 ${CONFIDENCE_TONE[g.confidence]}`}
>
<div className="flex items-start justify-between gap-2 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{g.field_name}</p>
<p className="text-xs mt-0.5">{g.why_missing_matters}</p>
{g.suggested_value !== null && (
<p className="text-xs mt-1">
<span className="font-medium">Suggested: </span>
<span className="font-mono break-all">
{g.suggested_value}
</span>
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Badge variant="outline" className="text-[10px] uppercase">
{g.confidence}
</Badge>
<Button
size="sm"
disabled={
!canWrite ||
g.suggested_value === null ||
g.suggested_value === '' ||
busy
}
onClick={() => onApply(g, 'field_gap', audit.id)}
title={
!canWrite
? 'Requires admin'
: g.suggested_value === null
? 'No concrete suggestion'
: 'Apply to IT Glue'
}
>
{busy ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<CheckCircle2 className="w-3 h-3 mr-1" />
)}
Apply
</Button>
</div>
</div>
</div>
);
})}
{audit.notes_promotions.map((p, i) => {
const k = `${assetType}:${m.id}:note_promotion:${p.target_field}:${i}`;
const busy = busyKey === `${assetType}:${m.id}:note_promotion:${p.target_field}`;
return (
<div
key={k}
className="border-l-4 border-primary/40 bg-primary/5 rounded p-2"
>
<div className="flex items-start justify-between gap-2 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-xs font-mono italic text-muted-foreground break-words">
&ldquo;{p.quoted_note_text}&rdquo;
</p>
<p className="text-xs mt-1">
<span className="font-medium">{p.target_field}</span>:{' '}
<span className="font-mono break-all">{p.suggested_value}</span>
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<Badge variant="outline" className="text-[10px] uppercase">
{p.confidence}
</Badge>
<Button
size="sm"
disabled={!canWrite || busy}
onClick={() => onApply(p, 'note_promotion', audit.id)}
title={!canWrite ? 'Requires admin' : 'Apply to IT Glue'}
>
{busy ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<CheckCircle2 className="w-3 h-3 mr-1" />
)}
Apply
</Button>
</div>
</div>
</div>
);
})}
{audit.contradictions.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1">
{audit.contradictions.map((c, i) => (
<p key={i}>
{c.description}
<span className="ml-1"> {c.evidence}</span>
</p>
))}
</div>
)}
</div>
)}
</div>
);
}
interface MatchedAssetExt extends MatchedAsset {
hostname?: string | null;
}
void ({} as MatchedAssetExt);

View file

@ -0,0 +1,75 @@
'use client';
import { Sparkles, Zap } from 'lucide-react';
export type AnalyzerProvider = 'anthropic' | 'openrouter';
interface ProviderToggleProps {
value: AnalyzerProvider;
onChange: (next: AnalyzerProvider) => void;
disabled?: boolean;
size?: 'sm' | 'md';
}
const OPTIONS: Array<{
value: AnalyzerProvider;
label: string;
hint: string;
icon: typeof Sparkles;
}> = [
{
value: 'anthropic',
label: 'Claude',
hint: 'Haiku → Sonnet → Opus',
icon: Sparkles,
},
{
value: 'openrouter',
label: 'DeepSeek',
hint: 'V4 Flash → V4 Pro → R1',
icon: Zap,
},
];
export function ProviderToggle({
value,
onChange,
disabled = false,
size = 'md',
}: ProviderToggleProps) {
const padding = size === 'sm' ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm';
return (
<div
className="inline-flex rounded-md border bg-muted/40 p-0.5"
role="radiogroup"
aria-label="LLM provider"
>
{OPTIONS.map((opt) => {
const Icon = opt.icon;
const active = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={active}
disabled={disabled}
onClick={() => onChange(opt.value)}
title={opt.hint}
className={[
padding,
'rounded-sm flex items-center gap-1.5 transition-colors',
active
? 'bg-background shadow-sm font-medium'
: 'text-muted-foreground hover:text-foreground',
disabled ? 'opacity-50 cursor-not-allowed' : '',
].join(' ')}
>
<Icon className={size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
{opt.label}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,368 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { toast } from 'sonner';
import { Sparkles, Loader2, Network } from 'lucide-react';
import type {
AggregateReportStatus,
DiscoveredLinks,
TicketRef,
} from '@/lib/types/analyzer';
interface RelatedTicketsPanelProps {
ticketNumber: string;
/** LLM provider for the bundle run. Defaults to 'anthropic'. */
provider?: 'anthropic' | 'openrouter';
}
type Phase =
| 'idle'
| 'starting'
| 'pending_analyses'
| 'pending'
| 'running'
| 'complete'
| 'failed';
const REPORT_POLL_TIMEOUT_MS = 10 * 60 * 1000;
export function RelatedTicketsPanel({
ticketNumber,
provider = 'anthropic',
}: RelatedTicketsPanelProps) {
const router = useRouter();
const [links, setLinks] = useState<DiscoveredLinks | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [includeSuggested, setIncludeSuggested] = useState(false);
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
const [phase, setPhase] = useState<Phase>('idle');
const [statusLabel, setStatusLabel] = useState<string>('');
// Initial cheap fetch.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/links`
);
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const data = (await res.json()) as DiscoveredLinks;
if (cancelled) return;
setLinks(data);
// Pre-check all explicit refs.
setSelected(new Set(data.explicit.map((r) => r.ticket_number)));
} catch (err) {
if (!cancelled)
setLoadError(err instanceof Error ? err.message : 'Unknown error');
}
})();
return () => {
cancelled = true;
};
}, [ticketNumber]);
async function loadSuggestions(): Promise<void> {
if (!links) return;
setSuggestionsLoading(true);
try {
const res = await fetch(
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/links`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ includeSuggested: true }),
}
);
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const data = (await res.json()) as DiscoveredLinks;
setLinks(data);
} catch (err) {
toast.error(
err instanceof Error
? `Suggestion failed: ${err.message}`
: 'Suggestion failed'
);
setIncludeSuggested(false);
} finally {
setSuggestionsLoading(false);
}
}
function toggleRef(ref: TicketRef): void {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(ref.ticket_number)) next.delete(ref.ticket_number);
else next.add(ref.ticket_number);
return next;
});
}
async function pollReport(reportId: string): Promise<void> {
const start = Date.now();
while (Date.now() - start < REPORT_POLL_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, 3000));
const res = await fetch(`/api/analyzer/aggregate-reports/${reportId}`);
if (!res.ok) throw new Error(`Report poll failed: ${res.status}`);
const data = (await res.json()) as {
report: { status: AggregateReportStatus; errorMessage: string | null };
};
const status = data.report.status;
setPhase(status as Phase);
setStatusLabel(
status === 'pending_analyses'
? 'Analyzing linked tickets…'
: status === 'pending' || status === 'running'
? 'Building bundle report…'
: status === 'complete'
? 'Done'
: status === 'failed'
? 'Failed'
: ''
);
if (status === 'complete') {
router.push(`/analyzer/reports/${reportId}`);
return;
}
if (status === 'failed') {
throw new Error(data.report.errorMessage ?? 'Bundle report failed');
}
}
throw new Error('Bundle report timed out after 10 minutes');
}
async function submit(opts: { confirmedCost?: boolean } = {}): Promise<void> {
if (selected.size === 0) {
toast.error('Select at least one linked ticket');
return;
}
setPhase('starting');
setStatusLabel('Queueing analyses…');
try {
const res = await fetch(
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/analyze-bundle`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
linkedTicketNumbers: Array.from(selected),
includeItglueContext: true,
confirmedCost: opts.confirmedCost ?? false,
provider,
}),
}
);
if (res.status === 400) {
const data = (await res.json().catch(() => ({}))) as {
requiresConfirmation?: boolean;
message?: string;
estimatedCost?: number;
};
if (data.requiresConfirmation) {
const ok = window.confirm(
`${data.message ?? 'Confirmation required'}.\n\nEstimated cost: $${data.estimatedCost?.toFixed(2) ?? '?'}\n\nProceed?`
);
if (ok) {
await submit({ confirmedCost: true });
return;
}
setPhase('idle');
setStatusLabel('');
return;
}
throw new Error(data.message ?? 'Bundle request rejected');
}
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const data = (await res.json()) as {
aggregateReportId: string;
status: AggregateReportStatus;
};
setPhase(data.status as Phase);
setStatusLabel(
data.status === 'pending_analyses'
? 'Analyzing linked tickets…'
: 'Building bundle report…'
);
await pollReport(data.aggregateReportId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Bundle failed');
setPhase('idle');
setStatusLabel('');
}
}
const allRefs = useMemo<TicketRef[]>(
() => (links ? [...links.explicit, ...links.suggested] : []),
[links]
);
const isRunning = phase !== 'idle' && phase !== 'failed' && phase !== 'complete';
const selectedCount = selected.size;
const hasContent = links && (links.explicit.length > 0 || links.isProblemTicket);
if (loadError) {
return (
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t check for related tickets</AlertTitle>
<AlertDescription>{loadError}</AlertDescription>
</Alert>
);
}
if (links === null) {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
);
}
if (!hasContent) {
// Nothing to show — render nothing, the regular AnalyzeButton on the
// parent page is sufficient.
return null;
}
return (
<Card className={links.isProblemTicket ? 'border-primary' : ''}>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Network className="w-5 h-5" />
<CardTitle className="text-base">
Related tickets detected ({allRefs.length})
</CardTitle>
{links.isProblemTicket && (
<Badge variant="secondary">Problem ticket</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Switch
id="include-suggested"
checked={includeSuggested}
disabled={isRunning || suggestionsLoading}
onCheckedChange={(v) => {
const next = Boolean(v);
setIncludeSuggested(next);
if (next && links.suggested.length === 0) {
void loadSuggestions();
}
}}
/>
<Label htmlFor="include-suggested" className="text-xs">
{suggestionsLoading ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Asking AI
</span>
) : (
'AI-suggest more'
)}
</Label>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{links.isProblemTicket
? 'This looks like a problem/master ticket. Bundling will analyze every linked ticket and produce a cross-ticket report.'
: 'This ticket references other tickets. Bundle them to get a cross-ticket analysis.'}
</p>
<ul className="divide-y">
{allRefs.map((ref) => (
<li
key={ref.ticket_number + ':' + ref.source}
className="py-2 flex items-start gap-3"
>
<Checkbox
id={'rt-' + ref.ticket_number}
checked={selected.has(ref.ticket_number)}
onCheckedChange={() => toggleRef(ref)}
disabled={isRunning}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm">{ref.ticket_number}</span>
{ref.confidence === 'high' && (
<Badge variant="default" className="text-[10px] py-0">
explicit
</Badge>
)}
{ref.source === 'llm_suggested' && (
<Badge variant="outline" className="text-[10px] py-0">
AI-suggested
</Badge>
)}
{ref.status_label && (
<Badge variant="secondary" className="text-[10px] py-0">
{ref.status_label}
</Badge>
)}
</div>
{ref.title && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{ref.title}
</p>
)}
{ref.reason && (
<p className="text-xs text-muted-foreground italic mt-0.5">
{ref.reason}
</p>
)}
</div>
</li>
))}
</ul>
<div className="flex items-center gap-2 flex-wrap pt-2">
<Button
onClick={() => void submit()}
disabled={isRunning || selectedCount === 0}
variant={links.isProblemTicket ? 'default' : 'secondary'}
>
{isRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{statusLabel || 'Working…'}
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
Analyze with {selectedCount} linked ticket
{selectedCount === 1 ? '' : 's'}
</>
)}
</Button>
<span className="text-xs text-muted-foreground">
({selectedCount + 1} total master + linked)
</span>
</div>
</CardContent>
</Card>
);
}

View file

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
@ -14,20 +14,104 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Share2 } from 'lucide-react';
import { Share2, Clock, Users, Check } from 'lucide-react';
import { toast } from 'sonner';
interface ShareModalProps {
analysisId: string;
}
interface DirectoryEntry {
email: string;
displayName: string | null;
jobTitle: string | null;
department: string | null;
}
interface RecentEntry {
email: string;
lastSharedAt: string;
}
interface RecipientsResponse {
recent: RecentEntry[];
directory: DirectoryEntry[];
allowedDomains: string[];
}
const MAX_DIRECTORY_VISIBLE = 12;
function timeAgo(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
if (ms < hour) return `${Math.max(1, Math.round(ms / minute))}m ago`;
if (ms < day) return `${Math.round(ms / hour)}h ago`;
return `${Math.round(ms / day)}d ago`;
}
export function ShareModal({ analysisId }: ShareModalProps) {
const [open, setOpen] = useState(false);
const [recipientEmail, setRecipientEmail] = useState('');
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
const [recipients, setRecipients] = useState<RecipientsResponse | null>(null);
const [recipientsError, setRecipientsError] = useState<string | null>(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Fetch recipients lazily on first dialog open.
useEffect(() => {
if (!open) return;
if (recipients !== null) return;
let cancelled = false;
(async () => {
try {
const res = await fetch('/api/analyzer/share/recipients');
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const data = (await res.json()) as RecipientsResponse;
if (!cancelled) setRecipients(data);
} catch (err) {
if (!cancelled) {
setRecipientsError(
err instanceof Error ? err.message : 'Unknown error'
);
}
}
})();
return () => {
cancelled = true;
};
}, [open, recipients]);
const filteredDirectory = useMemo(() => {
if (!recipients) return [];
const q = recipientEmail.trim().toLowerCase();
if (!q) return recipients.directory.slice(0, MAX_DIRECTORY_VISIBLE);
return recipients.directory
.filter(
(d) =>
d.email.toLowerCase().includes(q) ||
(d.displayName ?? '').toLowerCase().includes(q)
)
.slice(0, MAX_DIRECTORY_VISIBLE);
}, [recipients, recipientEmail]);
const hasRecent = (recipients?.recent.length ?? 0) > 0;
function pick(email: string): void {
setRecipientEmail(email);
setShowSuggestions(false);
inputRef.current?.blur();
}
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setSubmitting(true);
try {
@ -66,8 +150,16 @@ export function ShareModal({ analysisId }: ShareModalProps) {
}
}
// Reset transient state when the dialog closes.
function onOpenChange(next: boolean): void {
setOpen(next);
if (!next) {
setShowSuggestions(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Share2 className="w-4 h-4 mr-2" />
@ -78,22 +170,107 @@ export function ShareModal({ analysisId }: ShareModalProps) {
<DialogHeader>
<DialogTitle>Share this analysis</DialogTitle>
<DialogDescription>
Recipient must be on an allowed domain (set via
ALLOWED_SHARE_DOMAINS).
{recipients?.allowedDomains.length
? `Allowed domains: ${recipients.allowedDomains.join(', ')}`
: 'Recipient must be on an allowed domain.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="recipient">Recipient email</Label>
<Input
id="recipient"
type="email"
required
value={recipientEmail}
onChange={(e) => setRecipientEmail(e.target.value)}
placeholder="colleague@wulfconsulting.com"
/>
<Label htmlFor="recipient">Recipient</Label>
<div className="relative">
<Input
id="recipient"
ref={inputRef}
type="email"
required
autoComplete="off"
value={recipientEmail}
onChange={(e) => {
setRecipientEmail(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)}
onBlur={() => {
// Delay so a click on a suggestion lands before we hide.
setTimeout(() => setShowSuggestions(false), 150);
}}
placeholder="Search teammates or type an email…"
/>
{showSuggestions && recipients !== null && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md max-h-72 overflow-auto">
{hasRecent && recipientEmail.trim().length === 0 && (
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" /> Recent
</div>
)}
{hasRecent &&
recipientEmail.trim().length === 0 &&
recipients.recent.slice(0, 3).map((r) => (
<button
key={'recent-' + r.email}
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2"
onMouseDown={(e) => {
e.preventDefault();
pick(r.email);
}}
>
<span className="truncate">{r.email}</span>
<span className="text-xs text-muted-foreground shrink-0">
{timeAgo(r.lastSharedAt)}
</span>
</button>
))}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground flex items-center gap-1 border-t">
<Users className="w-3 h-3" /> Directory
</div>
{filteredDirectory.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
No matching directory users.
</div>
) : (
filteredDirectory.map((d) => (
<button
key={'dir-' + d.email}
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-start justify-between gap-2"
onMouseDown={(e) => {
e.preventDefault();
pick(d.email);
}}
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{d.displayName ?? d.email}
</div>
{d.displayName && (
<div className="truncate text-xs text-muted-foreground">
{d.email}
{d.jobTitle ? ` · ${d.jobTitle}` : ''}
</div>
)}
</div>
{recipientEmail === d.email && (
<Check className="w-4 h-4 shrink-0 text-primary" />
)}
</button>
))
)}
</div>
)}
</div>
{recipientsError && (
<p className="text-xs text-muted-foreground">
Couldn&rsquo;t load directory ({recipientsError}). Type any
allowed-domain email to share.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="note">Note (optional)</Label>
<Textarea
@ -105,6 +282,7 @@ export function ShareModal({ analysisId }: ShareModalProps) {
maxLength={2000}
/>
</div>
<DialogFooter>
<Button
type="button"

View file

@ -6,27 +6,13 @@ import { cn } from '@/lib/utils';
import {
LayoutDashboard,
Server,
Network,
Globe,
Smartphone,
Database,
RefreshCw,
ChevronDown,
Activity,
HardDrive,
Workflow,
GitBranch,
Sparkles,
Bell,
Zap,
Radio,
Shield,
Users,
TrendingUp,
Sun,
BarChart3,
DollarSign,
SlidersHorizontal,
GitCompare,
Brain,
Search,
@ -130,115 +116,25 @@ const navigationItems: NavItem[] = [
icon: AlertTriangle,
description: 'Analyses flagged for human review (low confidence or cost-ceiling skipped Opus)',
},
{
title: 'IT Glue — Applications',
href: '/analyzer/itglue/applications',
icon: Database,
description: 'Audit IT Glue Application records against ticket history; admins can apply or revert documentation changes',
},
{
title: 'IT Glue — Configurations',
href: '/analyzer/itglue/configurations',
icon: Database,
description: 'Audit IT Glue Configuration records (servers, workstations, devices) against ticket history; admins can apply or revert',
},
],
},
{
title: 'Admin',
href: '/admin',
icon: Activity,
children: [
{
title: 'Integrations & Sync',
href: '/admin/sync',
icon: RefreshCw,
description: 'Manage sync across PSA, RMM, NMS, Backup, and Apple RMM'
},
{
title: 'NMS Mapping (Auvik)',
href: '/auvik-mappings',
icon: Network,
description: 'Map Auvik tenants to companies'
},
{
title: 'RMM Mapping (Datto)',
href: '/rmm-mappings',
icon: Globe,
description: 'Map RMM sites to companies'
},
{
title: 'Zabbix WAN Monitor',
href: '/admin/zabbix-wan',
icon: Radio,
description: 'Sync RMM site WAN IPs to Zabbix with Autotask routing'
},
{
title: 'Apple RMM Mapping (Addigy)',
href: '/addigy-mappings',
icon: Smartphone,
description: 'Map Addigy devices to companies'
},
{
title: 'Ticket Workflows',
href: '/admin/workflow',
icon: Workflow,
description: 'Automated ticket triage and classification'
},
{
title: 'Classification Rules',
href: '/admin/workflow/classification-rules',
icon: GitBranch,
description: 'Keyword-based classification rules'
},
{
title: 'AI Templates',
href: '/admin/workflow/ai-templates',
icon: Sparkles,
description: 'AI prompt templates for enhancement'
},
{
title: 'Webhook Pipelines',
href: '/admin/workflow/pipelines',
icon: Zap,
description: 'Automated webhook processing workflows'
},
{
title: 'Morning NOC Summary',
href: '/admin/morning-summary',
icon: Sun,
description: 'Daily Zabbix overnight summary posted to Teams channels via webhook'
},
{
title: 'Ticket Digest Reports',
href: '/admin/ticket-digest',
icon: BarChart3,
description: 'LLM-analyzed ticket reports — noise, SLA, workload — daily/weekly/monthly'
},
{
title: 'Notification Channels',
href: '/admin/workflow/channels',
icon: Bell,
description: 'Teams, Telegram, and webhook notifications'
},
{
title: 'IT Glue Sync',
href: '/admin/sync/itglue',
icon: Shield,
description: 'IT Glue documentation backup — organizations, configs, passwords, flexible assets'
},
{
title: 'SentinelOne Sync',
href: '/admin/sync/sentinelone',
icon: Shield,
description: 'SentinelOne EDR — sites, agents, threats sync'
},
{
title: 'QuickBooks Online',
href: '/admin/qbo',
icon: DollarSign,
description: 'Sync invoices, payments, deposits, transactions and financial reports'
},
{
title: 'Display Settings',
href: '/admin/display-settings',
icon: SlidersHorizontal,
description: 'Configure company filters for Kiosk and Mobile dashboards'
},
{
title: 'Data Browser',
href: '/admin/data-browser',
icon: Database,
description: 'Browse and query system data'
},
]
description: 'Sync, mappings, workflow, reports, tools & access'
},
];

View file

@ -0,0 +1,189 @@
'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>
);
}

View file

@ -0,0 +1,183 @@
'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>
);
}

View file

@ -0,0 +1,198 @@
'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>
);
}