+
+
diff --git a/ui/src/components/analytics/cliproxy-stats-card.tsx b/ui/src/components/analytics/cliproxy-stats-card.tsx
index ba21ec9c..273ab9cc 100644
--- a/ui/src/components/analytics/cliproxy-stats-card.tsx
+++ b/ui/src/components/analytics/cliproxy-stats-card.tsx
@@ -18,13 +18,17 @@ import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats'
interface CliproxyStatsCardProps {
className?: string;
+ isLoading?: boolean; // External loading state from parent (for synchronized loading UI)
}
-export function CliproxyStatsCard({ className }: CliproxyStatsCardProps) {
+export function CliproxyStatsCard({
+ className,
+ isLoading: externalLoading,
+}: CliproxyStatsCardProps) {
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
- const isLoading = statusLoading || (status?.running && statsLoading);
+ const isLoading = externalLoading || statusLoading || (status?.running && statsLoading);
if (isLoading) {
return (
diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx
index 69f73400..d4bc7eeb 100644
--- a/ui/src/components/analytics/usage-trend-chart.tsx
+++ b/ui/src/components/analytics/usage-trend-chart.tsx
@@ -2,7 +2,7 @@
* Usage Trend Chart Component
*
* Displays usage trends over time with tokens and cost.
- * Supports daily and monthly granularity with interactive tooltips.
+ * Supports daily, hourly, and monthly granularity with interactive tooltips.
*/
import { useMemo } from 'react';
@@ -17,15 +17,15 @@ import {
} from 'recharts';
import { format } from 'date-fns';
import { Skeleton } from '@/components/ui/skeleton';
-import type { DateRange } from 'react-day-picker';
import { cn } from '@/lib/utils';
-import type { DailyUsage } from '@/hooks/use-usage';
+import type { DailyUsage, HourlyUsage } from '@/hooks/use-usage';
+
+type ChartData = DailyUsage | HourlyUsage;
interface UsageTrendChartProps {
- data: DailyUsage[];
+ data: ChartData[];
isLoading?: boolean;
- dateRange?: DateRange;
- granularity?: 'daily' | 'monthly';
+ granularity?: 'daily' | 'monthly' | 'hourly';
className?: string;
}
@@ -34,15 +34,22 @@ export function UsageTrendChart({
isLoading,
granularity = 'daily',
className,
-}: Omit
) {
+}: UsageTrendChartProps) {
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
- return [...data].reverse().map((item) => ({
- ...item,
- dateFormatted: formatDate(item.date, granularity),
- costRounded: Number(item.cost.toFixed(4)),
- }));
+ // For hourly data, already sorted ascending from API
+ const sortedData = granularity === 'hourly' ? data : [...data].reverse();
+
+ return sortedData.map((item) => {
+ // Handle hourly vs daily data format
+ const timeKey = 'hour' in item ? item.hour : (item as DailyUsage).date;
+ return {
+ ...item,
+ dateFormatted: formatTime(timeKey, granularity),
+ costRounded: Number(item.cost.toFixed(4)),
+ };
+ });
}, [data, granularity]);
if (isLoading) {
@@ -52,7 +59,9 @@ export function UsageTrendChart({
if (!data || data.length === 0) {
return (
-
No usage data available
+
+ {granularity === 'hourly' ? 'No usage data for today' : 'No usage data available'}
+
);
}
@@ -103,7 +112,7 @@ export function UsageTrendChart({
content={({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
- const data = payload[0].payload;
+ const tooltipData = payload[0].payload;
return (
{label}
@@ -115,7 +124,11 @@ export function UsageTrendChart({
: `$${entry.value}`}
))}
-
Requests: {data.requests}
+ {'requests' in tooltipData && (
+
+ Requests: {tooltipData.requests}
+
+ )}
);
}}
@@ -149,14 +162,26 @@ export function UsageTrendChart({
}
// Helper functions
-function formatDate(dateStr: string, granularity: 'daily' | 'monthly'): string {
- const date = new Date(dateStr);
+function formatTime(timeStr: string, granularity: 'daily' | 'monthly' | 'hourly'): string {
+ if (granularity === 'hourly') {
+ // Format: "YYYY-MM-DD HH:00" -> convert UTC to local time -> "HH:00"
+ // Parse as UTC and format in local timezone
+ const [datePart, timePart] = timeStr.split(' ');
+ if (datePart && timePart) {
+ // Create date in UTC: "2025-12-12 20:00" -> "2025-12-12T20:00:00Z"
+ const utcDate = new Date(`${datePart}T${timePart}:00Z`);
+ return format(utcDate, 'HH:mm');
+ }
+ return timeStr;
+ }
+
+ const date = new Date(timeStr);
if (granularity === 'monthly') {
return format(date, 'MMM yyyy');
}
- // For daily, show shorter format if range is > 30 days
+ // For daily, show shorter format
return format(date, 'MMM dd');
}
diff --git a/ui/src/components/github-link.tsx b/ui/src/components/github-link.tsx
new file mode 100644
index 00000000..8ece4cab
--- /dev/null
+++ b/ui/src/components/github-link.tsx
@@ -0,0 +1,20 @@
+/**
+ * GitHub Link Button
+ *
+ * Links to CCS GitHub issues page for bug reports and feature requests.
+ */
+
+import { Github } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+const GITHUB_REPO_URL = 'https://github.com/kaitranntt/ccs/issues';
+
+export function GitHubLink() {
+ return (
+
+ );
+}
diff --git a/ui/src/components/quick-setup-wizard.tsx b/ui/src/components/quick-setup-wizard.tsx
index ab4e58a5..dfc3d6b4 100644
--- a/ui/src/components/quick-setup-wizard.tsx
+++ b/ui/src/components/quick-setup-wizard.tsx
@@ -432,9 +432,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
{m.name}
{m.description && (
-
- - {m.description}
-
+ - {m.description}
)}
diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts
index 3bcbde88..39e8e393 100644
--- a/ui/src/hooks/use-usage.ts
+++ b/ui/src/hooks/use-usage.ts
@@ -43,6 +43,17 @@ export interface DailyUsage {
modelsUsed: number;
}
+export interface HourlyUsage {
+ hour: string;
+ tokens: number;
+ inputTokens: number;
+ outputTokens: number;
+ cacheTokens: number;
+ cost: number;
+ modelsUsed: number;
+ requests: number;
+}
+
export interface ModelUsage {
model: string;
tokens: number;
@@ -147,6 +158,12 @@ export const usageApi = {
if (options?.profile) params.append('profile', options.profile);
return request(`/usage/daily?${params}`);
},
+ hourly: (options?: UsageQueryOptions) => {
+ const params = new URLSearchParams();
+ if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
+ if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
+ return request(`/usage/hourly?${params}`);
+ },
models: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
@@ -224,6 +241,14 @@ export function useUsageTrends(options?: UsageQueryOptions) {
});
}
+export function useHourlyUsage(options?: UsageQueryOptions) {
+ return useQuery({
+ queryKey: ['usage', 'hourly', options],
+ queryFn: () => usageApi.hourly(options),
+ staleTime: 60 * 1000, // 1 minute
+ });
+}
+
export function useModelUsage(options?: UsageQueryOptions) {
return useQuery({
queryKey: ['usage', 'models', options],
diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx
index aa4a4f0c..3c3e9d64 100644
--- a/ui/src/pages/analytics.tsx
+++ b/ui/src/pages/analytics.tsx
@@ -23,6 +23,7 @@ import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucid
import {
useUsageSummary,
useUsageTrends,
+ useHourlyUsage,
useModelUsage,
useRefreshUsage,
useUsageStatus,
@@ -48,6 +49,7 @@ export function AnalyticsPage() {
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedModel, setSelectedModel] = useState(null);
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
+ const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
const popoverAnchorRef = useRef(null);
// Refresh hook
@@ -71,10 +73,24 @@ export function AnalyticsPage() {
// Fetch data
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
+ const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions);
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
const { data: status } = useUsageStatus();
+ // Handle "24H" preset click
+ const handleTodayClick = useCallback(() => {
+ const now = new Date();
+ setDateRange({ from: subDays(now, 1), to: now });
+ setViewMode('hourly');
+ }, []);
+
+ // Handle date range changes from DateRangeFilter
+ const handleDateRangeChange = useCallback((range: DateRange | undefined) => {
+ setDateRange(range);
+ setViewMode('daily'); // Switch back to daily view for multi-day ranges
+ }, []);
+
// Format "Last updated" text
const lastUpdatedText = useMemo(() => {
if (!status?.lastFetch) return null;
@@ -102,9 +118,17 @@ export function AnalyticsPage() {
Track usage & insights