feat: add sorting feature by duration

This commit is contained in:
Abhishek Kumar 2026-01-30 16:28:19 +05:30
parent 10265715f9
commit ae0dc812cd
12 changed files with 159 additions and 68 deletions

View file

@ -2,7 +2,7 @@ import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import func
from sqlalchemy import Float, func
from sqlalchemy.future import select
from sqlalchemy.orm import joinedload, selectinload
@ -103,10 +103,16 @@ class WorkflowRunClient(BaseDBClient):
limit: int = 50,
offset: int = 0,
filters: Optional[List[Dict[str, Any]]] = None,
sort_by: Optional[str] = None,
sort_order: str = "desc",
) -> tuple[list[dict], int]:
"""
Get paginated workflow runs for superadmin with organization information.
Returns tuple of (workflow_runs, total_count).
Args:
sort_by: Field to sort by ('duration', 'created_at', etc.)
sort_order: 'asc' or 'desc'
"""
async with self.async_session() as session:
# Build base query with joins
@ -128,6 +134,20 @@ 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
result = await session.execute(
base_query.options(
@ -138,7 +158,7 @@ class WorkflowRunClient(BaseDBClient):
.joinedload(WorkflowModel.user)
.joinedload(UserModel.selected_organization),
)
.order_by(WorkflowRunModel.created_at.desc())
.order_by(order_clause)
.limit(limit)
.offset(offset)
)

View file

