535 lines
20 KiB
TypeScript
535 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import Link from 'next/link';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
ArrowLeft,
|
|
Loader2,
|
|
Play,
|
|
Globe,
|
|
CheckCircle2,
|
|
XCircle,
|
|
AlertTriangle,
|
|
MinusCircle,
|
|
Filter,
|
|
RefreshCw,
|
|
Building2,
|
|
Server,
|
|
GitFork,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
type SyncMode = 'all' | 'client' | 'site';
|
|
|
|
interface SiteResult {
|
|
siteName: string;
|
|
siteUid: string;
|
|
companyId: number | null;
|
|
companyName: string | null;
|
|
wanIp: string | null;
|
|
qualifyingDevices: number;
|
|
multiWan: boolean;
|
|
singleDeviceFallback: boolean;
|
|
isp: string | null;
|
|
asn: string | null;
|
|
action: 'created' | 'updated' | 'filtered' | 'no-ip' | 'error' | 'skipped';
|
|
hostId: string | null;
|
|
filterReason?: string;
|
|
error?: string;
|
|
}
|
|
|
|
interface Mapping {
|
|
company_id: number;
|
|
company_name: string;
|
|
rmm_site_uid: string;
|
|
rmm_site_name: string;
|
|
}
|
|
|
|
interface Stats {
|
|
total: number;
|
|
created: number;
|
|
updated: number;
|
|
filtered: number;
|
|
noIp: number;
|
|
errors: number;
|
|
skipped: number;
|
|
multiWan: number;
|
|
}
|
|
|
|
const ACTION_CONFIG: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; icon: React.ElementType }> = {
|
|
created: { label: 'Created', variant: 'default', icon: CheckCircle2 },
|
|
updated: { label: 'Updated', variant: 'secondary', icon: RefreshCw },
|
|
filtered: { label: 'Filtered', variant: 'outline', icon: Filter },
|
|
'no-ip': { label: 'No IP', variant: 'outline', icon: MinusCircle },
|
|
skipped: { label: 'Dry Run', variant: 'outline', icon: MinusCircle },
|
|
error: { label: 'Error', variant: 'destructive', icon: XCircle },
|
|
};
|
|
|
|
function ActionBadge({ action }: { action: string }) {
|
|
const cfg = ACTION_CONFIG[action] ?? { label: action, variant: 'outline' as const, icon: MinusCircle };
|
|
const Icon = cfg.icon;
|
|
return (
|
|
<Badge variant={cfg.variant} className="gap-1 text-xs">
|
|
<Icon className="w-3 h-3" />
|
|
{cfg.label}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
export default function ZabbixWanPage() {
|
|
const [mode, setMode] = useState<SyncMode>('all');
|
|
const [companyId, setCompanyId] = useState<string>('');
|
|
const [siteUid, setSiteUid] = useState<string>('');
|
|
const [minDevices, setMinDevices] = useState(2);
|
|
const [maxLastSeenHours, setMaxLastSeenHours] = useState(48);
|
|
const [allowSingleDevice, setAllowSingleDevice] = useState(false);
|
|
const [dryRun, setDryRun] = useState(true);
|
|
|
|
const [mappings, setMappings] = useState<Mapping[]>([]);
|
|
const [loadingMappings, setLoadingMappings] = useState(true);
|
|
|
|
const [running, setRunning] = useState(false);
|
|
const [results, setResults] = useState<SiteResult[]>([]);
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [fatalError, setFatalError] = useState<string | null>(null);
|
|
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const tableBottomRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
fetch('/api/rmm/site-mappings')
|
|
.then((r) => r.json())
|
|
.then((d) => setMappings(d.mappings ?? []))
|
|
.catch(() => toast.error('Failed to load site mappings'))
|
|
.finally(() => setLoadingMappings(false));
|
|
}, []);
|
|
|
|
// Scroll results table as rows stream in
|
|
useEffect(() => {
|
|
if (running) tableBottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}, [results.length, running]);
|
|
|
|
// Deduplicated company list
|
|
const companies = Array.from(
|
|
new Map(mappings.filter((m) => m.company_id).map((m) => [m.company_id, m.company_name])).entries()
|
|
)
|
|
.map(([id, name]) => ({ id, name }))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
// Sites list (for site mode) — sorted by name
|
|
const sites = [...mappings].sort((a, b) => a.rmm_site_name.localeCompare(b.rmm_site_name));
|
|
|
|
// Sites filtered by selected company (for client mode label display)
|
|
const selectedCompanyName = companies.find((c) => String(c.id) === companyId)?.name;
|
|
const selectedSiteName = sites.find((s) => s.rmm_site_uid === siteUid)?.rmm_site_name;
|
|
|
|
const canRun =
|
|
!running &&
|
|
!loadingMappings &&
|
|
(mode === 'all' || (mode === 'client' && !!companyId) || (mode === 'site' && !!siteUid));
|
|
|
|
const handleRun = async () => {
|
|
setRunning(true);
|
|
setResults([]);
|
|
setStats(null);
|
|
setFatalError(null);
|
|
|
|
const ctrl = new AbortController();
|
|
abortRef.current = ctrl;
|
|
|
|
try {
|
|
const resp = await fetch('/api/zabbix/sync-wan', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
mode,
|
|
companyId: companyId ? Number(companyId) : undefined,
|
|
siteUid: siteUid || undefined,
|
|
minDevices,
|
|
maxLastSeenHours,
|
|
allowSingleDevice,
|
|
dryRun,
|
|
}),
|
|
signal: ctrl.signal,
|
|
});
|
|
|
|
if (!resp.ok || !resp.body) {
|
|
throw new Error(`Server error: ${resp.status}`);
|
|
}
|
|
|
|
const reader = resp.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
const lines = buf.split('\n');
|
|
buf = lines.pop() ?? '';
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const msg = JSON.parse(line);
|
|
if (msg.type === 'site' && msg.result) {
|
|
setResults((prev) => [...prev, msg.result]);
|
|
} else if (msg.type === 'summary') {
|
|
setStats(msg.stats);
|
|
} else if (msg.type === 'error') {
|
|
setFatalError(msg.message);
|
|
toast.error(msg.message);
|
|
}
|
|
} catch { /* skip malformed line */ }
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
if (err.name !== 'AbortError') {
|
|
setFatalError(String(err));
|
|
toast.error('Run failed: ' + String(err));
|
|
}
|
|
} finally {
|
|
setRunning(false);
|
|
abortRef.current = null;
|
|
}
|
|
};
|
|
|
|
const handleStop = () => {
|
|
abortRef.current?.abort();
|
|
setRunning(false);
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto py-8 max-w-6xl space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3">
|
|
<Link href="/admin/sync">
|
|
<Button variant="ghost" size="sm" className="gap-2">
|
|
<ArrowLeft className="w-4 h-4" /> Back
|
|
</Button>
|
|
</Link>
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
|
<Globe className="w-6 h-6" /> Zabbix WAN Monitor Setup
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
Create or update Zabbix hosts with WAN IPs and Autotask macros for alert routing
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Config card */}
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="text-base">Run Configuration</CardTitle>
|
|
<CardDescription>Select scope, filters, and whether to write to Zabbix</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Mode */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">Scope</Label>
|
|
<div className="flex gap-2">
|
|
{(['all', 'client', 'site'] as SyncMode[]).map((m) => (
|
|
<button
|
|
key={m}
|
|
onClick={() => { setMode(m); setCompanyId(''); setSiteUid(''); }}
|
|
className={`px-4 py-2 rounded-md text-sm font-medium border transition-colors ${
|
|
mode === m
|
|
? 'bg-primary text-primary-foreground border-primary'
|
|
: 'bg-background border-border hover:bg-accent'
|
|
}`}
|
|
>
|
|
{m === 'all' ? 'All Sites' : m === 'client' ? 'By Client' : 'Single Site'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Client selector */}
|
|
{mode === 'client' && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium flex items-center gap-1.5">
|
|
<Building2 className="w-3.5 h-3.5" /> Client
|
|
</Label>
|
|
<Select value={companyId} onValueChange={setCompanyId} disabled={loadingMappings}>
|
|
<SelectTrigger className="w-80">
|
|
<SelectValue placeholder={loadingMappings ? 'Loading…' : 'Select a client'} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{companies.map((c) => (
|
|
<SelectItem key={c.id} value={String(c.id)}>
|
|
{c.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Site selector */}
|
|
{mode === 'site' && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium flex items-center gap-1.5">
|
|
<Server className="w-3.5 h-3.5" /> Site
|
|
</Label>
|
|
<Select value={siteUid} onValueChange={setSiteUid} disabled={loadingMappings}>
|
|
<SelectTrigger className="w-80">
|
|
<SelectValue placeholder={loadingMappings ? 'Loading…' : 'Select a site'} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sites.map((s) => (
|
|
<SelectItem key={s.rmm_site_uid} value={s.rmm_site_uid}>
|
|
{s.rmm_site_name}
|
|
{s.company_name ? ` (${s.company_name})` : ''}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="min-devices" className="text-sm font-medium">
|
|
Min devices with same public IP
|
|
</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
id="min-devices"
|
|
type="number"
|
|
min={1}
|
|
max={50}
|
|
value={minDevices}
|
|
onChange={(e) => setMinDevices(Math.max(1, Number(e.target.value)))}
|
|
className="w-24"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">device(s)</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Skip site if fewer than this many devices share the top WAN IP
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="last-seen" className="text-sm font-medium">
|
|
Max device last-seen age
|
|
</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
id="last-seen"
|
|
type="number"
|
|
min={1}
|
|
max={8760}
|
|
value={maxLastSeenHours}
|
|
onChange={(e) => setMaxLastSeenHours(Math.max(1, Number(e.target.value)))}
|
|
className="w-24"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">hours</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Only count devices seen within this window
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Single-device fallback */}
|
|
<div className="flex items-center gap-3">
|
|
<Switch
|
|
id="allow-single"
|
|
checked={allowSingleDevice}
|
|
onCheckedChange={setAllowSingleDevice}
|
|
/>
|
|
<div>
|
|
<Label htmlFor="allow-single" className="text-sm font-medium cursor-pointer">
|
|
Allow single-device fallback
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
If laptop exclusion removes all IPs, accept any single device (desktop, server, network, etc.)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dry-run + actions */}
|
|
<div className="flex items-center justify-between pt-2 border-t">
|
|
<div className="flex items-center gap-3">
|
|
<Switch
|
|
id="dry-run"
|
|
checked={dryRun}
|
|
onCheckedChange={setDryRun}
|
|
/>
|
|
<div>
|
|
<Label htmlFor="dry-run" className="text-sm font-medium cursor-pointer">
|
|
Dry run
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Preview what would happen — no writes to Zabbix
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{running && (
|
|
<Button variant="outline" size="sm" onClick={handleStop}>
|
|
Stop
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={handleRun}
|
|
disabled={!canRun}
|
|
className="gap-2"
|
|
>
|
|
{running ? (
|
|
<><Loader2 className="w-4 h-4 animate-spin" /> Running…</>
|
|
) : (
|
|
<><Play className="w-4 h-4" /> {dryRun ? 'Preview' : 'Run'}</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Results */}
|
|
{(results.length > 0 || running || fatalError) && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
Results
|
|
{running && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
{results.length} site{results.length !== 1 ? 's' : ''} processed
|
|
{stats ? '' : running ? '…' : ''}
|
|
</span>
|
|
</CardTitle>
|
|
|
|
{/* Summary stats */}
|
|
{stats && (
|
|
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
|
{stats.created > 0 && <span className="text-green-600 font-medium">{stats.created} created</span>}
|
|
{stats.updated > 0 && <span className="text-blue-600 font-medium">{stats.updated} updated</span>}
|
|
{stats.skipped > 0 && <span>{stats.skipped} dry-run</span>}
|
|
{stats.filtered > 0 && <span className="text-yellow-600">{stats.filtered} filtered</span>}
|
|
{stats.noIp > 0 && <span>{stats.noIp} no-ip</span>}
|
|
{stats.multiWan > 0 && <span className="text-orange-500 font-medium">{stats.multiWan} multi-WAN</span>}
|
|
{stats.errors > 0 && <span className="text-red-600 font-medium">{stats.errors} errors</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{fatalError && (
|
|
<div className="flex items-center gap-2 text-sm text-destructive rounded-md border border-destructive/30 bg-destructive/5 p-3 mt-2">
|
|
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
|
{fatalError}
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="p-0">
|
|
<div className="max-h-[520px] overflow-y-auto">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 bg-background z-10">
|
|
<TableRow>
|
|
<TableHead>Site</TableHead>
|
|
<TableHead>Client</TableHead>
|
|
<TableHead>WAN IP</TableHead>
|
|
<TableHead>ISP</TableHead>
|
|
<TableHead className="text-center">Devices</TableHead>
|
|
<TableHead>Action</TableHead>
|
|
<TableHead>Reason</TableHead>
|
|
<TableHead>Zabbix ID</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{results.map((r, i) => (
|
|
<TableRow key={i} className={r.action === 'error' ? 'bg-destructive/5' : ''}>
|
|
<TableCell className="font-medium text-sm">{r.siteName}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{r.companyName ?? <span className="italic text-muted-foreground/60">unmapped</span>}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="space-y-0.5">
|
|
<span className="font-mono text-sm">
|
|
{r.wanIp ?? <span className="text-muted-foreground">—</span>}
|
|
</span>
|
|
{r.multiWan && (
|
|
<div className="flex items-center gap-1 text-xs text-orange-500">
|
|
<GitFork className="w-3 h-3" /> Multi-WAN
|
|
</div>
|
|
)}
|
|
{r.singleDeviceFallback && (
|
|
<div className="flex items-center gap-1 text-xs text-yellow-500">
|
|
<AlertTriangle className="w-3 h-3" /> Single device
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{r.isp ? (
|
|
<div className="space-y-0.5">
|
|
<span>{r.isp}</span>
|
|
{r.asn && <div className="text-xs text-muted-foreground">{r.asn}</div>}
|
|
</div>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-center text-sm tabular-nums">
|
|
{r.qualifyingDevices > 0 ? r.qualifyingDevices : '—'}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="space-y-1">
|
|
<ActionBadge action={r.action} />
|
|
{r.error && (
|
|
<p className="text-xs text-destructive leading-tight max-w-[240px] truncate" title={r.error}>
|
|
{r.error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground max-w-[220px]">
|
|
{r.filterReason ?? '—'}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm text-muted-foreground">
|
|
{r.hostId ?? '—'}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
<div ref={tableBottomRef} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{results.length === 0 && !running && !fatalError && (
|
|
<div className="text-center py-16 text-muted-foreground text-sm">
|
|
Configure your options above and click {dryRun ? 'Preview' : 'Run'} to start.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|