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 +

+ +
+
+ ); +} + +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 })} + /> +
+ +
+ + +
+ +
+ +