- 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>
234 lines
8.3 KiB
TypeScript
234 lines
8.3 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState, use } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { RmmScriptPicker } from '@/components/rmm/rmm-script-picker';
|
|
import { Server } from 'lucide-react';
|
|
|
|
interface SiteInfo {
|
|
companyId: string;
|
|
companyName: string | null;
|
|
itglueOrgId: string | null;
|
|
itglueOrgName: string | null;
|
|
dattoSiteId: number | null;
|
|
wnpHostname: string | null;
|
|
wnpDeviceUid: string | null;
|
|
wnpOnline: boolean | null;
|
|
sites: Array<{
|
|
device_uid: string;
|
|
hostname: string | null;
|
|
online: boolean;
|
|
site_id: number;
|
|
site_name: string;
|
|
site_device_count: number;
|
|
}>;
|
|
}
|
|
|
|
interface ExecRow {
|
|
id: string;
|
|
scriptId: string;
|
|
jobName: string;
|
|
targetHostname: string | null;
|
|
status: 'queued' | 'running' | 'complete' | 'failed' | 'timeout';
|
|
exitCode: number | null;
|
|
parsedEvidence: unknown;
|
|
queuedAt: string;
|
|
completedAt: string | null;
|
|
}
|
|
|
|
export default function SiteDiscoveryPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ companyId: string }>;
|
|
}) {
|
|
const { companyId } = use(params);
|
|
const [info, setInfo] = useState<SiteInfo | null>(null);
|
|
const [executions, setExecutions] = useState<ExecRow[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function loadAll(): Promise<void> {
|
|
try {
|
|
const [s, e] = await Promise.all([
|
|
fetch(`/api/analyzer/itglue/sites/${companyId}`).then((r) => r.json()),
|
|
fetch(`/api/rmm/executions?companyId=${companyId}&limit=50`).then((r) =>
|
|
r.json()
|
|
),
|
|
]);
|
|
if (s.error) throw new Error(s.error);
|
|
setInfo(s.site);
|
|
setExecutions(e.executions ?? []);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadAll();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [companyId]);
|
|
|
|
const recent = useMemo(() => executions ?? [], [executions]);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="container mx-auto px-6 py-6 max-w-4xl">
|
|
<Alert variant="destructive">
|
|
<AlertTitle>Couldn’t load this site</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!info) {
|
|
return (
|
|
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-4">
|
|
<Skeleton className="h-32 w-full" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
|
<div className="space-y-1 min-w-0">
|
|
<p className="text-sm text-muted-foreground">Site discovery</p>
|
|
<CardTitle className="text-2xl truncate">
|
|
{info.companyName ?? `Company ${info.companyId}`}
|
|
</CardTitle>
|
|
<p className="text-xs text-muted-foreground">
|
|
{info.wnpHostname ? (
|
|
<>
|
|
<Server className="inline w-3 h-3 mr-1" />
|
|
Primary target:{' '}
|
|
<span className="font-mono">{info.wnpHostname}</span>
|
|
{info.wnpOnline === false && (
|
|
<Badge variant="destructive" className="ml-2 text-[10px]">
|
|
offline
|
|
</Badge>
|
|
)}
|
|
{info.sites.length > 1 && (
|
|
<span className="ml-2">
|
|
({info.sites.length} WNP endpoints across this client’s sites)
|
|
</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="text-amber-600">
|
|
No Wulf Nurse Production endpoint registered for this client.
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<RmmScriptPicker
|
|
filter="site_anchor"
|
|
companyId={info.companyId}
|
|
onComplete={() => loadAll()}
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-muted-foreground">
|
|
Site-anchored discovery scripts run from the Wulf Nurse Production
|
|
endpoint and use native PowerShell + AD/DHCP/DNS cmdlets to gather
|
|
facts about the environment. Output is captured and made available
|
|
to subsequent IT Glue audits as live evidence.
|
|
</p>
|
|
{info.sites.length > 1 && (
|
|
<div className="mt-4">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">
|
|
All sites for this client
|
|
</p>
|
|
<ul className="text-xs space-y-1">
|
|
{info.sites.map((s) => (
|
|
<li
|
|
key={s.device_uid}
|
|
className="flex items-center justify-between gap-3"
|
|
>
|
|
<span>
|
|
<span className="font-mono">{s.hostname}</span>
|
|
<span className="ml-2 text-muted-foreground">
|
|
{s.site_name} · {s.site_device_count} devices
|
|
</span>
|
|
{s.hostname === info.wnpHostname && (
|
|
<Badge variant="secondary" className="ml-2 text-[10px]">
|
|
primary
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
{!s.online && (
|
|
<Badge variant="destructive" className="text-[10px]">
|
|
offline
|
|
</Badge>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
v1 dispatches site-anchored scripts to the primary target only.
|
|
A future version will let you pick a specific site here.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Recent runs ({recent.length})</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{recent.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No runs yet.</p>
|
|
) : (
|
|
<ul className="divide-y">
|
|
{recent.map((e) => (
|
|
<li key={e.id} className="py-3">
|
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium">
|
|
{e.scriptId}{' '}
|
|
<Badge
|
|
variant={
|
|
e.status === 'complete'
|
|
? 'default'
|
|
: e.status === 'failed' || e.status === 'timeout'
|
|
? 'destructive'
|
|
: 'outline'
|
|
}
|
|
className="ml-1 text-[10px]"
|
|
>
|
|
{e.status}
|
|
</Badge>
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{e.targetHostname ?? '—'} ·{' '}
|
|
{new Date(e.queuedAt).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{e.parsedEvidence !== null && e.parsedEvidence !== undefined && (
|
|
<details className="mt-2">
|
|
<summary className="text-xs text-muted-foreground cursor-pointer">
|
|
Show parsed evidence
|
|
</summary>
|
|
<pre className="text-[11px] bg-muted/50 rounded p-2 max-h-72 overflow-auto whitespace-pre-wrap mt-1">
|
|
{JSON.stringify(e.parsedEvidence, null, 2)}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|