feat: update dashboard page and dashboard flow component

(cherry picked from commit 4bacc346d95d41bd89af4a27fffe566dbf1d8d4b)
This commit is contained in:
Sergey Kozyrenko
2026-04-21 07:24:23 +07:00
parent ed222648b7
commit 4e55e30829
15 changed files with 572 additions and 431 deletions
+29
View File
@@ -32,6 +32,35 @@
rel="manifest"
href="/favicon/site.webmanifest"
/>
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/inter-regular.woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/inter-500.woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/inter-600.woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/inter-700.woff2"
crossorigin="anonymous"
/>
</head>
<body class="bg-background font-sans antialiased">
@@ -0,0 +1,46 @@
import type { ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import { ResponsiveContainer } from 'recharts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export const ChartCard = ({
children,
className,
description,
height = 300,
loading,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
height?: number;
loading?: boolean;
title: ReactNode;
}) => (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
{loading ? (
<div
className="flex items-center justify-center"
style={{ height }}
>
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : (
<ResponsiveContainer
height={height}
width="100%"
>
{children}
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
@@ -0,0 +1,48 @@
import { formatNumber } from '@/lib/utils/format';
export type ChartTooltipPayloadEntry = {
color: string;
name: string;
value: number;
};
export const ChartTooltip = ({
active,
formatter,
label,
labelFormatter,
payload,
}: {
active?: boolean;
formatter?: (value: number, name: string) => string;
label?: string;
labelFormatter?: (label: string) => string;
payload?: Array<ChartTooltipPayloadEntry>;
}) => {
if (!active || !payload?.length) {
return null;
}
const renderedLabel = label ? (labelFormatter ? labelFormatter(label) : label) : '';
return (
<div className="bg-popover text-popover-foreground rounded-lg border px-3 py-2 shadow-md">
{renderedLabel && <p className="text-muted-foreground mb-1 text-xs">{renderedLabel}</p>}
{payload.map((entry) => (
<div
className="flex items-center gap-2 text-sm"
key={entry.name}
>
<span
className="size-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">
{formatter ? formatter(entry.value, entry.name) : formatNumber(entry.value)}
</span>
</div>
))}
</div>
);
};
@@ -0,0 +1,3 @@
export { ChartCard } from './chart-card';
export { ChartTooltip, type ChartTooltipPayloadEntry } from './chart-tooltip';
export { MetricCard } from './metric-card';
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export const MetricCard = ({
className,
description,
icon,
loading,
title,
value,
}: {
className?: string;
description?: ReactNode;
icon?: ReactNode;
loading?: boolean;
title: ReactNode;
value: ReactNode;
}) => (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{loading ? <Skeleton className="h-5 w-24" /> : title}</CardTitle>
{loading ? <Skeleton className="size-4 shrink-0 rounded" /> : icon}
</CardHeader>
<CardContent className="flex flex-col gap-1">
{loading ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{value}</div>}
{description &&
(loading ? (
<Skeleton className="mt-1 h-3 w-32" />
) : (
<p className="text-muted-foreground text-xs">{description}</p>
))}
</CardContent>
</Card>
);
@@ -0,0 +1,24 @@
import { FlowStatusIcon } from '@/components/icons/flow-status-icon';
import { Badge } from '@/components/ui/badge';
import { StatusType } from '@/graphql/types';
const STATUS_LABELS: Record<StatusType, string> = {
[StatusType.Created]: 'Created',
[StatusType.Failed]: 'Failed',
[StatusType.Finished]: 'Finished',
[StatusType.Running]: 'Running',
[StatusType.Waiting]: 'Waiting',
};
export const FlowStatusBadge = ({ className, status }: { className?: string; status: StatusType }) => (
<Badge
className={className}
variant="outline"
>
<FlowStatusIcon
className="size-3"
status={status}
/>
{STATUS_LABELS[status]}
</Badge>
);
+9
View File
@@ -11,10 +11,17 @@ const badgeVariants = cva(
},
variants: {
variant: {
blue: 'border-blue-500/20 bg-blue-500/10 text-blue-600',
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
green: 'border-green-500/20 bg-green-500/10 text-green-600',
orange: 'border-orange-500/20 bg-orange-500/10 text-orange-600',
outline: 'text-foreground',
pink: 'border-pink-500/20 bg-pink-500/10 text-pink-600',
purple: 'border-purple-500/20 bg-purple-500/10 text-purple-600',
red: 'border-red-500/20 bg-red-500/10 text-red-600',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
yellow: 'border-yellow-500/20 bg-yellow-500/10 text-yellow-600',
},
},
},
@@ -22,6 +29,8 @@ const badgeVariants = cva(
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
export type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>;
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div
@@ -3,8 +3,9 @@ import { useMemo } from 'react';
import type { UsageStatsFragmentFragment } from '@/graphql/types';
import { MetricCard } from '@/components/dashboard';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
useFlowStatsByFlowQuery,
@@ -13,32 +14,7 @@ import {
useUsageStatsByAgentTypeForFlowQuery,
useUsageStatsByFlowQuery,
} from '@/graphql/types';
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/pages/dashboard/format-utils';
const StatCard = ({
description,
icon,
loading,
title,
value,
}: {
description: string;
icon: React.ReactNode;
loading: boolean;
title: string;
value: string;
}) => (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
{loading ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{value}</div>}
<p className="text-muted-foreground text-xs">{description}</p>
</CardContent>
</Card>
);
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/lib/utils/format';
const UsageStatsRow = ({ label, stats }: { label: string; stats: UsageStatsFragmentFragment }) => (
<TableRow>
@@ -126,33 +102,33 @@ export const FlowDashboardOverview = ({ flowId }: { flowId: string }) => {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
description="LLM spending for this flow"
icon={<CircleDollarSign className="text-muted-foreground size-4" />}
<MetricCard
description={`Subtasks: ${flowStats?.totalSubtasksCount ?? 0} · Assistants: ${flowStats?.totalAssistantsCount ?? 0}`}
icon={<GitFork className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Cost"
value={formatCost(totalCost)}
title="Tasks"
value={flowStats ? formatNumber(flowStats.totalTasksCount) : '0'}
/>
<StatCard
description="Input + Output tokens"
icon={<Cpu className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Tokens"
value={formatTokenCount(totalTokens)}
/>
<StatCard
<MetricCard
description={`Duration: ${toolcalls ? formatDuration(toolcalls.totalDurationSeconds) : '—'}`}
icon={<Activity className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Tool Calls"
value={toolcalls ? formatNumber(toolcalls.totalCount) : '0'}
/>
<StatCard
description={`Subtasks: ${flowStats?.totalSubtasksCount ?? 0} · Assistants: ${flowStats?.totalAssistantsCount ?? 0}`}
icon={<GitFork className="text-muted-foreground size-4" />}
<MetricCard
description="Input + Output tokens"
icon={<Cpu className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Tasks"
value={flowStats ? formatNumber(flowStats.totalTasksCount) : '0'}
title="Tokens"
value={formatTokenCount(totalTokens)}
/>
<MetricCard
description="LLM spending for this flow"
icon={<CircleDollarSign className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Cost"
value={formatCost(totalCost)}
/>
</div>
@@ -219,15 +195,9 @@ export const FlowDashboardOverview = ({ flowId }: { flowId: string }) => {
<TableRow key={item.functionName}>
<TableCell className="font-medium">{item.functionName}</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
item.isAgent
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
}`}
>
<Badge variant={item.isAgent ? 'secondary' : 'outline'}>
{item.isAgent ? 'Agent' : 'Tool'}
</span>
</Badge>
</TableCell>
<TableCell className="text-right">
{formatNumber(item.totalCount)}
+5 -1
View File
@@ -1,6 +1,6 @@
const STORAGE_KEY_SEPARATOR = '_4_';
export type LocalStorageKeyType = 'column' | 'page' | 'sorting';
export type LocalStorageKeyType = 'column' | 'page' | 'period' | 'sorting';
export function getColumnStorageKey(urlPath?: string): string {
return getStorageKey('column', urlPath);
@@ -10,6 +10,10 @@ export function getPageStorageKey(urlPath?: string): string {
return getStorageKey('page', urlPath);
}
export function getPeriodStorageKey(urlPath?: string): string {
return getStorageKey('period', urlPath);
}
export function getSortingStorageKey(urlPath?: string): string {
return getStorageKey('sorting', urlPath);
}
+56
View File
@@ -18,3 +18,59 @@ export const formatDate = (date: Date) => {
return format(date, 'HH:mm, d MMM yyyy', { locale: enUS });
};
export const formatNumber = (value: number): string => new Intl.NumberFormat('en-US').format(value);
export const formatTokenCount = (count: number): string => {
if (count >= 1_000_000_000) {
return `${(count / 1_000_000_000).toFixed(1)}B`;
}
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return count.toString();
};
export const formatCost = (cost: number): string => {
if (!cost) {
return '$0';
}
if (cost >= 1) {
return `$${cost.toFixed(2)}`;
}
if (cost >= 0.01) {
return `$${cost.toFixed(3)}`;
}
return `$${cost.toFixed(4)}`;
};
export const formatDuration = (seconds: number): string => {
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
}
if (seconds >= 1) {
return `${seconds.toFixed(1)}s`;
}
return `${(seconds * 1000).toFixed(0)}ms`;
};
@@ -1,19 +1,23 @@
import { format } from 'date-fns';
import { ChevronRight, Clock, Loader2, Wrench } from 'lucide-react';
import { useState } from 'react';
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { useMemo, useState } from 'react';
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
import type { UsageStatsPeriod } from '@/graphql/types';
import type { FlowFragmentFragment, UsageStatsPeriod } from '@/graphql/types';
import { ChartCard, ChartTooltip } from '@/components/dashboard';
import { FlowStatusBadge } from '@/components/icons/flow-status-badge';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
useFlowsExecutionStatsByPeriodQuery,
useFlowsQuery,
useFlowsStatsByPeriodQuery,
useToolcallsStatsByPeriodQuery,
useUsageStatsByPeriodQuery,
} from '@/graphql/types';
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/pages/dashboard/format-utils';
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/lib/utils/format';
const CHART_COLORS = {
area1: 'var(--color-chart-1)',
@@ -31,48 +35,7 @@ const formatDateLabel = (dateString: string): string => {
}
};
const ChartLoading = () => (
<div className="flex h-[300px] items-center justify-center">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
);
const CustomTooltip = ({
active,
formatter,
label,
payload,
}: {
active?: boolean;
formatter?: (value: number, name: string) => string;
label?: string;
payload?: Array<{ color: string; name: string; value: number }>;
}) => {
if (!active || !payload?.length) {
return null;
}
return (
<div className="bg-popover text-popover-foreground rounded-lg border px-3 py-2 shadow-md">
<p className="text-muted-foreground mb-1 text-xs">{label ? formatDateLabel(label) : ''}</p>
{payload.map((entry) => (
<div
className="flex items-center gap-2 text-sm"
key={entry.name}
>
<span
className="size-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">
{formatter ? formatter(entry.value, entry.name) : formatNumber(entry.value)}
</span>
</div>
))}
</div>
);
};
const axisTickStyle = { fill: 'var(--color-muted-foreground)', fontSize: 12 };
export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) => {
const { data: usageByPeriodData, loading: usageByPeriodLoading } = useUsageStatsByPeriodQuery({
@@ -87,12 +50,22 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) =>
const { data: executionStatsData, loading: executionStatsLoading } = useFlowsExecutionStatsByPeriodQuery({
variables: { period },
});
const { data: flowsData } = useFlowsQuery();
const flowsById = useMemo(() => {
const map = new Map<string, FlowFragmentFragment>();
(flowsData?.flows ?? []).forEach((flow) => {
map.set(flow.id, flow);
});
return map;
}, [flowsData?.flows]);
const usageChartData = [...(usageByPeriodData?.usageStatsByPeriod ?? [])].reverse().map((item) => ({
cacheIn: item.stats.totalUsageCacheIn,
costIn: item.stats.totalUsageCostIn,
costOut: item.stats.totalUsageCostOut,
date: formatDateLabel(item.date),
date: item.date,
tokensIn: item.stats.totalUsageIn,
tokensOut: item.stats.totalUsageOut,
totalCost: item.stats.totalUsageCostIn + item.stats.totalUsageCostOut,
@@ -100,13 +73,13 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) =>
const toolcallsChartData = [...(toolcallsByPeriodData?.toolcallsStatsByPeriod ?? [])].reverse().map((item) => ({
count: item.stats.totalCount,
date: formatDateLabel(item.date),
date: item.date,
duration: item.stats.totalDurationSeconds,
}));
const flowsChartData = [...(flowsByPeriodData?.flowsStatsByPeriod ?? [])].reverse().map((item) => ({
assistants: item.stats.totalAssistantsCount,
date: formatDateLabel(item.date),
date: item.date,
flows: item.stats.totalFlowsCount,
subtasks: item.stats.totalSubtasksCount,
tasks: item.stats.totalTasksCount,
@@ -116,210 +89,184 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) =>
return (
<div className="flex flex-col gap-6">
<ChartCard
description="Flows, tasks, and subtasks created per day"
height={320}
loading={flowsByPeriodLoading}
title="Flows Activity Over Time"
>
<BarChart data={flowsChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={axisTickStyle}
tickFormatter={formatDateLabel}
tickMargin={8}
/>
<YAxis
tick={axisTickStyle}
tickMargin={8}
/>
<Tooltip
content={<ChartTooltip labelFormatter={formatDateLabel} />}
cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }}
/>
<Bar
dataKey="flows"
fill={CHART_COLORS.area1}
name="Flows"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="tasks"
fill={CHART_COLORS.area2}
name="Tasks"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="subtasks"
fill={CHART_COLORS.area3}
name="Subtasks"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartCard>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Token Usage Over Time</CardTitle>
<CardDescription>Input and output tokens processed daily</CardDescription>
</CardHeader>
<CardContent>
{usageByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<AreaChart data={usageChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickFormatter={formatTokenCount}
tickMargin={8}
/>
<Tooltip
content={<CustomTooltip formatter={(value) => formatTokenCount(value)} />}
/>
<Area
dataKey="tokensIn"
fill={CHART_COLORS.area1}
fillOpacity={0.3}
name="Tokens In"
stroke={CHART_COLORS.area1}
type="monotone"
/>
<Area
dataKey="tokensOut"
fill={CHART_COLORS.area2}
fillOpacity={0.3}
name="Tokens Out"
stroke={CHART_COLORS.area2}
type="monotone"
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<ChartCard
description="Number of tool executions per day"
loading={toolcallsByPeriodLoading}
title="Tool Calls Over Time"
>
<BarChart data={toolcallsChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={axisTickStyle}
tickFormatter={formatDateLabel}
tickMargin={8}
/>
<YAxis
tick={axisTickStyle}
tickMargin={8}
/>
<Tooltip
content={<ChartTooltip labelFormatter={formatDateLabel} />}
cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }}
/>
<Bar
dataKey="count"
fill={CHART_COLORS.bar1}
name="Tool Calls"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartCard>
<Card>
<CardHeader>
<CardTitle>Cost Over Time</CardTitle>
<CardDescription>LLM spending per day</CardDescription>
</CardHeader>
<CardContent>
{usageByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<AreaChart data={usageChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickFormatter={(value) => formatCost(value)}
tickMargin={8}
/>
<Tooltip content={<CustomTooltip formatter={(value) => formatCost(value)} />} />
<Area
dataKey="costIn"
fill={CHART_COLORS.area1}
fillOpacity={0.3}
name="Cost In"
stroke={CHART_COLORS.area1}
type="monotone"
/>
<Area
dataKey="costOut"
fill={CHART_COLORS.area3}
fillOpacity={0.3}
name="Cost Out"
stroke={CHART_COLORS.area3}
type="monotone"
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tool Calls Over Time</CardTitle>
<CardDescription>Number of tool executions per day</CardDescription>
</CardHeader>
<CardContent>
{toolcallsByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<BarChart data={toolcallsChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }}
/>
<Bar
dataKey="count"
fill={CHART_COLORS.bar1}
name="Tool Calls"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Flows Activity Over Time</CardTitle>
<CardDescription>Flows, tasks, and subtasks created per day</CardDescription>
</CardHeader>
<CardContent>
{flowsByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<BarChart data={flowsChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }}
/>
<Bar
dataKey="flows"
fill={CHART_COLORS.area1}
name="Flows"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="tasks"
fill={CHART_COLORS.area2}
name="Tasks"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="subtasks"
fill={CHART_COLORS.area3}
name="Subtasks"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<ChartCard
description="Input and output tokens processed daily"
loading={usageByPeriodLoading}
title="Token Usage Over Time"
>
<AreaChart data={usageChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={axisTickStyle}
tickFormatter={formatDateLabel}
tickMargin={8}
/>
<YAxis
tick={axisTickStyle}
tickFormatter={formatTokenCount}
tickMargin={8}
/>
<Tooltip
content={
<ChartTooltip
formatter={(value) => formatTokenCount(value)}
labelFormatter={formatDateLabel}
/>
}
/>
<Area
dataKey="tokensIn"
fill={CHART_COLORS.area1}
fillOpacity={0.3}
name="Tokens In"
stroke={CHART_COLORS.area1}
type="monotone"
/>
<Area
dataKey="tokensOut"
fill={CHART_COLORS.area2}
fillOpacity={0.3}
name="Tokens Out"
stroke={CHART_COLORS.area2}
type="monotone"
/>
</AreaChart>
</ChartCard>
</div>
<ChartCard
description="LLM spending per day. May stay near zero when using local engines — this is expected."
height={240}
loading={usageByPeriodLoading}
title="Cost Over Time"
>
<AreaChart data={usageChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={axisTickStyle}
tickFormatter={formatDateLabel}
tickMargin={8}
/>
<YAxis
tick={axisTickStyle}
tickFormatter={(value) => formatCost(value)}
tickMargin={8}
/>
<Tooltip
content={
<ChartTooltip
formatter={(value) => formatCost(value)}
labelFormatter={formatDateLabel}
/>
}
/>
<Area
dataKey="costIn"
fill={CHART_COLORS.area1}
fillOpacity={0.3}
name="Cost In"
stroke={CHART_COLORS.area1}
type="monotone"
/>
<Area
dataKey="costOut"
fill={CHART_COLORS.area3}
fillOpacity={0.3}
name="Cost Out"
stroke={CHART_COLORS.area3}
type="monotone"
/>
</AreaChart>
</ChartCard>
<Card>
<CardHeader>
<CardTitle>Flow Execution Details</CardTitle>
@@ -339,6 +286,7 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) =>
{executionStats.map((flow) => (
<FlowExecutionItem
flow={flow}
flowMeta={flowsById.get(flow.flowId)}
key={flow.flowId}
/>
))}
@@ -370,25 +318,39 @@ type FlowExecution = {
totalToolcallsCount: number;
};
const FlowExecutionItem = ({ flow }: { flow: FlowExecution }) => {
const FlowExecutionItem = ({ flow, flowMeta }: { flow: FlowExecution; flowMeta?: FlowFragmentFragment }) => {
const [isOpen, setIsOpen] = useState(false);
const taskCount = flow.tasks.length;
const subtaskCount = flow.tasks.reduce((sum, task) => sum + task.subtasks.length, 0);
return (
<Collapsible
onOpenChange={setIsOpen}
open={isOpen}
>
<CollapsibleTrigger className="hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors">
<ChevronRight className={`size-4 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
<div className="flex-1 truncate font-medium">{flow.flowTitle || `Flow #${flow.flowId}`}</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<CollapsibleTrigger className="hover:bg-muted/50 group flex w-full items-start gap-3 rounded-lg px-3 py-2 text-left transition-colors">
<ChevronRight className={`mt-1 size-4 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium">{flow.flowTitle || `Flow #${flow.flowId}`}</span>
{flowMeta?.status && <FlowStatusBadge status={flowMeta.status} />}
{flowMeta?.provider?.name && <Badge variant="secondary">{flowMeta.provider.name}</Badge>}
</div>
<div className="text-muted-foreground mt-0.5 text-xs">
{taskCount} {taskCount === 1 ? 'task' : 'tasks'}
{subtaskCount > 0 && ` · ${subtaskCount} ${subtaskCount === 1 ? 'subtask' : 'subtasks'}`}
{flow.totalAssistantsCount > 0 &&
` · ${flow.totalAssistantsCount} ${flow.totalAssistantsCount === 1 ? 'assistant' : 'assistants'}`}
</div>
</div>
<div className="text-muted-foreground flex shrink-0 items-center gap-4 pt-1 text-sm">
<span className="flex items-center gap-1">
<Clock className="size-3" />
{formatDuration(flow.totalDurationSeconds)}
</span>
<span className="flex items-center gap-1">
<Wrench className="size-3" />
{flow.totalToolcallsCount}
{formatNumber(flow.totalToolcallsCount)}
</span>
</div>
</CollapsibleTrigger>
@@ -432,7 +394,7 @@ const TaskExecutionItem = ({ task }: { task: FlowExecution['tasks'][number] }) =
</span>
<span className="flex items-center gap-1">
<Wrench className="size-3" />
{task.totalToolcallsCount}
{formatNumber(task.totalToolcallsCount)}
</span>
</div>
</CollapsibleTrigger>
@@ -454,7 +416,7 @@ const TaskExecutionItem = ({ task }: { task: FlowExecution['tasks'][number] }) =
</span>
<span className="flex items-center gap-1">
<Wrench className="size-3" />
{subtask.totalToolcallsCount}
{formatNumber(subtask.totalToolcallsCount)}
</span>
</div>
</div>
@@ -2,8 +2,9 @@ import { Activity, CircleDollarSign, Cpu, GitFork, Loader2 } from 'lucide-react'
import type { UsageStatsFragmentFragment } from '@/graphql/types';
import { MetricCard } from '@/components/dashboard';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
useFlowsStatsTotalQuery,
@@ -14,32 +15,7 @@ import {
useUsageStatsByProviderQuery,
useUsageStatsTotalQuery,
} from '@/graphql/types';
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/pages/dashboard/format-utils';
const StatCard = ({
description,
icon,
loading,
title,
value,
}: {
description: string;
icon: React.ReactNode;
loading: boolean;
title: string;
value: string;
}) => (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
{loading ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{value}</div>}
<p className="text-muted-foreground text-xs">{description}</p>
</CardContent>
</Card>
);
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/lib/utils/format';
const UsageStatsRow = ({ label, stats }: { label: string; stats: UsageStatsFragmentFragment }) => (
<TableRow>
@@ -124,33 +100,33 @@ export const DashboardOverview = () => {
return (
<div className="flex flex-col gap-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
description="Total LLM spending across all providers"
icon={<CircleDollarSign className="text-muted-foreground size-4" />}
loading={usageTotalLoading}
title="Total Cost"
value={formatCost(totalCost)}
<MetricCard
description={`Tasks: ${flowsTotal?.totalTasksCount ?? 0} · Subtasks: ${flowsTotal?.totalSubtasksCount ?? 0} · Assistants: ${flowsTotal?.totalAssistantsCount ?? 0}`}
icon={<GitFork className="text-muted-foreground size-4" />}
loading={flowsTotalLoading}
title="Total Flows"
value={flowsTotal ? formatNumber(flowsTotal.totalFlowsCount) : '0'}
/>
<StatCard
description="Input + Output tokens processed"
icon={<Cpu className="text-muted-foreground size-4" />}
loading={usageTotalLoading}
title="Total Tokens"
value={formatTokenCount(totalTokens)}
/>
<StatCard
<MetricCard
description={`Total duration: ${toolcallsTotal ? formatDuration(toolcallsTotal.totalDurationSeconds) : '—'}`}
icon={<Activity className="text-muted-foreground size-4" />}
loading={toolcallsTotalLoading}
title="Tool Calls"
value={toolcallsTotal ? formatNumber(toolcallsTotal.totalCount) : '0'}
/>
<StatCard
description={`Tasks: ${flowsTotal?.totalTasksCount ?? 0} · Subtasks: ${flowsTotal?.totalSubtasksCount ?? 0} · Assistants: ${flowsTotal?.totalAssistantsCount ?? 0}`}
icon={<GitFork className="text-muted-foreground size-4" />}
loading={flowsTotalLoading}
title="Total Flows"
value={flowsTotal ? formatNumber(flowsTotal.totalFlowsCount) : '0'}
<MetricCard
description="Input + Output tokens processed"
icon={<Cpu className="text-muted-foreground size-4" />}
loading={usageTotalLoading}
title="Total Tokens"
value={formatTokenCount(totalTokens)}
/>
<MetricCard
description="Total LLM spending across all providers"
icon={<CircleDollarSign className="text-muted-foreground size-4" />}
loading={usageTotalLoading}
title="Total Cost"
value={formatCost(totalCost)}
/>
</div>
@@ -208,15 +184,9 @@ export const DashboardOverview = () => {
<TableRow key={item.functionName}>
<TableCell className="font-medium">{item.functionName}</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
item.isAgent
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
}`}
>
<Badge variant={item.isAgent ? 'secondary' : 'outline'}>
{item.isAgent ? 'Agent' : 'Tool'}
</span>
</Badge>
</TableCell>
<TableCell className="text-right">{formatNumber(item.totalCount)}</TableCell>
<TableCell className="text-right">
+35 -3
View File
@@ -1,11 +1,12 @@
import { LayoutDashboard } from 'lucide-react';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { UsageStatsPeriod } from '@/graphql/types';
import { getPeriodStorageKey } from '@/lib/storage-keys';
import { DashboardAnalytics } from '@/pages/dashboard/dashboard-analytics';
import { DashboardOverview } from '@/pages/dashboard/dashboard-overview';
@@ -15,9 +16,40 @@ const periodOptions: { label: string; value: UsageStatsPeriod }[] = [
{ label: 'Quarter', value: UsageStatsPeriod.Quarter },
];
const VALID_PERIODS = new Set<string>(Object.values(UsageStatsPeriod));
const loadPeriod = (storageKey: string): UsageStatsPeriod => {
try {
const stored = localStorage.getItem(storageKey);
if (stored && VALID_PERIODS.has(stored)) {
return stored as UsageStatsPeriod;
}
} catch {
/* localStorage may be unavailable */
}
return UsageStatsPeriod.Week;
};
const savePeriod = (storageKey: string, value: UsageStatsPeriod): void => {
try {
localStorage.setItem(storageKey, value);
} catch {
/* localStorage may be unavailable */
}
};
const Dashboard = () => {
const periodStorageKey = useMemo(() => getPeriodStorageKey(), []);
const [activeTab, setActiveTab] = useState('analytics');
const [period, setPeriod] = useState<UsageStatsPeriod>(UsageStatsPeriod.Week);
const [period, setPeriod] = useState<UsageStatsPeriod>(() => loadPeriod(periodStorageKey));
const handlePeriodChange = (value: string) => {
const next = value as UsageStatsPeriod;
setPeriod(next);
savePeriod(periodStorageKey, next);
};
return (
<>
@@ -53,7 +85,7 @@ const Dashboard = () => {
{activeTab === 'analytics' && (
<Tabs
onValueChange={(value) => setPeriod(value as UsageStatsPeriod)}
onValueChange={handlePeriodChange}
value={period}
>
<TabsList>
@@ -1,57 +0,0 @@
export const formatTokenCount = (count: number): string => {
if (count >= 1_000_000_000) {
return `${(count / 1_000_000_000).toFixed(1)}B`;
}
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return count.toString();
};
export const formatCost = (cost: number): string => {
if (!cost) {
return '$0';
}
if (cost >= 1) {
return `$${cost.toFixed(2)}`;
}
if (cost >= 0.01) {
return `$${cost.toFixed(3)}`;
}
return `$${cost.toFixed(4)}`;
};
export const formatDuration = (seconds: number): string => {
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
}
if (seconds >= 1) {
return `${seconds.toFixed(1)}s`;
}
return `${(seconds * 1000).toFixed(0)}ms`;
};
export const formatNumber = (value: number): string => {
return new Intl.NumberFormat('en-US').format(value);
};
+14 -5
View File
@@ -4,6 +4,15 @@
@custom-variant dark (&:is(.dark *));
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
ascent-override: 90%;
descent-override: 22.43%;
line-gap-override: 0%;
size-adjust: 107.64%;
}
@font-face {
font-family: 'Inter';
font-style: normal;
@@ -297,9 +306,9 @@
--sidebar-accent-foreground: oklch(0.32 0 0);
--sidebar-border: oklch(0.93 0.01 240);
--sidebar-ring: oklch(0.25 0.14 245);
--font-sans: Inter, sans-serif;
--font-serif: Inter, serif;
--font-mono: Roboto Mono, monospace;
--font-sans: Inter, 'Inter Fallback', sans-serif;
--font-serif: Inter, 'Inter Fallback', serif;
--font-mono: 'Roboto Mono', monospace;
--radius: 0.375rem;
--shadow-x: 0;
--shadow-y: 1px;
@@ -352,8 +361,8 @@
--sidebar-accent-foreground: oklch(0.92 0.02 245);
--sidebar-border: oklch(0.3 0.04 245);
--sidebar-ring: oklch(0.5 0.16 245);
--font-sans: Inter, sans-serif;
--font-serif: Inter, serif;
--font-sans: Inter, 'Inter Fallback', sans-serif;
--font-serif: Inter, 'Inter Fallback', serif;
--font-mono: 'Roboto Mono', monospace;
--radius: 0.5rem;
--shadow-x: 0;