feat: IT Glue integration, workflow engine, pipelines, Zabbix WAN, notification channels, backup status UI improvements, nav alignment fixes

This commit is contained in:
lorentz 2026-02-27 14:52:14 -05:00
parent ed6c4a8b65
commit 19605f82aa
97 changed files with 17080 additions and 304 deletions

View file

@ -0,0 +1,348 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
ArrowLeft, Activity, History, BookOpen, Loader2, RefreshCw,
ExternalLink, CheckCircle2, XCircle, Clock, AlertTriangle,
Building2, Monitor, Users, Key, FileText, Globe, Shield, Package,
} from 'lucide-react';
function fmtDate(d: string | null) {
if (!d) return 'Never';
return new Date(d).toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function fmtDuration(ms: number | null) {
if (!ms) return '—';
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
}
function StatCard({
label, value, sub, icon: Icon, cls,
}: {
label: string; value: string | number; sub?: string;
icon?: React.ElementType; cls?: string;
}) {
return (
<div className={`rounded-lg border p-4 flex flex-col gap-1 ${cls ?? ''}`}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{Icon && <Icon className="w-3.5 h-3.5" />}{label}
</div>
<div className="text-2xl font-bold tabular-nums">{value}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const cls =
status === 'completed' ? 'bg-green-500/15 text-green-700' :
status === 'failed' ? 'bg-red-500/15 text-red-600' :
status === 'running' ? 'bg-blue-500/15 text-blue-700' :
'bg-yellow-500/15 text-yellow-700';
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
{status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />}
{status}
</span>
);
}
function StatusTab({
syncData, onSync, syncing,
}: {
syncData: any; onSync: () => void; syncing: boolean;
}) {
if (!syncData) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
);
}
const counts = syncData.counts ?? {};
const latest = syncData.history?.[0];
return (
<div className="space-y-6">
{/* Connection bar */}
<div className="flex items-center justify-between rounded-lg border p-4 bg-muted/30">
<div className="space-y-0.5">
<p className="text-sm font-medium flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
Connected to IT Glue
</p>
<p className="text-xs text-muted-foreground">
Last sync: {fmtDate(latest?.completed_at ?? null)}
{latest?.duration_ms && ` · ${fmtDuration(latest.duration_ms)}`}
</p>
</div>
<div className="flex gap-2">
<a href="https://app.itglue.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="w-4 h-4" />Portal
</Button>
</a>
<Button size="sm" onClick={onSync} disabled={syncing || syncData.inProgress}>
{syncing || syncData.inProgress
? <Loader2 className="w-4 h-4 animate-spin mr-2" />
: <RefreshCw className="w-4 h-4 mr-2" />}
{syncing || syncData.inProgress ? 'Syncing…' : 'Full Sync'}
</Button>
</div>
</div>
{/* Record counts */}
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Synced Records</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Organizations" value={Number(counts.organizations ?? 0).toLocaleString()} icon={Building2} />
<StatCard label="Configurations" value={Number(counts.configurations ?? 0).toLocaleString()} icon={Monitor} />
<StatCard label="Contacts" value={Number(counts.contacts ?? 0).toLocaleString()} icon={Users} />
<StatCard label="Flexible Assets" value={Number(counts.flexible_assets ?? 0).toLocaleString()} icon={Package} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3">
<StatCard label="Passwords" value={Number(counts.passwords ?? 0).toLocaleString()} icon={Key} />
<StatCard label="Documents" value={Number(counts.documents ?? 0).toLocaleString()} icon={FileText} />
<StatCard label="Locations" value={Number(counts.locations ?? 0).toLocaleString()} icon={Building2} />
<StatCard label="Domains" value={Number(counts.domains ?? 0).toLocaleString()} icon={Globe} />
</div>
</div>
{/* Latest sync entity breakdown */}
{latest?.entities?.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
Last Sync Breakdown
{latest.status && <span className="ml-2"><StatusBadge status={latest.status} /></span>}
</p>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Entity</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Duration</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody>
{latest.entities.map((e: any, i: number) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-mono text-xs">{e.entity}</td>
<td className="px-4 py-2 text-right tabular-nums">{e.recordsUpserted.toLocaleString()}</td>
<td className="px-4 py-2 text-right text-muted-foreground">{fmtDuration(e.duration)}</td>
<td className="px-4 py-2 text-right">
{e.success
? <CheckCircle2 className="w-4 h-4 text-green-500 inline" />
: <span title={e.error}><XCircle className="w-4 h-4 text-red-500 inline" /></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
function HistoryTab({ history }: { history: any[] }) {
if (!history.length) {
return (
<div className="text-center py-12 text-muted-foreground text-sm">
No sync history yet run a sync to populate
</div>
);
}
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Triggered By</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
{history.map((row: any, i: number) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2"><StatusBadge status={row.status} /></td>
<td className="px-4 py-2 text-muted-foreground capitalize">{row.triggered_by ?? 'system'}</td>
<td className="px-4 py-2 text-right tabular-nums">{(row.total_upserted ?? 0).toLocaleString()}</td>
<td className="px-4 py-2 text-muted-foreground">{fmtDate(row.started_at)}</td>
<td className="px-4 py-2 text-right text-muted-foreground">{fmtDuration(row.duration_ms)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function AboutTab() {
return (
<div className="space-y-4 text-sm text-muted-foreground">
<div className="rounded-lg border p-4 space-y-3">
<p className="font-medium text-foreground">Synced Entities</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{[
['Organizations', 'All IT Glue organizations with type, status, PSA linkage'],
['Locations', 'Physical locations per organization with address details'],
['Contacts', 'Contacts with emails, phones, type, and location linkage'],
['Configurations', 'All CIs with hostname, IP, serial, OS, manufacturer, model'],
['Flexible Assets', 'All flexible asset types with full trait data as JSONB'],
['Flexible Asset Types', 'Type definitions and field schemas'],
['Passwords', 'Credentials with category, folder, username, URL'],
['Password Folders', 'Folder hierarchy per organization'],
['Documents', 'IT Glue documents with full content'],
['Domains', 'Domain records with expiry and registrar info'],
['Expirations', 'All expiration records across organizations'],
['Reference Tables', 'Org types/statuses, config types/statuses, contact types, manufacturers, models, OS, platforms, countries'],
].map(([name, desc]) => (
<div key={name} className="flex gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
<div>
<span className="font-medium text-foreground">{name}</span>
<span className="text-xs block">{desc}</span>
</div>
</div>
))}
</div>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Authentication</p>
<p>API key via <code className="text-xs bg-muted px-1 py-0.5 rounded">x-api-key</code> header · Base URL: <code className="text-xs bg-muted px-1 py-0.5 rounded">https://api.itglue.com</code></p>
<p>Response format: JSON:API (<code className="text-xs bg-muted px-1 py-0.5 rounded">application/vnd.api+json</code>)</p>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Database Tables</p>
<p>All data is stored in tables prefixed <code className="text-xs bg-muted px-1 py-0.5 rounded">itg_</code> in the Pulse PostgreSQL database. Each table includes a <code className="text-xs bg-muted px-1 py-0.5 rounded">synced_at</code> timestamp and uses <code className="text-xs bg-muted px-1 py-0.5 rounded">ON CONFLICT DO UPDATE</code> for idempotent upserts.</p>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-500" />
Notes
</p>
<ul className="space-y-1 list-disc list-inside text-xs">
<li>A full sync takes ~710 minutes depending on data volume</li>
<li>Configuration interfaces are not synced the IT Glue API has no flat endpoint and per-config calls are impractical at 14k+ configs</li>
<li>Flexible assets require a per-type API call (API enforces <code className="bg-muted px-1 py-0.5 rounded">filter[flexible-asset-type-id]</code>)</li>
<li>Password folders, documents, and expirations require per-organization calls</li>
</ul>
</div>
</div>
);
}
export default function ITGluePage() {
const [syncData, setSyncData] = useState<any>(null);
const [syncing, setSyncing] = useState(false);
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/itglue/sync');
if (res.ok) setSyncData(await res.json());
} catch {}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Poll while sync is in progress
useEffect(() => {
if (!syncData?.inProgress && !syncing) return;
const interval = setInterval(fetchStatus, 5000);
return () => clearInterval(interval);
}, [syncData?.inProgress, syncing, fetchStatus]);
const handleSync = async () => {
setSyncing(true);
try {
await fetch('/api/itglue/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ triggeredBy: 'manual' }),
});
await fetchStatus();
} catch {
setSyncing(false);
}
// syncing flag cleared by poll detecting inProgress=false
};
// Clear syncing flag once inProgress goes false
useEffect(() => {
if (syncData && !syncData.inProgress && syncing) {
setSyncing(false);
}
}, [syncData, syncing]);
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-blue-500/30 bg-blue-500/5">
<Shield className="w-5 h-5 text-blue-500" />
</div>
<div>
<h1 className="text-2xl font-bold">IT Glue</h1>
<p className="text-sm text-muted-foreground">
Organizations, configurations, contacts, passwords, flexible assets
</p>
</div>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-md grid-cols-3">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />Status
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="h-4 w-4" />History
</TabsTrigger>
<TabsTrigger value="about" className="gap-2">
<BookOpen className="h-4 w-4" />About
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
<StatusTab syncData={syncData} onSync={handleSync} syncing={syncing} />
</TabsContent>
<TabsContent value="history" className="mt-6">
<HistoryTab history={syncData?.history ?? []} />
</TabsContent>
<TabsContent value="about" className="mt-6">
<AboutTab />
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -7,7 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
ArrowLeft, Activity, History, Calendar, Shield, RefreshCw, Loader2,
CheckCircle2, XCircle, AlertTriangle, Clock, Server, HardDrive,
Bot, Bell, ChevronDown, ChevronRight,
Bot, Bell, ChevronDown, ChevronRight, Target, Play,
} from 'lucide-react';
import SyncScheduler from '@/components/admin/SyncScheduler';
@ -382,6 +382,166 @@ function AlarmsTab({ refreshKey }: { refreshKey: number }) {
);
}
// ── RPO Tab ───────────────────────────────────────────────────────────────────
function RpoTab({ refreshKey }: { refreshKey: number }) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const [lastResult, setLastResult] = useState<any>(null);
const fetchStatus = () => {
setLoading(true);
fetch('/api/veeam/rpo-check')
.then(r => r.json())
.then(d => setData(d))
.catch(() => setData(null))
.finally(() => setLoading(false));
};
useEffect(() => { fetchStatus(); }, [refreshKey]);
const runCheck = async () => {
setRunning(true);
try {
const r = await fetch('/api/veeam/rpo-check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
const d = await r.json();
setLastResult(d);
fetchStatus();
} catch { /* ignore */ }
finally { setRunning(false); }
};
if (loading) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
const s = data?.summary ?? {};
const jobs: any[] = data?.jobs ?? [];
const breachedJobs = jobs.filter((j: any) => j.is_breached);
const healthyJobs = jobs.filter((j: any) => !j.is_breached);
return (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border p-4 bg-muted/30">
<div className="space-y-0.5">
<p className="text-sm font-medium">RPO-Based Workstation Backup Alerting</p>
<p className="text-xs text-muted-foreground">
One ticket per job created when RPO is breached, auto-resolved when backup succeeds. Enable the scheduler to run every 30 min.
</p>
</div>
<Button size="sm" onClick={runCheck} disabled={running}>
{running ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Play className="w-4 h-4 mr-2" />}
Run Check Now
</Button>
</div>
{lastResult && !lastResult.error && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 text-sm">
<p className="font-medium text-blue-700 mb-1">Last Run Result</p>
<div className="flex gap-4 text-xs text-muted-foreground">
<span>Checked: <strong>{lastResult.checked}</strong></span>
<span className="text-green-700">New Tickets: <strong>{lastResult.newTickets}</strong></span>
<span className="text-yellow-700">Escalated: <strong>{lastResult.escalated}</strong></span>
<span>Resolved: <strong>{lastResult.resolved}</strong></span>
{lastResult.errors?.length > 0 && <span className="text-red-600">Errors: <strong>{lastResult.errors.length}</strong></span>}
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<StatCard label="Total Jobs" value={s.total ?? 0} icon={Target} />
<StatCard label="Within RPO" value={s.healthy ?? 0} icon={CheckCircle2} cls="border-green-500/30 bg-green-500/5" />
<StatCard label="RPO Breached" value={s.breached ?? 0} icon={XCircle}
cls={(s.breached ?? 0) > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Open Tickets" value={s.withOpenTicket ?? 0} icon={AlertTriangle}
cls={(s.withOpenTicket ?? 0) > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
<StatCard label="Critical / High" value={`${s.critical ?? 0} / ${s.high ?? 0}`} icon={Clock}
cls={(s.critical ?? 0) > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
</div>
{breachedJobs.length > 0 && (
<div className="rounded-lg border overflow-hidden">
<div className="px-4 py-2.5 bg-red-500/5 border-b flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" />
<p className="text-xs font-semibold uppercase tracking-wider text-red-700">RPO Breached ({breachedJobs.length})</p>
</div>
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Job</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Backup</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Overdue</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Failure Reason</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Ticket</th>
</tr>
</thead>
<tbody>
{breachedJobs.map((j: any) => {
const hrs = j.hours_since_backup;
const display = hrs === null ? 'Never' : hrs >= 48 ? `${Math.round(hrs / 24)}d` : `${Math.round(hrs)}h`;
const ticketPriCls = j.open_ticket?.priority_level === 'critical' ? 'bg-red-500/15 text-red-700'
: j.open_ticket?.priority_level === 'high' ? 'bg-orange-500/15 text-orange-700'
: 'bg-yellow-500/15 text-yellow-700';
return (
<tr key={j.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-medium text-xs">{j.job_name}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{j.org_name}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(j.last_end_time)}</td>
<td className="px-4 py-2 tabular-nums text-xs font-semibold text-red-600">{display}</td>
<td className="px-4 py-2 text-xs text-muted-foreground max-w-xs truncate" title={j.failure_category ?? ''}>
{j.failure_category ?? '—'}
</td>
<td className="px-4 py-2 text-xs">
{j.open_ticket ? (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${ticketPriCls}`}>
{j.open_ticket.at_ticket_number} · {j.open_ticket.priority_level}
</span>
) : (
<span className="text-muted-foreground">No ticket yet</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{healthyJobs.length > 0 && (
<details className="rounded-lg border overflow-hidden">
<summary className="px-4 py-2.5 bg-green-500/5 border-b cursor-pointer flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-green-700">
<CheckCircle2 className="w-4 h-4" />Within RPO ({healthyJobs.length})
</summary>
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Job</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Backup</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Hours Ago</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">RPO</th>
</tr>
</thead>
<tbody>
{healthyJobs.map((j: any) => (
<tr key={j.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-medium text-xs">{j.job_name}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{j.org_name}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(j.last_end_time)}</td>
<td className="px-4 py-2 tabular-nums text-xs text-green-700">
{j.hours_since_backup !== null ? `${j.hours_since_backup}h` : '—'}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{j.rpo_hours}h</td>
</tr>
))}
</tbody>
</table>
</details>
)}
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function VeeamSyncPage() {
const [status, setStatus] = useState<any>(null);
@ -446,8 +606,9 @@ export default function VeeamSyncPage() {
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-2xl grid-cols-5">
<TabsList className="grid w-full max-w-3xl grid-cols-6">
<TabsTrigger value="status" className="gap-1.5"><Activity className="h-4 w-4" />Status</TabsTrigger>
<TabsTrigger value="rpo" className="gap-1.5"><Target className="h-4 w-4" />RPO</TabsTrigger>
<TabsTrigger value="history" className="gap-1.5"><History className="h-4 w-4" />History</TabsTrigger>
<TabsTrigger value="agents" className="gap-1.5"><Bot className="h-4 w-4" />Agents</TabsTrigger>
<TabsTrigger value="alarms" className="gap-1.5"><Bell className="h-4 w-4" />Alarms</TabsTrigger>
@ -455,6 +616,7 @@ export default function VeeamSyncPage() {
</TabsList>
<TabsContent value="status" className="mt-6"><VeeamStatusTab data={status} onSync={handleSync} syncing={syncing} /></TabsContent>
<TabsContent value="rpo" className="mt-6"><RpoTab refreshKey={refreshKey} /></TabsContent>
<TabsContent value="history" className="mt-6"><VeeamHistoryTab refreshKey={refreshKey} /></TabsContent>
<TabsContent value="agents" className="mt-6"><AgentsTab refreshKey={refreshKey} /></TabsContent>
<TabsContent value="alarms" className="mt-6"><AlarmsTab refreshKey={refreshKey} /></TabsContent>

View file

@ -0,0 +1,526 @@
'use client';
import { useState, useEffect, use } 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 { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
ArrowLeft,
Save,
Play,
Trash2,
Plus,
GripVertical,
Settings,
Zap,
CheckCircle2,
XCircle,
Clock,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { toast } from 'sonner';
interface TicketWorkflow {
id: number;
name: string;
description: string | null;
is_active: boolean;
trigger_event: string;
trigger_conditions: any[];
sort_order: number;
}
interface TicketWorkflowStep {
id?: number;
workflow_id?: number;
step_order: number;
step_type: string;
name: string;
config: Record<string, any>;
on_failure: 'continue' | 'stop' | 'skip_to';
skip_to_step: number | null;
is_active: boolean;
condition: any | null;
}
const STEP_TYPES = [
{ value: 'classify', label: 'Classify', color: 'bg-purple-100 text-purple-900 border-purple-200', description: 'Match keywords to classify tickets (branch, type, issue, priority, queue)' },
{ value: 'validate', label: 'Validate', color: 'bg-yellow-100 text-yellow-900 border-yellow-200', description: 'Validate classification results against database picklists' },
{ value: 'ai_classify', label: 'AI Classify', color: 'bg-blue-100 text-blue-900 border-blue-200', description: 'Use AI to classify fields that robotic classification missed' },
{ value: 'ai_title', label: 'AI Title', color: 'bg-blue-100 text-blue-900 border-blue-200', description: 'Clean up messy ticket titles using AI' },
{ value: 'ai_troubleshooting', label: 'AI Troubleshooting', color: 'bg-blue-100 text-blue-900 border-blue-200', description: 'Generate troubleshooting steps for incidents' },
{ value: 'delay', label: 'Delay', color: 'bg-gray-100 text-gray-900 border-gray-200', description: 'Wait N milliseconds before continuing' },
{ value: 'update_ticket', label: 'Update Ticket', color: 'bg-green-100 text-green-900 border-green-200', description: 'Write field changes back to Autotask' },
];
const STEP_HELP: Record<string, { purpose: string; config: string; example: string }> = {
classify: {
purpose: 'Uses keyword-based classification rules from the classification_rules table to automatically categorize tickets',
config: 'rule_type: branch_routing|ticket_type|issue_classification|priority|queue_routing\nresult_field: where to store the result\nresult_field_2: (optional) for sub_issue_type\ndefault_value: fallback if no rules match',
example: '{"rule_type": "branch_routing", "result_field": "branch", "default_value": "service_desk"}'
},
validate: {
purpose: 'Validates classification results against database picklists to ensure data integrity',
config: 'required_fields: (optional) array of fields that must be present',
example: '{"required_fields": []}'
},
ai_classify: {
purpose: 'Uses AI to classify ambiguous fields when robotic classification fails validation',
config: 'template_purpose: ambiguous_classification\nskip_if_valid: skip if validation passed',
example: '{"template_purpose": "ambiguous_classification", "skip_if_valid": true}'
},
ai_title: {
purpose: 'Uses AI to clean up messy ticket titles (email subjects, too long, garbled text)',
config: 'template_purpose: title_cleanup',
example: '{"template_purpose": "title_cleanup"}'
},
ai_troubleshooting: {
purpose: 'Generates AI-powered troubleshooting steps for incidents',
config: 'template_purpose: troubleshooting_steps\ncreate_note: whether to create a ticket note',
example: '{"template_purpose": "troubleshooting_steps", "create_note": true}'
},
delay: {
purpose: 'Waits for a specified duration before continuing to the next step',
config: 'duration_ms: milliseconds to wait (supports templates like {{settings.autotask_update_delay_ms}})',
example: '{"duration_ms": "{{settings.autotask_update_delay_ms}}"}'
},
update_ticket: {
purpose: 'Writes all accumulated field_changes back to Autotask and updates local database',
config: 'use_field_changes: boolean (always true)',
example: '{"use_field_changes": true}'
},
};
export default function WorkflowEditorPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params);
const workflowId = resolvedParams.id;
const [workflow, setWorkflow] = useState<TicketWorkflow | null>(null);
const [steps, setSteps] = useState<TicketWorkflowStep[]>([]);
const [expandedStep, setExpandedStep] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
loadWorkflow();
}, [workflowId]);
const loadWorkflow = async () => {
setIsLoading(true);
try {
const res = await fetch(`/api/ticket-workflows/${workflowId}`);
if (res.ok) {
const data = await res.json();
setWorkflow(data.workflow);
setSteps(data.steps || []);
} else {
toast.error('Failed to load workflow');
}
} catch (error) {
console.error('Failed to load workflow:', error);
toast.error('Failed to load workflow');
} finally {
setIsLoading(false);
}
};
const saveWorkflow = async () => {
if (!workflow) return;
setIsSaving(true);
try {
// Save workflow metadata
const workflowRes = await fetch(`/api/ticket-workflows/${workflowId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: workflow.name,
description: workflow.description,
is_active: workflow.is_active,
trigger_event: workflow.trigger_event,
trigger_conditions: workflow.trigger_conditions,
}),
});
if (!workflowRes.ok) {
toast.error('Failed to save workflow');
return;
}
// Save steps (bulk replace)
const stepsRes = await fetch(`/api/ticket-workflows/${workflowId}/steps`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ steps }),
});
if (!stepsRes.ok) {
toast.error('Failed to save steps');
return;
}
toast.success('Workflow saved successfully');
loadWorkflow(); // Reload to get IDs for new steps
} catch (error) {
console.error('Failed to save workflow:', error);
toast.error('Failed to save workflow');
} finally {
setIsSaving(false);
}
};
const addStep = (stepType: string) => {
const newStep: TicketWorkflowStep = {
step_order: steps.length + 1,
step_type: stepType,
name: STEP_TYPES.find(t => t.value === stepType)?.label || stepType,
config: {},
on_failure: 'continue',
skip_to_step: null,
is_active: true,
condition: null,
};
setSteps([...steps, newStep]);
setExpandedStep(newStep.step_order);
};
const updateStep = (stepOrder: number, updates: Partial<TicketWorkflowStep>) => {
setSteps(steps.map(s => s.step_order === stepOrder ? { ...s, ...updates } : s));
};
const deleteStep = (stepOrder: number) => {
const filtered = steps.filter(s => s.step_order !== stepOrder);
// Renumber remaining steps
const renumbered = filtered.map((s, idx) => ({ ...s, step_order: idx + 1 }));
setSteps(renumbered);
};
const moveStep = (stepOrder: number, direction: 'up' | 'down') => {
const index = steps.findIndex(s => s.step_order === stepOrder);
if (index === -1) return;
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === steps.length - 1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
const reordered = [...steps];
[reordered[index], reordered[newIndex]] = [reordered[newIndex], reordered[index]];
// Renumber all steps
const renumbered = reordered.map((s, idx) => ({ ...s, step_order: idx + 1 }));
setSteps(renumbered);
};
if (isLoading || !workflow) {
return (
<div className="container mx-auto p-6">
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Loading workflow...
</CardContent>
</Card>
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">{workflow.name}</h1>
<p className="text-sm text-muted-foreground">{workflow.description || 'No description'}</p>
</div>
<Badge variant={workflow.is_active ? 'default' : 'secondary'}>
{workflow.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button onClick={saveWorkflow} disabled={isSaving}>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="steps" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="steps">Steps ({steps.length})</TabsTrigger>
<TabsTrigger value="trigger">Trigger</TabsTrigger>
<TabsTrigger value="test">Test</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
{/* Steps Tab */}
<TabsContent value="steps" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Workflow Steps</CardTitle>
<CardDescription>Define the step-by-step execution flow</CardDescription>
</div>
<div className="flex gap-2">
{STEP_TYPES.map(type => (
<Button
key={type.value}
variant="outline"
size="sm"
onClick={() => addStep(type.value)}
>
<Plus className="w-4 h-4 mr-1" />
{type.label}
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{steps.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
No steps yet. Add steps using the buttons above.
</p>
) : (
steps.map((step, index) => {
const stepTypeConfig = STEP_TYPES.find(t => t.value === step.step_type);
const isExpanded = expandedStep === step.step_order;
return (
<div key={step.step_order} className={`border rounded-lg ${stepTypeConfig?.color || ''}`}>
{/* Step Header */}
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-3 flex-1">
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="sm"
className="h-4 p-0"
onClick={() => moveStep(step.step_order, 'up')}
disabled={index === 0}
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-4 p-0"
onClick={() => moveStep(step.step_order, 'down')}
disabled={index === steps.length - 1}
>
<ChevronDown className="w-4 h-4" />
</Button>
</div>
<span className="font-mono text-sm text-muted-foreground">#{step.step_order}</span>
<span className="font-medium">{step.name}</span>
<Badge variant="outline" className="text-xs">{step.step_type}</Badge>
{step.condition && <Badge variant="secondary" className="text-xs">Conditional</Badge>}
</div>
<div className="flex items-center gap-2">
<Switch
checked={step.is_active}
onCheckedChange={(checked) => updateStep(step.step_order, { is_active: checked })}
/>
<Button
variant="ghost"
size="sm"
onClick={() => setExpandedStep(isExpanded ? null : step.step_order)}
>
{isExpanded ? 'Collapse' : 'Expand'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteStep(step.step_order)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
{/* Step Config (Expanded) */}
{isExpanded && (
<div className="border-t p-4 bg-white space-y-4">
{/* Step Help */}
{STEP_HELP[step.step_type] && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-2">
<h4 className="font-semibold text-sm text-blue-900">What This Step Does</h4>
<p className="text-sm text-blue-800">{STEP_HELP[step.step_type].purpose}</p>
<div className="mt-2">
<h5 className="font-semibold text-xs text-blue-900 mb-1">Configuration Fields:</h5>
<pre className="text-xs text-blue-800 whitespace-pre-wrap">{STEP_HELP[step.step_type].config}</pre>
</div>
<div className="mt-2">
<h5 className="font-semibold text-xs text-blue-900 mb-1">Example:</h5>
<code className="text-xs text-blue-800 bg-white px-2 py-1 rounded">{STEP_HELP[step.step_type].example}</code>
</div>
</div>
)}
<div>
<Label>Step Name</Label>
<Input
value={step.name}
onChange={(e) => updateStep(step.step_order, { name: e.target.value })}
/>
</div>
<div>
<Label>On Failure</Label>
<select
className="w-full border rounded-md p-2"
value={step.on_failure}
onChange={(e) => updateStep(step.step_order, { on_failure: e.target.value as any })}
>
<option value="continue">Continue to next step</option>
<option value="stop">Stop workflow</option>
</select>
</div>
<div>
<Label>Configuration (JSON)</Label>
<Textarea
value={JSON.stringify(step.config, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
updateStep(step.step_order, { config: parsed });
} catch {}
}}
rows={6}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Edit the JSON configuration above. See the blue help box for available fields.
</p>
</div>
</div>
)}
</div>
);
})
)}
</CardContent>
</Card>
</TabsContent>
{/* Trigger Tab */}
<TabsContent value="trigger" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Trigger Configuration</CardTitle>
<CardDescription>Define when this workflow should run</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Workflow Name</Label>
<Input
value={workflow.name}
onChange={(e) => setWorkflow({ ...workflow, name: e.target.value })}
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={workflow.description || ''}
onChange={(e) => setWorkflow({ ...workflow, description: e.target.value })}
rows={3}
/>
</div>
<div>
<Label>Trigger Event</Label>
<select
className="w-full border rounded-md p-2"
value={workflow.trigger_event}
onChange={(e) => setWorkflow({ ...workflow, trigger_event: e.target.value })}
>
<option value="ticket.created">Ticket Created</option>
<option value="ticket.updated">Ticket Updated</option>
</select>
</div>
<div>
<Label>Trigger Conditions (JSON)</Label>
<Textarea
value={JSON.stringify(workflow.trigger_conditions, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
setWorkflow({ ...workflow, trigger_conditions: parsed });
} catch {}
}}
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Array of conditions: {`[{"field": "ticket_category", "operator": "in", "value": [2,3]}]`}
</p>
</div>
<div className="flex items-center justify-between">
<Label>Workflow Active</Label>
<Switch
checked={workflow.is_active}
onCheckedChange={(checked) => setWorkflow({ ...workflow, is_active: checked })}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Test Tab */}
<TabsContent value="test" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Test Workflow</CardTitle>
<CardDescription>Run a dry-run test on a ticket without making changes</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Test functionality will be implemented here. Use the API endpoint directly:
<code className="block mt-2 p-2 bg-muted rounded text-sm">
POST /api/ticket-workflows/{workflowId}/test
</code>
</p>
</CardContent>
</Card>
</TabsContent>
{/* History Tab */}
<TabsContent value="history" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Execution History</CardTitle>
<CardDescription>Recent workflow executions</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Execution history will be loaded here from:
<code className="block mt-2 p-2 bg-muted rounded text-sm">
GET /api/ticket-workflows/{workflowId}/executions
</code>
</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,315 @@
'use client';
import { useState, useEffect } 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 { Switch } from '@/components/ui/switch';
import {
ArrowLeft,
Plus,
Trash2,
Send,
Bell,
MessageSquare,
Globe,
CheckCircle2,
XCircle,
Loader2,
Pencil,
} from 'lucide-react';
interface Channel {
id: number;
name: string;
channel_type: string;
config: Record<string, any>;
is_active: boolean;
created_at: string;
updated_at: string;
}
const CHANNEL_TYPES = [
{ value: 'teams', label: 'Microsoft Teams', icon: MessageSquare, color: 'bg-indigo-100 text-indigo-700', fields: [
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', placeholder: 'https://...webhook.office.com/...' },
]},
{ value: 'telegram', label: 'Telegram', icon: Send, color: 'bg-blue-100 text-blue-700', fields: [
{ key: 'bot_token', label: 'Bot Token', type: 'password', placeholder: '123456:ABC-DEF...' },
{ key: 'chat_id', label: 'Chat ID', type: 'text', placeholder: '-1001234567890' },
{ key: 'parse_mode', label: 'Parse Mode', type: 'select', options: ['HTML', 'Markdown', 'MarkdownV2'] },
]},
{ value: 'ntfy', label: 'ntfy', icon: Bell, color: 'bg-green-100 text-green-700', fields: [
{ key: 'server_url', label: 'Server URL', type: 'url', placeholder: 'https://ntfy.sh' },
{ key: 'topic', label: 'Topic', type: 'text', placeholder: 'pulse-alerts' },
{ key: 'auth_token', label: 'Auth Token (optional)', type: 'password', placeholder: 'tk_...' },
{ key: 'default_priority', label: 'Default Priority', type: 'select', options: ['min', 'low', 'default', 'high', 'urgent'] },
]},
{ value: 'webhook', label: 'Generic Webhook', icon: Globe, color: 'bg-gray-100 text-gray-700', fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'https://...' },
{ key: 'method', label: 'Method', type: 'select', options: ['POST', 'PUT', 'PATCH'] },
]},
];
export default function ChannelsPage() {
const [channels, setChannels] = useState<Channel[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formType, setFormType] = useState('teams');
const [formName, setFormName] = useState('');
const [formConfig, setFormConfig] = useState<Record<string, any>>({});
const [testStatus, setTestStatus] = useState<Record<number, 'idle' | 'testing' | 'success' | 'error'>>({});
useEffect(() => { loadChannels(); }, []);
const loadChannels = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/notification-channels');
if (res.ok) {
const data = await res.json();
setChannels(data.data || []);
}
} catch (err) {
console.error('Failed to load channels:', err);
} finally {
setIsLoading(false);
}
};
const saveChannel = async () => {
if (!formName) return;
try {
const payload = { name: formName, channel_type: formType, config: formConfig, is_active: true };
if (editingId) {
await fetch(`/api/notification-channels/${editingId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} else {
await fetch('/api/notification-channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
resetForm();
loadChannels();
} catch (err) {
console.error('Failed to save channel:', err);
}
};
const deleteChannel = async (id: number) => {
if (!confirm('Delete this notification channel?')) return;
try {
await fetch(`/api/notification-channels/${id}`, { method: 'DELETE' });
setChannels(prev => prev.filter(c => c.id !== id));
} catch (err) {
console.error('Failed to delete channel:', err);
}
};
const toggleChannel = async (id: number, active: boolean) => {
try {
await fetch(`/api/notification-channels/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: active }),
});
setChannels(prev => prev.map(c => c.id === id ? { ...c, is_active: active } : c));
} catch (err) {
console.error('Failed to toggle channel:', err);
}
};
const testChannel = async (id: number) => {
setTestStatus(prev => ({ ...prev, [id]: 'testing' }));
try {
const res = await fetch(`/api/notification-channels/${id}/test`, { method: 'POST' });
const data = await res.json();
setTestStatus(prev => ({ ...prev, [id]: data.success ? 'success' : 'error' }));
setTimeout(() => setTestStatus(prev => ({ ...prev, [id]: 'idle' })), 3000);
} catch {
setTestStatus(prev => ({ ...prev, [id]: 'error' }));
setTimeout(() => setTestStatus(prev => ({ ...prev, [id]: 'idle' })), 3000);
}
};
const editChannel = (channel: Channel) => {
setEditingId(channel.id);
setFormType(channel.channel_type);
setFormName(channel.name);
setFormConfig(channel.config);
setShowCreate(true);
};
const resetForm = () => {
setShowCreate(false);
setEditingId(null);
setFormType('teams');
setFormName('');
setFormConfig({});
};
const typeDef = CHANNEL_TYPES.find(t => t.value === formType);
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="icon"><ArrowLeft className="h-4 w-4" /></Button>
</Link>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Bell className="h-6 w-6" /> Notification Channels
</h1>
<p className="text-muted-foreground text-sm">Configure Teams, Telegram, ntfy, and webhook destinations</p>
</div>
</div>
<Button onClick={() => { resetForm(); setShowCreate(true); }}>
<Plus className="h-4 w-4 mr-2" /> New Channel
</Button>
</div>
{showCreate && (
<Card>
<CardHeader>
<CardTitle className="text-lg">{editingId ? 'Edit Channel' : 'Create Channel'}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Channel Name</label>
<input
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
placeholder="e.g., NOC Teams Channel"
value={formName}
onChange={e => setFormName(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium">Type</label>
<select
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
value={formType}
onChange={e => { setFormType(e.target.value); setFormConfig({}); }}
disabled={!!editingId}
>
{CHANNEL_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
{typeDef && (
<div className="space-y-3">
{typeDef.fields.map(field => (
<div key={field.key}>
<label className="text-sm font-medium">{field.label}</label>
{field.type === 'select' ? (
<select
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
value={formConfig[field.key] || (field.options?.[0] || '')}
onChange={e => setFormConfig(prev => ({ ...prev, [field.key]: e.target.value }))}
>
{field.options?.map((opt: string) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : (
<input
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
type={field.type}
placeholder={field.placeholder}
value={formConfig[field.key] || ''}
onChange={e => setFormConfig(prev => ({ ...prev, [field.key]: e.target.value }))}
/>
)}
</div>
))}
</div>
)}
<div className="flex gap-2">
<Button onClick={saveChannel} disabled={!formName}>{editingId ? 'Update' : 'Create'}</Button>
<Button variant="outline" onClick={resetForm}>Cancel</Button>
</div>
</CardContent>
</Card>
)}
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">Loading channels...</div>
) : channels.length === 0 && !showCreate ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Bell className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p>No notification channels configured yet.</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{channels.map(channel => {
const cType = CHANNEL_TYPES.find(t => t.value === channel.channel_type);
const Icon = cType?.icon || Globe;
const status = testStatus[channel.id] || 'idle';
return (
<Card key={channel.id} className={!channel.is_active ? 'opacity-60' : ''}>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Switch
checked={channel.is_active}
onCheckedChange={(checked) => toggleChannel(channel.id, checked)}
/>
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{channel.name}</span>
<Badge className={cType?.color || ''}>{cType?.label || channel.channel_type}</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{channel.channel_type === 'teams' && channel.config.webhook_url && `URL: ${channel.config.webhook_url.substring(0, 50)}...`}
{channel.channel_type === 'telegram' && `Chat: ${channel.config.chat_id || 'not set'}`}
{channel.channel_type === 'ntfy' && `Topic: ${channel.config.topic || 'not set'} @ ${channel.config.server_url || 'ntfy.sh'}`}
{channel.channel_type === 'webhook' && `${channel.config.method || 'POST'} ${channel.config.url || 'not set'}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => testChannel(channel.id)}
disabled={status === 'testing'}
>
{status === 'testing' && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
{status === 'success' && <CheckCircle2 className="h-3 w-3 mr-1 text-green-500" />}
{status === 'error' && <XCircle className="h-3 w-3 mr-1 text-red-500" />}
{status === 'idle' && <Send className="h-3 w-3 mr-1" />}
Test
</Button>
<Button variant="ghost" size="icon" onClick={() => editChannel(channel)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => deleteChannel(channel.id)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View file

@ -7,283 +7,281 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
ArrowLeft,
Workflow,
Bot,
Cog,
ListFilter,
History,
FileText,
Plus,
Settings,
Activity,
CheckCircle2,
XCircle,
Clock,
Zap,
Brain,
Activity,
Edit,
PlayCircle,
PauseCircle,
} from 'lucide-react';
import { toast } from 'sonner';
interface ExecutionStats {
total: number;
completed: number;
failed: number;
skipped: number;
robotic: number;
ai: number;
hybrid: number;
interface TicketWorkflow {
id: number;
name: string;
description: string | null;
is_active: boolean;
trigger_event: string;
sort_order: number;
step_count?: number;
executions_today?: number;
}
export default function WorkflowDashboardPage() {
const [enabled, setEnabled] = useState(false);
const [stats, setStats] = useState<ExecutionStats>({ total: 0, completed: 0, failed: 0, skipped: 0, robotic: 0, ai: 0, hybrid: 0 });
const [recentExecutions, setRecentExecutions] = useState<any[]>([]);
export default function WorkflowListPage() {
const [globalEnabled, setGlobalEnabled] = useState(false);
const [workflows, setWorkflows] = useState<TicketWorkflow[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadDashboard();
loadData();
}, []);
const loadDashboard = async () => {
const loadData = async () => {
setIsLoading(true);
try {
const [settingsRes, execRes] = await Promise.all([
const [settingsRes, workflowsRes] = await Promise.all([
fetch('/api/workflow/settings'),
fetch('/api/workflow/executions?limit=10'),
fetch('/api/ticket-workflows'),
]);
if (settingsRes.ok) {
const settings = await settingsRes.json();
setEnabled(settings.workflow_engine_enabled?.value ?? false);
setGlobalEnabled(settings.workflow_engine_enabled?.value ?? false);
}
if (execRes.ok) {
const execData = await execRes.json();
setRecentExecutions(execData.data || []);
// Calculate stats from recent executions
const all = execData.data || [];
setStats({
total: execData.total || 0,
completed: all.filter((e: any) => e.status === 'completed').length,
failed: all.filter((e: any) => e.status === 'failed').length,
skipped: all.filter((e: any) => e.status === 'skipped').length,
robotic: all.filter((e: any) => e.classification_method === 'robotic').length,
ai: all.filter((e: any) => e.classification_method === 'ai').length,
hybrid: all.filter((e: any) => e.classification_method === 'hybrid').length,
});
if (workflowsRes.ok) {
const data = await workflowsRes.json();
setWorkflows(data.workflows || []);
}
} catch (error) {
console.error('Failed to load dashboard:', error);
console.error('Failed to load workflows:', error);
toast.error('Failed to load workflows');
} finally {
setIsLoading(false);
}
};
const toggleEngine = async () => {
const toggleGlobalEngine = async () => {
try {
const newValue = !enabled;
await fetch('/api/workflow/settings', {
const newValue = !globalEnabled;
const res = await fetch('/api/workflow/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_engine_enabled: newValue }),
});
setEnabled(newValue);
if (res.ok) {
setGlobalEnabled(newValue);
toast.success(`Workflow engine ${newValue ? 'enabled' : 'disabled'}`);
} else {
toast.error('Failed to toggle engine');
}
} catch (error) {
console.error('Failed to toggle engine:', error);
toast.error('Failed to toggle engine');
}
};
const navCards = [
{
title: 'Classification Rules',
description: 'Keyword-based rules for ticket classification',
href: '/admin/workflow/classification',
icon: Bot,
color: 'text-blue-500',
},
{
title: 'Filter Rules',
description: 'Exclusion/inclusion filters for ticket processing',
href: '/admin/workflow/rules',
icon: ListFilter,
color: 'text-orange-500',
},
{
title: 'AI Templates',
description: 'Prompt templates for AI-assisted classification',
href: '/admin/workflow/templates',
icon: Brain,
color: 'text-purple-500',
},
{
title: 'Execution History',
description: 'View past workflow executions and results',
href: '/admin/workflow/history',
icon: History,
color: 'text-green-500',
},
{
title: 'Settings',
description: 'AI providers, thresholds, and delays',
href: '/admin/workflow/settings',
icon: Settings,
color: 'text-gray-500',
},
];
const toggleWorkflow = async (workflowId: number, currentState: boolean) => {
try {
const newState = !currentState;
const res = await fetch(`/api/ticket-workflows/${workflowId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: newState }),
});
if (res.ok) {
setWorkflows(prev =>
prev.map(w => w.id === workflowId ? { ...w, is_active: newState } : w)
);
toast.success(`Workflow ${newState ? 'enabled' : 'disabled'}`);
} else {
toast.error('Failed to toggle workflow');
}
} catch (error) {
console.error('Failed to toggle workflow:', error);
toast.error('Failed to toggle workflow');
}
};
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/sync">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Workflow className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Workflow Engine</h1>
<h1 className="text-2xl font-bold">Ticket Workflows</h1>
<p className="text-sm text-muted-foreground">Automated ticket triage and classification</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{enabled ? 'Engine Active' : 'Engine Disabled'}
</span>
<Switch checked={enabled} onCheckedChange={toggleEngine} />
<Badge variant={enabled ? 'default' : 'secondary'}>
{enabled ? 'ON' : 'OFF'}
</Badge>
</div>
<Link href="/admin/workflow/create">
<Button>
<Plus className="w-4 h-4 mr-2" />
Create Workflow
</Button>
</Link>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Executions</p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<Activity className="w-8 h-8 text-muted-foreground/50" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Completed</p>
<p className="text-2xl font-bold text-green-600">{stats.completed}</p>
</div>
<CheckCircle2 className="w-8 h-8 text-green-500/50" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Robotic</p>
<p className="text-2xl font-bold text-blue-600">{stats.robotic}</p>
</div>
<Zap className="w-8 h-8 text-blue-500/50" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">AI/Hybrid</p>
<p className="text-2xl font-bold text-purple-600">{stats.ai + stats.hybrid}</p>
</div>
<Brain className="w-8 h-8 text-purple-500/50" />
</div>
</CardContent>
</Card>
</div>
{/* Navigation Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{navCards.map((card) => (
<Link key={card.href} href={card.href}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardHeader>
<div className="flex items-center gap-3">
<card.icon className={`w-5 h-5 ${card.color}`} />
<CardTitle className="text-lg">{card.title}</CardTitle>
</div>
<CardDescription>{card.description}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
{/* Recent Executions */}
<Card>
{/* Master Control */}
<Card className={globalEnabled ? 'border-green-500/50' : 'border-gray-300'}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Recent Executions</CardTitle>
<CardDescription>Last 10 workflow runs</CardDescription>
<CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
Master Control
</CardTitle>
<CardDescription>
Emergency kill switch for all ticket workflows
</CardDescription>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">
{globalEnabled ? (
<span className="text-green-600 flex items-center gap-1">
<PlayCircle className="w-4 h-4" /> Enabled
</span>
) : (
<span className="text-gray-600 flex items-center gap-1">
<PauseCircle className="w-4 h-4" /> Disabled
</span>
)}
</span>
<Switch checked={globalEnabled} onCheckedChange={toggleGlobalEngine} />
</div>
<Link href="/admin/workflow/history">
<Button variant="outline" size="sm">View All</Button>
</Link>
</div>
</CardHeader>
<CardContent>
{recentExecutions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No executions yet. Workflow engine will process incoming tickets when enabled.
</p>
) : (
<div className="space-y-2">
{recentExecutions.map((exec) => (
<Link key={exec.id} href={`/admin/workflow/history?id=${exec.id}`}>
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors cursor-pointer">
<div className="flex items-center gap-3">
{exec.status === 'completed' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
{exec.status === 'failed' && <XCircle className="w-4 h-4 text-red-500" />}
{exec.status === 'skipped' && <Clock className="w-4 h-4 text-gray-500" />}
{exec.status === 'running' && <Activity className="w-4 h-4 text-blue-500 animate-pulse" />}
<div>
<span className="font-medium text-sm">
{exec.ticket_number ? `Ticket #${exec.ticket_number}` : `Entity ${exec.entity_id}`}
</span>
<span className="text-xs text-muted-foreground ml-2">
{new Date(exec.created_at).toLocaleString()}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{exec.branch && (
<Badge variant="outline" className="text-xs">{exec.branch}</Badge>
)}
<Badge variant={
exec.classification_method === 'robotic' ? 'default' :
exec.classification_method === 'ai' ? 'secondary' : 'outline'
} className="text-xs">
{exec.classification_method || 'n/a'}
<p className="text-sm text-muted-foreground">
{globalEnabled ? (
<>All active workflows will process incoming tickets. Individual workflows can be toggled below.</>
) : (
<>All workflows are currently disabled. Enable the master switch to allow workflows to run.</>
)}
</p>
</CardContent>
</Card>
{/* Workflow List */}
<div className="space-y-4">
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Loading workflows...
</CardContent>
</Card>
) : workflows.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Workflow className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground mb-4">No workflows yet</p>
<Link href="/admin/workflow/create">
<Button>
<Plus className="w-4 h-4 mr-2" />
Create Your First Workflow
</Button>
</Link>
</CardContent>
</Card>
) : (
workflows.map((workflow) => (
<Card
key={workflow.id}
className={workflow.is_active ? 'border-blue-500/50' : 'border-gray-300'}
>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<CardTitle className="text-lg">{workflow.name}</CardTitle>
<Badge
variant={workflow.is_active ? 'default' : 'secondary'}
className="text-xs"
>
{workflow.is_active ? 'Active' : 'Inactive'}
</Badge>
<Badge variant="outline" className="text-xs">
{workflow.trigger_event}
</Badge>
{exec.duration_ms && (
<span className="text-xs text-muted-foreground">{exec.duration_ms}ms</span>
)}
</div>
<CardDescription className="text-sm">
{workflow.description || 'No description'}
</CardDescription>
</div>
</Link>
))}
</div>
)}
<div className="flex items-center gap-2 ml-4">
<Switch
checked={workflow.is_active}
onCheckedChange={() => toggleWorkflow(workflow.id, workflow.is_active)}
disabled={!globalEnabled}
/>
<Link href={`/admin/workflow/${workflow.id}`}>
<Button variant="outline" size="sm">
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
</Link>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-muted-foreground" />
<span className="text-muted-foreground">
{workflow.step_count || 0} steps
</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<span className="text-muted-foreground">
{workflow.executions_today || 0} runs today
</span>
</div>
{!globalEnabled && (
<Badge variant="outline" className="text-xs text-orange-600">
Master switch disabled
</Badge>
)}
</div>
</CardContent>
</Card>
))
)}
</div>
{/* Quick Links */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Related</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Link href="/admin/workflow/classification-rules">
<Button variant="outline" className="w-full justify-start">
Classification Rules
</Button>
</Link>
<Link href="/admin/workflow/ai-templates">
<Button variant="outline" className="w-full justify-start">
AI Templates
</Button>
</Link>
<Link href="/admin/workflow/settings">
<Button variant="outline" className="w-full justify-start">
Workflow Settings
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>

View file

@ -0,0 +1,597 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
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 { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
ArrowLeft,
Plus,
Save,
Trash2,
GripVertical,
Play,
ChevronDown,
ChevronUp,
AlertCircle,
CheckCircle2,
Clock,
XCircle,
Loader2,
Code,
Eye,
} from 'lucide-react';
import StepConfigEditor from '@/components/admin/pipeline/StepConfigEditor';
interface Pipeline {
id: number;
name: string;
description: string | null;
is_active: boolean;
trigger_source: string;
trigger_conditions: any[];
sort_order: number;
steps: PipelineStep[];
recent_executions: any[];
}
interface PipelineStep {
id?: number;
pipeline_id?: number;
step_order: number;
step_type: string;
name: string;
config: Record<string, any>;
on_failure: string;
skip_to_step: number | null;
is_active: boolean;
timeout_ms: number | null;
}
const STEP_TYPES = [
{ value: 'filter', label: 'Filter', description: 'Evaluate conditions, skip if not met', category: 'Logic' },
{ value: 'transform', label: 'Transform', description: 'Map payload fields to context', category: 'Logic' },
{ value: 'set_variable', label: 'Set Variable', description: 'Set a context variable', category: 'Logic' },
{ value: 'delay', label: 'Delay', description: 'Wait before next step', category: 'Logic' },
{ value: 'enrich_device', label: 'Enrich Device', description: 'Lookup device from RMM', category: 'Enrich' },
{ value: 'enrich_company', label: 'Enrich Company', description: 'Lookup company from site', category: 'Enrich' },
{ value: 'enrich_ticket', label: 'Enrich Ticket', description: 'Lookup ticket from Autotask', category: 'Enrich' },
{ value: 'enrich_vspc', label: 'Enrich VSPC', description: 'Veeam backup status lookup', category: 'Enrich' },
{ value: 'db_query', label: 'DB Query', description: 'Run SQL query on local DB', category: 'Enrich' },
{ value: 'create_ticket', label: 'Create Ticket', description: 'Create Autotask ticket', category: 'Action' },
{ value: 'update_ticket', label: 'Update Ticket', description: 'Update Autotask ticket', category: 'Action' },
{ value: 'create_note', label: 'Create Note', description: 'Add note to ticket', category: 'Action' },
{ value: 'ai_analyze', label: 'AI Analyze', description: 'Send to AI for analysis', category: 'Action' },
{ value: 'notify', label: 'Notify', description: 'Send notification', category: 'Notify' },
{ value: 'approval', label: 'Approval', description: 'Wait for human approval', category: 'Notify' },
{ value: 'rmm_quick_job', label: 'RMM Quick Job', description: 'Run job on device', category: 'RMM' },
{ value: 'rmm_get_job_results', label: 'RMM Job Results', description: 'Get job results', category: 'RMM' },
{ value: 'fetch_b2_result', label: 'Fetch B2 Result', description: 'Download JSON from B2 storage', category: 'Data' },
];
const CATEGORY_COLORS: Record<string, string> = {
Logic: 'bg-slate-100 text-slate-700',
Enrich: 'bg-blue-100 text-blue-700',
Data: 'bg-cyan-100 text-cyan-700',
Action: 'bg-green-100 text-green-700',
Notify: 'bg-orange-100 text-orange-700',
RMM: 'bg-purple-100 text-purple-700',
};
const STATUS_ICONS: Record<string, any> = {
completed: <CheckCircle2 className="h-4 w-4 text-green-500" />,
failed: <XCircle className="h-4 w-4 text-red-500" />,
running: <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />,
waiting: <Clock className="h-4 w-4 text-yellow-500" />,
pending: <Clock className="h-4 w-4 text-gray-400" />,
};
export default function PipelineEditorPage() {
const params = useParams();
const router = useRouter();
const pipelineId = params.id as string;
const [pipeline, setPipeline] = useState<Pipeline | null>(null);
const [steps, setSteps] = useState<PipelineStep[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [expandedStep, setExpandedStep] = useState<number | null>(null);
const [executions, setExecutions] = useState<any[]>([]);
const [testPayload, setTestPayload] = useState('{\n "triggered": "True",\n "alert_type": "PERF_MON",\n "alert_priority": "HIGH",\n "alert_message_en": "Test alert",\n "device_hostname": "SVR01",\n "device_uid": "test-uid",\n "site_name": "Test Site",\n "site_uid": "test-site-uid"\n}');
const [testResult, setTestResult] = useState<any>(null);
const [isTesting, setIsTesting] = useState(false);
const [channels, setChannels] = useState<{ id: number; name: string; channel_type: string }[]>([]);
const [jsonMode, setJsonMode] = useState<Record<number, boolean>>({});
const loadPipeline = useCallback(async () => {
setIsLoading(true);
try {
const res = await fetch(`/api/pipelines/${pipelineId}`);
if (res.ok) {
const data = await res.json();
setPipeline(data);
setSteps(data.steps || []);
setExecutions(data.recent_executions || []);
}
} catch (err) {
console.error('Failed to load pipeline:', err);
} finally {
setIsLoading(false);
}
}, [pipelineId]);
useEffect(() => { loadPipeline(); loadChannels(); }, [loadPipeline]);
const loadChannels = async () => {
try {
const res = await fetch('/api/notification-channels');
if (res.ok) {
const data = await res.json();
setChannels(data.data || []);
}
} catch (err) {
console.error('Failed to load channels:', err);
}
};
const saveSteps = async () => {
setIsSaving(true);
try {
await fetch(`/api/pipelines/${pipelineId}/steps`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ steps }),
});
setHasChanges(false);
loadPipeline();
} catch (err) {
console.error('Failed to save steps:', err);
} finally {
setIsSaving(false);
}
};
const savePipelineSettings = async (updates: Partial<Pipeline>) => {
try {
const res = await fetch(`/api/pipelines/${pipelineId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (res.ok) {
const data = await res.json();
setPipeline(prev => prev ? { ...prev, ...data } : prev);
}
} catch (err) {
console.error('Failed to update pipeline:', err);
}
};
const addStep = (stepType: string) => {
const typeDef = STEP_TYPES.find(t => t.value === stepType);
const newStep: PipelineStep = {
step_order: steps.length + 1,
step_type: stepType,
name: typeDef?.label || stepType,
config: {},
on_failure: 'stop',
skip_to_step: null,
is_active: true,
timeout_ms: null,
};
setSteps([...steps, newStep]);
setExpandedStep(steps.length);
setHasChanges(true);
};
const updateStep = (index: number, updates: Partial<PipelineStep>) => {
setSteps(prev => prev.map((s, i) => i === index ? { ...s, ...updates } : s));
setHasChanges(true);
};
const removeStep = (index: number) => {
setSteps(prev => {
const updated = prev.filter((_, i) => i !== index);
return updated.map((s, i) => ({ ...s, step_order: i + 1 }));
});
setHasChanges(true);
};
const moveStep = (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= steps.length) return;
setSteps(prev => {
const updated = [...prev];
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
return updated.map((s, i) => ({ ...s, step_order: i + 1 }));
});
setHasChanges(true);
};
const runTest = async () => {
setIsTesting(true);
setTestResult(null);
try {
const payload = JSON.parse(testPayload);
const res = await fetch(`/api/pipelines/${pipelineId}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payload }),
});
const data = await res.json();
setTestResult(data);
loadPipeline();
} catch (err) {
setTestResult({ error: err instanceof Error ? err.message : String(err) });
} finally {
setIsTesting(false);
}
};
if (isLoading || !pipeline) {
return <div className="container mx-auto p-6 text-center text-muted-foreground">Loading...</div>;
}
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow/pipelines">
<Button variant="ghost" size="icon"><ArrowLeft className="h-4 w-4" /></Button>
</Link>
<div>
<h1 className="text-2xl font-bold">{pipeline.name}</h1>
<p className="text-muted-foreground text-sm">{pipeline.description || 'No description'}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Active</span>
<Switch
checked={pipeline.is_active}
onCheckedChange={(checked) => savePipelineSettings({ is_active: checked })}
/>
</div>
{hasChanges && (
<Button onClick={saveSteps} disabled={isSaving}>
<Save className="h-4 w-4 mr-2" />
{isSaving ? 'Saving...' : 'Save Steps'}
</Button>
)}
</div>
</div>
<Tabs defaultValue="steps">
<TabsList>
<TabsTrigger value="steps">Steps ({steps.length})</TabsTrigger>
<TabsTrigger value="trigger">Trigger</TabsTrigger>
<TabsTrigger value="test">Test</TabsTrigger>
<TabsTrigger value="history">History ({executions.length})</TabsTrigger>
</TabsList>
{/* Steps Tab */}
<TabsContent value="steps" className="space-y-4">
{steps.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No steps yet. Add a step to build your pipeline.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{steps.map((step, index) => {
const typeDef = STEP_TYPES.find(t => t.value === step.step_type);
const category = typeDef ? CATEGORY_COLORS[typeDef.category] || '' : '';
const isExpanded = expandedStep === index;
return (
<Card key={index} className={!step.is_active ? 'opacity-50' : ''}>
<CardContent className="py-3">
<div className="flex items-center gap-3">
<div className="flex flex-col gap-0.5">
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => moveStep(index, 'up')} disabled={index === 0}>
<ChevronUp className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => moveStep(index, 'down')} disabled={index === steps.length - 1}>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<Badge variant="outline" className="font-mono text-xs w-6 justify-center">{step.step_order}</Badge>
<Badge className={category}>{typeDef?.category || 'Other'}</Badge>
<div className="flex-1 cursor-pointer" onClick={() => setExpandedStep(isExpanded ? null : index)}>
<div className="flex items-center gap-2">
<span className="font-medium">{step.name}</span>
<span className="text-xs text-muted-foreground">({step.step_type})</span>
</div>
</div>
<Switch
checked={step.is_active}
onCheckedChange={(checked) => updateStep(index, { is_active: checked })}
/>
<Button variant="ghost" size="icon" onClick={() => removeStep(index)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
{isExpanded && (
<div className="mt-4 pl-12 space-y-3 border-t pt-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Step Name</label>
<input
className="w-full mt-1 px-2 py-1.5 text-sm border rounded bg-background"
value={step.name}
onChange={e => updateStep(index, { name: e.target.value })}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">On Failure</label>
<select
className="w-full mt-1 px-2 py-1.5 text-sm border rounded bg-background"
value={step.on_failure}
onChange={e => updateStep(index, { on_failure: e.target.value })}
>
<option value="stop">Stop Pipeline</option>
<option value="continue">Continue</option>
<option value="skip_to">Skip To Step</option>
</select>
</div>
</div>
{/* Visual / JSON toggle */}
<div className="flex items-center justify-end gap-1">
<Button
variant={jsonMode[index] ? 'ghost' : 'secondary'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setJsonMode(prev => ({ ...prev, [index]: false }))}
>
<Eye className="h-3 w-3 mr-1" /> Visual
</Button>
<Button
variant={jsonMode[index] ? 'secondary' : 'ghost'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setJsonMode(prev => ({ ...prev, [index]: true }))}
>
<Code className="h-3 w-3 mr-1" /> JSON
</Button>
</div>
{jsonMode[index] ? (
<div>
<label className="text-xs font-medium text-muted-foreground">Config (JSON)</label>
<textarea
className="w-full mt-1 px-2 py-1.5 text-sm border rounded bg-background font-mono"
rows={8}
value={JSON.stringify(step.config, null, 2)}
onChange={e => {
try {
const parsed = JSON.parse(e.target.value);
updateStep(index, { config: parsed });
} catch {
// Allow invalid JSON while typing
}
}}
/>
</div>
) : (
<StepConfigEditor
stepType={step.step_type}
config={step.config}
triggerSource={pipeline.trigger_source}
priorSteps={steps.slice(0, index).map(s => ({
step_type: s.step_type,
name: s.name,
config: s.config,
}))}
channels={channels}
onChange={config => updateStep(index, { config })}
/>
)}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* Add Step */}
<Card>
<CardHeader>
<CardTitle className="text-sm">Add Step</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
{STEP_TYPES.map(type => (
<Button
key={type.value}
variant="outline"
className="h-auto py-2 px-3 flex flex-col items-start text-left"
onClick={() => addStep(type.value)}
>
<div className="flex items-center gap-1.5">
<Badge className={`${CATEGORY_COLORS[type.category]} text-[10px] px-1`}>{type.category}</Badge>
<span className="text-xs font-medium">{type.label}</span>
</div>
<span className="text-[10px] text-muted-foreground mt-0.5">{type.description}</span>
</Button>
))}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Trigger Tab */}
<TabsContent value="trigger" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Trigger Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Pipeline Name</label>
<input
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
value={pipeline.name}
onChange={e => savePipelineSettings({ name: e.target.value })}
/>
</div>
<div>
<label className="text-sm font-medium">Trigger Source</label>
<select
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
value={pipeline.trigger_source}
onChange={e => savePipelineSettings({ trigger_source: e.target.value } as any)}
>
<option value="datto_rmm">Datto RMM</option>
<option value="autotask">Autotask</option>
<option value="veeam">Veeam</option>
<option value="manual">Manual</option>
</select>
</div>
</div>
<div>
<label className="text-sm font-medium">Description</label>
<textarea
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
rows={2}
value={pipeline.description || ''}
onChange={e => savePipelineSettings({ description: e.target.value })}
/>
</div>
<div>
<label className="text-sm font-medium">Trigger Conditions (JSON)</label>
<p className="text-xs text-muted-foreground mb-1">
Array of conditions: {`[{"field": "triggered", "operator": "equals", "value": "True"}]`}
</p>
<textarea
className="w-full mt-1 px-3 py-2 border rounded-md bg-background font-mono text-sm"
rows={5}
defaultValue={JSON.stringify(pipeline.trigger_conditions, null, 2)}
onBlur={e => {
try {
const parsed = JSON.parse(e.target.value);
savePipelineSettings({ trigger_conditions: parsed });
} catch {}
}}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Test Tab */}
<TabsContent value="test" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Test Pipeline</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium">Sample Payload (JSON)</label>
<textarea
className="w-full mt-1 px-3 py-2 border rounded-md bg-background font-mono text-sm"
rows={12}
value={testPayload}
onChange={e => setTestPayload(e.target.value)}
/>
</div>
<Button onClick={runTest} disabled={isTesting}>
<Play className="h-4 w-4 mr-2" />
{isTesting ? 'Running...' : 'Run Test'}
</Button>
{testResult && (
<div className="mt-4 space-y-3">
{testResult.error ? (
<div className="p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
<AlertCircle className="h-4 w-4 inline mr-2" />
{testResult.error}
</div>
) : (
<>
<div className={`p-3 rounded text-sm ${testResult.matched ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-yellow-50 border border-yellow-200 text-yellow-700'}`}>
{testResult.matched ? '✓ Trigger conditions matched' : '✗ Trigger conditions did not match'}
</div>
{testResult.execution && (
<div className="p-3 bg-muted rounded text-sm space-y-2">
<div className="flex items-center gap-2">
{STATUS_ICONS[testResult.execution.status]}
<span className="font-medium">Execution #{testResult.execution.id}: {testResult.execution.status}</span>
{testResult.execution.duration_ms && (
<span className="text-muted-foreground">({testResult.execution.duration_ms}ms)</span>
)}
</div>
{testResult.execution.error_message && (
<div className="text-red-600 text-xs">{testResult.execution.error_message}</div>
)}
</div>
)}
{testResult.steps && testResult.steps.length > 0 && (
<div className="space-y-1">
{testResult.steps.map((s: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-sm p-2 bg-muted/50 rounded">
{STATUS_ICONS[s.status] || STATUS_ICONS.pending}
<Badge variant="outline" className="text-xs">{s.step_order}</Badge>
<span>{s.step_name || s.step_type}</span>
<span className="text-muted-foreground text-xs">({s.step_type})</span>
{s.duration_ms != null && <span className="text-xs text-muted-foreground ml-auto">{s.duration_ms}ms</span>}
{s.error_message && <span className="text-xs text-red-500 ml-2">{s.error_message}</span>}
</div>
))}
</div>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* History Tab */}
<TabsContent value="history" className="space-y-4">
{executions.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No executions yet.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{executions.map((exec: any) => (
<Card key={exec.id}>
<CardContent className="py-3">
<div className="flex items-center gap-3">
{STATUS_ICONS[exec.status] || STATUS_ICONS.pending}
<span className="font-medium">#{exec.id}</span>
<Badge variant="outline">{exec.status}</Badge>
<span className="text-sm text-muted-foreground">{exec.trigger_source}</span>
{exec.duration_ms && <span className="text-sm text-muted-foreground">{exec.duration_ms}ms</span>}
<span className="text-xs text-muted-foreground ml-auto">
{new Date(exec.started_at).toLocaleString()}
</span>
</div>
{exec.error_message && (
<p className="text-xs text-red-500 mt-1 pl-7">{exec.error_message}</p>
)}
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,229 @@
'use client';
import { useState, useEffect } 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 { Switch } from '@/components/ui/switch';
import {
ArrowLeft,
Plus,
Workflow,
Play,
Pause,
Trash2,
Settings,
History,
Zap,
GitBranch,
} from 'lucide-react';
interface Pipeline {
id: number;
name: string;
description: string | null;
is_active: boolean;
trigger_source: string;
trigger_conditions: any[];
sort_order: number;
step_count: number;
created_at: string;
updated_at: string;
}
const SOURCE_LABELS: Record<string, { label: string; color: string }> = {
datto_rmm: { label: 'Datto RMM', color: 'bg-blue-100 text-blue-800' },
autotask: { label: 'Autotask', color: 'bg-green-100 text-green-800' },
veeam: { label: 'Veeam', color: 'bg-purple-100 text-purple-800' },
manual: { label: 'Manual', color: 'bg-gray-100 text-gray-800' },
};
export default function PipelinesPage() {
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newPipeline, setNewPipeline] = useState({ name: '', description: '', trigger_source: 'datto_rmm' });
useEffect(() => { loadPipelines(); }, []);
const loadPipelines = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/pipelines');
if (res.ok) {
const data = await res.json();
setPipelines(data.data || []);
}
} catch (err) {
console.error('Failed to load pipelines:', err);
} finally {
setIsLoading(false);
}
};
const togglePipeline = async (id: number, active: boolean) => {
try {
await fetch(`/api/pipelines/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: active }),
});
setPipelines(prev => prev.map(p => p.id === id ? { ...p, is_active: active } : p));
} catch (err) {
console.error('Failed to toggle pipeline:', err);
}
};
const deletePipeline = async (id: number) => {
if (!confirm('Delete this pipeline and all its steps?')) return;
try {
await fetch(`/api/pipelines/${id}`, { method: 'DELETE' });
setPipelines(prev => prev.filter(p => p.id !== id));
} catch (err) {
console.error('Failed to delete pipeline:', err);
}
};
const createPipeline = async () => {
if (!newPipeline.name) return;
try {
const res = await fetch('/api/pipelines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newPipeline, is_active: false }),
});
if (res.ok) {
setShowCreate(false);
setNewPipeline({ name: '', description: '', trigger_source: 'datto_rmm' });
loadPipelines();
}
} catch (err) {
console.error('Failed to create pipeline:', err);
}
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="icon"><ArrowLeft className="h-4 w-4" /></Button>
</Link>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<GitBranch className="h-6 w-6" /> Webhook Pipelines
</h1>
<p className="text-muted-foreground text-sm">Automate actions triggered by incoming webhooks</p>
</div>
</div>
<Button onClick={() => setShowCreate(!showCreate)}>
<Plus className="h-4 w-4 mr-2" /> New Pipeline
</Button>
</div>
{showCreate && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Create Pipeline</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium">Name</label>
<input
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
placeholder="e.g., RMM Alert → Ticket"
value={newPipeline.name}
onChange={e => setNewPipeline(p => ({ ...p, name: e.target.value }))}
/>
</div>
<div>
<label className="text-sm font-medium">Trigger Source</label>
<select
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
value={newPipeline.trigger_source}
onChange={e => setNewPipeline(p => ({ ...p, trigger_source: e.target.value }))}
>
<option value="datto_rmm">Datto RMM</option>
<option value="autotask">Autotask</option>
<option value="veeam">Veeam</option>
<option value="manual">Manual</option>
</select>
</div>
<div>
<label className="text-sm font-medium">Description</label>
<input
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
placeholder="Optional description"
value={newPipeline.description}
onChange={e => setNewPipeline(p => ({ ...p, description: e.target.value }))}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={createPipeline} disabled={!newPipeline.name}>Create</Button>
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
</div>
</CardContent>
</Card>
)}
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">Loading pipelines...</div>
) : pipelines.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Workflow className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p>No pipelines yet. Create one to get started.</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{pipelines.map(pipeline => {
const source = SOURCE_LABELS[pipeline.trigger_source] || { label: pipeline.trigger_source, color: 'bg-gray-100 text-gray-800' };
return (
<Card key={pipeline.id} className={!pipeline.is_active ? 'opacity-60' : ''}>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<Switch
checked={pipeline.is_active}
onCheckedChange={(checked) => togglePipeline(pipeline.id, checked)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Link href={`/admin/workflow/pipelines/${pipeline.id}`} className="font-medium hover:underline">
{pipeline.name}
</Link>
<Badge variant="outline" className={source.color}>{source.label}</Badge>
<Badge variant="outline">{pipeline.step_count} steps</Badge>
{pipeline.trigger_conditions?.length > 0 && (
<Badge variant="outline" className="bg-yellow-50 text-yellow-700">
{pipeline.trigger_conditions.length} condition{pipeline.trigger_conditions.length > 1 ? 's' : ''}
</Badge>
)}
</div>
{pipeline.description && (
<p className="text-sm text-muted-foreground mt-1 truncate">{pipeline.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Link href={`/admin/workflow/pipelines/${pipeline.id}`}>
<Button variant="ghost" size="icon"><Settings className="h-4 w-4" /></Button>
</Link>
<Button variant="ghost" size="icon" onClick={() => deletePipeline(pipeline.id)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View file

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

View file

@ -4,7 +4,7 @@ import { postgresClient } from '@/lib/services/postgres-client';
// Transform snake_case database columns to camelCase for TypeScript interface
function transformCompany(row: any) {
return {
id: row.id,
id: Number(row.id),
companyName: row.company_name,
companyType: row.company_type,
isActive: row.is_active,

View file

@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { getITGlueClient } from '@/lib/services/itglue-client';
export async function GET() {
try {
const client = getITGlueClient();
const result = await client.testConnection();
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 500 });
}
}

View file

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { getITGlueSyncService } from '@/lib/services/itglue-sync-service';
import postgresClient from '@/lib/services/postgres-client';
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const triggeredBy = body.triggeredBy || 'manual';
const svc = getITGlueSyncService();
if (svc.isSyncInProgress()) {
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 });
}
// Fire and forget — return immediately, sync runs in background
svc.fullSync(triggeredBy).catch(err =>
console.error('[ITGlue] Background sync error:', err.message)
);
return NextResponse.json({ ok: true, message: 'IT Glue sync started' });
}
export async function GET() {
try {
const svc = getITGlueSyncService();
const inProgress = svc.isSyncInProgress();
const { rows } = await postgresClient.query(
`SELECT id, sync_type, status, triggered_by, started_at, completed_at,
duration_ms, total_upserted, entities, error
FROM itg_sync_history
ORDER BY started_at DESC
LIMIT 10`
);
const counts = await postgresClient.query(`
SELECT
(SELECT COUNT(*) FROM itg_organizations) AS organizations,
(SELECT COUNT(*) FROM itg_configurations) AS configurations,
(SELECT COUNT(*) FROM itg_flexible_assets) AS flexible_assets,
(SELECT COUNT(*) FROM itg_contacts) AS contacts,
(SELECT COUNT(*) FROM itg_passwords) AS passwords,
(SELECT COUNT(*) FROM itg_documents) AS documents,
(SELECT COUNT(*) FROM itg_locations) AS locations,
(SELECT COUNT(*) FROM itg_domains) AS domains
`);
return NextResponse.json({
inProgress,
counts: counts.rows[0],
history: rows,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View file

@ -0,0 +1,71 @@
/**
* Single Notification Channel API get, update, delete.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const result = await postgresClient.query(
`SELECT * FROM notification_channels WHERE id = $1`, [id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Channel not found' }, { status: 404 });
}
return NextResponse.json(result.rows[0]);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const { name, channel_type, config, is_active } = body;
const result = await postgresClient.query(
`UPDATE notification_channels
SET name = COALESCE($1, name),
channel_type = COALESCE($2, channel_type),
config = COALESCE($3, config),
is_active = COALESCE($4, is_active),
updated_at = NOW()
WHERE id = $5
RETURNING *`,
[name, channel_type, config ? JSON.stringify(config) : null, is_active, id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Channel not found' }, { status: 404 });
}
return NextResponse.json(result.rows[0]);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const result = await postgresClient.query(
`DELETE FROM notification_channels WHERE id = $1 RETURNING id`, [id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Channel not found' }, { status: 404 });
}
return NextResponse.json({ deleted: true, id: Number(id) });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,121 @@
/**
* Test Notification Channel send a test message.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const result = await postgresClient.query(
`SELECT * FROM notification_channels WHERE id = $1`, [id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Channel not found' }, { status: 404 });
}
const channel = result.rows[0];
const testMessage = `🧪 Test notification from Pulse Pipeline Engine\nChannel: ${channel.name}\nTime: ${new Date().toISOString()}`;
let resp: Response;
switch (channel.channel_type) {
case 'teams': {
if (!channel.config.webhook_url) {
return NextResponse.json({ error: 'Missing webhook_url in channel config' }, { status: 400 });
}
const card = {
type: 'message',
attachments: [{
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.4',
body: [
{ type: 'TextBlock', text: '🧪 Pulse Test Notification', weight: 'bolder', size: 'medium' },
{ type: 'TextBlock', text: `Channel: ${channel.name}`, wrap: true },
{ type: 'TextBlock', text: `Time: ${new Date().toISOString()}`, size: 'small', isSubtle: true },
],
},
}],
};
resp = await fetch(channel.config.webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(card),
});
break;
}
case 'telegram': {
if (!channel.config.bot_token || !channel.config.chat_id) {
return NextResponse.json({ error: 'Missing bot_token or chat_id in channel config' }, { status: 400 });
}
resp = await fetch(`https://api.telegram.org/bot${channel.config.bot_token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: channel.config.chat_id,
text: testMessage,
parse_mode: channel.config.parse_mode || 'HTML',
}),
});
break;
}
case 'ntfy': {
if (!channel.config.topic) {
return NextResponse.json({ error: 'Missing topic in channel config' }, { status: 400 });
}
const serverUrl = channel.config.server_url || 'https://ntfy.sh';
const headers: Record<string, string> = {
'Title': 'Pulse Test Notification',
'Priority': 'default',
'Tags': 'test_tube',
};
if (channel.config.auth_token) {
headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
}
resp = await fetch(`${serverUrl}/${channel.config.topic}`, {
method: 'POST',
headers,
body: testMessage,
});
break;
}
case 'webhook': {
if (!channel.config.url) {
return NextResponse.json({ error: 'Missing url in channel config' }, { status: 400 });
}
resp = await fetch(channel.config.url, {
method: channel.config.method || 'POST',
headers: {
'Content-Type': 'application/json',
...(channel.config.headers || {}),
},
body: JSON.stringify({ test: true, message: testMessage, timestamp: new Date().toISOString() }),
});
break;
}
default:
return NextResponse.json({ error: `Unknown channel type: ${channel.channel_type}` }, { status: 400 });
}
const status = resp.status;
const responseText = await resp.text();
return NextResponse.json({
success: resp.ok,
status,
response: responseText.substring(0, 500),
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,46 @@
/**
* Notification Channels API list and create channels.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET() {
try {
const result = await postgresClient.query(
`SELECT * FROM notification_channels ORDER BY name`
);
return NextResponse.json({ data: result.rows, total: result.rows.length });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, channel_type, config, is_active } = body;
if (!name || !channel_type) {
return NextResponse.json({ error: 'name and channel_type are required' }, { status: 400 });
}
const validTypes = ['teams', 'telegram', 'ntfy', 'webhook'];
if (!validTypes.includes(channel_type)) {
return NextResponse.json({ error: `channel_type must be one of: ${validTypes.join(', ')}` }, { status: 400 });
}
const result = await postgresClient.query(
`INSERT INTO notification_channels (name, channel_type, config, is_active)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[name, channel_type, JSON.stringify(config || {}), is_active ?? true]
);
return NextResponse.json(result.rows[0], { status: 201 });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,24 @@
/**
* Pipeline Executions API view execution history for a pipeline.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 200);
const result = await postgresClient.query(
`SELECT * FROM pipeline_executions WHERE pipeline_id = $1 ORDER BY created_at DESC LIMIT $2`,
[id, limit]
);
return NextResponse.json({ data: result.rows, total: result.rows.length });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,86 @@
/**
* Single Pipeline API get, update, delete a pipeline with its steps.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const pipelineResult = await postgresClient.query(
`SELECT * FROM webhook_pipelines WHERE id = $1`, [id]
);
if (pipelineResult.rows.length === 0) {
return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 });
}
const stepsResult = await postgresClient.query(
`SELECT * FROM pipeline_steps WHERE pipeline_id = $1 ORDER BY step_order`, [id]
);
const execResult = await postgresClient.query(
`SELECT id, status, trigger_source, started_at, completed_at, duration_ms, error_message
FROM pipeline_executions WHERE pipeline_id = $1 ORDER BY created_at DESC LIMIT 20`, [id]
);
return NextResponse.json({
...pipelineResult.rows[0],
steps: stepsResult.rows,
recent_executions: execResult.rows,
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const { name, description, is_active, trigger_source, trigger_conditions, sort_order } = body;
const result = await postgresClient.query(
`UPDATE webhook_pipelines
SET name = COALESCE($1, name),
description = COALESCE($2, description),
is_active = COALESCE($3, is_active),
trigger_source = COALESCE($4, trigger_source),
trigger_conditions = COALESCE($5, trigger_conditions),
sort_order = COALESCE($6, sort_order),
updated_at = NOW()
WHERE id = $7
RETURNING *`,
[name, description, is_active, trigger_source, trigger_conditions ? JSON.stringify(trigger_conditions) : null, sort_order, id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 });
}
return NextResponse.json(result.rows[0]);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const result = await postgresClient.query(
`DELETE FROM webhook_pipelines WHERE id = $1 RETURNING id`, [id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 });
}
return NextResponse.json({ deleted: true, id: Number(id) });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,75 @@
/**
* Pipeline Steps API list and manage steps for a pipeline.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const result = await postgresClient.query(
`SELECT * FROM pipeline_steps WHERE pipeline_id = $1 ORDER BY step_order`, [id]
);
return NextResponse.json({ data: result.rows });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const { step_order, step_type, name, config, on_failure, skip_to_step, is_active, timeout_ms } = body;
if (!step_type || !name) {
return NextResponse.json({ error: 'step_type and name are required' }, { status: 400 });
}
const result = await postgresClient.query(
`INSERT INTO pipeline_steps (pipeline_id, step_order, step_type, name, config, on_failure, skip_to_step, is_active, timeout_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[id, step_order || 0, step_type, name, JSON.stringify(config || {}), on_failure || 'stop', skip_to_step || null, is_active ?? true, timeout_ms || null]
);
return NextResponse.json(result.rows[0], { status: 201 });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const { steps } = body;
if (!Array.isArray(steps)) {
return NextResponse.json({ error: 'steps array is required' }, { status: 400 });
}
// Replace all steps for this pipeline
await postgresClient.query(`DELETE FROM pipeline_steps WHERE pipeline_id = $1`, [id]);
for (const step of steps) {
await postgresClient.query(
`INSERT INTO pipeline_steps (pipeline_id, step_order, step_type, name, config, on_failure, skip_to_step, is_active, timeout_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[id, step.step_order, step.step_type, step.name, JSON.stringify(step.config || {}), step.on_failure || 'stop', step.skip_to_step || null, step.is_active ?? true, step.timeout_ms || null]
);
}
const result = await postgresClient.query(
`SELECT * FROM pipeline_steps WHERE pipeline_id = $1 ORDER BY step_order`, [id]
);
return NextResponse.json({ data: result.rows });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,68 @@
/**
* Pipeline Test API dry-run a pipeline with a sample payload.
*/
import { NextRequest, NextResponse } from 'next/server';
import '@/lib/services/pipeline-steps';
import { pipelineEngine } from '@/lib/services/pipeline-engine';
import { postgresClient } from '@/lib/services/postgres-client';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const { payload } = body;
if (!payload || typeof payload !== 'object') {
return NextResponse.json({ error: 'payload object is required' }, { status: 400 });
}
// Load pipeline with steps
const pipelineResult = await postgresClient.query(
`SELECT * FROM webhook_pipelines WHERE id = $1`, [id]
);
if (pipelineResult.rows.length === 0) {
return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 });
}
const pipeline = pipelineResult.rows[0];
const stepsResult = await postgresClient.query(
`SELECT * FROM pipeline_steps WHERE pipeline_id = $1 AND is_active = true ORDER BY step_order`, [id]
);
const pipelineWithSteps = { ...pipeline, steps: stepsResult.rows };
// Check trigger conditions
const conditions = Array.isArray(pipeline.trigger_conditions) ? pipeline.trigger_conditions : [];
const conditionsMatch = pipelineEngine.evaluateConditions(conditions, payload);
if (!conditionsMatch) {
return NextResponse.json({
matched: false,
message: 'Trigger conditions did not match the provided payload',
conditions,
});
}
// Execute the pipeline
const executionId = await pipelineEngine.executePipeline(pipelineWithSteps, pipeline.trigger_source, payload);
// Load execution results
const execResult = await postgresClient.query(
`SELECT * FROM pipeline_executions WHERE id = $1`, [executionId]
);
const stepsLog = await postgresClient.query(
`SELECT * FROM pipeline_execution_steps WHERE execution_id = $1 ORDER BY step_order`, [executionId]
);
return NextResponse.json({
matched: true,
execution: execResult.rows[0],
steps: stepsLog.rows,
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,92 @@
/**
* Approval Callback API receives approval responses from Teams/Telegram/ntfy.
* GET or POST /api/pipelines/approval/:id?response=Approve
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
import '@/lib/services/pipeline-steps';
import { pipelineEngine } from '@/lib/services/pipeline-engine';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return handleApproval(request, await params);
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return handleApproval(request, await params);
}
async function handleApproval(request: NextRequest, params: { id: string }) {
try {
const approvalId = Number(params.id);
const response = request.nextUrl.searchParams.get('response') || 'Approve';
const respondedBy = request.nextUrl.searchParams.get('by') || request.headers.get('x-responded-by') || 'unknown';
// Load approval request
const result = await postgresClient.query(
`SELECT * FROM approval_requests WHERE id = $1`, [approvalId]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Approval request not found' }, { status: 404 });
}
const approval = result.rows[0];
if (approval.status !== 'pending') {
return NextResponse.json({
error: `Approval already ${approval.status}`,
responded_by: approval.responded_by,
responded_at: approval.responded_at,
}, { status: 409 });
}
// Check expiry
if (approval.expires_at && new Date(approval.expires_at) < new Date()) {
await postgresClient.query(
`UPDATE approval_requests SET status = 'timeout' WHERE id = $1`, [approvalId]
);
return NextResponse.json({ error: 'Approval has expired' }, { status: 410 });
}
// Update approval
const status = response.toLowerCase() === 'reject' || response.toLowerCase() === 'rejected'
? 'rejected' : 'approved';
await postgresClient.query(
`UPDATE approval_requests
SET status = $1, responded_by = $2, responded_at = NOW(), response_data = $3
WHERE id = $4`,
[status, respondedBy, JSON.stringify({ response, responded_by: respondedBy }), approvalId]
);
console.log(`[APPROVAL] #${approvalId} ${status} by ${respondedBy} (response: ${response})`);
// Resume the pipeline execution
try {
await pipelineEngine.resumeExecution(approval.execution_id, {
approval_id: approvalId,
status,
response,
responded_by: respondedBy,
});
} catch (err) {
console.error(`[APPROVAL] Failed to resume execution #${approval.execution_id}:`, err);
}
// Return a simple HTML page for browser-based approvals (Teams Action.OpenUrl)
const html = `<!DOCTYPE html><html><body style="font-family:sans-serif;text-align:center;padding:40px">
<h2>Approval ${status === 'approved' ? 'Accepted' : 'Rejected'}</h2>
<p>Response: <strong>${response}</strong></p>
<p>By: ${respondedBy}</p>
<p style="color:#888">You can close this window.</p>
</body></html>`;
return new NextResponse(html, {
headers: { 'Content-Type': 'text/html' },
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,64 @@
/**
* Pipelines API list and create webhook pipelines.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const source = searchParams.get('source');
let query = `SELECT * FROM webhook_pipelines`;
const params: any[] = [];
if (source) {
query += ` WHERE trigger_source = $1`;
params.push(source);
}
query += ` ORDER BY sort_order, name`;
const result = await postgresClient.query(query, params);
// Load step counts
const pipelines = await Promise.all(
result.rows.map(async (p: any) => {
const stepsResult = await postgresClient.query(
`SELECT COUNT(*) as count FROM pipeline_steps WHERE pipeline_id = $1`,
[p.id]
);
return { ...p, step_count: parseInt(stepsResult.rows[0].count) };
})
);
return NextResponse.json({ data: pipelines, total: pipelines.length });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, description, is_active, trigger_source, trigger_conditions, sort_order } = body;
if (!name || !trigger_source) {
return NextResponse.json({ error: 'name and trigger_source are required' }, { status: 400 });
}
const result = await postgresClient.query(
`INSERT INTO webhook_pipelines (name, description, is_active, trigger_source, trigger_conditions, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[name, description || null, is_active ?? true, trigger_source, JSON.stringify(trigger_conditions || []), sort_order || 0]
);
return NextResponse.json(result.rows[0], { status: 201 });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,33 @@
/**
* RMM Components API list available Datto RMM automation components for quick jobs.
*/
import { NextResponse } from 'next/server';
import { DattoRMMClient } from '@/lib/services/datto-rmm-client';
let _client: DattoRMMClient | null = null;
function getClient(): DattoRMMClient {
if (!_client) {
_client = new DattoRMMClient({
apiUrl: process.env.DATTO_RMM_API_URL || 'https://concord-api.centrastage.net/api/v2',
apiKey: process.env.DATTO_RMM_API_KEY || '',
apiSecret: process.env.DATTO_RMM_API_SECRET || '',
});
}
return _client;
}
export async function GET() {
try {
const client = getClient();
const components = await client.getComponents();
return NextResponse.json({
data: components,
total: components.length,
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,43 @@
/**
* GET /api/ticket-workflows/:id/executions - List executions for a workflow
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
import { TicketWorkflowExecution } from '@/lib/types/ticket-workflow';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const { searchParams } = new URL(request.url);
const limit = Number(searchParams.get('limit')) || 50;
const offset = Number(searchParams.get('offset')) || 0;
const result = await postgresClient.query<TicketWorkflowExecution>(
`SELECT * FROM ticket_workflow_executions
WHERE workflow_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
[id, limit, offset]
);
const countResult = await postgresClient.query(
`SELECT COUNT(*) FROM ticket_workflow_executions WHERE workflow_id = $1`,
[id]
);
return NextResponse.json({
executions: result.rows,
total: Number(countResult.rows[0].count)
});
} catch (error) {
console.error('[API] Error fetching workflow executions:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch executions' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,110 @@
/**
* GET /api/ticket-workflows/:id - Get workflow with steps
* PUT /api/ticket-workflows/:id - Update workflow
* DELETE /api/ticket-workflows/:id - Delete workflow
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
import { TicketWorkflow, TicketWorkflowStep } from '@/lib/types/ticket-workflow';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const workflowResult = await postgresClient.query<TicketWorkflow>(
`SELECT * FROM ticket_workflows WHERE id = $1`,
[id]
);
if (workflowResult.rows.length === 0) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 });
}
const stepsResult = await postgresClient.query<TicketWorkflowStep>(
`SELECT * FROM ticket_workflow_steps WHERE workflow_id = $1 ORDER BY step_order`,
[id]
);
return NextResponse.json({
workflow: workflowResult.rows[0],
steps: stepsResult.rows
});
} catch (error) {
console.error('[API] Error fetching workflow:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch workflow' },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { name, description, is_active, trigger_event, trigger_conditions, sort_order } = body;
const result = await postgresClient.query<TicketWorkflow>(
`UPDATE ticket_workflows
SET name = COALESCE($1, name),
description = COALESCE($2, description),
is_active = COALESCE($3, is_active),
trigger_event = COALESCE($4, trigger_event),
trigger_conditions = COALESCE($5, trigger_conditions),
sort_order = COALESCE($6, sort_order),
updated_at = NOW()
WHERE id = $7
RETURNING *`,
[
name,
description,
is_active,
trigger_event,
trigger_conditions ? JSON.stringify(trigger_conditions) : undefined,
sort_order,
id
]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 });
}
return NextResponse.json({ workflow: result.rows[0] });
} catch (error) {
console.error('[API] Error updating workflow:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to update workflow' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await postgresClient.query(
`DELETE FROM ticket_workflows WHERE id = $1`,
[id]
);
return NextResponse.json({ success: true });
} catch (error) {
console.error('[API] Error deleting workflow:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to delete workflow' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,65 @@
/**
* PUT /api/ticket-workflows/:id/steps - Replace all steps (bulk update)
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
import { TicketWorkflowStep } from '@/lib/types/ticket-workflow';
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { steps } = body;
if (!Array.isArray(steps)) {
return NextResponse.json(
{ error: 'Invalid request: steps must be an array' },
{ status: 400 }
);
}
// Use transaction to replace all steps
await postgresClient.transaction(async (client) => {
// Delete existing steps
await client.query('DELETE FROM ticket_workflow_steps WHERE workflow_id = $1', [id]);
// Insert new steps
for (const step of steps) {
await client.query(
`INSERT INTO ticket_workflow_steps
(workflow_id, step_order, step_type, name, config, on_failure, skip_to_step, is_active, condition)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
id,
step.step_order,
step.step_type,
step.name,
JSON.stringify(step.config || {}),
step.on_failure || 'continue',
step.skip_to_step || null,
step.is_active !== undefined ? step.is_active : true,
step.condition ? JSON.stringify(step.condition) : null
]
);
}
});
// Fetch updated steps
const result = await postgresClient.query<TicketWorkflowStep>(
`SELECT * FROM ticket_workflow_steps WHERE workflow_id = $1 ORDER BY step_order`,
[id]
);
return NextResponse.json({ steps: result.rows });
} catch (error) {
console.error('[API] Error updating workflow steps:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to update steps' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,34 @@
/**
* POST /api/ticket-workflows/:id/test - Dry-run test workflow on a ticket
*/
import { NextRequest, NextResponse } from 'next/server';
import { ticketWorkflowEngine } from '@/lib/services/ticket-workflow-engine';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { ticket_id } = body;
if (!ticket_id) {
return NextResponse.json(
{ error: 'Missing required field: ticket_id' },
{ status: 400 }
);
}
const result = await ticketWorkflowEngine.dryRun(Number(id), Number(ticket_id));
return NextResponse.json(result);
} catch (error) {
console.error('[API] Error running workflow test:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to run test' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,64 @@
/**
* GET /api/ticket-workflows - List all ticket workflows
* POST /api/ticket-workflows - Create a new workflow
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
import { TicketWorkflow } from '@/lib/types/ticket-workflow';
export async function GET(request: NextRequest) {
try {
const result = await postgresClient.query<TicketWorkflow>(
`SELECT tw.*,
(SELECT COUNT(*) FROM ticket_workflow_steps WHERE workflow_id = tw.id) as step_count,
(SELECT COUNT(*) FROM ticket_workflow_executions WHERE workflow_id = tw.id AND created_at > NOW() - INTERVAL '24 hours') as executions_today
FROM ticket_workflows tw
ORDER BY sort_order, id`
);
return NextResponse.json({ workflows: result.rows });
} catch (error) {
console.error('[API] Error fetching ticket workflows:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch workflows' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, description, trigger_event, trigger_conditions, is_active, sort_order } = body;
if (!name || !trigger_event) {
return NextResponse.json(
{ error: 'Missing required fields: name, trigger_event' },
{ status: 400 }
);
}
const result = await postgresClient.query<TicketWorkflow>(
`INSERT INTO ticket_workflows (name, description, trigger_event, trigger_conditions, is_active, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
name,
description || null,
trigger_event,
JSON.stringify(trigger_conditions || []),
is_active !== undefined ? is_active : true,
sort_order || 0
]
);
return NextResponse.json({ workflow: result.rows[0] }, { status: 201 });
} catch (error) {
console.error('[API] Error creating ticket workflow:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to create workflow' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { getVeeamRpoService } from '@/lib/services/veeam-rpo-service';
export async function GET() {
try {
const svc = getVeeamRpoService();
const status = await svc.getStatus();
return NextResponse.json(status);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}));
const dryRun = body.dryRun === true;
if (dryRun) {
const svc = getVeeamRpoService();
const status = await svc.getStatus();
const MAX_AGE = 720;
const wouldCreate = status.jobs.filter(j =>
j.is_breached && !j.open_ticket && (j.hours_since_backup ?? 0) <= MAX_AGE
).length;
const wouldSkipTooOld = status.jobs.filter(j =>
j.is_breached && !j.open_ticket && (j.hours_since_backup ?? 0) > MAX_AGE
).length;
const wouldResolve = status.jobs.filter(j => !j.is_breached && j.open_ticket).length;
const wouldEscalate = status.jobs.filter(j => {
if (!j.is_breached || !j.open_ticket) return false;
const hours = j.hours_since_backup ?? 0;
const target = hours >= 168 ? 'critical' : hours >= 72 ? 'high' : 'medium';
const rank: Record<string, number> = { medium: 1, high: 2, critical: 3 };
return (rank[target] ?? 0) > (rank[j.open_ticket.priority_level] ?? 0);
}).length;
return NextResponse.json({ dryRun: true, wouldCreate, wouldResolve, wouldEscalate, wouldSkipTooOld, ...status });
}
const svc = getVeeamRpoService();
const result = await svc.runCheck();
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View file

@ -0,0 +1,43 @@
/**
* Datto RMM Webhook Logs API
* Browse captured webhook payloads for inspection.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
/**
* GET /api/webhooks/datto-rmm/logs
* Returns recent webhook logs, newest first.
* Query params: limit (default 50), status (optional filter)
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 500);
const status = searchParams.get('status');
let query = `SELECT * FROM datto_rmm_webhook_logs`;
const params: any[] = [];
if (status) {
query += ` WHERE status = $1`;
params.push(status);
}
query += ` ORDER BY received_at DESC LIMIT $${params.length + 1}`;
params.push(limit);
const result = await postgresClient.query(query, params);
return NextResponse.json({
success: true,
count: result.rows.length,
logs: result.rows,
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error('[DATTO-RMM-WEBHOOK-LOGS] Error:', msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,100 @@
/**
* Datto RMM Webhook Receiver
* Generic endpoint that accepts any payload from Datto RMM and logs it raw.
* No processing logic yet refine after inspecting real payloads.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
import '@/lib/services/pipeline-steps';
import { pipelineEngine } from '@/lib/services/pipeline-engine';
/**
* POST /api/webhooks/datto-rmm
* Accepts any payload, stores it for inspection, returns 200.
*/
export async function POST(request: NextRequest) {
const receivedAt = new Date();
try {
const sourceIp =
request.headers.get('cf-connecting-ip') ||
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
request.headers.get('x-real-ip') ||
'unknown';
const userAgent = request.headers.get('user-agent') || 'unknown';
// Verify shared secret header
const secret = process.env.DATTO_RMM_WEBHOOK_SECRET;
if (secret) {
const provided = request.headers.get('x-datto-webhook-secret');
if (provided !== secret) {
console.warn(`[DATTO-RMM-WEBHOOK] Invalid or missing X-Datto-Webhook-Secret from ${sourceIp}`);
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
// Capture all headers as a plain object
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
// Read raw body
const rawBody = await request.text();
// Try to parse as JSON; fall back to null
let payload: any = null;
try {
payload = JSON.parse(rawBody);
} catch {
// Not valid JSON — store raw_body only
}
console.log(
`[DATTO-RMM-WEBHOOK] Received payload from ${sourceIp} (${rawBody.length} bytes)`
);
// Store in database
await postgresClient.query(
`INSERT INTO datto_rmm_webhook_logs
(received_at, source_ip, user_agent, headers, payload, raw_body, status)
VALUES ($1, $2, $3, $4, $5, $6, 'received')`,
[
receivedAt,
sourceIp,
userAgent,
JSON.stringify(headers),
payload ? JSON.stringify(payload) : null,
rawBody || null,
]
);
// Fire matching pipelines (fire-and-forget)
if (payload && typeof payload === 'object') {
pipelineEngine.processTrigger('datto_rmm', payload).catch(err =>
console.error('[DATTO-RMM-WEBHOOK] Pipeline processing error:', err)
);
}
return NextResponse.json({ success: true, received_at: receivedAt.toISOString() });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error('[DATTO-RMM-WEBHOOK] Error storing webhook:', msg);
// Still return 200 to avoid Datto disabling the webhook
return NextResponse.json({ success: false, error: msg }, { status: 200 });
}
}
/**
* GET /api/webhooks/datto-rmm
* Health check
*/
export async function GET() {
return NextResponse.json({
status: 'active',
endpoint: '/api/webhooks/datto-rmm',
message: 'Datto RMM webhook receiver is ready',
});
}

View file

@ -0,0 +1,468 @@
import { NextRequest } from 'next/server';
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
import { ZabbixClient } from '@/lib/services/zabbix-client';
import { postgresClient } from '@/lib/services/postgres-client';
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
import { ZabbixHostMacro, ZabbixHostTag } from '@/lib/types/zabbix';
export const maxDuration = 300;
type SyncMode = 'all' | 'client' | 'site';
type SiteAction = 'created' | 'updated' | 'filtered' | 'no-ip' | 'error' | 'skipped';
interface IspInfo {
isp: string; // "Comcast Cable Communications, LLC"
asn: string; // "AS7922"
city: string;
region: string;
country: string;
}
interface WanResolution {
ip: string | null;
count: number;
multiWan: boolean; // true when qualifying devices report 2+ distinct IPs
allIps: string[]; // all distinct IPs seen (for multi-WAN visibility)
singleDeviceFallback: boolean; // true when result came from the single-device fallback
noIpReason?: string; // set only when ip is null
}
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: SiteAction;
hostId: string | null;
filterReason?: string;
error?: string;
}
interface StreamMessage {
type: 'site' | 'summary' | 'error';
result?: SiteResult;
stats?: {
total: number;
created: number;
updated: number;
filtered: number;
noIp: number;
errors: number;
skipped: number;
multiWan: number;
};
message?: string;
}
// ---------------------------------------------------------------------------
// WAN IP resolution
// ---------------------------------------------------------------------------
function isLaptop(d: DattoRMMDevice): boolean {
const cat = (d.deviceType?.category ?? '').toLowerCase();
const type = (d.deviceType?.type ?? '').toLowerCase();
return cat.includes('laptop') || cat.includes('notebook') ||
type.includes('laptop') || type.includes('notebook');
}
function resolveWanIp(
devices: DattoRMMDevice[],
minDevices: number,
maxLastSeenHours: number,
allowSingleDevice: boolean,
): WanResolution {
const cutoffMs = Date.now() - maxLastSeenHours * 3600 * 1000;
const qualifying = devices.filter(
(d) =>
!d.suspended &&
!d.deleted &&
d.extIpAddress &&
d.extIpAddress !== '0.0.0.0' &&
d.extIpAddress.trim() !== '' &&
d.lastSeen != null &&
d.lastSeen > cutoffMs
);
// Group devices by IP
const ipDevices = new Map<string, DattoRMMDevice[]>();
for (const d of qualifying) {
const ip = d.extIpAddress;
if (!ipDevices.has(ip)) ipDevices.set(ip, []);
ipDevices.get(ip)!.push(d);
}
// Drop IPs seen only once from a single laptop — likely a remote/travelling device
const filtered = new Map(ipDevices);
for (const [ip, devs] of filtered) {
if (devs.length === 1 && isLaptop(devs[0])) {
filtered.delete(ip);
}
}
// If filtering wiped everything out and allowSingleDevice is on, fall back to
// the full set (accepts any device type including a lone laptop/server/etc.)
const effective = filtered.size > 0
? filtered
: allowSingleDevice && ipDevices.size > 0
? ipDevices
: null;
if (!effective) {
let noIpReason = 'No qualifying devices';
if (devices.length === 0) {
noIpReason = 'No devices in site';
} else if (qualifying.length === 0) {
const active = devices.filter((d) => !d.suspended && !d.deleted);
if (active.length === 0) {
noIpReason = `All ${devices.length} devices suspended or deleted`;
} else {
const withIp = active.filter(
(d) => d.extIpAddress && d.extIpAddress !== '0.0.0.0' && d.extIpAddress.trim() !== ''
);
if (withIp.length === 0) {
noIpReason = `${active.length} active device${active.length !== 1 ? 's' : ''}, none report a public IP`;
} else {
noIpReason = `${withIp.length} device${withIp.length !== 1 ? 's' : ''} have an IP but none seen in last ${maxLastSeenHours}h`;
}
}
} else {
// qualifying > 0 but all unique-laptop IPs were dropped and fallback is off
noIpReason = 'Only laptops found — enable single-device fallback';
}
return { ip: null, count: 0, multiWan: false, allIps: [], singleDeviceFallback: false, noIpReason };
}
const sorted = Array.from(effective.entries())
.map(([ip, devs]) => [ip, devs.length] as [string, number])
.sort((a, b) => b[1] - a[1]);
const allIps = sorted.map(([ip]) => ip);
const [topIp, topCount] = sorted[0];
const multiWan = sorted.length > 1;
const singleDeviceFallback = filtered.size === 0; // used the fallback path
return { ip: topIp, count: topCount, multiWan, allIps, singleDeviceFallback };
}
// ---------------------------------------------------------------------------
// ISP lookup via ipinfo.io (free, no key required for basic fields)
// Results are cached within a run to avoid duplicate lookups for the same IP
// ---------------------------------------------------------------------------
const ispCache = new Map<string, IspInfo | null>();
async function lookupIsp(ip: string): Promise<IspInfo | null> {
if (ispCache.has(ip)) return ispCache.get(ip)!;
try {
const token = process.env.IPINFO_TOKEN;
const headers: Record<string, string> = { Accept: 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`https://ipinfo.io/${ip}/json`, {
headers,
cache: 'no-store',
signal: AbortSignal.timeout(6000),
});
if (!res.ok) { ispCache.set(ip, null); return null; }
const data = await res.json();
// org field format: "AS7922 Comcast Cable Communications, LLC"
const org: string = data.org ?? '';
const m = org.match(/^(AS\d+)\s+(.+)$/);
const info: IspInfo = {
isp: m ? m[2] : org,
asn: m ? m[1] : '',
city: data.city ?? '',
region: data.region ?? '',
country: data.country ?? '',
};
ispCache.set(ip, info);
return info;
} catch {
ispCache.set(ip, null);
return null;
}
}
// ---------------------------------------------------------------------------
// Zabbix host technical name sanitization
// Zabbix rejects: + ' , . & ( ) and other special chars in the `host` field.
// We sanitize to alphanumeric, spaces, hyphens, underscores only.
// The display `name` field is left as-is (accepts any UTF-8).
// ---------------------------------------------------------------------------
function sanitizeHostname(name: string): string {
return name
.replace(/[^a-zA-Z0-9 \-_]/g, '') // strip disallowed chars
.replace(/\s+/g, ' ') // collapse multiple spaces
.trim();
}
// ---------------------------------------------------------------------------
// API route
// ---------------------------------------------------------------------------
export async function POST(request: NextRequest) {
const body = await request.json();
const {
mode = 'all' as SyncMode,
companyId,
siteUid,
minDevices = 1,
maxLastSeenHours = 48,
allowSingleDevice = false,
dryRun = false,
} = body;
ispCache.clear(); // fresh cache per request
const encoder = new TextEncoder();
const transform = new TransformStream<Uint8Array, Uint8Array>();
const writer = transform.writable.getWriter();
const send = async (msg: StreamMessage) => {
await writer.write(encoder.encode(JSON.stringify(msg) + '\n'));
};
(async () => {
try {
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
await send({ type: 'error', message: 'Zabbix is not configured. Add ZABBIX_API_URL and ZABBIX_API_TOKEN to your environment.' });
return;
}
const rmmClient = getDattoRMMClient();
const zabbix = new ZabbixClient({
apiUrl: process.env.ZABBIX_API_URL!,
apiToken: process.env.ZABBIX_API_TOKEN!,
});
// Ensure base group + ICMP template (skipped in dry-run)
let globalGroupId = 'dry-run';
let icmpTemplateId: string | null = null;
if (!dryRun) {
globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites');
for (const name of ['ICMP Ping', 'Template Module ICMP Ping', 'Template Module ICMP Ping by Zabbix agent']) {
const tmpl = await zabbix.findTemplate(name);
if (tmpl) { icmpTemplateId = tmpl.templateid; break; }
}
}
// Load site → Autotask mappings (keyed by RMM site UID)
const mappingRows = await postgresClient.query<{
rmm_site_uid: string;
company_id: number;
company_name: string;
}>(
`SELECT rsm.rmm_site_uid, rsm.company_id, c.company_name
FROM rmm_site_mappings rsm
JOIN companies c ON c.id = rsm.company_id`
);
const mappingBySiteUid = new Map(
mappingRows.rows.map((r) => [r.rmm_site_uid, { companyId: r.company_id, companyName: r.company_name }])
);
// Determine which sites to process
let sites: { uid: string; name: string }[] = [];
if (mode === 'all') {
const allSites = await rmmClient.getAllSites();
sites = allSites
.filter((s) => mappingBySiteUid.has(s.uid))
.map((s) => ({ uid: s.uid, name: s.name }));
} else if (mode === 'client' && companyId) {
const res = await postgresClient.query<{ rmm_site_uid: string; rmm_site_name: string }>(
'SELECT rmm_site_uid, rmm_site_name FROM rmm_site_mappings WHERE company_id = $1',
[companyId]
);
sites = res.rows.map((r) => ({ uid: r.rmm_site_uid, name: r.rmm_site_name }));
} else if (mode === 'site' && siteUid) {
const res = await postgresClient.query<{ rmm_site_name: string }>(
'SELECT rmm_site_name FROM rmm_site_mappings WHERE rmm_site_uid = $1 LIMIT 1',
[siteUid]
);
sites = [{ uid: siteUid, name: res.rows[0]?.rmm_site_name ?? siteUid }];
}
if (sites.length === 0) {
await send({ type: 'error', message: 'No sites found to process.' });
return;
}
const stats = { total: sites.length, created: 0, updated: 0, filtered: 0, noIp: 0, errors: 0, skipped: 0, multiWan: 0 };
for (const site of sites) {
const mapping = mappingBySiteUid.get(site.uid);
let devices: DattoRMMDevice[] = [];
try {
devices = await rmmClient.getDevicesBySite(site.uid);
} catch (err) {
stats.errors++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp: null, qualifyingDevices: 0, multiWan: false, singleDeviceFallback: false, isp: null, asn: null,
action: 'error', hostId: null, error: String(err),
}});
continue;
}
const { ip: wanIp, count: qualifyingDevices, multiWan, allIps, singleDeviceFallback, noIpReason } = resolveWanIp(devices, minDevices, maxLastSeenHours, allowSingleDevice);
if (multiWan) stats.multiWan++;
if (!wanIp) {
stats.noIp++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp: null, qualifyingDevices: 0, multiWan, singleDeviceFallback: false, isp: null, asn: null,
action: 'no-ip', hostId: null, filterReason: noIpReason,
}});
continue;
}
// ISP lookup (runs even in dry-run and for filtered sites so we can show it in preview)
const ispInfo = await lookupIsp(wanIp);
// Skip minDevices gate if the single-device fallback is active — the user
// explicitly opted in, so enforcing the threshold here would silently undo it.
if (!singleDeviceFallback && qualifyingDevices < minDevices) {
stats.filtered++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback: false,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action: 'filtered', hostId: null,
filterReason: `${qualifyingDevices} device${qualifyingDevices !== 1 ? 's' : ''} at IP (min ${minDevices})`,
}});
continue;
}
if (dryRun) {
stats.skipped++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action: 'skipped', hostId: null,
}});
continue;
}
try {
const onlineCount = devices.filter((d) => d.online && !d.suspended && !d.deleted).length;
const templates = icmpTemplateId ? [{ templateid: icmpTemplateId }] : undefined;
// Build groups: always global, + per-client, + per-ISP
const groups: Array<{ groupid: string }> = [{ groupid: globalGroupId }];
if (mapping) {
const clientGroupId = await zabbix.ensureHostGroup(`Clients/${mapping.companyName}`);
groups.push({ groupid: clientGroupId });
}
if (ispInfo?.isp) {
const ispGroupId = await zabbix.ensureHostGroup(`ISP/${ispInfo.isp}`);
groups.push({ groupid: ispGroupId });
}
// Build macros: Autotask identity + ISP context
const macros: ZabbixHostMacro[] = [];
if (mapping) {
macros.push(
{ macro: '{$AUTOTASK_COMPANY_ID}', value: String(mapping.companyId), description: 'Autotask company ID' },
{ macro: '{$AUTOTASK_COMPANY_NAME}', value: mapping.companyName, description: 'Autotask company name' },
{ macro: '{$RMM_SITE_UID}', value: site.uid, description: 'Datto RMM site UID' },
);
}
if (ispInfo) {
macros.push(
{ macro: '{$ISP_NAME}', value: ispInfo.isp, description: 'ISP / carrier name' },
{ macro: '{$ASN}', value: ispInfo.asn, description: 'Autonomous System Number' },
{ macro: '{$ISP_CITY}', value: ispInfo.city, description: 'City (from IP geolocation)' },
{ macro: '{$ISP_REGION}', value: ispInfo.region, description: 'Region (from IP geolocation)' },
{ macro: '{$ISP_COUNTRY}', value: ispInfo.country, description: 'Country code (from IP geolocation)' },
);
}
if (multiWan) {
macros.push({ macro: '{$MULTI_WAN_IPS}', value: allIps.join(', '), description: 'All public IPs seen (multi-WAN site)' });
}
// Build tags: for dashboard filtering and problem correlation
const tags: ZabbixHostTag[] = [{ tag: 'source', value: 'datto-rmm' }];
if (mapping) {
tags.push({ tag: 'client', value: mapping.companyName });
}
if (ispInfo?.isp) {
tags.push({ tag: 'isp', value: ispInfo.isp });
}
if (ispInfo?.asn) {
tags.push({ tag: 'asn', value: ispInfo.asn });
}
if (multiWan) {
tags.push({ tag: 'multi-wan', value: 'true' });
}
if (singleDeviceFallback) {
tags.push({ tag: 'single-device-fallback', value: 'true' });
}
const description = [
`Datto RMM site WAN IP from ${onlineCount} online devices`,
ispInfo ? `ISP: ${ispInfo.isp} (${ispInfo.asn}) — ${ispInfo.city}, ${ispInfo.region}, ${ispInfo.country}` : null,
multiWan ? `Multi-WAN detected: ${allIps.join(', ')}` : null,
singleDeviceFallback ? `Note: IP sourced from single device (no multi-device confirmation)` : null,
].filter(Boolean).join('\n');
const { action, hostid } = await zabbix.upsertHost({
host: sanitizeHostname(site.name), name: site.name, description,
interfaces: [{ type: 1, main: 1, useip: 1, ip: wanIp, dns: '', port: '10050' }],
groups, templates,
macros: macros.length > 0 ? macros : undefined,
tags,
});
if (action === 'created') stats.created++; else stats.updated++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action, hostId: hostid,
}});
} catch (err) {
stats.errors++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action: 'error', hostId: null, error: String(err),
}});
}
}
await send({ type: 'summary', stats });
} catch (err) {
await send({ type: 'error', message: String(err) });
} finally {
await writer.close();
}
})();
return new Response(transform.readable, {
headers: { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' },
});
}

View file

@ -1,7 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { PageHeader } from '@/components/navigation/app-navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@ -9,7 +8,7 @@ import { BackupSummaryCards } from '@/components/backup/backup-summary-cards';
import { CompanyBackupTable, CompanyBackupRow } from '@/components/backup/company-backup-table';
import { ComplianceSummaryCards } from '@/components/backup/compliance-summary-cards';
import { ComplianceDetailTable } from '@/components/backup/compliance-detail-table';
import { RefreshCw, AlertTriangle } from 'lucide-react';
import { RefreshCw } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
interface BackupStatusData {
@ -107,66 +106,49 @@ export default function BackupStatusPage() {
if (loading) {
return (
<div>
<PageHeader title="Backup Status" description="Veeam backup health and compliance overview" />
<div className="container py-6 space-y-6">
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
<Skeleton className="h-96" />
<div className="container px-6 py-6 space-y-6">
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
<Skeleton className="h-96" />
</div>
);
}
return (
<div>
<PageHeader
title="Backup Status"
description="Veeam backup health and compliance overview"
actions={
<div className="flex items-center gap-3">
{status?.lastSyncAt && (
<span className="text-sm text-muted-foreground">
Last sync: {timeAgo(status.lastSyncAt)}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={handleSync}
disabled={syncing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing...' : 'Sync Now'}
</Button>
</div>
}
/>
<div className="container py-6 space-y-6">
{/* Stale sync warning */}
{isSyncStale && !loading && (
<div className="flex items-center gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm">
Backup data may be outdated. Last sync: {status?.lastSyncAt ? timeAgo(status.lastSyncAt) : 'Never'}.
</span>
</div>
)}
<div className="container px-6 py-6 space-y-6">
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview">Backup Overview</TabsTrigger>
<TabsTrigger value="compliance">
Contract Compliance
{compliance && (compliance.summary.contractedNotBackedUp > 0 || compliance.summary.backedUpNotContracted > 0) && (
<Badge variant="destructive" className="ml-2 h-5 px-1.5 text-xs">
{compliance.summary.contractedNotBackedUp + compliance.summary.backedUpNotContracted}
</Badge>
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="overview">Backup Overview</TabsTrigger>
<TabsTrigger value="compliance">
Contract Compliance
{compliance && (compliance.summary.contractedNotBackedUp > 0 || compliance.summary.backedUpNotContracted > 0) && (
<Badge variant="destructive" className="ml-2 h-5 px-1.5 text-xs">
{compliance.summary.contractedNotBackedUp + compliance.summary.backedUpNotContracted}
</Badge>
)}
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-3">
{status?.lastSyncAt && (
<span className="text-sm text-muted-foreground">
Last sync: {timeAgo(status.lastSyncAt)}
</span>
)}
</TabsTrigger>
</TabsList>
<Button
variant="outline"
size="sm"
onClick={handleSync}
disabled={syncing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing...' : 'Sync Now'}
</Button>
</div>
</div>
<TabsContent value="overview" className="space-y-6">
{status && (

View file

@ -72,6 +72,7 @@ export default function RMMSiteMappingsPage() {
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<'all' | 'mapped' | 'unmapped'>('all');
const [filterCompany, setFilterCompany] = useState<string>('all');
@ -111,6 +112,26 @@ export default function RMMSiteMappingsPage() {
}
};
const handleSyncCompanies = async () => {
setSyncing(true);
try {
const res = await fetch('/api/sync/entity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entities: ['companies'], triggeredBy: 'manual' }),
});
if (!res.ok) throw new Error('Sync failed');
// Poll briefly then refresh — companies sync is fast
await new Promise(r => setTimeout(r, 4000));
await fetchData();
toast({ title: 'Done', description: 'Companies synced from Autotask' });
} catch (error) {
toast({ title: 'Error', description: 'Failed to sync companies', variant: 'destructive' });
} finally {
setSyncing(false);
}
};
const handleSaveMapping = async (
siteUid: string,
siteName: string,
@ -120,9 +141,6 @@ export default function RMMSiteMappingsPage() {
setSaving(siteUid);
try {
const company = companies.find((c) => c.id === companyId);
if (!company) {
throw new Error('Company not found');
}
const response = await fetch('/api/rmm/site-mappings', {
method: 'POST',
@ -131,7 +149,7 @@ export default function RMMSiteMappingsPage() {
rmmSiteUid: siteUid,
rmmSiteName: siteName,
companyId: companyId,
companyName: company.companyName,
companyName: company?.companyName ?? null,
isPrimary: isPrimary,
}),
});
@ -142,7 +160,7 @@ export default function RMMSiteMappingsPage() {
toast({
title: 'Success',
description: `Mapped ${siteName} to ${company.companyName}`,
description: `Mapped ${siteName} to ${company?.companyName ?? `Company #${companyId}`}`,
});
await fetchData();
@ -227,10 +245,19 @@ export default function RMMSiteMappingsPage() {
Map RMM (Datto) sites to Autotask companies for complete device coverage
</p>
</div>
<Button onClick={fetchData} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<div className="flex gap-2">
<Button onClick={handleSyncCompanies} variant="outline" size="sm" disabled={syncing}>
{syncing ? (
<><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Syncing...</>
) : (
<><Building2 className="w-4 h-4 mr-2" />Sync Companies</>
)}
</Button>
<Button onClick={fetchData} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* Stats Cards */}