@ -105,6 +105,8 @@ async def get_workflow_runs(
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
limit: int = Query(50, ge=1, le=100, description="Number of items per page"),
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
sort_by: Optional[str] = Query(None, description="Field to sort by (e.g., 'duration', 'created_at')"),
sort_order: Optional[str] = Query("desc", description="Sort order ('asc' or 'desc')"),
user: UserModel = Depends(get_superuser),
) -> SuperuserWorkflowRunsListResponse:
"""
@ -124,8 +126,12 @@ async def get_workflow_runs(
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid filter format")
# Validate sort_order
if sort_order not in ("asc", "desc"):
sort_order = "desc"
workflow_runs, total_count = await db_client.get_workflow_runs_for_superadmin(
limit=limit, offset=offset, filters=filter_criteria
limit=limit, offset=offset, filters=filter_criteria, sort_by=sort_by, sort_order=sort_order
)
total_pages = (total_count + limit - 1) // limit # Ceiling division

View file

@ -1,6 +1,6 @@
"use client";
import { AlertTriangle, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, MessageSquare, RefreshCw } from 'lucide-react';
import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, MessageSquare, RefreshCw } from 'lucide-react';
import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from "react";
@ -31,7 +31,6 @@ import {
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useUserConfig } from '@/context/UserConfigContext';
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
import{ superadminFilterAttributes } from "@/lib/filterAttributes";
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
import { impersonateAsSuperadmin } from '@/lib/utils';
@ -93,6 +92,10 @@ export default function RunsPage() {
return decodeFiltersFromURL(searchParams, superadminFilterAttributes);
});
// Sort state
const [sortBy, setSortBy] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Dialog state for comment editing
const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false);
const [commentRunId, setCommentRunId] = useState<number | null>(null);
@ -104,7 +107,13 @@ export default function RunsPage() {
// Media preview dialog
const mediaPreview = MediaPreviewDialog({ accessToken });
const fetchRuns = useCallback(async (page: number, filters?: ActiveFilter[], isAutoRefresh = false) => {
const fetchRuns = useCallback(async (
page: number,
filters?: ActiveFilter[],
isAutoRefresh = false,
sortByParam?: string | null,
sortOrderParam?: 'asc' | 'desc'
) => {
if (!accessToken) return;
// Don't show loading state for auto-refresh to prevent UI flicker
@ -130,7 +139,9 @@ export default function RunsPage() {
query: {
page,
limit,
...(filterParam && { filters: filterParam })
...(filterParam && { filters: filterParam }),
...(sortByParam && { sort_by: sortByParam }),
...(sortOrderParam && { sort_order: sortOrderParam }),
},
headers: {
'Authorization': `Bearer ${accessToken}`,
@ -173,11 +184,11 @@ export default function RunsPage() {
}, [router]);
useEffect(() => {
// Fetch runs when token is available and when page changes
// Fetch runs when token is available and when page/sort changes
if (accessToken) {
fetchRuns(currentPage, appliedFilters);
fetchRuns(currentPage, appliedFilters, false, sortBy, sortOrder);
}
}, [currentPage, accessToken, appliedFilters, fetchRuns]);
}, [currentPage, accessToken, appliedFilters, fetchRuns, sortBy, sortOrder]);
// Auto-refresh every 5 seconds when enabled and filters are active
useEffect(() => {
@ -188,17 +199,17 @@ export default function RunsPage() {
const intervalId = setInterval(() => {
// Pass true to indicate this is an auto-refresh
fetchRuns(currentPage, appliedFilters, true);
fetchRuns(currentPage, appliedFilters, true, sortBy, sortOrder);
}, 5000);
// Cleanup interval on unmount or when dependencies change
return () => clearInterval(intervalId);
}, [currentPage, appliedFilters, fetchRuns, autoRefresh]);
}, [currentPage, appliedFilters, fetchRuns, autoRefresh, sortBy, sortOrder]);
const handlePageChange = (page: number) => {
setCurrentPage(page);
updatePageInUrl(page, appliedFilters);
fetchRuns(page, appliedFilters);
fetchRuns(page, appliedFilters, false, sortBy, sortOrder);
};
const handleApplyFilters = useCallback(async () => {
@ -206,9 +217,9 @@ export default function RunsPage() {
setCurrentPage(1); // Reset to first page when applying filters
setAppliedFilters(activeFilters); // Update applied filters
updatePageInUrl(1, activeFilters);
await fetchRuns(1, activeFilters);
await fetchRuns(1, activeFilters, false, sortBy, sortOrder);
setIsExecutingFilters(false);
}, [activeFilters, fetchRuns, updatePageInUrl]);
}, [activeFilters, fetchRuns, updatePageInUrl, sortBy, sortOrder]);
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
setActiveFilters(filters);
@ -219,9 +230,23 @@ export default function RunsPage() {
setCurrentPage(1);
setAppliedFilters([]); // Clear applied filters
updatePageInUrl(1, []); // Clear filters from URL
await fetchRuns(1, []); // Fetch all runs without filters
await fetchRuns(1, [], false, sortBy, sortOrder); // Fetch all runs without filters
setIsExecutingFilters(false);
}, [fetchRuns, updatePageInUrl]);
}, [fetchRuns, updatePageInUrl, sortBy, sortOrder]);
const handleSort = useCallback((field: string) => {
// Reset to first page when sort changes
setCurrentPage(1);
if (sortBy === field) {
// Toggle order if same field
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
// New field, default to desc
setSortBy(field);
setSortOrder('desc');
}
}, [sortBy]);
// Save comment function declared outside JSX (requirement #2)
const saveAdminComment = useCallback(async () => {
@ -356,9 +381,33 @@ export default function RunsPage() {
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Tags</TableHead>
<TableHead className="font-semibold">Comment</TableHead>
<TableHead className="font-semibold">Duration</TableHead>
<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>
<TableHead className="font-semibold">Dograh Token</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<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>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
@ -366,7 +415,7 @@ export default function RunsPage() {
{runs.map((run) => (
<TableRow
key={run.id}
className={selectedRowId === run.id ? "bg-blue-50" : ""}>
className={selectedRowId === run.id ? "bg-primary/20 ring-1 ring-primary/50" : ""}>
<TableCell className="font-mono text-sm">
#{run.id}
</TableCell>
@ -395,7 +444,7 @@ export default function RunsPage() {
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
<Badge variant="default">
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (

View file

@ -23,7 +23,6 @@ import {
TableRow,
} from '@/components/ui/table';
import { useUserConfig } from '@/context/UserConfigContext';
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
import { usageFilterAttributes } from '@/lib/filterAttributes';
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
import { ActiveFilter, DateRangeValue } from '@/types/filters';
@ -534,7 +533,7 @@ export default function UsagePage() {
</TableCell>
<TableCell>
{run.disposition ? (
<Badge variant={getDispositionBadgeVariant(run.disposition)}>
<Badge variant="default">
{run.disposition}
</Badge>
) : (

View file

@ -20,7 +20,6 @@ import {
} from "@/components/ui/table";
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
import { useUserConfig } from '@/context/UserConfigContext';
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
import { downloadFile } from "@/lib/files";
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
@ -249,7 +248,7 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
<Badge variant="default">
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (

View file

@ -1,9 +1,8 @@
// This file is auto-generated by @hey-api/openapi-ts
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';
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-fetch';
import { createClientConfig } from '../lib/apiClient';
/**
* The `createClientConfig()` function will be called on client initialization
@ -17,4 +16,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

@ -1494,6 +1494,27 @@ export type HandleInboundFallbackApiV1TelephonyInboundFallbackPostResponses = {
200: unknown;
};
export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/telephony/cloudonix/cdr';
};
export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostErrors = {
/**
* Not found
*/
404: unknown;
};
export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type ImpersonateApiV1SuperuserImpersonatePostData = {
body: ImpersonateRequest;
headers?: {
@ -1547,6 +1568,14 @@ export type GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData = {
* JSON-encoded filter criteria
*/
filters?: string | null;
/**
* Field to sort by (e.g., 'duration', 'created_at')
*/
sort_by?: string | null;
/**
* Sort order ('asc' or 'desc')
*/
sort_order?: string | null;
};
url: '/api/v1/superuser/workflow-runs';
};
@ -4662,4 +4691,4 @@ export type HealthApiV1HealthGetResponse = HealthApiV1HealthGetResponses[keyof H
export type ClientOptions = {
baseUrl: 'http://127.0.0.1:8000' | (string & {});
};
};

View file

@ -24,8 +24,9 @@ const AppLayout: React.FC<AppLayoutProps> = ({
// Hide sidebar for root (/) and /handler routes (Stack Auth routes)
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler");
// Check if we're in workflow editor mode - collapse sidebar by default
// Check if we're in workflow editor mode or superadmin runs - collapse sidebar by default
const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname);
const isSuperadmin = pathname.startsWith("/superadmin");
// If no sidebar needed, just return children
if (!shouldShowSidebar) {
@ -33,7 +34,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
}
return (
<SidebarProvider defaultOpen={!isWorkflowEditor}>
<SidebarProvider defaultOpen={!isWorkflowEditor && !isSuperadmin}>
<div className="flex min-h-screen w-full">
<AppSidebar />
<SidebarInset className="flex-1">

View file

@ -3,23 +3,13 @@
* Update this array when adding new disposition codes
*/
export const DISPOSITION_CODES = [
'CALLBK',
'end_call_tool',
'user_hangup',
'call_duration_exceeded',
'DAIR',
'DNC',
'HU',
'LB',
'ND',
'NIBP',
'NQ',
'user_idle_max_duration_exceeded',
'system_connect_error',
'unknown',
'user_disqualified',
'user_idle_max_duration_exceeded',
'VM',
'voicemail_detected',
'WN',
'XFER',
'voicemail_detected'
] as const;
export type DispositionCode = typeof DISPOSITION_CODES[number];

View file

@ -1,17 +0,0 @@
// Color variants for disposition code
export const getDispositionBadgeVariant = (code: string | undefined): "default" | "secondary" | "destructive" | "outline" | "success" => {
if (!code) return "outline";
const upperCode = code.toUpperCase();
switch (upperCode) {
case "XFER":
return "success"; // Green color for transfers
case "HU":
case "NIBP":
return "destructive"; // Red color for hang up and NIBP
case "VM":
return "secondary";
default:
return "default"; // Default color for all other codes
}
};