mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-19 08:28:10 +02:00
651 lines
32 KiB
TypeScript
651 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronLeft, ChevronRight, Download, Globe } 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, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen';
|
|
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
|
import { CallTypeCell } from '@/components/CallTypeCell';
|
|
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 { 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;
|
|
|
|
export default function UsagePage() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { 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 [preferences, setPreferences] = useState<OrganizationPreferences>({});
|
|
const [preferencesLoading, setPreferencesLoading] = useState(true);
|
|
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]);
|
|
|
|
const fetchPreferences = useCallback(async () => {
|
|
if (!auth.isAuthenticated) return;
|
|
|
|
setPreferencesLoading(true);
|
|
try {
|
|
const response = await getPreferencesApiV1OrganizationsPreferencesGet();
|
|
const nextPreferences = response.data || {};
|
|
setPreferences(nextPreferences);
|
|
setSelectedTimezone(nextPreferences.timezone || localTimezone);
|
|
} catch (error) {
|
|
console.error('Failed to fetch organization preferences:', error);
|
|
setSelectedTimezone(localTimezone);
|
|
} finally {
|
|
setPreferencesLoading(false);
|
|
}
|
|
}, [auth.isAuthenticated, localTimezone]);
|
|
|
|
// 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);
|
|
const previousTimezone = preferences.timezone || localTimezone;
|
|
try {
|
|
const tzValue = typeof timezone === 'string' ? timezone : timezone.value;
|
|
const response = await savePreferencesApiV1OrganizationsPreferencesPut({
|
|
body: {
|
|
...preferences,
|
|
timezone: tzValue,
|
|
},
|
|
});
|
|
if (response.error) {
|
|
throw new Error('Failed to save timezone');
|
|
}
|
|
setPreferences(response.data || { ...preferences, timezone: tzValue });
|
|
} catch (error) {
|
|
console.error('Failed to save timezone:', error);
|
|
setSelectedTimezone(previousTimezone);
|
|
} finally {
|
|
setSavingTimezone(false);
|
|
}
|
|
};
|
|
|
|
// Update timezone when organization preferences load.
|
|
useEffect(() => {
|
|
fetchPreferences();
|
|
}, [fetchPreferences]);
|
|
|
|
// 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 || preferencesLoading}
|
|
placeholder={preferencesLoading ? "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>
|
|
);
|
|
}
|