2025-09-09 14:37:32 +05:30
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-02-25 13:53:30 +05:30
|
|
|
|
import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, RefreshCw } from 'lucide-react';
|
2025-09-09 14:37:32 +05:30
|
|
|
|
import Image from 'next/image';
|
|
|
|
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
|
|
|
2026-02-25 13:53:30 +05:30
|
|
|
|
import { getWorkflowRunsApiV1SuperuserWorkflowRunsGet } from '@/client/sdk.gen';
|
2025-09-09 14:37:32 +05:30
|
|
|
|
import { FilterBuilder } from "@/components/filters/FilterBuilder";
|
2026-02-12 15:41:44 +05:30
|
|
|
|
import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
|
2025-09-09 14:37:32 +05:30
|
|
|
|
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
2026-02-18 13:16:49 +05:30
|
|
|
|
import { useAuth } from '@/lib/auth';
|
2025-09-09 14:37:32 +05:30
|
|
|
|
import{ superadminFilterAttributes } from "@/lib/filterAttributes";
|
|
|
|
|
|
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
|
|
|
|
|
|
import { impersonateAsSuperadmin } from '@/lib/utils';
|
|
|
|
|
|
import { ActiveFilter } from '@/types/filters';
|
|
|
|
|
|
|
|
|
|
|
|
interface WorkflowRun {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
workflow_id: number;
|
|
|
|
|
|
workflow_name?: string;
|
|
|
|
|
|
user_id?: number;
|
|
|
|
|
|
organization_id?: number;
|
|
|
|
|
|
organization_name?: string;
|
|
|
|
|
|
mode: string;
|
|
|
|
|
|
is_completed: boolean;
|
|
|
|
|
|
recording_url?: string;
|
|
|
|
|
|
transcript_url?: string;
|
|
|
|
|
|
usage_info?: Record<string, unknown>;
|
|
|
|
|
|
cost_info?: Record<string, unknown>;
|
|
|
|
|
|
initial_context?: Record<string, unknown>;
|
|
|
|
|
|
gathered_context?: Record<string, unknown>;
|
|
|
|
|
|
created_at: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface WorkflowRunsResponse {
|
|
|
|
|
|
workflow_runs: WorkflowRun[];
|
|
|
|
|
|
total_count: number;
|
|
|
|
|
|
page: number;
|
|
|
|
|
|
limit: number;
|
|
|
|
|
|
total_pages: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export default function RunsPage() {
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const searchParams = useSearchParams();
|
|
|
|
|
|
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
|
|
|
|
|
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 [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState("");
|
|
|
|
|
|
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
|
|
|
|
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
|
|
|
|
|
const [isAutoRefreshing, setIsAutoRefreshing] = useState(false);
|
|
|
|
|
|
const limit = 50;
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize filters from URL
|
|
|
|
|
|
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
|
|
|
|
|
|
return decodeFiltersFromURL(searchParams, superadminFilterAttributes);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-30 17:08:15 +05:30
|
|
|
|
// Applied filters are the ones actually used for fetching (only updated on Apply click)
|
|
|
|
|
|
const [appliedFilters, setAppliedFilters] = useState<ActiveFilter[]>(() => {
|
|
|
|
|
|
return decodeFiltersFromURL(searchParams, superadminFilterAttributes);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-06 09:01:06 +05:30
|
|
|
|
// Sort state (initialized from URL)
|
|
|
|
|
|
const [sortBy, setSortBy] = useState<string | null>(() => {
|
|
|
|
|
|
return searchParams.get('sort_by') || null;
|
|
|
|
|
|
});
|
|
|
|
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => {
|
|
|
|
|
|
const order = searchParams.get('sort_order');
|
|
|
|
|
|
return order === 'asc' ? 'asc' : 'desc';
|
|
|
|
|
|
});
|
2026-01-30 17:08:15 +05:30
|
|
|
|
|
2025-09-09 14:37:32 +05:30
|
|
|
|
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
|
|
|
|
|
|
2026-02-18 13:16:49 +05:30
|
|
|
|
const auth = useAuth();
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
|
|
// Media preview dialog
|
2026-02-18 13:16:49 +05:30
|
|
|
|
const mediaPreview = MediaPreviewDialog();
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
2026-01-30 17:08:15 +05:30
|
|
|
|
const fetchRuns = useCallback(async (
|
|
|
|
|
|
page: number,
|
|
|
|
|
|
filters?: ActiveFilter[],
|
|
|
|
|
|
isAutoRefresh = false,
|
|
|
|
|
|
sortByParam?: string | null,
|
|
|
|
|
|
sortOrderParam?: 'asc' | 'desc'
|
|
|
|
|
|
) => {
|
2026-02-18 13:16:49 +05:30
|
|
|
|
if (!auth.isAuthenticated) return;
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
|
|
// Don't show loading state for auto-refresh to prevent UI flicker
|
|
|
|
|
|
if (!isAutoRefresh) {
|
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setIsAutoRefreshing(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
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 getWorkflowRunsApiV1SuperuserWorkflowRunsGet({
|
|
|
|
|
|
query: {
|
|
|
|
|
|
page,
|
|
|
|
|
|
limit,
|
2026-01-30 17:08:15 +05:30
|
|
|
|
...(filterParam && { filters: filterParam }),
|
|
|
|
|
|
...(sortByParam && { sort_by: sortByParam }),
|
|
|
|
|
|
...(sortOrderParam && { sort_order: sortOrderParam }),
|
2025-09-09 14:37:32 +05:30
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.data) {
|
|
|
|
|
|
const data = response.data as WorkflowRunsResponse;
|
|
|
|
|
|
setRuns(data.workflow_runs);
|
|
|
|
|
|
setCurrentPage(data.page);
|
|
|
|
|
|
setTotalPages(data.total_pages);
|
|
|
|
|
|
setTotalCount(data.total_count);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError("Failed to fetch workflow runs. Please try again.");
|
|
|
|
|
|
console.error("Fetch runs error:", err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (!isAutoRefresh) {
|
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setIsAutoRefreshing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-18 13:16:49 +05:30
|
|
|
|
}, [limit, auth.isAuthenticated]);
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
2026-02-06 09:01:06 +05:30
|
|
|
|
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[], sortByParam?: string | null, sortOrderParam?: 'asc' | 'desc') => {
|
2025-09-09 14:37:32 +05:30
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 09:01:06 +05:30
|
|
|
|
// Add sort to URL if present
|
|
|
|
|
|
if (sortByParam) {
|
|
|
|
|
|
params.set('sort_by', sortByParam);
|
|
|
|
|
|
params.set('sort_order', sortOrderParam || 'desc');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-09 14:37:32 +05:30
|
|
|
|
router.push(`/superadmin/runs?${params.toString()}`);
|
|
|
|
|
|
}, [router]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-02-18 13:16:49 +05:30
|
|
|
|
// Fetch runs when auth is available and when page/sort changes
|
|
|
|
|
|
if (auth.isAuthenticated) {
|
2026-01-30 17:08:15 +05:30
|
|
|
|
fetchRuns(currentPage, appliedFilters, false, sortBy, sortOrder);
|
2025-09-09 14:37:32 +05:30
|
|
|
|
}
|
2026-02-18 13:16:49 +05:30
|
|
|
|
}, [currentPage, auth.isAuthenticated, appliedFilters, fetchRuns, sortBy, sortOrder]);
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
|
|
// Auto-refresh every 5 seconds when enabled and filters are active
|
|
|
|
|
|
useEffect(() => {
|
2026-01-30 17:08:15 +05:30
|
|
|
|
// Only set up interval if auto-refresh is enabled and there are applied filters
|
|
|
|
|
|
if (!autoRefresh || appliedFilters.length === 0) {
|
2025-09-09 14:37:32 +05:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const intervalId = setInterval(() => {
|
|
|
|
|
|
// Pass true to indicate this is an auto-refresh
|
2026-01-30 17:08:15 +05:30
|
|
|
|
fetchRuns(currentPage, appliedFilters, true, sortBy, sortOrder);
|
2025-09-09 14:37:32 +05:30
|
|
|
|
}, 5000);
|
|
|
|
|
|
|
|
|
|
|
|
// Cleanup interval on unmount or when dependencies change
|
|
|
|
|
|
return () => clearInterval(intervalId);
|
2026-01-30 17:08:15 +05:30
|
|
|
|
}, [currentPage, appliedFilters, fetchRuns, autoRefresh, sortBy, sortOrder]);
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
|
|
const handlePageChange = (page: number) => {
|
|
|
|
|
|
setCurrentPage(page);
|
2026-02-06 09:01:06 +05:30
|
|
|
|
updatePageInUrl(page, appliedFilters, sortBy, sortOrder);
|
2026-01-30 17:08:15 +05:30
|
|
|
|
fetchRuns(page, appliedFilters, false, sortBy, sortOrder);
|
2025-09-09 14:37:32 +05:30
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleApplyFilters = useCallback(async () => {
|
|
|
|
|
|
setIsExecutingFilters(true);
|
|
|
|
|
|
setCurrentPage(1); // Reset to first page when applying filters
|
2026-01-30 17:08:15 +05:30
|
|
|
|
setAppliedFilters(activeFilters); // Update applied filters
|
2026-02-06 09:01:06 +05:30
|
|
|
|
updatePageInUrl(1, activeFilters, sortBy, sortOrder);
|
2026-01-30 17:08:15 +05:30
|
|
|
|
await fetchRuns(1, activeFilters, false, sortBy, sortOrder);
|
2025-09-09 14:37:32 +05:30
|
|
|
|
setIsExecutingFilters(false);
|
2026-01-30 17:08:15 +05:30
|
|
|
|
}, [activeFilters, fetchRuns, updatePageInUrl, sortBy, sortOrder]);
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
|
|
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
|
|
|
|
|
|
setActiveFilters(filters);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const handleClearFilters = useCallback(async () => {
|
|
|
|
|
|
setIsExecutingFilters(true);
|
|
|
|
|
|
setCurrentPage(1);
|
2026-01-30 17:08:15 +05:30
|
|
|
|
setAppliedFilters([]); // Clear applied filters
|
2026-02-06 09:01:06 +05:30
|
|
|
|
updatePageInUrl(1, [], sortBy, sortOrder); // Clear filters from URL
|
2026-01-30 17:08:15 +05:30
|
|
|
|
await fetchRuns(1, [], false, sortBy, sortOrder); // Fetch all runs without filters
|
2025-09-09 14:37:32 +05:30
|
|
|
|
setIsExecutingFilters(false);
|
2026-01-30 17:08:15 +05:30
|
|
|
|
}, [fetchRuns, updatePageInUrl, sortBy, sortOrder]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleSort = useCallback((field: string) => {
|
|
|
|
|
|
// Reset to first page when sort changes
|
|
|
|
|
|
setCurrentPage(1);
|
|
|
|
|
|
|
2026-02-06 09:01:06 +05:30
|
|
|
|
const newSortBy = field;
|
|
|
|
|
|
let newSortOrder: 'asc' | 'desc' = 'desc';
|
2026-01-30 17:08:15 +05:30
|
|
|
|
if (sortBy === field) {
|
2026-02-06 09:01:06 +05:30
|
|
|
|
newSortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
2026-01-30 17:08:15 +05:30
|
|
|
|
}
|
2026-02-06 09:01:06 +05:30
|
|
|
|
|
|
|
|
|
|
setSortBy(newSortBy);
|
|
|
|
|
|
setSortOrder(newSortOrder);
|
|
|
|
|
|
updatePageInUrl(1, appliedFilters, newSortBy, newSortOrder);
|
|
|
|
|
|
}, [sortBy, sortOrder, updatePageInUrl, appliedFilters]);
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* ----------------------------------------------------------------------------------
|
|
|
|
|
|
* Helpers
|
|
|
|
|
|
* ----------------------------------------------------------------------------------
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
|
|
|
|
|
|
|
2026-01-30 17:08:15 +05:30
|
|
|
|
const calculateDuration = (isCompleted: boolean, usageInfo?: Record<string, unknown>) => {
|
2025-09-09 14:37:32 +05:30
|
|
|
|
if (isCompleted && typeof usageInfo?.call_duration_seconds === 'number') {
|
|
|
|
|
|
return `${Number(usageInfo.call_duration_seconds).toFixed(2)}s`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return '-';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Wrapper around shared impersonation util – we only need to fetch the
|
|
|
|
|
|
* current superadmin token and then delegate the heavy lifting.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const impersonateAndMaybeRedirect = useCallback(
|
|
|
|
|
|
async (targetUserId: number | undefined, redirectPath?: string) => {
|
2026-02-18 13:16:49 +05:30
|
|
|
|
if (!targetUserId || !auth.isAuthenticated) return;
|
2025-09-09 14:37:32 +05:30
|
|
|
|
try {
|
2026-02-18 13:16:49 +05:30
|
|
|
|
const token = await auth.getAccessToken();
|
2025-09-09 14:37:32 +05:30
|
|
|
|
await impersonateAsSuperadmin({
|
2026-02-18 13:16:49 +05:30
|
|
|
|
accessToken: token,
|
2025-09-09 14:37:32 +05:30
|
|
|
|
userId: targetUserId,
|
|
|
|
|
|
redirectPath,
|
|
|
|
|
|
openInNewTab: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to impersonate user', err);
|
|
|
|
|
|
alert('Failed to impersonate the user. Please try again.');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-02-18 13:16:49 +05:30
|
|
|
|
[auth],
|
2025-09-09 14:37:32 +05:30
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (isLoading && runs.length === 0) {
|
|
|
|
|
|
return (
|
2025-10-07 17:40:01 +05:30
|
|
|
|
<div className="container mx-auto p-6 flex items-center justify-center min-h-[400px]">
|
2025-09-09 14:37:32 +05:30
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
|
<Loader2 className="h-6 w-6 animate-spin" />
|
|
|
|
|
|
<span>Loading workflow runs...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2025-10-07 17:40:01 +05:30
|
|
|
|
<div className="container mx-auto p-6 space-y-6 max-w-full">
|
|
|
|
|
|
<div>
|
2025-11-29 15:39:57 +05:30
|
|
|
|
<h1 className="text-3xl font-bold mb-2">Workflow Runs</h1>
|
|
|
|
|
|
<p className="text-muted-foreground">View and manage all workflow runs across organizations</p>
|
2025-10-07 17:40:01 +05:30
|
|
|
|
</div>
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
2025-10-07 17:40:01 +05:30
|
|
|
|
{error && (
|
2025-09-09 14:37:32 +05:30
|
|
|
|
<div className="mb-6 bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<FilterBuilder
|
|
|
|
|
|
availableAttributes={superadminFilterAttributes}
|
|
|
|
|
|
activeFilters={activeFilters}
|
|
|
|
|
|
onFiltersChange={handleFiltersChange}
|
|
|
|
|
|
onApplyFilters={handleApplyFilters}
|
|
|
|
|
|
onClearFilters={handleClearFilters}
|
|
|
|
|
|
isExecuting={isExecutingFilters}
|
|
|
|
|
|
autoRefresh={autoRefresh}
|
|
|
|
|
|
onAutoRefreshChange={setAutoRefresh}
|
2026-02-06 09:01:06 +05:30
|
|
|
|
hasAppliedFilters={appliedFilters.length > 0}
|
2025-09-09 14:37:32 +05:30
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<CardTitle>All Workflow Runs</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Showing {runs.length} of {totalCount} total runs
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{isAutoRefreshing && (
|
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
|
|
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
|
|
|
|
<span>Refreshing...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{runs.length === 0 ? (
|
2025-11-29 15:39:57 +05:30
|
|
|
|
<div className="text-center py-8 text-muted-foreground">
|
2025-09-09 14:37:32 +05:30
|
|
|
|
No workflow runs found.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2025-11-29 15:39:57 +05:30
|
|
|
|
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
|
2025-09-09 14:37:32 +05:30
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
2025-11-29 15:39:57 +05:30
|
|
|
|
<TableRow className="bg-muted">
|
2025-09-09 14:37:32 +05:30
|
|
|
|
<TableHead className="font-semibold">ID</TableHead>
|
|
|
|
|
|
<TableHead className="font-semibold">Workflow</TableHead>
|
|
|
|
|
|
<TableHead className="font-semibold">Status</TableHead>
|
|
|
|
|
|
<TableHead className="font-semibold">Disposition</TableHead>
|
|
|
|
|
|
<TableHead className="font-semibold">Tags</TableHead>
|
2026-01-30 17:08:15 +05:30
|
|
|
|
<TableHead
|
|
|
|
|
|
className="font-semibold cursor-pointer hover:bg-muted/50 select-none"
|
|
|
|
|
|
onClick={() => handleSort('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>
|
2026-03-09 15:55:20 +05:30
|
|
|
|
<TableHead className="font-semibold">Details</TableHead>
|
2026-01-30 17:08:15 +05:30
|
|
|
|
<TableHead
|
|
|
|
|
|
className="font-semibold cursor-pointer hover:bg-muted/50 select-none"
|
|
|
|
|
|
onClick={() => handleSort('created_at')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
Created At
|
|
|
|
|
|
{sortBy === 'created_at' ? (
|
|
|
|
|
|
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>
|
2025-09-09 14:37:32 +05:30
|
|
|
|
<TableHead className="font-semibold">Actions</TableHead>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
{runs.map((run) => (
|
|
|
|
|
|
<TableRow
|
|
|
|
|
|
key={run.id}
|
2026-01-30 17:08:15 +05:30
|
|
|
|
className={selectedRowId === run.id ? "bg-primary/20 ring-1 ring-primary/50" : ""}>
|
2025-09-09 14:37:32 +05:30
|
|
|
|
<TableCell className="font-mono text-sm">
|
|
|
|
|
|
#{run.id}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
<span className="font-medium text-sm">
|
|
|
|
|
|
{run.workflow_name ? (
|
|
|
|
|
|
run.workflow_name.length > 15
|
|
|
|
|
|
? `${run.workflow_name.substring(0, 15)}...`
|
|
|
|
|
|
: run.workflow_name
|
|
|
|
|
|
) : 'Unknown Workflow'}
|
|
|
|
|
|
</span>
|
2025-11-29 15:39:57 +05:30
|
|
|
|
<span className="text-xs text-muted-foreground font-mono">
|
2025-09-09 14:37:32 +05:30
|
|
|
|
ID: {String(run.workflow_id).length > 12
|
|
|
|
|
|
? `${String(run.workflow_id).substring(0, 12)}...`
|
|
|
|
|
|
: run.workflow_id}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-center">
|
|
|
|
|
|
{run.is_completed ? (
|
|
|
|
|
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
{run.gathered_context?.mapped_call_disposition ? (
|
2026-01-30 17:08:15 +05:30
|
|
|
|
<Badge variant="default">
|
2025-09-09 14:37:32 +05:30
|
|
|
|
{run.gathered_context.mapped_call_disposition as string}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-sm text-muted-foreground">-</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
{Array.isArray(run.gathered_context?.call_tags) && run.gathered_context.call_tags.length > 0 ? (
|
|
|
|
|
|
<div className="flex flex-wrap gap-1">
|
|
|
|
|
|
{run.gathered_context.call_tags.map((tag: string) => (
|
|
|
|
|
|
<Badge key={tag} variant="default">
|
|
|
|
|
|
{tag}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-sm text-muted-foreground">-</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-sm whitespace-pre-wrap break-words">
|
|
|
|
|
|
<span className={!run.is_completed ? "font-semibold text-blue-600" : ""}>
|
2026-01-30 17:08:15 +05:30
|
|
|
|
{calculateDuration(run.is_completed, run.usage_info)}
|
2025-09-09 14:37:32 +05:30
|
|
|
|
</span>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-sm">
|
|
|
|
|
|
<div className="flex items-center space-x-1">
|
2026-03-09 15:55:20 +05:30
|
|
|
|
{run.gathered_context && (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Info className="h-4 w-4 text-blue-500 cursor-pointer" />
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent sideOffset={4} className="max-w-sm whitespace-pre-wrap break-words">
|
|
|
|
|
|
<p className="font-semibold text-xs mb-1">Gathered Context</p>
|
|
|
|
|
|
<pre className="max-w-sm whitespace-pre-wrap break-words text-xs">
|
|
|
|
|
|
{JSON.stringify(run.gathered_context, null, 2)}
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{run.usage_info && (
|
2025-09-09 14:37:32 +05:30
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
2025-11-29 15:39:57 +05:30
|
|
|
|
<Info className="h-4 w-4 text-muted-foreground cursor-pointer" />
|
2025-09-09 14:37:32 +05:30
|
|
|
|
</TooltipTrigger>
|
2026-03-09 15:55:20 +05:30
|
|
|
|
<TooltipContent sideOffset={4} className="max-w-sm whitespace-pre-wrap break-words">
|
|
|
|
|
|
<p className="font-semibold text-xs mb-1">Usage Info</p>
|
|
|
|
|
|
<pre className="max-w-sm whitespace-pre-wrap break-words text-xs">
|
|
|
|
|
|
{JSON.stringify(run.usage_info, null, 2)}
|
2025-09-09 14:37:32 +05:30
|
|
|
|
</pre>
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
)}
|
2026-03-09 15:55:20 +05:30
|
|
|
|
{!run.gathered_context && !run.usage_info && (
|
|
|
|
|
|
<span className="text-muted-foreground">-</span>
|
|
|
|
|
|
)}
|
2025-09-09 14:37:32 +05:30
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-sm">
|
|
|
|
|
|
{formatDate(run.created_at)}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<div className="flex space-x-2">
|
2026-02-12 15:41:44 +05:30
|
|
|
|
<MediaPreviewButton
|
2025-09-09 14:37:32 +05:30
|
|
|
|
recordingUrl={run.recording_url}
|
|
|
|
|
|
transcriptUrl={run.transcript_url}
|
|
|
|
|
|
runId={run.id}
|
2026-02-12 15:41:44 +05:30
|
|
|
|
onOpenPreview={mediaPreview.openPreview}
|
2025-09-09 14:37:32 +05:30
|
|
|
|
onSelect={setSelectedRowId}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const query = encodeURIComponent(
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
children: [
|
|
|
|
|
|
{
|
|
|
|
|
|
field: 'extra.run_id',
|
|
|
|
|
|
op: '==',
|
|
|
|
|
|
value: run.id,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
field: '',
|
|
|
|
|
|
op: 'and',
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
window.open(
|
|
|
|
|
|
`https://app.axiom.co/dograh-of6c/stream/${process.env.NEXT_PUBLIC_AXIOM_LOG_DATASET}?q=${query}`,
|
|
|
|
|
|
'_blank',
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Image
|
|
|
|
|
|
src="/axiom_icon.svg"
|
|
|
|
|
|
alt="Traces"
|
|
|
|
|
|
width={16}
|
|
|
|
|
|
height={16}
|
|
|
|
|
|
className="h-4 w-4"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
onClick={() => {
|
2026-03-24 17:50:45 +05:30
|
|
|
|
if (run.gathered_context?.trace_url) {
|
|
|
|
|
|
window.open(String(run.gathered_context.trace_url), '_blank');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const filter = encodeURIComponent(
|
|
|
|
|
|
`metadata;stringObject;attributes;contains;conversation.id,metadata;stringObject;attributes;contains;${run.id}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
window.open(
|
|
|
|
|
|
`${process.env.NEXT_PUBLIC_LANGFUSE_ENDPOINT}/project/${process.env.NEXT_PUBLIC_LANGFUSE_PROJECT_ID}/traces?search=&filter=${filter}&dateRange=All+time`,
|
|
|
|
|
|
'_blank',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-09-09 14:37:32 +05:30
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Image
|
|
|
|
|
|
src="/langfuse_icon.svg"
|
|
|
|
|
|
alt="Langfuse Traces"
|
|
|
|
|
|
width={16}
|
|
|
|
|
|
height={16}
|
|
|
|
|
|
className="h-4 w-4"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Quick-link to open the workflow inside the *regular* app after
|
|
|
|
|
|
successfully impersonating the owner of the workflow. */}
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
title="Open workflow as user"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const appBaseUrl = window.location.origin.includes('superadmin.')
|
|
|
|
|
|
? window.location.origin.replace('superadmin.', 'app.')
|
|
|
|
|
|
: window.location.origin;
|
|
|
|
|
|
impersonateAndMaybeRedirect(
|
|
|
|
|
|
run.user_id,
|
|
|
|
|
|
`${appBaseUrl}/workflow/${run.workflow_id}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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">
|
2025-11-29 15:39:57 +05:30
|
|
|
|
<div className="text-sm text-muted-foreground">
|
2025-09-09 14:37:32 +05:30
|
|
|
|
Page {currentPage} of {totalPages} ({totalCount} total runs)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex space-x-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
|
|
|
|
disabled={currentPage === 1 || isLoading}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
|
|
|
|
Previous
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Page numbers */}
|
|
|
|
|
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
|
|
|
|
let pageNum;
|
|
|
|
|
|
if (totalPages <= 5) {
|
|
|
|
|
|
pageNum = i + 1;
|
|
|
|
|
|
} else if (currentPage <= 3) {
|
|
|
|
|
|
pageNum = i + 1;
|
|
|
|
|
|
} else if (currentPage >= totalPages - 2) {
|
|
|
|
|
|
pageNum = totalPages - 4 + i;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pageNum = currentPage - 2 + i;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
key={pageNum}
|
|
|
|
|
|
variant={currentPage === pageNum ? "default" : "outline"}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handlePageChange(pageNum)}
|
|
|
|
|
|
disabled={isLoading}
|
|
|
|
|
|
>
|
|
|
|
|
|
{pageNum}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
|
|
|
|
disabled={currentPage === totalPages || isLoading}
|
|
|
|
|
|
>
|
|
|
|
|
|
Next
|
|
|
|
|
|
<ChevronRight className="h-4 w-4 ml-1" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Media Preview Dialog */}
|
|
|
|
|
|
{mediaPreview.dialog}
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|