chore: refactor workfow run view

This commit is contained in:
Abhishek Kumar 2026-01-30 17:01:01 +05:30
parent ae0dc812cd
commit 1065ae001f
15 changed files with 794 additions and 387 deletions

View file

@ -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:

View file

@ -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("->>")(

View file

@ -2,12 +2,12 @@ import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import Float, func
from sqlalchemy import func
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,
@ -134,21 +134,8 @@ class WorkflowRunClient(BaseDBClient):
count_result = await session.execute(count_query)
total_count = count_result.scalar()
# Determine sort column
if sort_by == "duration":
# Sort by call_duration_seconds from usage_info JSON field
sort_column = WorkflowRunModel.usage_info.op("->>")("call_duration_seconds").cast(Float)
else:
# Default to created_at
sort_column = WorkflowRunModel.created_at
# Apply sort order
if sort_order == "asc":
order_clause = sort_column.asc().nullslast()
else:
order_clause = sort_column.desc().nullslast()
# 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(
@ -245,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
@ -271,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(

View file

@ -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")

View file

@ -105,8 +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')"),
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:
"""
@ -131,7 +135,11 @@ async def get_workflow_runs(
sort_order = "desc"
workflow_runs, total_count = await db_client.get_workflow_runs_for_superadmin(
limit=limit, offset=offset, filters=filter_criteria, sort_by=sort_by, sort_order=sort_order
limit=limit,
offset=offset,
filters=filter_criteria,
sort_by=sort_by,
sort_order=sort_order,
)
total_pages = (total_count + limit - 1) // limit # Ceiling division

View file

@ -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

View file

@ -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<CampaignResponse | null>(null);
const [isLoadingCampaign, setIsLoadingCampaign] = useState(true);
// Runs state
const [runs, setRuns] = useState<WorkflowRunResponse[]>([]);
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() {
</Card>
{/* Workflow Runs */}
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
Executions triggered by this campaign
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingRuns ? (
<div className="animate-pulse space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-muted rounded"></div>
))}
</div>
) : runs.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Run ID</TableHead>
<TableHead>State</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<TableRow
key={run.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRunClick(run.id)}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
<TableCell>
<Badge variant={run.state === 'completed' ? 'secondary' : 'default'}>
{run.state}
</Badge>
</TableCell>
<TableCell>{formatDateTime(run.created_at)}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRunClick(run.id);
}}
>
View
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className="text-center py-8 text-muted-foreground">
{campaign.state === 'created'
? 'No runs yet. Start the campaign to begin execution.'
: 'No workflow runs found for this campaign.'}
</p>
)}
</CardContent>
</Card>
<CampaignRuns
campaignId={campaignId}
workflowId={campaign.workflow_id}
searchParams={searchParams}
/>
</div>
);
}

View file

@ -1,26 +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 { downloadFile } from "@/lib/files";
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
@ -43,6 +30,10 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
const [configuredAttributes, setConfiguredAttributes] = useState<FilterAttribute[]>(availableAttributes);
// Sort state
const [sortBy, setSortBy] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const { accessToken } = useUserConfig();
// Initialize filters from URL
@ -50,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;
@ -88,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);
@ -108,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}`,
@ -151,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);
@ -170,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 (
<div className="container mx-auto py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>
<FilterBuilder
availableAttributes={configuredAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecuting={isExecutingFilters}
/>
</div>
{loading ? (
<div className="flex justify-center">
<div className="animate-pulse">Loading workflow runs...</div>
</div>
) : error ? (
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded">
{error}
</div>
) : workflowRuns.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">No workflow runs found</p>
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
Showing {workflowRuns.length} of {totalCount} total runs
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold">Call Type</TableHead>
<TableHead className="font-semibold">Duration</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Dograh Token</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflowRuns.map((run) => (
<TableRow
key={run.id}
className="cursor-pointer"
onClick={() => window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank')}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
<TableCell>
<Badge variant={run.is_completed ? "default" : "secondary"}>
{run.is_completed ? "Completed" : "In Progress"}
</Badge>
</TableCell>
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
<TableCell>
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
</Badge>
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.call_duration_seconds === 'number'
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
: "-"}
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant="default">
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.dograh_token_usage === 'number'
? `${run.cost_info.dograh_token_usage.toFixed(2)}`
: "-"}
</TableCell>
<TableCell>
<div className="flex space-x-2">
{run.transcript_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.transcript_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Transcript
</Button>
)}
{run.recording_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.recording_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Recording
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank');
}}
>
<ExternalLink className="h-3 w-3 mr-1" />
View
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage - 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage + 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
<WorkflowRunsTable
runs={workflowRuns}
loading={loading}
error={error}
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
onPageChange={handlePageChange}
availableAttributes={configuredAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecutingFilters={isExecutingFilters}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
workflowId={workflowId}
accessToken={accessToken}
/>
</div>
);
}

View file

@ -1,8 +1,9 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ClientOptions } from './types.gen';
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-fetch';
import { type ClientOptions as DefaultClientOptions, type Config, createClient, createConfig } from '@hey-api/client-fetch';
import { createClientConfig } from '../lib/apiClient';
import type { ClientOptions } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
@ -16,4 +17,4 @@ export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
baseUrl: 'http://127.0.0.1:8000'
})));
})));

View file

@ -1,3 +1,3 @@
// This file is auto-generated by @hey-api/openapi-ts
export * from './sdk.gen';
export * from './types.gen';
export * from './sdk.gen';

File diff suppressed because one or more lines are too long

View file

@ -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;
@ -1963,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';
};
@ -2630,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';
};
@ -2651,7 +2679,7 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses = {
/**
* Successful Response
*/
200: Array<WorkflowRunResponse>;
200: CampaignRunsResponse;
};
export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse = GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses[keyof GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses];
@ -4691,4 +4719,4 @@ export type HealthApiV1HealthGetResponse = HealthApiV1HealthGetResponses[keyof H
export type ClientOptions = {
baseUrl: 'http://127.0.0.1:8000' | (string & {});
};
};

View file

@ -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<WorkflowRunResponseSchema[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string | null>(null);
// Sort state
const [sortBy, setSortBy] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Initialize filters from URL
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
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 (
<WorkflowRunsTable
runs={runs}
loading={loading}
error={error}
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
onPageChange={handlePageChange}
availableAttributes={campaignFilterAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecutingFilters={isExecutingFilters}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
workflowId={workflowId}
accessToken={accessToken}
title="Campaign Workflow Runs"
emptyMessage="No workflow runs found for this campaign"
/>
);
}

View file

@ -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<number | null>(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 (
<div className="space-y-6">
{/* Title and Filters */}
{showFilters && (
<div className="mb-6">
<h1 className="text-2xl font-bold mb-4">{title}</h1>
<FilterBuilder
availableAttributes={availableAttributes}
activeFilters={activeFilters}
onFiltersChange={onFiltersChange}
onApplyFilters={onApplyFilters}
onClearFilters={onClearFilters}
isExecuting={isExecutingFilters}
/>
</div>
)}
{/* Loading State */}
{loading ? (
<div className="flex justify-center">
<div className="animate-pulse">Loading workflow runs...</div>
</div>
) : error ? (
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded">
{error}
</div>
) : runs.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">{emptyMessage}</p>
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
{subtitle || `Showing ${runs.length} of ${totalCount} total runs`}
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold">Call Type</TableHead>
<TableHead
className="font-semibold cursor-pointer hover:bg-muted/50 select-none"
onClick={() => onSort?.('duration')}
>
<div className="flex items-center gap-1">
Duration
{sortBy === 'duration' ? (
sortOrder === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
) : (
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<TableRow
key={run.id}
className={`cursor-pointer hover:bg-muted/50 ${selectedRowId === run.id ? "bg-primary/20 ring-1 ring-primary/50" : ""}`}
onClick={() => handleRowClick(run.id)}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
<TableCell>
<Badge variant={run.is_completed ? "default" : "secondary"}>
{run.is_completed ? "Completed" : "In Progress"}
</Badge>
</TableCell>
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
<TableCell>
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
</Badge>
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.call_duration_seconds === 'number'
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
: "-"}
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant="default">
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex space-x-2" onClick={(e) => e.stopPropagation()}>
<MediaPreviewButtons
recordingUrl={run.recording_url}
transcriptUrl={run.transcript_url}
runId={run.id}
onOpenAudio={mediaPreview.openAudioModal}
onOpenTranscript={mediaPreview.openTranscriptModal}
onSelect={setSelectedRowId}
/>
<Button
variant="outline"
size="icon"
onClick={() => window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank')}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Media Preview Dialog */}
{mediaPreview.dialog}
</div>
);
}

View file

@ -0,0 +1,3 @@
export { CampaignRuns } from "./CampaignRuns";
export type { WorkflowRunsTableProps } from "./WorkflowRunsTable";
export { WorkflowRunsTable } from "./WorkflowRunsTable";