wulf-pulse/app/admin/zabbix-wan/page.tsx
lorentz c518eefdb2 feat: Morning NOC Summary adaptive card for Teams
- Add MorningSummaryService with Zabbix aggregation and adaptive card builder
- Add webhook delivery system with Teams incoming webhooks
- Add admin UI at /admin/morning-summary for webhook/config management
- Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history
- Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM)
- Add outages_only filter (Unavailable triggers only)
- Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts
- Fix resolved events: event.get value:1 scoped to window with r_eventid filter
- Remove emojis from fact rows and section headers in card
- Remove Open Zabbix button (duplicate of View Problems)
- Add migrations: morning_summary_config + morning_summaries tables
- Add outages_only column to morning_summary_config
2026-03-11 09:34:51 -04:00

737 lines
28 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,
Plus,
ChevronDown,
ChevronRight,
Network,
} from 'lucide-react';
import { toast } from 'sonner';
import { HostManager } from '@/components/zabbix/host-manager';
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);
// Manual host creation state
const [manualOpen, setManualOpen] = useState(false);
const [manualIp, setManualIp] = useState('');
const [manualSiteName, setManualSiteName] = useState('');
const [manualCompanyId, setManualCompanyId] = useState<string>('');
const [manualDryRun, setManualDryRun] = useState(true);
const [manualRunning, setManualRunning] = useState(false);
const [manualResult, setManualResult] = useState<{
action: string;
dryRun: boolean;
siteName: string;
ip: string;
companyName: string | null;
isp: string | null;
asn: string | null;
hostId: string | null;
error?: string;
} | null>(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));
// Pre-fill manual IP with the user's current public IP (client-side)
fetch('https://ipinfo.io/json')
.then((r) => r.json())
.then((d) => { if (d.ip) setManualIp(d.ip); })
.catch(() => { /* ignore */ });
}, []);
// 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);
};
// Manual host creation
const ipv4Valid = /^(\d{1,3}\.){3}\d{1,3}$/.test(manualIp);
const canCreateManual = !manualRunning && manualIp.trim() !== '' && manualSiteName.trim() !== '' && ipv4Valid;
const handleManualCreate = async () => {
setManualRunning(true);
setManualResult(null);
try {
const resp = await fetch('/api/zabbix/create-host', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip: manualIp.trim(),
siteName: manualSiteName.trim(),
companyId: manualCompanyId && manualCompanyId !== 'none' ? Number(manualCompanyId) : undefined,
dryRun: manualDryRun,
}),
});
const data = await resp.json();
if (!resp.ok) {
setManualResult({ action: 'error', dryRun: manualDryRun, siteName: manualSiteName, ip: manualIp, companyName: null, isp: null, asn: null, hostId: null, error: data.error });
toast.error(data.error ?? 'Failed to create host');
} else {
setManualResult(data);
if (data.action === 'created') toast.success(`Host "${data.siteName}" created (id=${data.hostId})`);
else if (data.action === 'updated') toast.success(`Host "${data.siteName}" updated (id=${data.hostId})`);
else if (data.action === 'skipped') toast.info('Dry run — no changes written to Zabbix');
}
} catch (err) {
setManualResult({ action: 'error', dryRun: manualDryRun, siteName: manualSiteName, ip: manualIp, companyName: null, isp: null, asn: null, hostId: null, error: String(err) });
toast.error('Request failed: ' + String(err));
} finally {
setManualRunning(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>
{/* Manual Host Creation */}
<Card>
<CardHeader
className="pb-4 cursor-pointer select-none"
onClick={() => setManualOpen((v) => !v)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{manualOpen ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
<Network className="w-4 h-4" />
<CardTitle className="text-base">Manual Host</CardTitle>
</div>
<CardDescription className="mt-0">Create a Zabbix host from an IP address for testing or clients not in RMM</CardDescription>
</div>
</CardHeader>
{manualOpen && (
<CardContent className="space-y-5 pt-0">
<div className="grid grid-cols-2 gap-6">
{/* IP Address */}
<div className="space-y-2">
<Label htmlFor="manual-ip" className="text-sm font-medium">IP Address</Label>
<Input
id="manual-ip"
placeholder="203.0.113.42"
value={manualIp}
onChange={(e) => setManualIp(e.target.value)}
className={`w-56 font-mono ${manualIp && !ipv4Valid ? 'border-destructive' : ''}`}
/>
{manualIp && !ipv4Valid && (
<p className="text-xs text-destructive">Enter a valid IPv4 address</p>
)}
</div>
{/* Site Name */}
<div className="space-y-2">
<Label htmlFor="manual-site" className="text-sm font-medium">Site Name</Label>
<Input
id="manual-site"
placeholder="Acme Corp - Main Office"
value={manualSiteName}
onChange={(e) => setManualSiteName(e.target.value)}
className="w-80"
/>
<p className="text-xs text-muted-foreground">Becomes the Zabbix host display name</p>
</div>
</div>
{/* Client selector */}
<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 (optional)
</Label>
<Select value={manualCompanyId} onValueChange={setManualCompanyId} disabled={loadingMappings}>
<SelectTrigger className="w-80">
<SelectValue placeholder={loadingMappings ? 'Loading…' : 'No client (unlinked)'} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No client (unlinked)</SelectItem>
{companies.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Links the host to an Autotask client with macros, tags, and a client host group
</p>
</div>
{/* Dry-run + actions */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex items-center gap-3">
<Switch
id="manual-dry-run"
checked={manualDryRun}
onCheckedChange={setManualDryRun}
/>
<div>
<Label htmlFor="manual-dry-run" className="text-sm font-medium cursor-pointer">
Dry run
</Label>
<p className="text-xs text-muted-foreground">
Preview only no writes to Zabbix
</p>
</div>
</div>
<Button
onClick={handleManualCreate}
disabled={!canCreateManual}
className="gap-2"
>
{manualRunning ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Creating</>
) : (
<><Plus className="w-4 h-4" /> {manualDryRun ? 'Preview' : 'Create Host'}</>
)}
</Button>
</div>
{/* Manual result */}
{manualResult && (
<div className={`rounded-md border p-4 space-y-2 ${
manualResult.action === 'error'
? 'border-destructive/30 bg-destructive/5'
: manualResult.action === 'created'
? 'border-green-500/30 bg-green-500/5'
: manualResult.action === 'updated'
? 'border-blue-500/30 bg-blue-500/5'
: 'border-border bg-muted/20'
}`}>
<div className="flex items-center gap-3">
<ActionBadge action={manualResult.action} />
<span className="font-medium text-sm">{manualResult.siteName}</span>
<span className="font-mono text-sm text-muted-foreground">{manualResult.ip}</span>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
{manualResult.companyName && <span>Client: <strong>{manualResult.companyName}</strong></span>}
{manualResult.isp && <span>ISP: {manualResult.isp}</span>}
{manualResult.asn && <span>{manualResult.asn}</span>}
{manualResult.hostId && <span>Zabbix ID: <strong className="font-mono">{manualResult.hostId}</strong></span>}
{manualResult.dryRun && <span className="italic">Dry run no changes written</span>}
</div>
{manualResult.error && (
<p className="text-xs text-destructive">{manualResult.error}</p>
)}
</div>
)}
</CardContent>
)}
</Card>
{/* Host Manager */}
<HostManager companies={companies} />
{/* 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>
);
}