diff --git a/api/db/campaign_client.py b/api/db/campaign_client.py index d58d0a7..2e0a53b 100644 --- a/api/db/campaign_client.py +++ b/api/db/campaign_client.py @@ -1,11 +1,13 @@ from datetime import UTC, datetime -from typing import Optional +from typing import Any, Dict, List, Optional from sqlalchemy import func from sqlalchemy.future import select from api.db.base_client import BaseDBClient +from api.db.filters import apply_workflow_run_filters, get_workflow_run_order_clause from api.db.models import CampaignModel, QueuedRunModel, WorkflowRunModel +from api.schemas.workflow import WorkflowRunResponseSchema class CampaignClient(BaseDBClient): @@ -165,6 +167,89 @@ class CampaignClient(BaseDBClient): result = await session.execute(query) return list(result.scalars().all()) + async def get_campaign_runs_paginated( + self, + campaign_id: int, + organization_id: int, + limit: int = 50, + offset: int = 0, + filters: Optional[List[Dict[str, Any]]] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "desc", + ) -> tuple[list[WorkflowRunResponseSchema], int]: + """Get workflow runs for a campaign with pagination, filters and sorting""" + async with self.async_session() as session: + # First verify campaign belongs to organization + campaign_query = select(CampaignModel).where( + CampaignModel.id == campaign_id, + CampaignModel.organization_id == organization_id, + ) + campaign_result = await session.execute(campaign_query) + campaign = campaign_result.scalar_one_or_none() + + if not campaign: + raise ValueError(f"Campaign {campaign_id} not found") + + # Build base query + base_query = select(WorkflowRunModel).where( + WorkflowRunModel.campaign_id == campaign_id + ) + + # Apply filters + base_query = apply_workflow_run_filters(base_query, filters) + + # Count total with filters + count_query = base_query.with_only_columns(func.count(WorkflowRunModel.id)) + count_result = await session.execute(count_query) + total_count = count_result.scalar() + + # Get paginated results with filters and sorting + order_clause = get_workflow_run_order_clause(sort_by, sort_order) + result = await session.execute( + base_query.order_by(order_clause).limit(limit).offset(offset) + ) + + runs = [ + WorkflowRunResponseSchema.model_validate( + { + "id": run.id, + "workflow_id": run.workflow_id, + "name": run.name, + "mode": run.mode, + "created_at": run.created_at, + "is_completed": run.is_completed, + "recording_url": run.recording_url, + "transcript_url": run.transcript_url, + "cost_info": { + "dograh_token_usage": ( + run.cost_info.get("dograh_token_usage") + if run.cost_info + and "dograh_token_usage" in run.cost_info + else round( + float(run.cost_info.get("total_cost_usd", 0)) * 100, + 2, + ) + if run.cost_info and "total_cost_usd" in run.cost_info + else 0 + ), + "call_duration_seconds": int( + round(run.cost_info.get("call_duration_seconds") or 0) + ) + if run.cost_info + else None, + } + if run.cost_info + else None, + "definition_id": run.definition_id, + "initial_context": run.initial_context, + "gathered_context": run.gathered_context, + "call_type": run.call_type, + } + ) + for run in result.scalars().all() + ] + return runs, total_count + async def get_campaign_by_id(self, campaign_id: int) -> Optional[CampaignModel]: """Get campaign by ID without organization check (for internal use)""" async with self.async_session() as session: diff --git a/api/db/filters.py b/api/db/filters.py index 0630c66..f515f33 100644 --- a/api/db/filters.py +++ b/api/db/filters.py @@ -3,16 +3,47 @@ from datetime import datetime from typing import Any, Dict, List, Optional -from sqlalchemy import Integer, and_, cast, func +from sqlalchemy import Float, Integer, and_, cast, func from sqlalchemy.dialects.postgresql import JSONB from api.db.models import WorkflowRunModel + +def get_workflow_run_order_clause( + sort_by: Optional[str] = None, + sort_order: str = "desc", +): + """ + Get the order clause for workflow run queries. + + Args: + sort_by: Field to sort by ('duration', 'created_at', etc.) + sort_order: 'asc' or 'desc' + + Returns: + SQLAlchemy order clause + """ + # Determine sort column + if sort_by == "duration": + sort_column = WorkflowRunModel.cost_info.op("->>")( + "call_duration_seconds" + ).cast(Float) + else: + # Default to created_at + sort_column = WorkflowRunModel.created_at + + # Apply sort order + if sort_order == "asc": + return sort_column.asc().nullslast() + else: + return sort_column.desc().nullslast() + + # Mapping of attribute names to database fields ATTRIBUTE_FIELD_MAPPING = { "dateRange": "created_at", "dispositionCode": "gathered_context.mapped_call_disposition", - "duration": "usage_info.call_duration_seconds", + "duration": "cost_info.call_duration_seconds", "status": "is_completed", "tokenUsage": "cost_info.total_cost_usd", "runId": "id", @@ -153,7 +184,7 @@ def apply_workflow_run_filters( min_val = value.get("min") max_val = value.get("max") - if field == "usage_info.call_duration_seconds": + if field == "cost_info.call_duration_seconds": # Use ->> operator for compatibility with all PostgreSQL versions # (subscript [] only works in PostgreSQL 14+) duration_text = cast(WorkflowRunModel.usage_info, JSONB).op("->>")( diff --git a/api/db/workflow_run_client.py b/api/db/workflow_run_client.py index 3ec1a18..657804e 100644 --- a/api/db/workflow_run_client.py +++ b/api/db/workflow_run_client.py @@ -7,7 +7,7 @@ from sqlalchemy.future import select from sqlalchemy.orm import joinedload, selectinload from api.db.base_client import BaseDBClient -from api.db.filters import apply_workflow_run_filters +from api.db.filters import apply_workflow_run_filters, get_workflow_run_order_clause from api.db.models import ( OrganizationModel, UserModel, @@ -103,10 +103,16 @@ class WorkflowRunClient(BaseDBClient): limit: int = 50, offset: int = 0, filters: Optional[List[Dict[str, Any]]] = None, + sort_by: Optional[str] = None, + sort_order: str = "desc", ) -> tuple[list[dict], int]: """ Get paginated workflow runs for superadmin with organization information. Returns tuple of (workflow_runs, total_count). + + Args: + sort_by: Field to sort by ('duration', 'created_at', etc.) + sort_order: 'asc' or 'desc' """ async with self.async_session() as session: # Build base query with joins @@ -128,7 +134,8 @@ class WorkflowRunClient(BaseDBClient): count_result = await session.execute(count_query) total_count = count_result.scalar() - # Get paginated results with filters + # Get paginated results with filters and sorting + order_clause = get_workflow_run_order_clause(sort_by, sort_order) result = await session.execute( base_query.options( joinedload(WorkflowRunModel.workflow).joinedload( @@ -138,7 +145,7 @@ class WorkflowRunClient(BaseDBClient): .joinedload(WorkflowModel.user) .joinedload(UserModel.selected_organization), ) - .order_by(WorkflowRunModel.created_at.desc()) + .order_by(order_clause) .limit(limit) .offset(offset) ) @@ -225,6 +232,8 @@ class WorkflowRunClient(BaseDBClient): limit: int = 50, offset: int = 0, filters: Optional[List[Dict[str, Any]]] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "desc", ) -> tuple[list[WorkflowRunResponseSchema], int]: async with self.async_session() as session: # Build base query @@ -251,11 +260,10 @@ class WorkflowRunClient(BaseDBClient): count_result = await session.execute(count_query) total_count = count_result.scalar() - # Get paginated results with filters + # Get paginated results with filters and sorting + order_clause = get_workflow_run_order_clause(sort_by, sort_order) result = await session.execute( - base_query.order_by(WorkflowRunModel.created_at.desc()) - .limit(limit) - .offset(offset) + base_query.order_by(order_clause).limit(limit).offset(offset) ) runs = [ WorkflowRunResponseSchema.model_validate( diff --git a/api/routes/campaign.py b/api/routes/campaign.py index cd61169..1dc8b9d 100644 --- a/api/routes/campaign.py +++ b/api/routes/campaign.py @@ -1,7 +1,8 @@ +import json from datetime import datetime from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT @@ -91,6 +92,16 @@ class WorkflowRunResponse(BaseModel): completed_at: Optional[datetime] +class CampaignRunsResponse(BaseModel): + """Paginated response for campaign workflow runs""" + + runs: List[dict] # WorkflowRunResponseSchema from schemas + total_count: int + page: int + limit: int + total_pages: int + + class CampaignProgressResponse(BaseModel): campaign_id: int state: str @@ -296,21 +307,65 @@ async def pause_campaign( @router.get("/{campaign_id}/runs") async def get_campaign_runs( campaign_id: int, + page: int = 1, + limit: int = 50, + filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"), + sort_by: Optional[str] = Query( + None, description="Field to sort by (e.g., 'duration', 'created_at')" + ), + sort_order: Optional[str] = Query( + "desc", description="Sort order ('asc' or 'desc')" + ), user: UserModel = Depends(get_user), -) -> List[WorkflowRunResponse]: - """Get campaign workflow runs""" - runs = await db_client.get_campaign_runs(campaign_id, user.selected_organization_id) +) -> CampaignRunsResponse: + """Get campaign workflow runs with pagination, filters and sorting""" + offset = (page - 1) * limit - return [ - WorkflowRunResponse( - id=run.id, - workflow_id=run.workflow_id, - state="completed" if run.is_completed else "running", - created_at=run.created_at, - completed_at=run.created_at if run.is_completed else None, + # Parse filters if provided + filter_criteria = [] + if filters: + try: + filter_criteria = json.loads(filters) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid filter format") + + # Restrict allowed filter attributes for regular users + allowed_attributes = { + "dateRange", + "dispositionCode", + "duration", + "status", + "tokenUsage", + } + for filter_item in filter_criteria: + attribute = filter_item.get("attribute") + if attribute and attribute not in allowed_attributes: + raise HTTPException( + status_code=403, detail=f"Invalid attribute '{attribute}'" + ) + + try: + runs, total_count = await db_client.get_campaign_runs_paginated( + campaign_id, + user.selected_organization_id, + limit=limit, + offset=offset, + filters=filter_criteria if filter_criteria else None, + sort_by=sort_by, + sort_order=sort_order, ) - for run in runs - ] + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + total_pages = (total_count + limit - 1) // limit + + return CampaignRunsResponse( + runs=[run.model_dump() for run in runs], + total_count=total_count, + page=page, + limit=limit, + total_pages=total_pages, + ) @router.post("/{campaign_id}/resume") diff --git a/api/routes/superuser.py b/api/routes/superuser.py index 78ca064..e6c10aa 100644 --- a/api/routes/superuser.py +++ b/api/routes/superuser.py @@ -105,6 +105,12 @@ async def get_workflow_runs( page: int = Query(1, ge=1, description="Page number (starts from 1)"), limit: int = Query(50, ge=1, le=100, description="Number of items per page"), filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"), + sort_by: Optional[str] = Query( + None, description="Field to sort by (e.g., 'duration', 'created_at')" + ), + sort_order: Optional[str] = Query( + "desc", description="Sort order ('asc' or 'desc')" + ), user: UserModel = Depends(get_superuser), ) -> SuperuserWorkflowRunsListResponse: """ @@ -124,8 +130,16 @@ async def get_workflow_runs( except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid filter format") + # Validate sort_order + if sort_order not in ("asc", "desc"): + sort_order = "desc" + workflow_runs, total_count = await db_client.get_workflow_runs_for_superadmin( - limit=limit, offset=offset, filters=filter_criteria + limit=limit, + offset=offset, + filters=filter_criteria, + sort_by=sort_by, + sort_order=sort_order, ) total_pages = (total_count + limit - 1) // limit # Ceiling division diff --git a/api/routes/workflow.py b/api/routes/workflow.py index 126314e..9967005 100644 --- a/api/routes/workflow.py +++ b/api/routes/workflow.py @@ -666,10 +666,16 @@ async def get_workflow_runs( page: int = 1, limit: int = 50, filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"), + sort_by: Optional[str] = Query( + None, description="Field to sort by (e.g., 'duration', 'created_at')" + ), + sort_order: Optional[str] = Query( + "desc", description="Sort order ('asc' or 'desc')" + ), user: UserModel = Depends(get_user), ) -> WorkflowRunsResponse: """ - Get workflow runs with optional filtering. + Get workflow runs with optional filtering and sorting. Filters should be provided as a JSON-encoded array of filter criteria. Example: [{"attribute": "dateRange", "value": {"from": "2024-01-01", "to": "2024-01-31"}}] @@ -699,23 +705,15 @@ async def get_workflow_runs( status_code=403, detail=f"Invalid attribute '{attribute}'" ) - # Apply filters if any - if filter_criteria: - runs, total_count = await db_client.get_workflow_runs_by_workflow_id( - workflow_id, - organization_id=user.selected_organization_id, - limit=limit, - offset=offset, - filters=filter_criteria, - ) - else: - # Use existing logic for unfiltered results - runs, total_count = await db_client.get_workflow_runs_by_workflow_id( - workflow_id, - organization_id=user.selected_organization_id, - limit=limit, - offset=offset, - ) + runs, total_count = await db_client.get_workflow_runs_by_workflow_id( + workflow_id, + organization_id=user.selected_organization_id, + limit=limit, + offset=offset, + filters=filter_criteria if filter_criteria else None, + sort_by=sort_by, + sort_order=sort_order, + ) total_pages = (total_count + limit - 1) // limit diff --git a/ui/src/app/campaigns/[campaignId]/page.tsx b/ui/src/app/campaigns/[campaignId]/page.tsx index fc32258..2354b88 100644 --- a/ui/src/app/campaigns/[campaignId]/page.tsx +++ b/ui/src/app/campaigns/[campaignId]/page.tsx @@ -1,36 +1,29 @@ "use client"; import { ArrowLeft, Check, Pause, Play, RefreshCw, X } from 'lucide-react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { getCampaignApiV1CampaignCampaignIdGet, - getCampaignRunsApiV1CampaignCampaignIdRunsGet, getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet, pauseCampaignApiV1CampaignCampaignIdPausePost, resumeCampaignApiV1CampaignCampaignIdResumePost, startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen'; -import type { CampaignResponse, WorkflowRunResponse } from '@/client/types.gen'; +import type { CampaignResponse } from '@/client/types.gen'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; +import { CampaignRuns } from '@/components/workflow-runs'; import { useAuth } from '@/lib/auth'; export default function CampaignDetailPage() { const { user, getAccessToken, redirectToLogin, loading } = useAuth(); const router = useRouter(); const params = useParams(); + const searchParams = useSearchParams(); const campaignId = parseInt(params.campaignId as string); // Redirect if not authenticated @@ -44,10 +37,6 @@ export default function CampaignDetailPage() { const [campaign, setCampaign] = useState(null); const [isLoadingCampaign, setIsLoadingCampaign] = useState(true); - // Runs state - const [runs, setRuns] = useState([]); - const [isLoadingRuns, setIsLoadingRuns] = useState(false); - // Action state const [isExecutingAction, setIsExecutingAction] = useState(false); @@ -77,36 +66,10 @@ export default function CampaignDetailPage() { } }, [user, getAccessToken, campaignId]); - // Fetch campaign runs - const fetchCampaignRuns = useCallback(async () => { - if (!user) return; - setIsLoadingRuns(true); - try { - const accessToken = await getAccessToken(); - const response = await getCampaignRunsApiV1CampaignCampaignIdRunsGet({ - path: { - campaign_id: campaignId, - }, - headers: { - 'Authorization': `Bearer ${accessToken}`, - } - }); - - if (response.data) { - setRuns(response.data); - } - } catch (error) { - console.error('Failed to fetch campaign runs:', error); - } finally { - setIsLoadingRuns(false); - } - }, [user, getAccessToken, campaignId]); - // Initial load useEffect(() => { fetchCampaign(); - fetchCampaignRuns(); - }, [fetchCampaign, fetchCampaignRuns]); + }, [fetchCampaign]); // Handle back navigation const handleBack = () => { @@ -120,13 +83,6 @@ export default function CampaignDetailPage() { } }; - // Handle run click - const handleRunClick = (runId: number) => { - if (campaign) { - router.push(`/workflow/${campaign.workflow_id}/run/${runId}`); - } - }; - // Handle CSV download const handleDownloadCsv = async () => { if (!user || !campaign || campaign.source_type !== 'csv') return; @@ -497,71 +453,11 @@ export default function CampaignDetailPage() { {/* Workflow Runs */} - - - Workflow Runs - - Executions triggered by this campaign - - - - {isLoadingRuns ? ( -
- {[...Array(3)].map((_, i) => ( -
- ))} -
- ) : runs.length > 0 ? ( -
- - - - Run ID - State - Created - Action - - - - {runs.map((run) => ( - handleRunClick(run.id)} - > - #{run.id} - - - {run.state} - - - {formatDateTime(run.created_at)} - - - - - ))} - -
-
- ) : ( -

- {campaign.state === 'created' - ? 'No runs yet. Start the campaign to begin execution.' - : 'No workflow runs found for this campaign.'} -

- )} -
-
+ ); } diff --git a/ui/src/app/superadmin/runs/page.tsx b/ui/src/app/superadmin/runs/page.tsx index c1b4b32..2b2b663 100644 --- a/ui/src/app/superadmin/runs/page.tsx +++ b/ui/src/app/superadmin/runs/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertTriangle, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, MessageSquare, RefreshCw } from 'lucide-react'; +import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, MessageSquare, RefreshCw } from 'lucide-react'; import Image from 'next/image'; import { useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useState } from "react"; @@ -31,7 +31,6 @@ import { import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useUserConfig } from '@/context/UserConfigContext'; -import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant'; import{ superadminFilterAttributes } from "@/lib/filterAttributes"; import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters'; import { impersonateAsSuperadmin } from '@/lib/utils'; @@ -81,7 +80,6 @@ export default function RunsPage() { const [isExecutingFilters, setIsExecutingFilters] = useState(false); const [autoRefresh, setAutoRefresh] = useState(false); const [isAutoRefreshing, setIsAutoRefreshing] = useState(false); - const [currentTime, setCurrentTime] = useState(Date.now()); const limit = 50; // Initialize filters from URL @@ -89,6 +87,15 @@ export default function RunsPage() { return decodeFiltersFromURL(searchParams, superadminFilterAttributes); }); + // Applied filters are the ones actually used for fetching (only updated on Apply click) + const [appliedFilters, setAppliedFilters] = useState(() => { + return decodeFiltersFromURL(searchParams, superadminFilterAttributes); + }); + + // Sort state + const [sortBy, setSortBy] = useState(null); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + // Dialog state for comment editing const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false); const [commentRunId, setCommentRunId] = useState(null); @@ -100,7 +107,13 @@ export default function RunsPage() { // Media preview dialog const mediaPreview = MediaPreviewDialog({ accessToken }); - const fetchRuns = useCallback(async (page: number, filters?: ActiveFilter[], isAutoRefresh = false) => { + const fetchRuns = useCallback(async ( + page: number, + filters?: ActiveFilter[], + isAutoRefresh = false, + sortByParam?: string | null, + sortOrderParam?: 'asc' | 'desc' + ) => { if (!accessToken) return; // Don't show loading state for auto-refresh to prevent UI flicker @@ -126,7 +139,9 @@ export default function RunsPage() { query: { page, limit, - ...(filterParam && { filters: filterParam }) + ...(filterParam && { filters: filterParam }), + ...(sortByParam && { sort_by: sortByParam }), + ...(sortOrderParam && { sort_order: sortOrderParam }), }, headers: { 'Authorization': `Bearer ${accessToken}`, @@ -169,55 +184,42 @@ export default function RunsPage() { }, [router]); useEffect(() => { - // Fetch runs when token is available and when page changes + // Fetch runs when token is available and when page/sort changes if (accessToken) { - fetchRuns(currentPage, activeFilters); + fetchRuns(currentPage, appliedFilters, false, sortBy, sortOrder); } - }, [currentPage, accessToken, activeFilters, fetchRuns]); + }, [currentPage, accessToken, appliedFilters, fetchRuns, sortBy, sortOrder]); // Auto-refresh every 5 seconds when enabled and filters are active useEffect(() => { - // Only set up interval if auto-refresh is enabled and there are active filters - if (!autoRefresh || activeFilters.length === 0) { + // Only set up interval if auto-refresh is enabled and there are applied filters + if (!autoRefresh || appliedFilters.length === 0) { return; } const intervalId = setInterval(() => { // Pass true to indicate this is an auto-refresh - fetchRuns(currentPage, activeFilters, true); + fetchRuns(currentPage, appliedFilters, true, sortBy, sortOrder); }, 5000); // Cleanup interval on unmount or when dependencies change return () => clearInterval(intervalId); - }, [currentPage, activeFilters, fetchRuns, autoRefresh]); - - // Update current time every second to show live duration for running calls - useEffect(() => { - const hasRunningCalls = runs.some(run => !run.is_completed); - if (!hasRunningCalls) { - return; - } - - const intervalId = setInterval(() => { - setCurrentTime(Date.now()); - }, 1000); - - return () => clearInterval(intervalId); - }, [runs]); + }, [currentPage, appliedFilters, fetchRuns, autoRefresh, sortBy, sortOrder]); const handlePageChange = (page: number) => { setCurrentPage(page); - updatePageInUrl(page, activeFilters); - fetchRuns(page, activeFilters); + updatePageInUrl(page, appliedFilters); + fetchRuns(page, appliedFilters, false, sortBy, sortOrder); }; const handleApplyFilters = useCallback(async () => { setIsExecutingFilters(true); setCurrentPage(1); // Reset to first page when applying filters + setAppliedFilters(activeFilters); // Update applied filters updatePageInUrl(1, activeFilters); - await fetchRuns(1, activeFilters); + await fetchRuns(1, activeFilters, false, sortBy, sortOrder); setIsExecutingFilters(false); - }, [activeFilters, fetchRuns, updatePageInUrl]); + }, [activeFilters, fetchRuns, updatePageInUrl, sortBy, sortOrder]); const handleFiltersChange = useCallback((filters: ActiveFilter[]) => { setActiveFilters(filters); @@ -226,10 +228,25 @@ export default function RunsPage() { const handleClearFilters = useCallback(async () => { setIsExecutingFilters(true); setCurrentPage(1); + setAppliedFilters([]); // Clear applied filters updatePageInUrl(1, []); // Clear filters from URL - await fetchRuns(1, []); // Fetch all runs without filters + await fetchRuns(1, [], false, sortBy, sortOrder); // Fetch all runs without filters setIsExecutingFilters(false); - }, [fetchRuns, updatePageInUrl]); + }, [fetchRuns, updatePageInUrl, sortBy, sortOrder]); + + const handleSort = useCallback((field: string) => { + // Reset to first page when sort changes + setCurrentPage(1); + + if (sortBy === field) { + // Toggle order if same field + setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + // New field, default to desc + setSortBy(field); + setSortOrder('desc'); + } + }, [sortBy]); // Save comment function declared outside JSX (requirement #2) const saveAdminComment = useCallback(async () => { @@ -265,29 +282,10 @@ export default function RunsPage() { const formatDate = (dateString: string) => new Date(dateString).toLocaleString(); - const calculateDuration = (createdAt: string, isCompleted: boolean, usageInfo?: Record) => { + const calculateDuration = (isCompleted: boolean, usageInfo?: Record) => { if (isCompleted && typeof usageInfo?.call_duration_seconds === 'number') { return `${Number(usageInfo.call_duration_seconds).toFixed(2)}s`; } - - if (!isCompleted) { - const startTime = new Date(createdAt).getTime(); - const duration = Math.floor((currentTime - startTime) / 1000); - - // If duration exceeds 5 minutes (300 seconds), show "-" as it's likely an error - if (duration > 300) { - return '-'; - } - - if (duration < 60) { - return `${duration}s`; - } else { - const minutes = Math.floor(duration / 60); - const seconds = duration % 60; - return `${minutes}m ${seconds}s`; - } - } - return '-'; }; @@ -383,9 +381,33 @@ export default function RunsPage() { Disposition Tags Comment - Duration + handleSort('duration')} + > +
+ Duration + {sortBy === 'duration' ? ( + sortOrder === 'asc' ? : + ) : ( + + )} +
+
Dograh Token - Created At + handleSort('created_at')} + > +
+ Created At + {sortBy === 'created_at' ? ( + sortOrder === 'asc' ? : + ) : ( + + )} +
+
Actions @@ -393,7 +415,7 @@ export default function RunsPage() { {runs.map((run) => ( + className={selectedRowId === run.id ? "bg-primary/20 ring-1 ring-primary/50" : ""}> #{run.id} @@ -422,7 +444,7 @@ export default function RunsPage() { {run.gathered_context?.mapped_call_disposition ? ( - + {run.gathered_context.mapped_call_disposition as string} ) : ( @@ -451,7 +473,7 @@ export default function RunsPage() { - {calculateDuration(run.created_at, run.is_completed, run.usage_info)} + {calculateDuration(run.is_completed, run.usage_info)} diff --git a/ui/src/app/usage/page.tsx b/ui/src/app/usage/page.tsx index d38b7ce..05e5198 100644 --- a/ui/src/app/usage/page.tsx +++ b/ui/src/app/usage/page.tsx @@ -23,7 +23,6 @@ import { TableRow, } from '@/components/ui/table'; import { useUserConfig } from '@/context/UserConfigContext'; -import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant'; import { usageFilterAttributes } from '@/lib/filterAttributes'; import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters'; import { ActiveFilter, DateRangeValue } from '@/types/filters'; @@ -534,7 +533,7 @@ export default function UsagePage() { {run.disposition ? ( - + {run.disposition} ) : ( diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx index 20ba69a..4e7c51d 100644 --- a/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx @@ -1,27 +1,13 @@ "use client"; -import { ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { getWorkflowApiV1WorkflowFetchWorkflowIdGet, getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen"; import { WorkflowRunResponseSchema } from "@/client/types.gen"; -import { FilterBuilder } from "@/components/filters/FilterBuilder"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { WorkflowRunsTable } from "@/components/workflow-runs"; import { DISPOSITION_CODES } from "@/constants/dispositionCodes"; import { useUserConfig } from '@/context/UserConfigContext'; -import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant'; -import { downloadFile } from "@/lib/files"; import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters"; import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters"; @@ -44,6 +30,10 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti const [isExecutingFilters, setIsExecutingFilters] = useState(false); const [configuredAttributes, setConfiguredAttributes] = useState(availableAttributes); + // Sort state + const [sortBy, setSortBy] = useState(null); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const { accessToken } = useUserConfig(); // Initialize filters from URL @@ -51,8 +41,6 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti return decodeFiltersFromURL(searchParams, availableAttributes); }); - const formatDate = (dateString: string) => new Date(dateString).toLocaleString(); - // Load disposition codes from workflow configuration const loadDispositionCodes = useCallback(async () => { if (!accessToken) return; @@ -89,7 +77,12 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti loadDispositionCodes(); }, [loadDispositionCodes]); - const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => { + const fetchWorkflowRuns = useCallback(async ( + page: number, + filters?: ActiveFilter[], + sortByParam?: string | null, + sortOrderParam?: 'asc' | 'desc' + ) => { if (!accessToken) return; try { setLoading(true); @@ -109,7 +102,9 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti query: { page: page, limit: 50, - ...(filterParam && { filters: filterParam }) + ...(filterParam && { filters: filterParam }), + ...(sortByParam && { sort_by: sortByParam }), + ...(sortOrderParam && { sort_order: sortOrderParam }), }, headers: { 'Authorization': `Bearer ${accessToken}`, @@ -152,16 +147,16 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti }, [router, workflowId]); useEffect(() => { - fetchWorkflowRuns(currentPage, activeFilters); - }, [currentPage, activeFilters, fetchWorkflowRuns]); + fetchWorkflowRuns(currentPage, activeFilters, sortBy, sortOrder); + }, [currentPage, activeFilters, fetchWorkflowRuns, sortBy, sortOrder]); const handleApplyFilters = useCallback(async () => { setIsExecutingFilters(true); setCurrentPage(1); // Reset to first page when applying filters updatePageInUrl(1, activeFilters); - await fetchWorkflowRuns(1, activeFilters); + await fetchWorkflowRuns(1, activeFilters, sortBy, sortOrder); setIsExecutingFilters(false); - }, [activeFilters, fetchWorkflowRuns, updatePageInUrl]); + }, [activeFilters, fetchWorkflowRuns, updatePageInUrl, sortBy, sortOrder]); const handleFiltersChange = useCallback((filters: ActiveFilter[]) => { setActiveFilters(filters); @@ -171,182 +166,51 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti setIsExecutingFilters(true); setCurrentPage(1); updatePageInUrl(1, []); // Clear filters from URL - await fetchWorkflowRuns(1, []); // Fetch all workflows without filters + await fetchWorkflowRuns(1, [], sortBy, sortOrder); // Fetch all workflows without filters setIsExecutingFilters(false); - }, [fetchWorkflowRuns, updatePageInUrl]); + }, [fetchWorkflowRuns, updatePageInUrl, sortBy, sortOrder]); + + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page); + updatePageInUrl(page, activeFilters); + }, [updatePageInUrl, activeFilters]); + + const handleSort = useCallback((field: string) => { + // Reset to first page when sort changes + setCurrentPage(1); + + if (sortBy === field) { + // Toggle order if same field + setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + // New field, default to desc + setSortBy(field); + setSortOrder('desc'); + } + }, [sortBy]); return (
-
-

Workflow Run History

- -
- {loading ? ( -
-
Loading workflow runs...
-
- ) : error ? ( -
- {error} -
- ) : workflowRuns.length === 0 ? ( -
-

No workflow runs found

-
- ) : ( - - - Workflow Runs - - Showing {workflowRuns.length} of {totalCount} total runs - - - -
- - - - ID - Status - Created At - Call Type - Duration - Disposition - Dograh Token - Actions - - - - {workflowRuns.map((run) => ( - window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank')} - > - #{run.id} - - - {run.is_completed ? "Completed" : "In Progress"} - - - {formatDate(run.created_at)} - - - {run.call_type === 'inbound' ? 'Inbound' : 'Outbound'} - - - - {typeof run.cost_info?.call_duration_seconds === 'number' - ? `${run.cost_info.call_duration_seconds.toFixed(1)}s` - : "-"} - - - {run.gathered_context?.mapped_call_disposition ? ( - - {run.gathered_context.mapped_call_disposition as string} - - ) : ( - - - )} - - - {typeof run.cost_info?.dograh_token_usage === 'number' - ? `${run.cost_info.dograh_token_usage.toFixed(2)}` - : "-"} - - -
- {run.transcript_url && ( - - )} - {run.recording_url && ( - - )} - -
-
-
- ))} -
-
-
- - {/* Pagination */} - {totalPages > 1 && ( -
-

- Page {currentPage} of {totalPages} -

-
- - -
-
- )} -
-
- )} +
); } diff --git a/ui/src/client/sdk.gen.ts b/ui/src/client/sdk.gen.ts index 7d3c1b1..3995f2c 100644 --- a/ui/src/client/sdk.gen.ts +++ b/ui/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client,Options as ClientOptions, TDataShape } from '@hey-api/client-fetch'; import { client as _heyApiClient } from './client.gen'; -import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostError, CreateCredentialApiV1CredentialsPostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostError, CreateToolApiV1ToolsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteError, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponse, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteResponse, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetData, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetError, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetError, GetCredentialApiV1CredentialsCredentialUuidGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetError, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponse, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetError, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetError, GetToolApiV1ToolsToolUuidGetResponse, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostError, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponse, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetError, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetError, GetWorkflowCountApiV1WorkflowCountGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostError, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HealthApiV1HealthGetData, HealthApiV1HealthGetResponse,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostError, InitializeEmbedSessionApiV1PublicEmbedInitPostResponse, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostError, InitiateCallApiV1PublicAgentUuidPostResponse, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetError, ListCredentialsApiV1CredentialsGetResponse, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetError, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponse, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetError, ListToolsApiV1ToolsGetResponse, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsError, OptionsInitApiV1PublicEmbedInitOptionsData, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostError, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostError, SearchChunksApiV1KnowledgeBaseSearchPostResponse, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutError, UpdateCredentialApiV1CredentialsCredentialUuidPutResponse, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutError, UpdateToolApiV1ToolsToolUuidPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; +import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostError, CreateCredentialApiV1CredentialsPostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostError, CreateToolApiV1ToolsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteError, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponse, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteResponse, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetData, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetError, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetError, GetCredentialApiV1CredentialsCredentialUuidGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetError, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponse, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetError, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetError, GetToolApiV1ToolsToolUuidGetResponse, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostError, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponse, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetError, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetError, GetWorkflowCountApiV1WorkflowCountGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostError, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HealthApiV1HealthGetData, HealthApiV1HealthGetResponse,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostError, InitializeEmbedSessionApiV1PublicEmbedInitPostResponse, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostError, InitiateCallApiV1PublicAgentUuidPostResponse, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetError, ListCredentialsApiV1CredentialsGetResponse, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetError, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponse, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetError, ListToolsApiV1ToolsGetResponse, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsError, OptionsInitApiV1PublicEmbedInitOptionsData, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostError, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostError, SearchChunksApiV1KnowledgeBaseSearchPostResponse, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutError, UpdateCredentialApiV1CredentialsCredentialUuidPutResponse, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutError, UpdateToolApiV1ToolsToolUuidPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; export type Options = ClientOptions & { /** @@ -133,6 +133,23 @@ export const handleInboundFallbackApiV1TelephonyInboundFallbackPost = (options?: Options) => { + return (options?.client ?? _heyApiClient).post({ + url: '/api/v1/telephony/cloudonix/cdr', + ...options + }); +}; + /** * Impersonate * Impersonate a user as a super-admin. @@ -350,7 +367,7 @@ export const updateWorkflowApiV1WorkflowWorkflowIdPut = (options: Options) => { return (options.client ?? _heyApiClient).get({ diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index 3b61b67..d1127ee 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -81,6 +81,19 @@ export type CampaignResponse = { max_concurrency?: number | null; }; +/** + * Paginated response for campaign workflow runs + */ +export type CampaignRunsResponse = { + runs: Array<{ + [key: string]: unknown; + }>; + total_count: number; + page: number; + limit: number; + total_pages: number; +}; + export type CampaignSourceDownloadResponse = { download_url: string; expires_in: number; @@ -1138,14 +1151,6 @@ export type WorkflowRunDetail = { created_at: string; }; -export type WorkflowRunResponse = { - id: number; - workflow_id: number; - state: string; - created_at: string; - completed_at: string | null; -}; - export type WorkflowRunResponseSchema = { id: number; workflow_id: number; @@ -1494,6 +1499,27 @@ export type HandleInboundFallbackApiV1TelephonyInboundFallbackPostResponses = { 200: unknown; }; +export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/telephony/cloudonix/cdr'; +}; + +export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostErrors = { + /** + * Not found + */ + 404: unknown; +}; + +export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + export type ImpersonateApiV1SuperuserImpersonatePostData = { body: ImpersonateRequest; headers?: { @@ -1547,6 +1573,14 @@ export type GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData = { * JSON-encoded filter criteria */ filters?: string | null; + /** + * Field to sort by (e.g., 'duration', 'created_at') + */ + sort_by?: string | null; + /** + * Sort order ('asc' or 'desc') + */ + sort_order?: string | null; }; url: '/api/v1/superuser/workflow-runs'; }; @@ -1934,6 +1968,14 @@ export type GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData = { * JSON-encoded filter criteria */ filters?: string | null; + /** + * Field to sort by (e.g., 'duration', 'created_at') + */ + sort_by?: string | null; + /** + * Sort order ('asc' or 'desc') + */ + sort_order?: string | null; }; url: '/api/v1/workflow/{workflow_id}/runs'; }; @@ -2601,7 +2643,22 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetData = { path: { campaign_id: number; }; - query?: never; + query?: { + page?: number; + limit?: number; + /** + * JSON-encoded filter criteria + */ + filters?: string | null; + /** + * Field to sort by (e.g., 'duration', 'created_at') + */ + sort_by?: string | null; + /** + * Sort order ('asc' or 'desc') + */ + sort_order?: string | null; + }; url: '/api/v1/campaign/{campaign_id}/runs'; }; @@ -2622,7 +2679,7 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses = { /** * Successful Response */ - 200: Array; + 200: CampaignRunsResponse; }; export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse = GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses[keyof GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses]; diff --git a/ui/src/components/filters/DateRangeFilter.tsx b/ui/src/components/filters/DateRangeFilter.tsx index 414a656..0a43b27 100644 --- a/ui/src/components/filters/DateRangeFilter.tsx +++ b/ui/src/components/filters/DateRangeFilter.tsx @@ -1,5 +1,5 @@ import { CalendarIcon } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; @@ -25,6 +25,19 @@ export const DateRangeFilter: React.FC = ({ const [isFromOpen, setIsFromOpen] = useState(false); const [isToOpen, setIsToOpen] = useState(false); + // Local state for time inputs - only syncs to parent on blur + const [fromTime, setFromTime] = useState(value.from?.toTimeString().slice(0, 5) ?? ""); + const [toTime, setToTime] = useState(value.to?.toTimeString().slice(0, 5) ?? ""); + + // Sync local time state when parent value changes + useEffect(() => { + setFromTime(value.from?.toTimeString().slice(0, 5) ?? ""); + }, [value.from]); + + useEffect(() => { + setToTime(value.to?.toTimeString().slice(0, 5) ?? ""); + }, [value.to]); + const formatDate = (date: Date | null) => { if (!date) return "Select date"; return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); @@ -55,10 +68,11 @@ export const DateRangeFilter: React.FC = ({ setIsToOpen(false); }; - const handleTimeChange = (type: 'from' | 'to', timeString: string) => { + const handleTimeBlur = (type: 'from' | 'to') => { + const timeString = type === 'from' ? fromTime : toTime; const [hours, minutes] = timeString.split(':').map(Number); const date = type === 'from' ? value.from : value.to; - if (date) { + if (date && !isNaN(hours) && !isNaN(minutes)) { const newDate = new Date(date); newDate.setHours(hours, minutes); onChange({ ...value, [type]: newDate }); @@ -112,8 +126,9 @@ export const DateRangeFilter: React.FC = ({ id="from-time" type="time" className="w-full mt-1 px-3 py-2 border rounded-md" - value={value.from.toTimeString().slice(0, 5)} - onChange={(e) => handleTimeChange('from', e.target.value)} + value={fromTime} + onChange={(e) => setFromTime(e.target.value)} + onBlur={() => handleTimeBlur('from')} /> )} @@ -151,8 +166,9 @@ export const DateRangeFilter: React.FC = ({ id="to-time" type="time" className="w-full mt-1 px-3 py-2 border rounded-md" - value={value.to.toTimeString().slice(0, 5)} - onChange={(e) => handleTimeChange('to', e.target.value)} + value={toTime} + onChange={(e) => setToTime(e.target.value)} + onBlur={() => handleTimeBlur('to')} /> )} diff --git a/ui/src/components/filters/NumberFilter.tsx b/ui/src/components/filters/NumberFilter.tsx index 69f311d..f1b7c49 100644 --- a/ui/src/components/filters/NumberFilter.tsx +++ b/ui/src/components/filters/NumberFilter.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; + import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { NumberValue } from "@/types/filters"; @@ -21,12 +23,23 @@ export const NumberFilter: React.FC = ({ max, step = 1, }) => { + // Local state for fast typing - only syncs to parent on blur + const [localValue, setLocalValue] = useState(value.value?.toString() ?? ''); + + // Sync local state when parent value changes (e.g., from URL or clear) + useEffect(() => { + setLocalValue(value.value?.toString() ?? ''); + }, [value.value]); + const handleChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - if (newValue === '') { + setLocalValue(e.target.value); + }; + + const handleBlur = () => { + if (localValue === '') { onChange({ value: null }); } else { - const num = parseInt(newValue, 10); + const num = parseInt(localValue, 10); if (!isNaN(num)) { onChange({ value: num }); } @@ -40,8 +53,9 @@ export const NumberFilter: React.FC = ({ = ({ step = 1, presets = [], }) => { - const handleMinChange = (e: React.ChangeEvent) => { - const newValue = e.target.value === "" ? null : Number(e.target.value); + // Local state for fast typing - only syncs to parent on blur + const [localMin, setLocalMin] = useState(value.min?.toString() ?? ""); + const [localMax, setLocalMax] = useState(value.max?.toString() ?? ""); + + // Sync local state when parent value changes (e.g., from URL, clear, or presets) + useEffect(() => { + setLocalMin(value.min?.toString() ?? ""); + setLocalMax(value.max?.toString() ?? ""); + }, [value.min, value.max]); + + const handleMinBlur = () => { + const newValue = localMin === "" ? null : Number(localMin); onChange({ ...value, min: newValue }); }; - const handleMaxChange = (e: React.ChangeEvent) => { - const newValue = e.target.value === "" ? null : Number(e.target.value); + const handleMaxBlur = () => { + const newValue = localMax === "" ? null : Number(localMax); onChange({ ...value, max: newValue }); }; @@ -64,8 +76,9 @@ export const NumberRangeFilter: React.FC = ({ id="min-value" type="number" placeholder={`Min ${unit || 'value'}`} - value={value.min ?? ""} - onChange={handleMinChange} + value={localMin} + onChange={(e) => setLocalMin(e.target.value)} + onBlur={handleMinBlur} min={min} max={max} step={step} @@ -80,8 +93,9 @@ export const NumberRangeFilter: React.FC = ({ id="max-value" type="number" placeholder={`Max ${unit || 'value'}`} - value={value.max ?? ""} - onChange={handleMaxChange} + value={localMax} + onChange={(e) => setLocalMax(e.target.value)} + onBlur={handleMaxBlur} min={min} max={max} step={step} diff --git a/ui/src/components/filters/TagInputFilter.tsx b/ui/src/components/filters/TagInputFilter.tsx index d02466c..103583b 100644 --- a/ui/src/components/filters/TagInputFilter.tsx +++ b/ui/src/components/filters/TagInputFilter.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useState } from "react"; +import { ChangeEvent, useEffect, useState } from "react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -14,6 +14,11 @@ interface TagInputFilterProps { export const TagInputFilter: React.FC = ({ value, onChange, error, placeholder="Enter tags (comma separated)" }) => { const [text, setText] = useState(value.codes.join(", ")); + // Sync local state when parent value changes (e.g., from URL or clear) + useEffect(() => { + setText(value.codes.join(", ")); + }, [value.codes]); + const handleBlur = (e: ChangeEvent) => { const tags = e.target.value .split(/[,\n]/) diff --git a/ui/src/components/filters/TextFilter.tsx b/ui/src/components/filters/TextFilter.tsx index f379420..3354ea9 100644 --- a/ui/src/components/filters/TextFilter.tsx +++ b/ui/src/components/filters/TextFilter.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; + import { Input } from "@/components/ui/input"; import { TextValue } from "@/types/filters"; @@ -16,12 +18,25 @@ export const TextFilter: React.FC = ({ placeholder = "Enter text", maxLength, }) => { + // Local state for fast typing - only syncs to parent on blur + const [localValue, setLocalValue] = useState(value.value || ""); + + // Sync local state when parent value changes (e.g., from URL or clear) + useEffect(() => { + setLocalValue(value.value || ""); + }, [value.value]); + + const handleBlur = () => { + onChange({ value: localValue }); + }; + return (
onChange({ value: e.target.value })} + value={localValue} + onChange={(e) => setLocalValue(e.target.value)} + onBlur={handleBlur} placeholder={placeholder} maxLength={maxLength} className={error ? "border-red-500" : ""} diff --git a/ui/src/components/layout/AppLayout.tsx b/ui/src/components/layout/AppLayout.tsx index 19ae59a..98b571e 100644 --- a/ui/src/components/layout/AppLayout.tsx +++ b/ui/src/components/layout/AppLayout.tsx @@ -24,8 +24,9 @@ const AppLayout: React.FC = ({ // Hide sidebar for root (/) and /handler routes (Stack Auth routes) const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler"); - // Check if we're in workflow editor mode - collapse sidebar by default + // Check if we're in workflow editor mode or superadmin runs - collapse sidebar by default const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname); + const isSuperadmin = pathname.startsWith("/superadmin"); // If no sidebar needed, just return children if (!shouldShowSidebar) { @@ -33,7 +34,7 @@ const AppLayout: React.FC = ({ } return ( - +
diff --git a/ui/src/components/workflow-runs/CampaignRuns.tsx b/ui/src/components/workflow-runs/CampaignRuns.tsx new file mode 100644 index 0000000..e94c258 --- /dev/null +++ b/ui/src/components/workflow-runs/CampaignRuns.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { getCampaignRunsApiV1CampaignCampaignIdRunsGet } from "@/client/sdk.gen"; +import { WorkflowRunResponseSchema } from "@/client/types.gen"; +import { WorkflowRunsTable } from "@/components/workflow-runs"; +import { useAuth } from "@/lib/auth"; +import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters"; +import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters"; + +interface CampaignRunsProps { + campaignId: number; + workflowId: number; + searchParams?: URLSearchParams; +} + +export function CampaignRuns({ campaignId, workflowId, searchParams }: CampaignRunsProps) { + const router = useRouter(); + const { getAccessToken } = useAuth(); + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(() => { + const pageParam = searchParams?.get('page'); + return pageParam ? parseInt(pageParam, 10) : 1; + }); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const [isExecutingFilters, setIsExecutingFilters] = useState(false); + const [accessToken, setAccessToken] = useState(null); + + // Sort state + const [sortBy, setSortBy] = useState(null); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + // Initialize filters from URL + const [activeFilters, setActiveFilters] = useState(() => { + return searchParams ? decodeFiltersFromURL(searchParams, availableAttributes) : []; + }); + + // Get access token on mount + useEffect(() => { + const fetchToken = async () => { + const token = await getAccessToken(); + setAccessToken(token); + }; + fetchToken(); + }, [getAccessToken]); + + const fetchCampaignRuns = useCallback(async ( + page: number, + filters?: ActiveFilter[], + sortByParam?: string | null, + sortOrderParam?: 'asc' | 'desc' + ) => { + if (!accessToken) return; + try { + setLoading(true); + // Prepare filter data for API + let filterParam = undefined; + if (filters && filters.length > 0) { + const filterData = filters.map(filter => ({ + attribute: filter.attribute.id, + type: filter.attribute.type, + value: filter.value + })); + filterParam = JSON.stringify(filterData); + } + + const response = await getCampaignRunsApiV1CampaignCampaignIdRunsGet({ + path: { campaign_id: campaignId }, + query: { + page: page, + limit: 50, + ...(filterParam && { filters: filterParam }), + ...(sortByParam && { sort_by: sortByParam }), + ...(sortOrderParam && { sort_order: sortOrderParam }), + }, + headers: { + 'Authorization': `Bearer ${accessToken}`, + } + }); + + if (response.error) { + throw new Error("Failed to fetch campaign runs"); + } + + if (response.data) { + // The API returns runs as array of dicts, convert to WorkflowRunResponseSchema + setRuns((response.data.runs || []) as unknown as WorkflowRunResponseSchema[]); + setTotalPages(response.data.total_pages || 1); + setTotalCount(response.data.total_count || 0); + setCurrentPage(response.data.page || 1); + } + setError(null); + } catch (err) { + console.error("Error fetching campaign runs:", err); + setError("Failed to load campaign runs"); + } finally { + setLoading(false); + } + }, [campaignId, accessToken]); + + const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => { + const params = new URLSearchParams(); + params.set('page', page.toString()); + + // Add filters to URL if present + if (filters && filters.length > 0) { + const filterString = encodeFiltersToURL(filters); + if (filterString) { + const filterParams = new URLSearchParams(filterString); + filterParams.forEach((value, key) => params.set(key, value)); + } + } + + router.push(`/campaigns/${campaignId}?${params.toString()}`, { scroll: false }); + }, [router, campaignId]); + + useEffect(() => { + if (accessToken) { + fetchCampaignRuns(currentPage, activeFilters, sortBy, sortOrder); + } + }, [currentPage, activeFilters, fetchCampaignRuns, accessToken, sortBy, sortOrder]); + + const handleApplyFilters = useCallback(async () => { + setIsExecutingFilters(true); + setCurrentPage(1); + updatePageInUrl(1, activeFilters); + await fetchCampaignRuns(1, activeFilters, sortBy, sortOrder); + setIsExecutingFilters(false); + }, [activeFilters, fetchCampaignRuns, updatePageInUrl, sortBy, sortOrder]); + + const handleFiltersChange = useCallback((filters: ActiveFilter[]) => { + setActiveFilters(filters); + }, []); + + const handleClearFilters = useCallback(async () => { + setIsExecutingFilters(true); + setCurrentPage(1); + setActiveFilters([]); + updatePageInUrl(1, []); + await fetchCampaignRuns(1, [], sortBy, sortOrder); + setIsExecutingFilters(false); + }, [fetchCampaignRuns, updatePageInUrl, sortBy, sortOrder]); + + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page); + updatePageInUrl(page, activeFilters); + }, [updatePageInUrl, activeFilters]); + + const handleSort = useCallback((field: string) => { + // Reset to first page when sort changes + setCurrentPage(1); + + if (sortBy === field) { + // Toggle order if same field + setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + // New field, default to desc + setSortBy(field); + setSortOrder('desc'); + } + }, [sortBy]); + + // Use a subset of filter attributes relevant for campaigns + const campaignFilterAttributes: FilterAttribute[] = availableAttributes.filter( + attr => ['dateRange', 'dispositionCode', 'duration', 'status', 'tokenUsage'].includes(attr.id) + ); + + return ( + + ); +} diff --git a/ui/src/components/workflow-runs/WorkflowRunsTable.tsx b/ui/src/components/workflow-runs/WorkflowRunsTable.tsx new file mode 100644 index 0000000..4d161fd --- /dev/null +++ b/ui/src/components/workflow-runs/WorkflowRunsTable.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, ExternalLink } from "lucide-react"; +import { useState } from "react"; + +import { WorkflowRunResponseSchema } from "@/client/types.gen"; +import { FilterBuilder } from "@/components/filters/FilterBuilder"; +import { MediaPreviewButtons, MediaPreviewDialog } from "@/components/MediaPreviewDialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ActiveFilter, FilterAttribute } from "@/types/filters"; + +export interface WorkflowRunsTableProps { + // Data + runs: WorkflowRunResponseSchema[]; + loading: boolean; + error: string | null; + + // Pagination + currentPage: number; + totalPages: number; + totalCount: number; + onPageChange: (page: number) => void; + + // Filters + availableAttributes: FilterAttribute[]; + activeFilters: ActiveFilter[]; + onFiltersChange: (filters: ActiveFilter[]) => void; + onApplyFilters: () => void; + onClearFilters: () => void; + isExecutingFilters: boolean; + + // Sorting + sortBy?: string | null; + sortOrder?: 'asc' | 'desc'; + onSort?: (field: string) => void; + + // Navigation & Actions + workflowId: number; + accessToken: string | null; + + // Optional customization + title?: string; + subtitle?: string; + showFilters?: boolean; + emptyMessage?: string; +} + +export function WorkflowRunsTable({ + runs, + loading, + error, + currentPage, + totalPages, + totalCount, + onPageChange, + availableAttributes, + activeFilters, + onFiltersChange, + onApplyFilters, + onClearFilters, + isExecutingFilters, + sortBy, + sortOrder = 'desc', + onSort, + workflowId, + accessToken, + title = "Workflow Run History", + subtitle, + showFilters = true, + emptyMessage = "No workflow runs found", +}: WorkflowRunsTableProps) { + const [selectedRowId, setSelectedRowId] = useState(null); + + // Media preview dialog + const mediaPreview = MediaPreviewDialog({ accessToken }); + + const formatDate = (dateString: string) => new Date(dateString).toLocaleString(); + + const handleRowClick = (runId: number) => { + window.open(`/workflow/${workflowId}/run/${runId}`, '_blank'); + }; + + return ( +
+ {/* Title and Filters */} + {showFilters && ( +
+

{title}

+ +
+ )} + + {/* Loading State */} + {loading ? ( +
+
Loading workflow runs...
+
+ ) : error ? ( +
+ {error} +
+ ) : runs.length === 0 ? ( +
+

{emptyMessage}

+
+ ) : ( + + + Workflow Runs + + {subtitle || `Showing ${runs.length} of ${totalCount} total runs`} + + + +
+ + + + ID + Status + Created At + Call Type + onSort?.('duration')} + > +
+ Duration + {sortBy === 'duration' ? ( + sortOrder === 'asc' ? : + ) : ( + + )} +
+
+ Disposition + Actions +
+
+ + {runs.map((run) => ( + handleRowClick(run.id)} + > + #{run.id} + + + {run.is_completed ? "Completed" : "In Progress"} + + + {formatDate(run.created_at)} + + + {run.call_type === 'inbound' ? 'Inbound' : 'Outbound'} + + + + {typeof run.cost_info?.call_duration_seconds === 'number' + ? `${run.cost_info.call_duration_seconds.toFixed(1)}s` + : "-"} + + + {run.gathered_context?.mapped_call_disposition ? ( + + {run.gathered_context.mapped_call_disposition as string} + + ) : ( + - + )} + + +
e.stopPropagation()}> + + +
+
+
+ ))} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {currentPage} of {totalPages} +

+
+ + +
+
+ )} +
+
+ )} + + {/* Media Preview Dialog */} + {mediaPreview.dialog} +
+ ); +} diff --git a/ui/src/components/workflow-runs/index.ts b/ui/src/components/workflow-runs/index.ts new file mode 100644 index 0000000..af89077 --- /dev/null +++ b/ui/src/components/workflow-runs/index.ts @@ -0,0 +1,3 @@ +export { CampaignRuns } from "./CampaignRuns"; +export type { WorkflowRunsTableProps } from "./WorkflowRunsTable"; +export { WorkflowRunsTable } from "./WorkflowRunsTable"; diff --git a/ui/src/constants/dispositionCodes.ts b/ui/src/constants/dispositionCodes.ts index 4ecdfcc..141d151 100644 --- a/ui/src/constants/dispositionCodes.ts +++ b/ui/src/constants/dispositionCodes.ts @@ -3,23 +3,13 @@ * Update this array when adding new disposition codes */ export const DISPOSITION_CODES = [ - 'CALLBK', + 'end_call_tool', + 'user_hangup', 'call_duration_exceeded', - 'DAIR', - 'DNC', - 'HU', - 'LB', - 'ND', - 'NIBP', - 'NQ', + 'user_idle_max_duration_exceeded', 'system_connect_error', 'unknown', - 'user_disqualified', - 'user_idle_max_duration_exceeded', - 'VM', - 'voicemail_detected', - 'WN', - 'XFER', + 'voicemail_detected' ] as const; export type DispositionCode = typeof DISPOSITION_CODES[number]; diff --git a/ui/src/lib/dispositionBadgeVariant.ts b/ui/src/lib/dispositionBadgeVariant.ts deleted file mode 100644 index bc88cca..0000000 --- a/ui/src/lib/dispositionBadgeVariant.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Color variants for disposition code -export const getDispositionBadgeVariant = (code: string | undefined): "default" | "secondary" | "destructive" | "outline" | "success" => { - if (!code) return "outline"; - - const upperCode = code.toUpperCase(); - switch (upperCode) { - case "XFER": - return "success"; // Green color for transfers - case "HU": - case "NIBP": - return "destructive"; // Red color for hang up and NIBP - case "VM": - return "secondary"; - default: - return "default"; // Default color for all other codes - } -};