mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +02:00
chore: refactor workfow run view
This commit is contained in:
parent
ae0dc812cd
commit
1065ae001f
15 changed files with 794 additions and 387 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})));
|
||||
})));
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 & {});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
197
ui/src/components/workflow-runs/CampaignRuns.tsx
Normal file
197
ui/src/components/workflow-runs/CampaignRuns.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
251
ui/src/components/workflow-runs/WorkflowRunsTable.tsx
Normal file
251
ui/src/components/workflow-runs/WorkflowRunsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
ui/src/components/workflow-runs/index.ts
Normal file
3
ui/src/components/workflow-runs/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { CampaignRuns } from "./CampaignRuns";
|
||||
export type { WorkflowRunsTableProps } from "./WorkflowRunsTable";
|
||||
export { WorkflowRunsTable } from "./WorkflowRunsTable";
|
||||
Loading…
Add table
Add a link
Reference in a new issue