Skip to content

Commit 5c8f4f2

Browse files
committed
feat: implement core application pages and state management
Introduce About page with app version and update functionality. Add Login page for authentication and CLI proxy server management. Implement Dashboard for comprehensive usage statistics and trends. Create Providers page for managing API connections via OAuth. Develop Quota page to display API rate limits and usage. Integrate dedicated Zustand stores and presenter hooks for each feature. Enhance user experience with privacy modes and interactive elements. feat: introduce new UI framework, quota, and settings Introduce a comprehensive set of new UI components (Shadcn-like). Implement a responsive default layout with collapsible sidebar navigation. Add a new Quota feature with detailed and compact views for providers. Introduce a Settings page for CLI proxy, usage stats, theme, and language. Establish core state management (Zustand) for CLI proxy, config, theme. Integrate new API services for auth, config, OAuth, quota, and usage. Provide utility hooks for app version, interval, and global refresh events. Refactor page exports and add shared utility functions. feat(utils): introduce new utility functions for language, privacy, and quota management refactor(stores): remove stores barrel export as it is no longer needed
1 parent 7dd5a18 commit 5c8f4f2

50 files changed

Lines changed: 971 additions & 20 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import { useEffect, useCallback, useState, useMemo } from 'react';
2+
import { useAuthStore, useConfigStore, useUsageStore, useCliProxyStore } from '@/stores';
3+
import { useHeaderRefresh } from '@/shared/hooks';
4+
import { authFilesApi } from '@/services/api/auth.service';
5+
import type { ChartConfig } from '@/shared/components/ui/chart';
6+
import type { UsageResponse } from '@/types';
7+
8+
export type TimeGrouping = 'hour' | 'day';
9+
10+
export interface ComparisonLine {
11+
id: string;
12+
model: string;
13+
source: string;
14+
color: string;
15+
}
16+
17+
interface TrendEntry {
18+
date: string;
19+
requests: number;
20+
tokens: number;
21+
models: Record<string, { requests: number; tokens: number }>;
22+
sources: Record<string, { requests: number; tokens: number; models: Record<string, { requests: number; tokens: number }> }>;
23+
}
24+
25+
const CHART_COLORS = [
26+
'var(--chart-1)',
27+
'var(--chart-2)',
28+
'var(--chart-3)',
29+
'var(--chart-4)',
30+
'var(--chart-5)',
31+
];
32+
33+
const DEFAULT_LINE: ComparisonLine = { id: 'line-1', model: 'all', source: 'all', color: 'var(--chart-1)' };
34+
35+
export function formatNumber(num: number) {
36+
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M`;
37+
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
38+
return num.toString();
39+
}
40+
41+
export function formatAxisNumber(num: number) {
42+
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
43+
if (num >= 1_000) return `${Math.round(num / 1_000)}K`;
44+
return num.toString();
45+
}
46+
47+
export function maskApiName(name: string) {
48+
if (name.length <= 4) return name;
49+
return name.substring(0, 2) + '*'.repeat(Math.min(6, name.length - 4)) + name.substring(name.length - 2);
50+
}
51+
52+
function processUsageData(usage: UsageResponse | null) {
53+
if (!usage?.usage) {
54+
return {
55+
modelStats: [] as { name: string; requests: number; tokens: number; failed: number }[],
56+
trendsByDay: [] as TrendEntry[],
57+
trendsByHour: [] as TrendEntry[],
58+
tokenBreakdown: { cached: 0, reasoning: 0 },
59+
apiStats: [] as { name: string; requests: number; tokens: number; models: { name: string; requests: number; tokens: number }[] }[],
60+
availableModels: [] as string[],
61+
availableSources: [] as string[],
62+
};
63+
}
64+
65+
const models: { name: string; requests: number; tokens: number; failed: number }[] = [];
66+
const apis = usage.usage.apis || {};
67+
const apiList: { name: string; requests: number; tokens: number; models: { name: string; requests: number; tokens: number }[] }[] = [];
68+
69+
let cachedTokens = 0;
70+
let reasoningTokens = 0;
71+
72+
const dayTrends: Record<string, TrendEntry> = {};
73+
const hourTrends: Record<string, TrendEntry> = {};
74+
75+
for (const apiKey of Object.keys(apis)) {
76+
const api = apis[apiKey];
77+
const apiModels = api.models || {};
78+
const currentApiModels: { name: string; requests: number; tokens: number }[] = [];
79+
80+
for (const modelName of Object.keys(apiModels)) {
81+
const model = apiModels[modelName];
82+
const details = model.details || [];
83+
let modelFailed = 0;
84+
85+
currentApiModels.push({
86+
name: modelName,
87+
requests: model.total_requests || 0,
88+
tokens: model.total_tokens || 0
89+
});
90+
91+
for (const detail of details) {
92+
if (detail.tokens) {
93+
cachedTokens += detail.tokens.cached_tokens || 0;
94+
reasoningTokens += detail.tokens.reasoning_tokens || 0;
95+
}
96+
97+
if (detail.timestamp) {
98+
const dateObj = new Date(detail.timestamp);
99+
const dayKey = detail.timestamp.split('T')[0];
100+
const pad = (n: number) => n.toString().padStart(2, '0');
101+
const hourKey = `${dateObj.getFullYear()}-${pad(dateObj.getMonth() + 1)}-${pad(dateObj.getDate())} ${pad(dateObj.getHours())}:00`;
102+
103+
const accumulateTrend = (trends: Record<string, TrendEntry>, key: string) => {
104+
if (!trends[key]) trends[key] = { date: key, requests: 0, tokens: 0, models: {}, sources: {} };
105+
trends[key].requests += 1;
106+
trends[key].tokens += detail.tokens?.total_tokens || 0;
107+
108+
if (!trends[key].models[modelName]) trends[key].models[modelName] = { requests: 0, tokens: 0 };
109+
trends[key].models[modelName].requests += 1;
110+
trends[key].models[modelName].tokens += detail.tokens?.total_tokens || 0;
111+
112+
const source = detail.source || 'unknown';
113+
if (!trends[key].sources[source]) trends[key].sources[source] = { requests: 0, tokens: 0, models: {} };
114+
trends[key].sources[source].requests += 1;
115+
trends[key].sources[source].tokens += detail.tokens?.total_tokens || 0;
116+
117+
if (!trends[key].sources[source].models[modelName]) trends[key].sources[source].models[modelName] = { requests: 0, tokens: 0 };
118+
trends[key].sources[source].models[modelName].requests += 1;
119+
trends[key].sources[source].models[modelName].tokens += detail.tokens?.total_tokens || 0;
120+
};
121+
122+
accumulateTrend(dayTrends, dayKey);
123+
accumulateTrend(hourTrends, hourKey);
124+
}
125+
126+
if (detail.failed) modelFailed += 1;
127+
}
128+
129+
models.push({
130+
name: modelName,
131+
requests: model.total_requests || 0,
132+
tokens: model.total_tokens || 0,
133+
failed: modelFailed
134+
});
135+
}
136+
137+
currentApiModels.sort((a, b) => b.requests - a.requests);
138+
apiList.push({
139+
name: apiKey,
140+
requests: api.total_requests || 0,
141+
tokens: api.total_tokens || 0,
142+
models: currentApiModels
143+
});
144+
}
145+
146+
models.sort((a, b) => b.requests - a.requests);
147+
148+
const sources = new Set<string>();
149+
for (const apiKey of Object.keys(apis)) {
150+
const apiModels = apis[apiKey].models || {};
151+
for (const modelName of Object.keys(apiModels)) {
152+
for (const detail of apiModels[modelName].details || []) {
153+
if (detail.source) sources.add(detail.source);
154+
}
155+
}
156+
}
157+
158+
return {
159+
modelStats: models,
160+
trendsByDay: Object.values(dayTrends).sort((a, b) => a.date.localeCompare(b.date)),
161+
trendsByHour: Object.values(hourTrends).sort((a, b) => a.date.localeCompare(b.date)),
162+
tokenBreakdown: { cached: cachedTokens, reasoning: reasoningTokens },
163+
apiStats: apiList,
164+
availableModels: Array.from(new Set(models.map(m => m.name))).sort(),
165+
availableSources: Array.from(sources).sort(),
166+
};
167+
}
168+
169+
export function useDashboardPresenter() {
170+
const { connectionStatus, checkAuth, updateConnectionStatus, apiBase } = useAuthStore();
171+
const { fetchConfig } = useConfigStore();
172+
const { usage, loading: usageLoading, fetchUsage } = useUsageStore();
173+
const { isApiHealthy, checkApiHealth } = useCliProxyStore();
174+
175+
const [activeAccountsCount, setActiveAccountsCount] = useState<number>(0);
176+
const [requestTimeGrouping, setRequestTimeGrouping] = useState<TimeGrouping>('day');
177+
const [tokenTimeGrouping, setTokenTimeGrouping] = useState<TimeGrouping>('day');
178+
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
179+
const [comparisonLines, setComparisonLines] = useState<ComparisonLine[]>([DEFAULT_LINE]);
180+
181+
const addLine = useCallback(() => {
182+
if (comparisonLines.length >= 9) return;
183+
const newId = `line-${Date.now()}`;
184+
const color = CHART_COLORS[comparisonLines.length % CHART_COLORS.length];
185+
setComparisonLines(prev => [...prev, { id: newId, model: 'all', source: 'all', color }]);
186+
}, [comparisonLines.length]);
187+
188+
const removeLine = useCallback((id: string) => {
189+
setComparisonLines(prev => prev.length <= 1 ? prev : prev.filter(l => l.id !== id));
190+
}, []);
191+
192+
const updateLine = useCallback((id: string, field: 'model' | 'source', value: string) => {
193+
setComparisonLines(prev => prev.map(l => l.id === id ? { ...l, [field]: value } : l));
194+
}, []);
195+
196+
const resetLines = useCallback(() => {
197+
setComparisonLines([DEFAULT_LINE]);
198+
}, []);
199+
200+
const loadData = useCallback(async () => {
201+
try {
202+
await fetchConfig();
203+
const response = await authFilesApi.list();
204+
const filesList = response?.files ?? [];
205+
setActiveAccountsCount(filesList.length);
206+
await fetchUsage();
207+
} catch {
208+
// Error handled by store/api
209+
}
210+
}, [fetchConfig, fetchUsage]);
211+
212+
useEffect(() => {
213+
checkApiHealth(apiBase);
214+
}, [checkApiHealth, apiBase]);
215+
216+
useEffect(() => {
217+
if (isApiHealthy) {
218+
checkAuth();
219+
} else {
220+
updateConnectionStatus('disconnected');
221+
}
222+
}, [isApiHealthy, checkAuth, updateConnectionStatus]);
223+
224+
useEffect(() => {
225+
if (connectionStatus === 'connected') {
226+
loadData();
227+
}
228+
}, [connectionStatus, loadData]);
229+
230+
useHeaderRefresh(loadData);
231+
232+
const toggleApi = useCallback((apiName: string) => {
233+
setExpandedApis(prev => {
234+
const next = new Set(prev);
235+
if (next.has(apiName)) next.delete(apiName);
236+
else next.add(apiName);
237+
return next;
238+
});
239+
}, []);
240+
241+
const { modelStats, trendsByDay, trendsByHour, tokenBreakdown, apiStats, availableModels, availableSources } =
242+
useMemo(() => processUsageData(usage), [usage]);
243+
244+
const getComparisonData = useCallback((type: 'requests' | 'tokens') => {
245+
const grouping = type === 'requests' ? requestTimeGrouping : tokenTimeGrouping;
246+
const trends = grouping === 'hour' ? trendsByHour : trendsByDay;
247+
248+
return trends.map(trend => {
249+
const dataPoint: Record<string, unknown> = { date: trend.date };
250+
251+
comparisonLines.forEach(line => {
252+
let value = 0;
253+
254+
if (line.model === 'all' && line.source === 'all') {
255+
value = type === 'requests' ? trend.requests : trend.tokens;
256+
} else if (line.model === 'all' && line.source !== 'all') {
257+
const sourceData = trend.sources?.[line.source];
258+
value = sourceData ? (type === 'requests' ? sourceData.requests : sourceData.tokens) : 0;
259+
} else if (line.model !== 'all' && line.source === 'all') {
260+
const modelData = trend.models?.[line.model];
261+
value = modelData ? (type === 'requests' ? modelData.requests : modelData.tokens) : 0;
262+
} else {
263+
const sourceData = trend.sources?.[line.source];
264+
if (sourceData?.models?.[line.model]) {
265+
value = type === 'requests' ? sourceData.models[line.model].requests : sourceData.models[line.model].tokens;
266+
}
267+
}
268+
269+
dataPoint[line.id] = value;
270+
});
271+
272+
return dataPoint;
273+
});
274+
}, [comparisonLines, requestTimeGrouping, tokenTimeGrouping, trendsByDay, trendsByHour]);
275+
276+
const chartConfig = useMemo(() => {
277+
const config: ChartConfig = {};
278+
comparisonLines.forEach(line => {
279+
let label = '';
280+
const sourceLabel = maskApiName(line.source);
281+
282+
if (line.model === 'all' && line.source === 'all') label = 'All Activity';
283+
else if (line.model === 'all') label = sourceLabel;
284+
else if (line.source === 'all') label = line.model;
285+
else label = `${line.model}${sourceLabel}`;
286+
287+
config[line.id] = { label, color: line.color };
288+
});
289+
return config;
290+
}, [comparisonLines]);
291+
292+
const isUsageStatsEnabled = Boolean(useConfigStore.getState().config?.['usage-statistics-enabled']);
293+
294+
return {
295+
connectionStatus,
296+
usageLoading,
297+
usage,
298+
activeAccountsCount,
299+
loadData,
300+
301+
// Usage computed data
302+
modelStats,
303+
tokenBreakdown,
304+
apiStats,
305+
availableModels,
306+
availableSources,
307+
isUsageStatsEnabled,
308+
309+
// Time grouping
310+
requestTimeGrouping,
311+
setRequestTimeGrouping,
312+
tokenTimeGrouping,
313+
setTokenTimeGrouping,
314+
315+
// API details expansion
316+
expandedApis,
317+
toggleApi,
318+
319+
// Comparison lines
320+
comparisonLines,
321+
addLine,
322+
removeLine,
323+
updateLine,
324+
resetLines,
325+
326+
// Chart data
327+
getComparisonData,
328+
chartConfig,
329+
330+
// Formatting
331+
formatNumber,
332+
formatAxisNumber,
333+
maskApiName,
334+
};
335+
}

0 commit comments

Comments
 (0)