dograh/ui/src/app/usage/page.tsx
2026-05-21 12:30:56 +05:30

680 lines
34 KiB
TypeScript

"use client";
import { ArrowDownLeft, ArrowUpRight, ChevronLeft, ChevronRight, Download, Globe, MessageSquare, Phone } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useId, useState } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
import { toast } from 'sonner';
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
import { DailyUsageTable } from '@/components/DailyUsageTable';
import { FilterBuilder } from '@/components/filters/FilterBuilder';
import { MediaPreviewButton, 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 { Progress } from '@/components/ui/progress';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useUserConfig } from '@/context/UserConfigContext';
import { useAuth } from '@/lib/auth';
import { usageFilterAttributes } from '@/lib/filterAttributes';
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
import { ActiveFilter, DateRangeValue } from '@/types/filters';
// Get local timezone
const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
// Collapse a run's `mode` (from WorkflowRunMode in api/enums.py) into a coarse
// channel. Telephony providers (twilio, plivo, telnyx, vonage, vobiz, cloudonix,
// ari, ...) are phone calls; webrtc/smallwebrtc are browser web calls; textchat
// is a text conversation. Anything unknown falls back to "phone".
const WEB_CALL_MODES = new Set(['webrtc', 'smallwebrtc']);
const TEXT_CHAT_MODES = new Set(['textchat']);
const getCallChannel = (mode?: string | null): 'phone' | 'web' | 'chat' => {
if (mode && TEXT_CHAT_MODES.has(mode)) return 'chat';
if (mode && WEB_CALL_MODES.has(mode)) return 'web';
return 'phone';
};
// Render the call's channel (mode) and direction (call_type) as two compact
// icons in a single cell, with a tooltip spelling out the full label. The
// channel icon shows medium/how (phone / web / chat); the colored arrow shows
// direction (inbound = incoming/emerald, outbound = outgoing/blue).
const CallTypeCell = ({ mode, callType }: { mode?: string | null; callType?: string | null }) => {
if (!mode && !callType) {
return <span className="text-sm text-muted-foreground">-</span>;
}
const channel = getCallChannel(mode);
const ChannelIcon = channel === 'chat' ? MessageSquare : channel === 'web' ? Globe : Phone;
const channelLabel = channel === 'chat' ? 'Text chat' : channel === 'web' ? 'Web call' : 'Phone call';
const isInbound = callType === 'inbound';
const DirectionIcon = isInbound ? ArrowDownLeft : ArrowUpRight;
const directionLabel = isInbound ? 'Inbound' : 'Outbound';
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1">
<ChannelIcon className="h-4 w-4 text-muted-foreground" />
<DirectionIcon
className={`h-3.5 w-3.5 ${isInbound ? 'text-emerald-600' : 'text-blue-600'}`}
/>
</span>
</TooltipTrigger>
<TooltipContent sideOffset={4}>
{directionLabel} · {channelLabel}
</TooltipContent>
</Tooltip>
);
};
export default function UsagePage() {
const router = useRouter();
const searchParams = useSearchParams();
const { userConfig, saveUserConfig, loading: userConfigLoading, organizationPricing } = useUserConfig();
const auth = useAuth();
// MPS credits state
const [mpsCredits, setMpsCredits] = useState<MpsCreditsResponse | null>(null);
const [isLoadingCredits, setIsLoadingCredits] = useState(true);
// Usage history state
const [usageHistory, setUsageHistory] = useState<UsageHistoryResponse | null>(null);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [currentPage, setCurrentPage] = useState(() => {
const pageParam = searchParams.get('page');
return pageParam ? parseInt(pageParam, 10) : 1;
});
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
const [isDownloadingReport, setIsDownloadingReport] = useState(false);
// Daily usage breakdown state (only for paid orgs)
const [dailyUsage, setDailyUsage] = useState<DailyUsageBreakdownResponse | null>(null);
const [isLoadingDaily, setIsLoadingDaily] = useState(false);
// Initialize filters from URL. `activeFilters` tracks the in-progress
// edits in the FilterBuilder; `appliedFilters` is what's actually been
// committed via Apply (and what drives fetching + the download button).
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
return decodeFiltersFromURL(searchParams, usageFilterAttributes);
});
const [appliedFilters, setAppliedFilters] = useState<ActiveFilter[]>(() => {
return decodeFiltersFromURL(searchParams, usageFilterAttributes);
});
// Media preview dialog
const mediaPreview = MediaPreviewDialog();
// Timezone state - initialize with empty string to avoid hydration mismatch
const localTimezone = getLocalTimezone();
const [selectedTimezone, setSelectedTimezone] = useState<ITimezoneOption | string>('');
const [savingTimezone, setSavingTimezone] = useState(false);
const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch
// Fetch MPS credits
const fetchMpsCredits = useCallback(async () => {
if (!auth.isAuthenticated) return;
try {
const response = await getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet();
if (response.data) {
setMpsCredits(response.data);
}
} catch (error) {
console.error('Failed to fetch MPS credits:', error);
} finally {
setIsLoadingCredits(false);
}
}, [auth.isAuthenticated]);
// Translate the FilterBuilder state into the query-param shape the
// backend expects. Shared between the listing fetch and the CSV export
// so they stay in lockstep.
const buildUsageQueryParams = (filters?: ActiveFilter[]) => {
let filterParam: string | undefined;
let startDate = '';
let endDate = '';
if (filters && filters.length > 0) {
const dateRangeFilter = filters.find(f => f.attribute.id === 'dateRange');
if (dateRangeFilter && dateRangeFilter.value) {
const dateValue = dateRangeFilter.value as DateRangeValue;
if (dateValue.from) startDate = dateValue.from.toISOString();
if (dateValue.to) endDate = dateValue.to.toISOString();
}
const otherFilters = filters.filter(f => f.attribute.id !== 'dateRange');
if (otherFilters.length > 0) {
const filterData = otherFilters.map(filter => ({
attribute: filter.attribute.id,
type: filter.attribute.type,
value: filter.value,
}));
filterParam = JSON.stringify(filterData);
}
}
return {
...(startDate && { start_date: startDate }),
...(endDate && { end_date: endDate }),
...(filterParam && { filters: filterParam }),
};
};
// Fetch usage history
const fetchUsageHistory = useCallback(async (page: number, filters?: ActiveFilter[]) => {
if (!auth.isAuthenticated) return;
setIsLoadingHistory(true);
try {
const response = await getUsageHistoryApiV1OrganizationsUsageRunsGet({
query: {
page,
limit: 50,
...buildUsageQueryParams(filters),
},
});
if (response.data) {
setUsageHistory(response.data);
}
} catch (error) {
console.error('Failed to fetch usage history:', error);
} finally {
setIsLoadingHistory(false);
}
}, [auth.isAuthenticated]);
// Fetch daily usage breakdown
const fetchDailyUsage = useCallback(async () => {
if (!auth.isAuthenticated || !organizationPricing?.price_per_second_usd) return;
setIsLoadingDaily(true);
try {
const response = await getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet({
query: { days: 7 },
});
if (response.data) {
setDailyUsage(response.data);
}
} catch (error) {
console.error('Failed to fetch daily usage:', error);
} finally {
setIsLoadingDaily(false);
}
}, [auth.isAuthenticated, organizationPricing]);
// Download a CSV of all runs matching the current filters.
const handleDownloadReport = async () => {
if (!auth.isAuthenticated) return;
setIsDownloadingReport(true);
try {
const response = await downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet({
query: buildUsageQueryParams(appliedFilters),
parseAs: 'blob',
});
if (response.data) {
const blob = response.data as Blob;
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'usage_runs_report.csv';
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} else {
toast.error('Failed to download report');
}
} catch (error) {
console.error('Failed to download usage report:', error);
toast.error('Failed to download report');
} finally {
setIsDownloadingReport(false);
}
};
// Handle timezone change
const handleTimezoneChange = async (timezone: ITimezoneOption | string) => {
setSelectedTimezone(timezone);
setSavingTimezone(true);
try {
const tzValue = typeof timezone === 'string' ? timezone : timezone.value;
await saveUserConfig({ timezone: tzValue });
} catch (error) {
console.error('Failed to save timezone:', error);
// Revert to previous timezone on error
const prevTz = userConfig?.timezone || localTimezone;
setSelectedTimezone(prevTz);
} finally {
setSavingTimezone(false);
}
};
// Update timezone when userConfig loads
useEffect(() => {
if (!userConfigLoading) {
// Config has loaded - set the timezone
if (userConfig?.timezone) {
setSelectedTimezone(userConfig.timezone);
} else {
// No saved timezone, use local
setSelectedTimezone(localTimezone);
}
}
}, [userConfig, userConfigLoading, localTimezone]);
// Initial load - fetch when auth becomes available
useEffect(() => {
if (auth.isAuthenticated) {
fetchMpsCredits();
fetchUsageHistory(currentPage, appliedFilters);
}
}, [auth.isAuthenticated, currentPage, appliedFilters, fetchUsageHistory, fetchMpsCredits]);
// Fetch daily usage when organizationPricing becomes available
useEffect(() => {
if (auth.isAuthenticated && organizationPricing?.price_per_second_usd) {
fetchDailyUsage();
}
}, [auth.isAuthenticated, organizationPricing, fetchDailyUsage]);
// Update URL with query parameters
const updateUrlParams = useCallback((params: { page?: number; filters?: ActiveFilter[] }) => {
const newParams = new URLSearchParams();
if (params.page !== undefined) {
newParams.set('page', params.page.toString());
}
// Add filters to URL if present
if (params.filters && params.filters.length > 0) {
const filterString = encodeFiltersToURL(params.filters);
if (filterString) {
const filterParams = new URLSearchParams(filterString);
filterParams.forEach((value, key) => newParams.set(key, value));
}
}
router.push(`/usage?${newParams.toString()}`);
}, [router]);
const handleApplyFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1); // Reset to first page when applying filters
setAppliedFilters(activeFilters);
updateUrlParams({ page: 1, filters: activeFilters });
await fetchUsageHistory(1, activeFilters);
setIsExecutingFilters(false);
}, [activeFilters, fetchUsageHistory, updateUrlParams]);
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
setActiveFilters(filters);
}, []);
const handleClearFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1);
setActiveFilters([]);
setAppliedFilters([]);
updateUrlParams({ page: 1, filters: [] }); // Clear filters from URL
await fetchUsageHistory(1, []); // Fetch all runs without filters
setIsExecutingFilters(false);
}, [fetchUsageHistory, updateUrlParams]);
// Handle page change
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
updateUrlParams({ page: newPage, filters: appliedFilters });
fetchUsageHistory(newPage, appliedFilters);
};
// Handle row click to navigate to workflow run
const handleRowClick = (run: WorkflowRunUsageResponse) => {
router.push(`/workflow/${run.workflow_id}/run/${run.id}`);
};
// Format datetime for display with timezone support
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
const tzValue = typeof selectedTimezone === 'string' ? selectedTimezone : selectedTimezone.value;
// Use local timezone if none selected (during loading)
const effectiveTz = tzValue || localTimezone;
return date.toLocaleString('en-US', {
timeZone: effectiveTz,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
});
};
// Format duration for display
const formatDuration = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes === 0) return `${remainingSeconds}s`;
if (remainingSeconds === 0) return `${minutes}m`;
return `${minutes}m ${remainingSeconds}s`;
};
return (
<div className="container mx-auto p-6 space-y-6">
<div>
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold mb-2">Agent Runs</h1>
<p className="text-muted-foreground">See all your Agent Runs across all Voice Agents. You can use filters to filter out required Agent Runs.</p>
</div>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<div className="w-[300px]">
<TimezoneSelect
instanceId={timezoneSelectId}
value={selectedTimezone}
onChange={handleTimezoneChange}
isDisabled={savingTimezone || userConfigLoading}
placeholder={userConfigLoading ? "Loading..." : "Select timezone"}
styles={{
control: (base, state) => ({
...base,
minHeight: '36px',
fontSize: '14px',
backgroundColor: 'var(--background)',
borderColor: state.isFocused ? 'var(--ring)' : 'var(--border)',
boxShadow: state.isFocused ? '0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent)' : 'none',
'&:hover': {
borderColor: 'var(--border)',
},
}),
menu: (base) => ({
...base,
zIndex: 9999,
backgroundColor: 'var(--popover)',
border: '1px solid var(--border)',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
}),
menuList: (base) => ({
...base,
backgroundColor: 'var(--popover)',
padding: 0,
}),
option: (base, state) => ({
...base,
backgroundColor: state.isSelected
? 'var(--accent)'
: state.isFocused
? 'var(--accent)'
: 'var(--popover)',
color: 'var(--foreground)',
cursor: 'pointer',
'&:active': {
backgroundColor: 'var(--accent)',
},
}),
singleValue: (base) => ({
...base,
color: 'var(--foreground)',
}),
input: (base) => ({
...base,
color: 'var(--foreground)',
}),
placeholder: (base) => ({
...base,
color: 'var(--muted-foreground)',
}),
indicatorSeparator: (base) => ({
...base,
backgroundColor: 'var(--border)',
}),
dropdownIndicator: (base) => ({
...base,
color: 'var(--muted-foreground)',
'&:hover': {
color: 'var(--foreground)',
},
}),
}}
/>
</div>
</div>
</div>
</div>
{/* MPS Credits Card */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Dograh Model Credits</CardTitle>
<CardDescription>
These track usage of Dograh models using Dograh Service Keys.
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingCredits ? (
<div className="animate-pulse space-y-4">
<div className="h-4 bg-muted rounded w-1/4"></div>
<div className="h-8 bg-muted rounded"></div>
<div className="h-4 bg-muted rounded w-1/3"></div>
</div>
) : mpsCredits ? (
<div className="space-y-4">
<div className="flex justify-between items-baseline">
<div>
<p className="text-2xl font-bold">
{mpsCredits.total_credits_used.toFixed(2)} <span className="text-lg font-normal text-muted-foreground">/ {mpsCredits.total_quota.toFixed(2)}</span>
</p>
<p className="text-sm text-muted-foreground">Credits Used</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{mpsCredits.remaining_credits.toFixed(2)}</p>
<p className="text-sm text-muted-foreground">Remaining</p>
</div>
</div>
{mpsCredits.total_quota > 0 && (
<Progress value={(mpsCredits.total_credits_used / mpsCredits.total_quota) * 100} className="h-3" />
)}
</div>
) : (
<p className="text-muted-foreground">No Dograh service keys configured. Set up a service key in your model configuration to see usage.</p>
)}
</CardContent>
</Card>
{/* Daily Usage Table - Only for paid organizations */}
{organizationPricing?.price_per_second_usd && (
<div className="mb-6">
<DailyUsageTable
data={dailyUsage}
isLoading={isLoadingDaily}
/>
</div>
)}
{/* Filter Builder */}
<div className="mb-6 space-y-3">
<FilterBuilder
availableAttributes={usageFilterAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecuting={isExecutingFilters}
/>
{appliedFilters.length > 0 && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleDownloadReport}
disabled={isDownloadingReport}
>
<Download className="h-4 w-4 mr-2" />
{isDownloadingReport ? 'Preparing...' : 'Download Filtered Results'}
</Button>
</div>
)}
</div>
{/* Usage History */}
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<div className="space-y-1.5">
<CardTitle>All Runs</CardTitle>
<CardDescription>
Every agent run across your organization, with usage details
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{isLoadingHistory ? (
<div className="animate-pulse space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-12 bg-muted rounded"></div>
))}
</div>
) : usageHistory && usageHistory.runs.length > 0 ? (
<>
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="font-semibold">Run ID</TableHead>
<TableHead className="font-semibold">Agent Name</TableHead>
<TableHead className="font-semibold">Call Type</TableHead>
<TableHead className="font-semibold">Phone Number</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Date</TableHead>
<TableHead className="font-semibold text-right">Duration</TableHead>
<TableHead className="font-semibold text-right">
{organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Tokens'}
</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usageHistory.runs.map((run) => (
<TableRow
key={run.id}
>
<TableCell
className="font-mono text-sm cursor-pointer hover:underline"
onClick={() => handleRowClick(run)}
>
#{run.id}
</TableCell>
<TableCell>{run.workflow_name || 'Unknown'}</TableCell>
<TableCell>
<CallTypeCell mode={run.mode} callType={run.call_type} />
</TableCell>
<TableCell className="text-sm">
{(run.call_type === 'inbound'
? run.caller_number
: run.called_number) || '-'}
</TableCell>
<TableCell>
{run.disposition ? (
<Badge variant="default">
{run.disposition}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>{formatDateTime(run.created_at)}</TableCell>
<TableCell className="text-right">
{formatDuration(run.call_duration_seconds)}
</TableCell>
<TableCell className="text-right font-medium">
{organizationPricing?.price_per_second_usd && run.charge_usd !== undefined && run.charge_usd !== null
? `$${run.charge_usd.toFixed(2)}`
: run.dograh_token_usage.toLocaleString()
}
</TableCell>
<TableCell>
<MediaPreviewButton
recordingUrl={run.recording_url}
transcriptUrl={run.transcript_url}
runId={run.id}
onOpenPreview={mediaPreview.openPreview}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Summary */}
{appliedFilters.length > 0 && (
<div className="mt-4 p-3 bg-muted rounded-md">
<p className="text-sm text-muted-foreground">
Total for filtered period: <span className="font-semibold text-foreground">
{usageHistory.total_dograh_tokens.toLocaleString()} Dograh Tokens
</span>
{' • '}
<span className="font-semibold text-foreground">
{formatDuration(usageHistory.total_duration_seconds)}
</span>
</p>
</div>
)}
{/* Pagination */}
{usageHistory.total_pages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-muted-foreground">
Page {usageHistory.page} of {usageHistory.total_pages} ({usageHistory.total_count} total runs)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === usageHistory.total_pages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
) : (
<p className="text-center py-8 text-muted-foreground">No runs found</p>
)}
</CardContent>
</Card>
{/* Media Preview Dialog */}
{mediaPreview.dialog}
</div>
);
}