feat: IT Glue integration, workflow engine, pipelines, Zabbix WAN, notification channels, backup status UI improvements, nav alignment fixes
This commit is contained in:
parent
ed6c4a8b65
commit
19605f82aa
97 changed files with 17080 additions and 304 deletions
348
app/admin/sync/itglue/page.tsx
Normal file
348
app/admin/sync/itglue/page.tsx
Normal 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 ~7–10 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
526
app/admin/workflow/[id]/page.tsx
Normal file
526
app/admin/workflow/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
315
app/admin/workflow/channels/page.tsx
Normal file
315
app/admin/workflow/channels/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
597
app/admin/workflow/pipelines/[id]/page.tsx
Normal file
597
app/admin/workflow/pipelines/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
app/admin/workflow/pipelines/page.tsx
Normal file
229
app/admin/workflow/pipelines/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
535
app/admin/zabbix-wan/page.tsx
Normal file
535
app/admin/zabbix-wan/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
12
app/api/itglue/status/route.ts
Normal file
12
app/api/itglue/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
55
app/api/itglue/sync/route.ts
Normal file
55
app/api/itglue/sync/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
71
app/api/notification-channels/[id]/route.ts
Normal file
71
app/api/notification-channels/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
121
app/api/notification-channels/[id]/test/route.ts
Normal file
121
app/api/notification-channels/[id]/test/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
app/api/notification-channels/route.ts
Normal file
46
app/api/notification-channels/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
24
app/api/pipelines/[id]/executions/route.ts
Normal file
24
app/api/pipelines/[id]/executions/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
86
app/api/pipelines/[id]/route.ts
Normal file
86
app/api/pipelines/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
75
app/api/pipelines/[id]/steps/route.ts
Normal file
75
app/api/pipelines/[id]/steps/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
68
app/api/pipelines/[id]/test/route.ts
Normal file
68
app/api/pipelines/[id]/test/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
92
app/api/pipelines/approval/[id]/route.ts
Normal file
92
app/api/pipelines/approval/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
64
app/api/pipelines/route.ts
Normal file
64
app/api/pipelines/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
33
app/api/rmm/components/route.ts
Normal file
33
app/api/rmm/components/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
43
app/api/ticket-workflows/[id]/executions/route.ts
Normal file
43
app/api/ticket-workflows/[id]/executions/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
110
app/api/ticket-workflows/[id]/route.ts
Normal file
110
app/api/ticket-workflows/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
65
app/api/ticket-workflows/[id]/steps/route.ts
Normal file
65
app/api/ticket-workflows/[id]/steps/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
34
app/api/ticket-workflows/[id]/test/route.ts
Normal file
34
app/api/ticket-workflows/[id]/test/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
app/api/ticket-workflows/route.ts
Normal file
64
app/api/ticket-workflows/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
app/api/veeam/rpo-check/route.ts
Normal file
46
app/api/veeam/rpo-check/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
43
app/api/webhooks/datto-rmm/logs/route.ts
Normal file
43
app/api/webhooks/datto-rmm/logs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
100
app/api/webhooks/datto-rmm/route.ts
Normal file
100
app/api/webhooks/datto-rmm/route.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
468
app/api/zabbix/sync-wan/route.ts
Normal file
468
app/api/zabbix/sync-wan/route.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue