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
This commit is contained in:
parent
19605f82aa
commit
c518eefdb2
61 changed files with 11236 additions and 237 deletions
467
app/admin/morning-summary/page.tsx
Normal file
467
app/admin/morning-summary/page.tsx
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Send, RefreshCw, Trash2, Plus, CheckCircle2, XCircle,
|
||||
AlertTriangle, Clock, Loader2, ChevronDown, ChevronUp, ToggleLeft, ToggleRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WebhookConfig {
|
||||
id: number;
|
||||
label: string;
|
||||
webhook_url: string;
|
||||
enabled: boolean;
|
||||
last_delivered_at: string | null;
|
||||
last_status: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SummaryConfig {
|
||||
weekend_suppression: boolean;
|
||||
monday_extended_window: boolean;
|
||||
severity_filter: number;
|
||||
outages_only: boolean;
|
||||
}
|
||||
|
||||
interface SummaryRow {
|
||||
id: number;
|
||||
generated_at: string;
|
||||
open_count: number;
|
||||
resolved_count: number;
|
||||
mttr_minutes: number | null;
|
||||
is_weekend_window: boolean;
|
||||
delivery_status: Record<string, { success: boolean; httpStatus?: number; error?: string }>;
|
||||
card_payload?: object;
|
||||
window_from?: string;
|
||||
window_to?: string;
|
||||
clients_affected?: string[];
|
||||
}
|
||||
|
||||
function fmtDate(d: string | null) {
|
||||
if (!d) return 'Never';
|
||||
const date = new Date(d);
|
||||
const diff = Date.now() - date.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string | null }) {
|
||||
if (!status) return <span className="text-xs text-muted-foreground">Never sent</span>;
|
||||
if (status === 'success') return (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<CheckCircle2 className="h-3 w-3" /> Success
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500">
|
||||
<XCircle className="h-3 w-3" /> Failed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MorningSummaryPage() {
|
||||
const [webhooks, setWebhooks] = useState<WebhookConfig[]>([]);
|
||||
const [config, setConfig] = useState<SummaryConfig | null>(null);
|
||||
const [history, setHistory] = useState<SummaryRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [testingId, setTestingId] = useState<number | null>(null);
|
||||
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
|
||||
const [cardExpanded, setCardExpanded] = useState(false);
|
||||
const [showAddWebhook, setShowAddWebhook] = useState(false);
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
const [addingWebhook, setAddingWebhook] = useState(false);
|
||||
|
||||
const showToast = (msg: string, ok: boolean) => {
|
||||
setToast({ msg, ok });
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
};
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [wRes, cRes, hRes] = await Promise.all([
|
||||
fetch('/api/notifications/morning-summary/webhooks'),
|
||||
fetch('/api/notifications/morning-summary/config'),
|
||||
fetch('/api/notifications/morning-summary/history'),
|
||||
]);
|
||||
const [wData, cData, hData] = await Promise.all([wRes.json(), cRes.json(), hRes.json()]);
|
||||
setWebhooks(wData.webhooks ?? []);
|
||||
setConfig(cData.config ?? null);
|
||||
setHistory(hData.history ?? []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchAll(); }, [fetchAll]);
|
||||
|
||||
const handleSendAll = async () => {
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch('/api/notifications/morning-summary/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
const ok = data.results?.filter((r: any) => r.success).length ?? 0;
|
||||
const fail = data.results?.filter((r: any) => !r.success).length ?? 0;
|
||||
showToast(`Sent — ${ok} succeeded, ${fail} failed`, fail === 0);
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (webhookId: number, label: string) => {
|
||||
setTestingId(webhookId);
|
||||
try {
|
||||
const res = await fetch('/api/notifications/morning-summary/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ webhookId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
showToast(data.result?.success ? `✅ Test sent to ${label}` : `❌ Test failed: ${data.result?.error ?? data.error}`, data.result?.success);
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleWebhook = async (webhook: WebhookConfig) => {
|
||||
try {
|
||||
await fetch(`/api/notifications/morning-summary/webhooks/${webhook.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !webhook.enabled }),
|
||||
});
|
||||
setWebhooks(prev => prev.map(w => w.id === webhook.id ? { ...w, enabled: !w.enabled } : w));
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWebhook = async (id: number) => {
|
||||
if (!confirm('Delete this webhook?')) return;
|
||||
try {
|
||||
await fetch(`/api/notifications/morning-summary/webhooks/${id}`, { method: 'DELETE' });
|
||||
setWebhooks(prev => prev.filter(w => w.id !== id));
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddWebhook = async () => {
|
||||
if (!newLabel.trim() || !newUrl.trim()) return;
|
||||
setAddingWebhook(true);
|
||||
try {
|
||||
const res = await fetch('/api/notifications/morning-summary/webhooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: newLabel.trim(), webhook_url: newUrl.trim() }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setWebhooks(prev => [...prev, data.webhook]);
|
||||
setNewLabel('');
|
||||
setNewUrl('');
|
||||
setShowAddWebhook(false);
|
||||
showToast('Webhook added', true);
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
} finally {
|
||||
setAddingWebhook(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigToggle = async (field: keyof SummaryConfig) => {
|
||||
if (!config) return;
|
||||
const updated = { ...config, [field]: !config[field] };
|
||||
setConfig(updated);
|
||||
try {
|
||||
await fetch('/api/notifications/morning-summary/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: updated[field] }),
|
||||
});
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
setConfig(config);
|
||||
}
|
||||
};
|
||||
|
||||
const latestSummary = history[0] ?? null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2 ${toast.ok ? 'bg-green-500/10 border border-green-500/30 text-green-400' : 'bg-red-500/10 border border-red-500/30 text-red-400'}`}>
|
||||
{toast.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">☀️ Morning NOC Summary</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Scheduled 6:30 AM Mon–Fri · Posts to Teams channels via webhook</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="h-4 w-4 mr-1" /> Refresh</Button>
|
||||
<Button size="sm" onClick={handleSendAll} disabled={sending}>
|
||||
{sending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Send className="h-4 w-4 mr-1" />}
|
||||
Send Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Run Stats */}
|
||||
{latestSummary && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Last Run</h2>
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" /> {fmtDate(latestSummary.generated_at)}
|
||||
{latestSummary.is_weekend_window && <span className="ml-2 px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded text-xs">Weekend</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${latestSummary.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}`}>{latestSummary.open_count}</div>
|
||||
<div className="text-xs text-muted-foreground">Open</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${latestSummary.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}`}>{latestSummary.resolved_count}</div>
|
||||
<div className="text-xs text-muted-foreground">Resolved</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{latestSummary.mttr_minutes != null ? `${latestSummary.mttr_minutes}m` : '—'}</div>
|
||||
<div className="text-xs text-muted-foreground">Avg MTTR</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Delivery results */}
|
||||
{Object.keys(latestSummary.delivery_status).length > 0 && (
|
||||
<div className="pt-2 border-t space-y-1">
|
||||
<p className="text-xs text-muted-foreground font-medium">Delivery</p>
|
||||
{Object.entries(latestSummary.delivery_status).map(([wid, r]) => {
|
||||
const webhook = webhooks.find(w => w.id === parseInt(wid));
|
||||
return (
|
||||
<div key={wid} className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">{webhook?.label ?? `Webhook #${wid}`}</span>
|
||||
{r.success
|
||||
? <span className="text-green-500 flex items-center gap-1"><CheckCircle2 className="h-3 w-3" /> Delivered</span>
|
||||
: <span className="text-red-500 flex items-center gap-1"><XCircle className="h-3 w-3" /> {r.error ?? `HTTP ${r.httpStatus}`}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Card preview toggle */}
|
||||
{latestSummary.card_payload && (
|
||||
<div className="pt-2 border-t">
|
||||
<button
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setCardExpanded(v => !v)}
|
||||
>
|
||||
{cardExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{cardExpanded ? 'Hide' : 'Show'} card payload
|
||||
</button>
|
||||
{cardExpanded && (
|
||||
<pre className="mt-2 text-xs bg-muted/30 rounded p-3 overflow-auto max-h-64 text-muted-foreground">
|
||||
{JSON.stringify(latestSummary.card_payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhooks */}
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Webhooks</h2>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddWebhook(v => !v)}>
|
||||
<Plus className="h-3 w-3 mr-1" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showAddWebhook && (
|
||||
<div className="rounded-md border border-dashed p-3 space-y-2 bg-muted/10">
|
||||
<input
|
||||
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="Label (e.g. Technical / On-Call)"
|
||||
value={newLabel}
|
||||
onChange={e => setNewLabel(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
placeholder="Webhook URL"
|
||||
value={newUrl}
|
||||
onChange={e => setNewUrl(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAddWebhook} disabled={addingWebhook || !newLabel || !newUrl}>
|
||||
{addingWebhook ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Add Webhook'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setShowAddWebhook(false); setNewLabel(''); setNewUrl(''); }}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooks.length === 0 && !showAddWebhook && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No webhooks configured. Add one above.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{webhooks.map(webhook => (
|
||||
<div key={webhook.id} className={`flex items-center justify-between rounded-md border px-3 py-2.5 ${webhook.enabled ? 'bg-background' : 'bg-muted/20 opacity-60'}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{webhook.label}</span>
|
||||
{webhook.enabled
|
||||
? <span className="text-xs px-1.5 py-0.5 bg-green-500/10 text-green-400 rounded">Enabled</span>
|
||||
: <span className="text-xs px-1.5 py-0.5 bg-muted/40 text-muted-foreground rounded">Disabled</span>
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<StatusBadge status={webhook.last_status} />
|
||||
{webhook.last_delivered_at && (
|
||||
<span className="text-xs text-muted-foreground">{fmtDate(webhook.last_delivered_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={testingId === webhook.id}
|
||||
onClick={() => handleTest(webhook.id, webhook.label)}
|
||||
>
|
||||
{testingId === webhook.id ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Test'}
|
||||
</Button>
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleToggleWebhook(webhook)}
|
||||
title={webhook.enabled ? 'Disable' : 'Enable'}
|
||||
>
|
||||
{webhook.enabled
|
||||
? <ToggleRight className="h-4 w-4 text-green-500" />
|
||||
: <ToggleLeft className="h-4 w-4" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-red-500/10 transition-colors text-muted-foreground hover:text-red-500"
|
||||
onClick={() => handleDeleteWebhook(webhook.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule Config */}
|
||||
{config && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Schedule Settings</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Weekend Suppression</p>
|
||||
<p className="text-xs text-muted-foreground">Skip Saturday & Sunday (cron already limits to Mon–Fri)</p>
|
||||
</div>
|
||||
<button onClick={() => handleConfigToggle('weekend_suppression')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{config.weekend_suppression
|
||||
? <ToggleRight className="h-6 w-6 text-green-500" />
|
||||
: <ToggleLeft className="h-6 w-6" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Monday Extended Window</p>
|
||||
<p className="text-xs text-muted-foreground">On Mondays, extend window to cover the full weekend (Fri 6 PM → Mon 6:30 AM)</p>
|
||||
</div>
|
||||
<button onClick={() => handleConfigToggle('monday_extended_window')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{config.monday_extended_window
|
||||
? <ToggleRight className="h-6 w-6 text-green-500" />
|
||||
: <ToggleLeft className="h-6 w-6" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Outages Only</p>
|
||||
<p className="text-xs text-muted-foreground">Only show "Unavailable" problems — filter out slow response and other non-outage alerts</p>
|
||||
</div>
|
||||
<button onClick={() => handleConfigToggle('outages_only')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{config.outages_only
|
||||
? <ToggleRight className="h-6 w-6 text-green-500" />
|
||||
: <ToggleLeft className="h-6 w-6" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run History */}
|
||||
{history.length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Recent Runs</h2>
|
||||
<div className="space-y-1">
|
||||
{history.map(row => {
|
||||
const statusEntries = Object.values(row.delivery_status);
|
||||
const allOk = statusEntries.length > 0 && statusEntries.every((r: any) => r.success);
|
||||
const anyFail = statusEntries.some((r: any) => !r.success);
|
||||
return (
|
||||
<div key={row.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{allOk && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
|
||||
{anyFail && <AlertTriangle className="h-3.5 w-3.5 text-yellow-500 shrink-0" />}
|
||||
{statusEntries.length === 0 && <Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
|
||||
<span className="text-muted-foreground">{fmtDate(row.generated_at)}</span>
|
||||
{row.is_weekend_window && <span className="text-xs px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded">Weekend</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={row.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}>
|
||||
{row.open_count} open
|
||||
</span>
|
||||
<span className={row.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}>
|
||||
{row.resolved_count} resolved
|
||||
</span>
|
||||
{row.mttr_minutes != null && (
|
||||
<span className="text-muted-foreground">{row.mttr_minutes}m MTTR</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,8 +37,13 @@ import {
|
|||
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';
|
||||
|
||||
|
|
@ -117,12 +122,37 @@ export default function ZabbixWanPage() {
|
|||
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
|
||||
|
|
@ -219,6 +249,42 @@ export default function ZabbixWanPage() {
|
|||
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 */}
|
||||
|
|
@ -410,6 +476,142 @@ export default function ZabbixWanPage() {
|
|||
</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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue