From 19605f82aaa3a4b9d726ce7bb80955966daeca5b Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 27 Feb 2026 14:52:14 -0500 Subject: [PATCH] feat: IT Glue integration, workflow engine, pipelines, Zabbix WAN, notification channels, backup status UI improvements, nav alignment fixes --- .windsurf/workflows/plan.md | 0 app/admin/sync/itglue/page.tsx | 348 +++++ app/admin/sync/veeam/page.tsx | 166 +- app/admin/workflow/[id]/page.tsx | 526 +++++++ app/admin/workflow/channels/page.tsx | 315 ++++ app/admin/workflow/page.tsx | 426 +++--- app/admin/workflow/pipelines/[id]/page.tsx | 597 ++++++++ app/admin/workflow/pipelines/page.tsx | 229 +++ app/admin/zabbix-wan/page.tsx | 535 +++++++ app/api/companies/route.ts | 2 +- app/api/itglue/status/route.ts | 12 + app/api/itglue/sync/route.ts | 55 + app/api/notification-channels/[id]/route.ts | 71 + .../notification-channels/[id]/test/route.ts | 121 ++ app/api/notification-channels/route.ts | 46 + app/api/pipelines/[id]/executions/route.ts | 24 + app/api/pipelines/[id]/route.ts | 86 ++ app/api/pipelines/[id]/steps/route.ts | 75 + app/api/pipelines/[id]/test/route.ts | 68 + app/api/pipelines/approval/[id]/route.ts | 92 ++ app/api/pipelines/route.ts | 64 + app/api/rmm/components/route.ts | 33 + .../ticket-workflows/[id]/executions/route.ts | 43 + app/api/ticket-workflows/[id]/route.ts | 110 ++ app/api/ticket-workflows/[id]/steps/route.ts | 65 + app/api/ticket-workflows/[id]/test/route.ts | 34 + app/api/ticket-workflows/route.ts | 64 + app/api/veeam/rpo-check/route.ts | 46 + app/api/webhooks/datto-rmm/logs/route.ts | 43 + app/api/webhooks/datto-rmm/route.ts | 100 ++ app/api/zabbix/sync-wan/route.ts | 468 ++++++ app/backup-status/page.tsx | 86 +- app/rmm-mappings/page.tsx | 45 +- .../admin/pipeline/StepConfigEditor.tsx | 1333 +++++++++++++++++ components/navigation/app-navigation.tsx | 44 +- dev/WasabiIAMCredentials_20260220_172028.txt | 377 +++++ dev/s1_swagger_2_1.json | 1 + docs/IT Glue - Circuit.json | 260 ++++ docs/Mimecast Customers.json | 136 ++ docs/Mimecast.json | 294 ++++ docs/itglue-sync.md | 193 +++ docs/veeam-diagnostic-pipeline-progress.md | 200 +++ docs/webhook-pipeline-engine.md | 669 +++++++++ docs/workflow-editor-guide.md | 245 +++ docs/workflow-refactoring-complete.md | 542 +++++++ docs/workflow-refactoring-progress.md | 274 ++++ docs/workflow-refactoring-test-results.md | 299 ++++ lib/services/datto-rmm-client.ts | 85 ++ lib/services/itglue-client.ts | 430 ++++++ lib/services/itglue-sync-service.ts | 554 +++++++ lib/services/pipeline-engine.ts | 408 +++++ lib/services/pipeline-steps/ai-analyze.ts | 116 ++ lib/services/pipeline-steps/approval.ts | 138 ++ lib/services/pipeline-steps/create-note.ts | 52 + lib/services/pipeline-steps/create-ticket.ts | 68 + lib/services/pipeline-steps/db-query.ts | 76 + lib/services/pipeline-steps/delay.ts | 24 + lib/services/pipeline-steps/enrich-company.ts | 71 + lib/services/pipeline-steps/enrich-device.ts | 50 + lib/services/pipeline-steps/enrich-ticket.ts | 48 + lib/services/pipeline-steps/enrich-vspc.ts | 195 +++ .../pipeline-steps/fetch-b2-result.ts | 147 ++ lib/services/pipeline-steps/filter.ts | 29 + lib/services/pipeline-steps/index.ts | 22 + lib/services/pipeline-steps/notify.ts | 192 +++ lib/services/pipeline-steps/rmm-quick-job.ts | 89 ++ lib/services/pipeline-steps/set-variable.ts | 23 + lib/services/pipeline-steps/transform.ts | 22 + lib/services/pipeline-steps/update-ticket.ts | 47 + lib/services/sync-scheduler.ts | 21 +- lib/services/ticket-workflow-engine.ts | 503 +++++++ lib/services/veeam-rpo-service.ts | 481 ++++++ lib/services/webhook-service.ts | 11 +- lib/services/workflow-steps/ai-classify.ts | 99 ++ lib/services/workflow-steps/ai-title.ts | 53 + .../workflow-steps/ai-troubleshooting.ts | 52 + lib/services/workflow-steps/classify.ts | 113 ++ lib/services/workflow-steps/delay.ts | 31 + lib/services/workflow-steps/index.ts | 12 + lib/services/workflow-steps/update-ticket.ts | 83 + lib/services/workflow-steps/validate.ts | 50 + lib/services/zabbix-client.ts | 149 ++ lib/types/pipeline.ts | 171 +++ lib/types/ticket-workflow.ts | 128 ++ lib/types/webhook.ts | 2 + lib/types/zabbix.ts | 81 + .../031_create_datto_rmm_webhook_logs.sql | 21 + ...add_webhook_fields_to_datto_rmm_alerts.sql | 60 + .../033_create_pipeline_engine_tables.sql | 157 ++ ...034_seed_veeam_backup_failure_pipeline.sql | 152 ++ .../035_update_veeam_pipeline_b2_fetch.sql | 50 + .../036_create_ticket_workflow_tables.sql | 204 +++ migrations/037_create_itglue_tables.sql | 407 +++++ .../039_create_veeam_rpo_tickets_table.sql | 23 + scripts/passportal-import-wasabi.ts | 549 +++++++ .../veeam-backup-diagnostic.ps1 | 431 ++++++ scripts/setup-zabbix-wan-monitoring.ts | 542 +++++++ 97 files changed, 17080 insertions(+), 304 deletions(-) create mode 100644 .windsurf/workflows/plan.md create mode 100644 app/admin/sync/itglue/page.tsx create mode 100644 app/admin/workflow/[id]/page.tsx create mode 100644 app/admin/workflow/channels/page.tsx create mode 100644 app/admin/workflow/pipelines/[id]/page.tsx create mode 100644 app/admin/workflow/pipelines/page.tsx create mode 100644 app/admin/zabbix-wan/page.tsx create mode 100644 app/api/itglue/status/route.ts create mode 100644 app/api/itglue/sync/route.ts create mode 100644 app/api/notification-channels/[id]/route.ts create mode 100644 app/api/notification-channels/[id]/test/route.ts create mode 100644 app/api/notification-channels/route.ts create mode 100644 app/api/pipelines/[id]/executions/route.ts create mode 100644 app/api/pipelines/[id]/route.ts create mode 100644 app/api/pipelines/[id]/steps/route.ts create mode 100644 app/api/pipelines/[id]/test/route.ts create mode 100644 app/api/pipelines/approval/[id]/route.ts create mode 100644 app/api/pipelines/route.ts create mode 100644 app/api/rmm/components/route.ts create mode 100644 app/api/ticket-workflows/[id]/executions/route.ts create mode 100644 app/api/ticket-workflows/[id]/route.ts create mode 100644 app/api/ticket-workflows/[id]/steps/route.ts create mode 100644 app/api/ticket-workflows/[id]/test/route.ts create mode 100644 app/api/ticket-workflows/route.ts create mode 100644 app/api/veeam/rpo-check/route.ts create mode 100644 app/api/webhooks/datto-rmm/logs/route.ts create mode 100644 app/api/webhooks/datto-rmm/route.ts create mode 100644 app/api/zabbix/sync-wan/route.ts create mode 100644 components/admin/pipeline/StepConfigEditor.tsx create mode 100644 dev/WasabiIAMCredentials_20260220_172028.txt create mode 100644 dev/s1_swagger_2_1.json create mode 100644 docs/IT Glue - Circuit.json create mode 100644 docs/Mimecast Customers.json create mode 100644 docs/Mimecast.json create mode 100644 docs/itglue-sync.md create mode 100644 docs/veeam-diagnostic-pipeline-progress.md create mode 100644 docs/webhook-pipeline-engine.md create mode 100644 docs/workflow-editor-guide.md create mode 100644 docs/workflow-refactoring-complete.md create mode 100644 docs/workflow-refactoring-progress.md create mode 100644 docs/workflow-refactoring-test-results.md create mode 100644 lib/services/itglue-client.ts create mode 100644 lib/services/itglue-sync-service.ts create mode 100644 lib/services/pipeline-engine.ts create mode 100644 lib/services/pipeline-steps/ai-analyze.ts create mode 100644 lib/services/pipeline-steps/approval.ts create mode 100644 lib/services/pipeline-steps/create-note.ts create mode 100644 lib/services/pipeline-steps/create-ticket.ts create mode 100644 lib/services/pipeline-steps/db-query.ts create mode 100644 lib/services/pipeline-steps/delay.ts create mode 100644 lib/services/pipeline-steps/enrich-company.ts create mode 100644 lib/services/pipeline-steps/enrich-device.ts create mode 100644 lib/services/pipeline-steps/enrich-ticket.ts create mode 100644 lib/services/pipeline-steps/enrich-vspc.ts create mode 100644 lib/services/pipeline-steps/fetch-b2-result.ts create mode 100644 lib/services/pipeline-steps/filter.ts create mode 100644 lib/services/pipeline-steps/index.ts create mode 100644 lib/services/pipeline-steps/notify.ts create mode 100644 lib/services/pipeline-steps/rmm-quick-job.ts create mode 100644 lib/services/pipeline-steps/set-variable.ts create mode 100644 lib/services/pipeline-steps/transform.ts create mode 100644 lib/services/pipeline-steps/update-ticket.ts create mode 100644 lib/services/ticket-workflow-engine.ts create mode 100644 lib/services/veeam-rpo-service.ts create mode 100644 lib/services/workflow-steps/ai-classify.ts create mode 100644 lib/services/workflow-steps/ai-title.ts create mode 100644 lib/services/workflow-steps/ai-troubleshooting.ts create mode 100644 lib/services/workflow-steps/classify.ts create mode 100644 lib/services/workflow-steps/delay.ts create mode 100644 lib/services/workflow-steps/index.ts create mode 100644 lib/services/workflow-steps/update-ticket.ts create mode 100644 lib/services/workflow-steps/validate.ts create mode 100644 lib/services/zabbix-client.ts create mode 100644 lib/types/pipeline.ts create mode 100644 lib/types/ticket-workflow.ts create mode 100644 lib/types/zabbix.ts create mode 100644 migrations/031_create_datto_rmm_webhook_logs.sql create mode 100644 migrations/032_add_webhook_fields_to_datto_rmm_alerts.sql create mode 100644 migrations/033_create_pipeline_engine_tables.sql create mode 100644 migrations/034_seed_veeam_backup_failure_pipeline.sql create mode 100644 migrations/035_update_veeam_pipeline_b2_fetch.sql create mode 100644 migrations/036_create_ticket_workflow_tables.sql create mode 100644 migrations/037_create_itglue_tables.sql create mode 100644 migrations/039_create_veeam_rpo_tickets_table.sql create mode 100644 scripts/passportal-import-wasabi.ts create mode 100644 scripts/rmm-diagnostics/veeam-backup-diagnostic.ps1 create mode 100644 scripts/setup-zabbix-wan-monitoring.ts diff --git a/.windsurf/workflows/plan.md b/.windsurf/workflows/plan.md new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/sync/itglue/page.tsx b/app/admin/sync/itglue/page.tsx new file mode 100644 index 0000000..8e0e578 --- /dev/null +++ b/app/admin/sync/itglue/page.tsx @@ -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 ( +
+
+ {Icon && }{label} +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +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 ( + + {status === 'running' && } + {status} + + ); +} + +function StatusTab({ + syncData, onSync, syncing, +}: { + syncData: any; onSync: () => void; syncing: boolean; +}) { + if (!syncData) { + return ( +
+ +
+ ); + } + + const counts = syncData.counts ?? {}; + const latest = syncData.history?.[0]; + + return ( +
+ {/* Connection bar */} +
+
+

+ + Connected to IT Glue +

+

+ Last sync: {fmtDate(latest?.completed_at ?? null)} + {latest?.duration_ms && ` · ${fmtDuration(latest.duration_ms)}`} +

+
+
+ + + + +
+
+ + {/* Record counts */} +
+

Synced Records

+
+ + + + +
+
+ + + + +
+
+ + {/* Latest sync entity breakdown */} + {latest?.entities?.length > 0 && ( +
+

+ Last Sync Breakdown + {latest.status && } +

+
+ + + + + + + + + + + {latest.entities.map((e: any, i: number) => ( + + + + + + + ))} + +
EntityRecordsDurationStatus
{e.entity}{e.recordsUpserted.toLocaleString()}{fmtDuration(e.duration)} + {e.success + ? + : } +
+
+
+ )} +
+ ); +} + +function HistoryTab({ history }: { history: any[] }) { + if (!history.length) { + return ( +
+ No sync history yet — run a sync to populate +
+ ); + } + + return ( +
+ + + + + + + + + + + + {history.map((row: any, i: number) => ( + + + + + + + + ))} + +
StatusTriggered ByRecordsStartedDuration
{row.triggered_by ?? 'system'}{(row.total_upserted ?? 0).toLocaleString()}{fmtDate(row.started_at)}{fmtDuration(row.duration_ms)}
+
+ ); +} + +function AboutTab() { + return ( +
+
+

Synced Entities

+
+ {[ + ['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]) => ( +
+ +
+ {name} + {desc} +
+
+ ))} +
+
+ +
+

Authentication

+

API key via x-api-key header · Base URL: https://api.itglue.com

+

Response format: JSON:API (application/vnd.api+json)

+
+ +
+

Database Tables

+

All data is stored in tables prefixed itg_ in the Pulse PostgreSQL database. Each table includes a synced_at timestamp and uses ON CONFLICT DO UPDATE for idempotent upserts.

+
+ +
+

+ + Notes +

+
    +
  • A full sync takes ~7–10 minutes depending on data volume
  • +
  • Configuration interfaces are not synced — the IT Glue API has no flat endpoint and per-config calls are impractical at 14k+ configs
  • +
  • Flexible assets require a per-type API call (API enforces filter[flexible-asset-type-id])
  • +
  • Password folders, documents, and expirations require per-organization calls
  • +
+
+
+ ); +} + +export default function ITGluePage() { + const [syncData, setSyncData] = useState(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 ( +
+ {/* Header */} +
+ + + +
+
+ +
+
+

IT Glue

+

+ Organizations, configurations, contacts, passwords, flexible assets +

+
+
+
+ + + + + Status + + + History + + + About + + + + + + + + + + + + + + + +
+ ); +} diff --git a/app/admin/sync/veeam/page.tsx b/app/admin/sync/veeam/page.tsx index 328aa5c..5bceb72 100644 --- a/app/admin/sync/veeam/page.tsx +++ b/app/admin/sync/veeam/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [running, setRunning] = useState(false); + const [lastResult, setLastResult] = useState(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
; + + 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 ( +
+
+
+

RPO-Based Workstation Backup Alerting

+

+ One ticket per job — created when RPO is breached, auto-resolved when backup succeeds. Enable the scheduler to run every 30 min. +

+
+ +
+ + {lastResult && !lastResult.error && ( +
+

Last Run Result

+
+ Checked: {lastResult.checked} + New Tickets: {lastResult.newTickets} + Escalated: {lastResult.escalated} + Resolved: {lastResult.resolved} + {lastResult.errors?.length > 0 && Errors: {lastResult.errors.length}} +
+
+ )} + +
+ + + 0 ? 'border-red-500/30 bg-red-500/5' : ''} /> + 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} /> + 0 ? 'border-red-500/30 bg-red-500/5' : ''} /> +
+ + {breachedJobs.length > 0 && ( +
+
+ +

RPO Breached ({breachedJobs.length})

+
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
JobOrganizationLast BackupOverdueFailure ReasonTicket
{j.job_name}{j.org_name}{fmtDate(j.last_end_time)}{display} + {j.failure_category ?? '—'} + + {j.open_ticket ? ( + + {j.open_ticket.at_ticket_number} · {j.open_ticket.priority_level} + + ) : ( + No ticket yet + )} +
+
+ )} + + {healthyJobs.length > 0 && ( +
+ + Within RPO ({healthyJobs.length}) + + + + + + + + + + + + + {healthyJobs.map((j: any) => ( + + + + + + + + ))} + +
JobOrganizationLast BackupHours AgoRPO
{j.job_name}{j.org_name}{fmtDate(j.last_end_time)} + {j.hours_since_backup !== null ? `${j.hours_since_backup}h` : '—'} + {j.rpo_hours}h
+
+ )} +
+ ); +} + // ── Page ────────────────────────────────────────────────────────────────────── export default function VeeamSyncPage() { const [status, setStatus] = useState(null); @@ -446,8 +606,9 @@ export default function VeeamSyncPage() { - + Status + RPO History Agents Alarms @@ -455,6 +616,7 @@ export default function VeeamSyncPage() { + diff --git a/app/admin/workflow/[id]/page.tsx b/app/admin/workflow/[id]/page.tsx new file mode 100644 index 0000000..56ddb97 --- /dev/null +++ b/app/admin/workflow/[id]/page.tsx @@ -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; + 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 = { + 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(null); + const [steps, setSteps] = useState([]); + const [expandedStep, setExpandedStep] = useState(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) => { + 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 ( +
+ + + Loading workflow... + + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + +
+

{workflow.name}

+

{workflow.description || 'No description'}

+
+ + {workflow.is_active ? 'Active' : 'Inactive'} + +
+ +
+ +
+
+ + {/* Tabs */} + + + Steps ({steps.length}) + Trigger + Test + History + + + {/* Steps Tab */} + + + +
+
+ Workflow Steps + Define the step-by-step execution flow +
+
+ {STEP_TYPES.map(type => ( + + ))} +
+
+
+ + {steps.length === 0 ? ( +

+ No steps yet. Add steps using the buttons above. +

+ ) : ( + steps.map((step, index) => { + const stepTypeConfig = STEP_TYPES.find(t => t.value === step.step_type); + const isExpanded = expandedStep === step.step_order; + + return ( +
+ {/* Step Header */} +
+
+
+ + +
+ #{step.step_order} + {step.name} + {step.step_type} + {step.condition && Conditional} +
+ +
+ updateStep(step.step_order, { is_active: checked })} + /> + + +
+
+ + {/* Step Config (Expanded) */} + {isExpanded && ( +
+ {/* Step Help */} + {STEP_HELP[step.step_type] && ( +
+

What This Step Does

+

{STEP_HELP[step.step_type].purpose}

+
+
Configuration Fields:
+
{STEP_HELP[step.step_type].config}
+
+
+
Example:
+ {STEP_HELP[step.step_type].example} +
+
+ )} + +
+ + updateStep(step.step_order, { name: e.target.value })} + /> +
+ +
+ + +
+ +
+ +