mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: add sorting feature by duration
This commit is contained in:
parent
10265715f9
commit
ae0dc812cd
12 changed files with 159 additions and 68 deletions
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})));
|
||||
})));
|
||||
|
|
@ -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
|
|
@ -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 & {});
|
||||
};
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue