mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
refactor: 重大界面重构 - 删除仪表盘,重组导航结构
## 主要变更 ### 1. 删除仪表盘功能 - 删除 Dashboard.tsx 组件和相关前端代码 - 删除后端 get_dashboard_data 命令和 DashboardData 结构 - 删除 getDashboardData API 和相关类型定义 - 删除不再使用的 MonitoringPage 组件 ### 2. 重新设计导航结构 - API Server 现在是默认首页(替代仪表盘) - 左侧导航栏顺序:API Server、凭证池、配置管理、Flow Monitor、设置 - 移除仪表盘菜单项,简化导航体验 ### 3. 设置页面功能整合 - 将扩展功能(MCP、Prompts、Skills、Plugins)移到设置页面的"扩展"标签 - 将路由管理功能移到设置页面的"路由管理 (实验)"标签,标注为实验功能 - 创建 ExtensionsSettings.tsx 和 RoutingSettings.tsx 组件 - 保持所有功能完整性,只是重新组织了访问路径 ### 4. 代码清理 - 清理前后端所有仪表盘相关代码 - 移除未使用的导入和组件引用 - 修复 TypeScript 类型错误 ## 新的界面结构 ``` 左侧导航: ├─ API Server (首页) ├─ 凭证池 ├─ 配置管理 ├─ Flow Monitor └─ 设置 ├─ 通用 ├─ 代理服务 ├─ 安全 ├─ 高级 ├─ 扩展 (新增) ├─ 路由管理 (实验) (新增) └─ 关于 ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
This commit is contained in:
@@ -284,55 +284,3 @@ pub async fn get_token_stats_by_day(
|
||||
let tokens = state.tokens.read();
|
||||
Ok(tokens.by_day(days.unwrap_or(7)))
|
||||
}
|
||||
|
||||
// ========== 仪表盘数据命令 ==========
|
||||
|
||||
/// 仪表盘数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DashboardData {
|
||||
/// 统计摘要
|
||||
pub stats: StatsSummary,
|
||||
/// Token 摘要
|
||||
pub tokens: TokenStatsSummary,
|
||||
/// 按 Provider 统计
|
||||
pub by_provider: HashMap<String, ProviderStats>,
|
||||
/// 最近请求
|
||||
pub recent_logs: Vec<RequestLog>,
|
||||
}
|
||||
|
||||
/// 获取仪表盘数据
|
||||
#[tauri::command]
|
||||
pub async fn get_dashboard_data(
|
||||
state: tauri::State<'_, TelemetryState>,
|
||||
) -> Result<DashboardData, String> {
|
||||
// 获取最近 24 小时的统计
|
||||
let range = Some(TimeRange::last_hours(24));
|
||||
|
||||
let stats_guard = state.stats.read();
|
||||
let stats = stats_guard.summary(range);
|
||||
let by_provider: HashMap<String, ProviderStats> = stats_guard
|
||||
.by_provider(range)
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect();
|
||||
drop(stats_guard);
|
||||
|
||||
let tokens_guard = state.tokens.read();
|
||||
let tokens = tokens_guard.summary(
|
||||
Some(Utc::now() - chrono::Duration::hours(24)),
|
||||
Some(Utc::now()),
|
||||
);
|
||||
drop(tokens_guard);
|
||||
|
||||
// 获取最近 20 条日志
|
||||
let mut recent_logs = state.logger.get_all();
|
||||
recent_logs.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
recent_logs.truncate(20);
|
||||
|
||||
Ok(DashboardData {
|
||||
stats,
|
||||
tokens,
|
||||
by_provider,
|
||||
recent_logs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2042,7 +2042,6 @@ pub fn run() {
|
||||
commands::telemetry_cmd::get_token_stats_by_provider,
|
||||
commands::telemetry_cmd::get_token_stats_by_model,
|
||||
commands::telemetry_cmd::get_token_stats_by_day,
|
||||
commands::telemetry_cmd::get_dashboard_data,
|
||||
// Injection commands
|
||||
commands::injection_cmd::get_injection_config,
|
||||
commands::injection_cmd::set_injection_enabled,
|
||||
|
||||
+2
-14
@@ -1,27 +1,21 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Dashboard } from "./components/Dashboard";
|
||||
import { SettingsPage } from "./components/settings";
|
||||
import { ApiServerPage } from "./components/api-server/ApiServerPage";
|
||||
import { ProviderPoolPage } from "./components/provider-pool";
|
||||
import { RoutingManagementPage } from "./components/routing/RoutingManagementPage";
|
||||
import { ConfigManagementPage } from "./components/config/ConfigManagementPage";
|
||||
import { ExtensionsPage } from "./components/extensions";
|
||||
import { FlowMonitorPage } from "./pages";
|
||||
import { flowEventManager } from "./lib/flowEventManager";
|
||||
|
||||
type Page =
|
||||
| "dashboard"
|
||||
| "provider-pool"
|
||||
| "routing-management"
|
||||
| "config-management"
|
||||
| "extensions"
|
||||
| "api-server"
|
||||
| "flow-monitor"
|
||||
| "settings";
|
||||
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>("dashboard");
|
||||
const [currentPage, setCurrentPage] = useState<Page>("api-server");
|
||||
|
||||
// 在应用启动时初始化 Flow 事件订阅
|
||||
useEffect(() => {
|
||||
@@ -31,16 +25,10 @@ function App() {
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case "dashboard":
|
||||
return <Dashboard />;
|
||||
case "provider-pool":
|
||||
return <ProviderPoolPage />;
|
||||
case "routing-management":
|
||||
return <RoutingManagementPage />;
|
||||
case "config-management":
|
||||
return <ConfigManagementPage />;
|
||||
case "extensions":
|
||||
return <ExtensionsPage />;
|
||||
case "api-server":
|
||||
return <ApiServerPage />;
|
||||
case "flow-monitor":
|
||||
@@ -48,7 +36,7 @@ function App() {
|
||||
case "settings":
|
||||
return <SettingsPage />;
|
||||
default:
|
||||
return <Dashboard />;
|
||||
return <ApiServerPage />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Server,
|
||||
Zap,
|
||||
Clock,
|
||||
Key,
|
||||
Monitor,
|
||||
Globe,
|
||||
AlertCircle,
|
||||
Coins,
|
||||
RefreshCw,
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getServerStatus,
|
||||
getConfig,
|
||||
ServerStatus,
|
||||
Config,
|
||||
getDefaultProvider,
|
||||
} from "@/hooks/useTauri";
|
||||
import { useAllOAuthCredentials } from "@/hooks/useOAuthCredentials";
|
||||
import { useProviderPool } from "@/hooks/useProviderPool";
|
||||
import { TokenStats } from "./monitoring/TokenStats";
|
||||
import {
|
||||
getTokenStatsByDay,
|
||||
getTokenStatsByProvider,
|
||||
type PeriodTokenStats,
|
||||
type ProviderTokenStats,
|
||||
} from "@/lib/api/telemetry";
|
||||
|
||||
type TabType = "overview" | "tokens";
|
||||
|
||||
export function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>("overview");
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [defaultProvider, setDefaultProvider] = useState<string>("");
|
||||
const { reload: reloadCredentials } = useAllOAuthCredentials();
|
||||
const { refresh: refreshProviderPool } = useProviderPool();
|
||||
|
||||
// Token 数据状态
|
||||
const [tokensByDay, setTokensByDay] = useState<PeriodTokenStats[]>([]);
|
||||
const [tokensByProvider, setTokensByProvider] = useState<
|
||||
Record<string, ProviderTokenStats>
|
||||
>({});
|
||||
const [monitoringLoading, setMonitoringLoading] = useState(false);
|
||||
|
||||
// 获取基础数据
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [s, c, dp] = await Promise.all([
|
||||
getServerStatus(),
|
||||
getConfig(),
|
||||
getDefaultProvider(),
|
||||
]);
|
||||
setStatus(s);
|
||||
setConfig(c);
|
||||
setDefaultProvider(dp);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch data:", e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
reloadCredentials();
|
||||
refreshProviderPool();
|
||||
|
||||
const interval = setInterval(fetchData, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reloadCredentials, refreshProviderPool]);
|
||||
|
||||
// 获取Token数据
|
||||
const fetchMonitoringData = useCallback(async () => {
|
||||
try {
|
||||
setMonitoringLoading(true);
|
||||
const [dayStats, providerStats] = await Promise.all([
|
||||
getTokenStatsByDay(7),
|
||||
getTokenStatsByProvider({ preset: "7d" }),
|
||||
]);
|
||||
|
||||
setTokensByDay(dayStats);
|
||||
setTokensByProvider(providerStats);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch token data:", e);
|
||||
} finally {
|
||||
setMonitoringLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 切换到Token标签时加载数据
|
||||
useEffect(() => {
|
||||
if (activeTab === "tokens") {
|
||||
fetchMonitoringData();
|
||||
}
|
||||
}, [activeTab, fetchMonitoringData]);
|
||||
|
||||
// 定时刷新Token数据
|
||||
useEffect(() => {
|
||||
if (activeTab === "tokens") {
|
||||
const interval = setInterval(fetchMonitoringData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [activeTab, fetchMonitoringData]);
|
||||
|
||||
const formatUptime = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
const serverUrl = status
|
||||
? `http://${status.host}:${status.port}`
|
||||
: "http://localhost:8999";
|
||||
|
||||
const getProviderName = (id: string) => {
|
||||
switch (id) {
|
||||
case "kiro":
|
||||
return "Kiro Claude";
|
||||
case "gemini":
|
||||
return "Gemini CLI";
|
||||
case "qwen":
|
||||
return "通义千问";
|
||||
case "openai":
|
||||
return "OpenAI 自定义";
|
||||
case "claude":
|
||||
return "Claude 自定义";
|
||||
default:
|
||||
return id;
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { id: TabType; label: string; icon: React.ElementType }[] = [
|
||||
{ id: "overview", label: "概览", icon: LayoutDashboard },
|
||||
{ id: "tokens", label: "Token", icon: Coins },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">仪表盘</h2>
|
||||
<p className="text-muted-foreground">系统状态与监控</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{activeTab === "tokens" && (
|
||||
<button
|
||||
onClick={fetchMonitoringData}
|
||||
disabled={monitoringLoading}
|
||||
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${monitoringLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
刷新
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{activeTab === "overview" && (
|
||||
<OverviewTab
|
||||
status={status}
|
||||
config={config}
|
||||
defaultProvider={defaultProvider}
|
||||
serverUrl={serverUrl}
|
||||
formatUptime={formatUptime}
|
||||
getProviderName={getProviderName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "tokens" && (
|
||||
<TokenStats
|
||||
summary={{
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
record_count: 0,
|
||||
actual_count: 0,
|
||||
estimated_count: 0,
|
||||
avg_input_tokens: 0,
|
||||
avg_output_tokens: 0,
|
||||
}}
|
||||
byProvider={tokensByProvider}
|
||||
byDay={tokensByDay}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{activeTab === "tokens" &&
|
||||
monitoringLoading &&
|
||||
Object.keys(tokensByProvider).length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 概览标签页内容
|
||||
function OverviewTab({
|
||||
status,
|
||||
config,
|
||||
defaultProvider,
|
||||
serverUrl,
|
||||
formatUptime,
|
||||
getProviderName,
|
||||
}: {
|
||||
status: ServerStatus | null;
|
||||
config: Config | null;
|
||||
defaultProvider: string;
|
||||
serverUrl: string;
|
||||
formatUptime: (secs: number) => string;
|
||||
getProviderName: (id: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Server Status Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">服务状态</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${status?.running ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{status?.running ? "运行中" : "已停止"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">请求数</span>
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold">{status?.requests || 0}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">运行时间</span>
|
||||
</div>
|
||||
<div className="mt-2 font-medium">
|
||||
{formatUptime(status?.uptime_secs || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">默认 Provider</span>
|
||||
</div>
|
||||
<div className="mt-2 font-medium">
|
||||
{defaultProvider ? getProviderName(defaultProvider) : "加载中..."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<QuickLinkCard
|
||||
icon={Key}
|
||||
title="凭证管理"
|
||||
description="管理 OAuth 凭证"
|
||||
status="info"
|
||||
statusText="Provider Pool 管理"
|
||||
/>
|
||||
<QuickLinkCard
|
||||
icon={Monitor}
|
||||
title="配置切换"
|
||||
description="一键切换 Claude Code/Codex/Gemini CLI 的 API 配置"
|
||||
status="info"
|
||||
statusText="管理 Provider 配置"
|
||||
/>
|
||||
<QuickLinkCard
|
||||
icon={Globe}
|
||||
title="API Server"
|
||||
description={`${serverUrl}`}
|
||||
status={status?.running ? "success" : "warning"}
|
||||
statusText={status?.running ? "运行中" : "已停止"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server Info */}
|
||||
{config && (
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<h3 className="mb-4 font-semibold">服务器信息</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">API 地址:</span>
|
||||
<code className="ml-2 rounded bg-muted px-2 py-1">
|
||||
{serverUrl}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">API Key:</span>
|
||||
<code className="ml-2 rounded bg-muted px-2 py-1">
|
||||
{config.server.api_key.length > 8
|
||||
? `${config.server.api_key.slice(0, 4)}****${config.server.api_key.slice(-4)}`
|
||||
: "****"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickLinkCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
statusText,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
status: "success" | "warning" | "error" | "info";
|
||||
statusText: string;
|
||||
}) {
|
||||
const statusColors = {
|
||||
success: "text-green-600",
|
||||
warning: "text-yellow-600",
|
||||
error: "text-red-600",
|
||||
info: "text-blue-600",
|
||||
};
|
||||
|
||||
const StatusIcon = status === "success" ? CheckCircle2 : AlertCircle;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="rounded-lg bg-primary/10 p-2">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">{title}</h4>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-1 text-xs ${statusColors[status]}`}
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,9 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Globe,
|
||||
Database,
|
||||
Route,
|
||||
FileCode,
|
||||
Puzzle,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import { Settings, Globe, Database, FileCode, Activity } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Page =
|
||||
| "dashboard"
|
||||
| "provider-pool"
|
||||
| "routing-management"
|
||||
| "config-management"
|
||||
| "extensions"
|
||||
| "api-server"
|
||||
| "flow-monitor"
|
||||
| "settings";
|
||||
@@ -26,12 +14,9 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ id: "dashboard" as Page, label: "仪表盘", icon: LayoutDashboard },
|
||||
{ id: "provider-pool" as Page, label: "凭证池", icon: Database },
|
||||
{ id: "routing-management" as Page, label: "路由管理", icon: Route },
|
||||
{ id: "config-management" as Page, label: "配置管理", icon: FileCode },
|
||||
{ id: "extensions" as Page, label: "扩展", icon: Puzzle },
|
||||
{ id: "api-server" as Page, label: "API Server", icon: Globe },
|
||||
{ id: "provider-pool" as Page, label: "凭证池", icon: Database },
|
||||
{ id: "config-management" as Page, label: "配置管理", icon: FileCode },
|
||||
{ id: "flow-monitor" as Page, label: "Flow Monitor", icon: Activity },
|
||||
{ id: "settings" as Page, label: "设置", icon: Settings },
|
||||
];
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Trash2,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import type { RequestLog, RequestStatus } from "@/lib/api/telemetry";
|
||||
|
||||
interface LogViewerProps {
|
||||
logs: RequestLog[];
|
||||
onClear: () => void;
|
||||
onFilter?: (filter: { provider?: string; status?: RequestStatus }) => void;
|
||||
}
|
||||
|
||||
export function LogViewer({ logs, onClear, onFilter }: LogViewerProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [filterProvider, setFilterProvider] = useState<string>("");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("");
|
||||
|
||||
const getStatusIcon = (status: RequestStatus) => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case "failed":
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case "timeout":
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
case "retrying":
|
||||
return <AlertTriangle className="h-4 w-4 text-orange-500" />;
|
||||
case "cancelled":
|
||||
return <XCircle className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: RequestStatus) => {
|
||||
const texts: Record<RequestStatus, string> = {
|
||||
success: "成功",
|
||||
failed: "失败",
|
||||
timeout: "超时",
|
||||
retrying: "重试中",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
return texts[status];
|
||||
};
|
||||
|
||||
const getProviderName = (id: string) => {
|
||||
const names: Record<string, string> = {
|
||||
kiro: "Kiro",
|
||||
gemini: "Gemini",
|
||||
qwen: "Qwen",
|
||||
openai: "OpenAI",
|
||||
claude: "Claude",
|
||||
antigravity: "Antigravity",
|
||||
};
|
||||
return names[id] || id;
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
const handleFilterChange = () => {
|
||||
onFilter?.({
|
||||
provider: filterProvider || undefined,
|
||||
status: (filterStatus as RequestStatus) || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// 获取唯一的 providers
|
||||
const providers = [...new Set(logs.map((l) => l.provider))];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 过滤器和操作 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
className="rounded border bg-background px-2 py-1 text-sm"
|
||||
value={filterProvider}
|
||||
onChange={(e) => {
|
||||
setFilterProvider(e.target.value);
|
||||
handleFilterChange();
|
||||
}}
|
||||
>
|
||||
<option value="">所有 Provider</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{getProviderName(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="rounded border bg-background px-2 py-1 text-sm"
|
||||
value={filterStatus}
|
||||
onChange={(e) => {
|
||||
setFilterStatus(e.target.value);
|
||||
handleFilterChange();
|
||||
}}
|
||||
>
|
||||
<option value="">所有状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="timeout">超时</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
清空日志
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 日志列表 */}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-4 py-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="font-medium">请求日志</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({logs.length} 条)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
暂无请求日志
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y max-h-[500px] overflow-y-auto">
|
||||
{logs.map((log) => (
|
||||
<LogEntry
|
||||
key={log.id}
|
||||
log={log}
|
||||
expanded={expandedId === log.id}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === log.id ? null : log.id)
|
||||
}
|
||||
getStatusIcon={getStatusIcon}
|
||||
getStatusText={getStatusText}
|
||||
getProviderName={getProviderName}
|
||||
formatTime={formatTime}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogEntry({
|
||||
log,
|
||||
expanded,
|
||||
onToggle,
|
||||
getStatusIcon,
|
||||
getStatusText,
|
||||
getProviderName,
|
||||
formatTime,
|
||||
formatDuration,
|
||||
}: {
|
||||
log: RequestLog;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
getStatusIcon: (status: RequestStatus) => React.ReactNode;
|
||||
getStatusText: (status: RequestStatus) => string;
|
||||
getProviderName: (id: string) => string;
|
||||
formatTime: (timestamp: string) => string;
|
||||
formatDuration: (ms: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="hover:bg-muted/50">
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-2 cursor-pointer"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{getStatusIcon(log.status)}
|
||||
<span className="text-xs text-muted-foreground w-32">
|
||||
{formatTime(log.timestamp)}
|
||||
</span>
|
||||
<span className="font-medium w-20">
|
||||
{getProviderName(log.provider)}
|
||||
</span>
|
||||
<span className="text-sm flex-1 truncate">{log.model}</span>
|
||||
<span className="text-sm text-muted-foreground w-16 text-right">
|
||||
{formatDuration(log.duration_ms)}
|
||||
</span>
|
||||
{log.total_tokens && (
|
||||
<span className="text-xs text-muted-foreground w-20 text-right">
|
||||
{log.total_tokens} tokens
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-3 pl-12 space-y-2 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4 rounded bg-muted/50 p-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground">状态:</span>{" "}
|
||||
<span
|
||||
className={
|
||||
log.status === "success" ? "text-green-600" : "text-red-600"
|
||||
}
|
||||
>
|
||||
{getStatusText(log.status)}
|
||||
</span>
|
||||
{log.http_status && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
(HTTP {log.http_status})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">流式:</span>{" "}
|
||||
{log.is_streaming ? "是" : "否"}
|
||||
</div>
|
||||
{log.input_tokens !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">输入 Token:</span>{" "}
|
||||
{log.input_tokens}
|
||||
</div>
|
||||
)}
|
||||
{log.output_tokens !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">输出 Token:</span>{" "}
|
||||
{log.output_tokens}
|
||||
</div>
|
||||
)}
|
||||
{log.retry_count > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">重试次数:</span>{" "}
|
||||
{log.retry_count}
|
||||
</div>
|
||||
)}
|
||||
{log.credential_id && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">凭证 ID:</span>{" "}
|
||||
<code className="text-xs">
|
||||
{log.credential_id.slice(0, 8)}...
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{log.error_message && (
|
||||
<div className="rounded bg-red-50 dark:bg-red-950/20 p-2 text-red-600 dark:text-red-400">
|
||||
<span className="font-medium">错误:</span> {log.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Activity, FileText, Coins, RefreshCw } from "lucide-react";
|
||||
import { StatsOverview } from "./StatsOverview";
|
||||
import { LogViewer } from "./LogViewer";
|
||||
import { TokenStats } from "./TokenStats";
|
||||
import {
|
||||
getDashboardData,
|
||||
getRequestLogs,
|
||||
clearRequestLogs,
|
||||
getTokenStatsByDay,
|
||||
getTokenStatsByProvider,
|
||||
type DashboardData,
|
||||
type RequestLog,
|
||||
type PeriodTokenStats,
|
||||
type ProviderTokenStats,
|
||||
type RequestStatus,
|
||||
} from "@/lib/api/telemetry";
|
||||
|
||||
type TabType = "overview" | "logs" | "tokens";
|
||||
|
||||
export function MonitoringPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>("overview");
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(
|
||||
null,
|
||||
);
|
||||
const [logs, setLogs] = useState<RequestLog[]>([]);
|
||||
const [tokensByDay, setTokensByDay] = useState<PeriodTokenStats[]>([]);
|
||||
const [tokensByProvider, setTokensByProvider] = useState<
|
||||
Record<string, ProviderTokenStats>
|
||||
>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [dashboard, logsData, dayStats, providerStats] = await Promise.all([
|
||||
getDashboardData(),
|
||||
getRequestLogs({ limit: 100 }),
|
||||
getTokenStatsByDay(7),
|
||||
getTokenStatsByProvider({ preset: "7d" }),
|
||||
]);
|
||||
|
||||
setDashboardData(dashboard);
|
||||
setLogs(logsData);
|
||||
setTokensByDay(dayStats);
|
||||
setTokensByProvider(providerStats);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch monitoring data:", e);
|
||||
setError(e instanceof Error ? e.message : "加载数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// 每 30 秒刷新一次
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
try {
|
||||
await clearRequestLogs();
|
||||
setLogs([]);
|
||||
} catch (e) {
|
||||
console.error("Failed to clear logs:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterLogs = async (filter: {
|
||||
provider?: string;
|
||||
status?: RequestStatus;
|
||||
}) => {
|
||||
try {
|
||||
const filteredLogs = await getRequestLogs({
|
||||
...filter,
|
||||
limit: 100,
|
||||
});
|
||||
setLogs(filteredLogs);
|
||||
} catch (e) {
|
||||
console.error("Failed to filter logs:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { id: TabType; label: string; icon: React.ElementType }[] = [
|
||||
{ id: "overview", label: "统计概览", icon: Activity },
|
||||
{ id: "logs", label: "请求日志", icon: FileText },
|
||||
{ id: "tokens", label: "Token 统计", icon: Coins },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">监控中心</h2>
|
||||
<p className="text-muted-foreground">
|
||||
请求统计、日志和 Token 使用情况
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 dark:bg-red-950/20 p-4 text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容区域 */}
|
||||
{loading && !dashboardData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === "overview" && dashboardData && (
|
||||
<StatsOverview
|
||||
stats={dashboardData.stats}
|
||||
byProvider={dashboardData.by_provider}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "logs" && (
|
||||
<LogViewer
|
||||
logs={logs}
|
||||
onClear={handleClearLogs}
|
||||
onFilter={handleFilterLogs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "tokens" && dashboardData && (
|
||||
<TokenStats
|
||||
summary={dashboardData.tokens}
|
||||
byProvider={tokensByProvider}
|
||||
byDay={tokensByDay}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { StatsSummary, ProviderStats } from "@/lib/api/telemetry";
|
||||
|
||||
interface StatsOverviewProps {
|
||||
stats: StatsSummary;
|
||||
byProvider: Record<string, ProviderStats>;
|
||||
}
|
||||
|
||||
export function StatsOverview({ stats, byProvider }: StatsOverviewProps) {
|
||||
const formatLatency = (ms: number) => {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
const formatRate = (rate: number) => `${(rate * 100).toFixed(1)}%`;
|
||||
|
||||
const getProviderName = (id: string) => {
|
||||
const names: Record<string, string> = {
|
||||
kiro: "Kiro Claude",
|
||||
gemini: "Gemini",
|
||||
qwen: "通义千问",
|
||||
openai: "OpenAI",
|
||||
claude: "Claude",
|
||||
antigravity: "Antigravity",
|
||||
};
|
||||
return names[id] || id;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 总体统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="总请求数"
|
||||
value={stats.total_requests.toString()}
|
||||
subValue={`成功 ${stats.successful_requests}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={CheckCircle2}
|
||||
label="成功率"
|
||||
value={formatRate(stats.success_rate)}
|
||||
subValue={`失败 ${stats.failed_requests}`}
|
||||
valueColor={
|
||||
stats.success_rate >= 0.9
|
||||
? "text-green-600"
|
||||
: stats.success_rate >= 0.7
|
||||
? "text-yellow-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="平均延迟"
|
||||
value={formatLatency(stats.avg_latency_ms)}
|
||||
subValue={
|
||||
stats.max_latency_ms
|
||||
? `最大 ${formatLatency(stats.max_latency_ms)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Zap}
|
||||
label="总 Token"
|
||||
value={formatNumber(stats.total_tokens)}
|
||||
subValue={`输入 ${formatNumber(stats.total_input_tokens)} / 输出 ${formatNumber(stats.total_output_tokens)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 按 Provider 统计 */}
|
||||
{Object.keys(byProvider).length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />按 Provider 统计
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(byProvider).map(([provider, providerStats]) => (
|
||||
<ProviderStatRow
|
||||
key={provider}
|
||||
name={getProviderName(provider)}
|
||||
stats={providerStats}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
valueColor = "text-foreground",
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: string;
|
||||
subValue?: string;
|
||||
valueColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<div className={`mt-2 text-2xl font-bold ${valueColor}`}>{value}</div>
|
||||
{subValue && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{subValue}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderStatRow({
|
||||
name,
|
||||
stats,
|
||||
}: {
|
||||
name: string;
|
||||
stats: ProviderStats;
|
||||
}) {
|
||||
const successRate = stats.success_rate * 100;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="font-medium">{name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{stats.total_requests} 请求
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
<span className="text-sm">{stats.successful_requests}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3 text-red-500" />
|
||||
<span className="text-sm">{stats.failed_requests}</span>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<div className="h-2 rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
successRate >= 90
|
||||
? "bg-green-500"
|
||||
: successRate >= 70
|
||||
? "bg-yellow-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
style={{ width: `${successRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium w-12 text-right">
|
||||
{successRate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import React from "react";
|
||||
import { Coins, TrendingUp, BarChart3 } from "lucide-react";
|
||||
import type {
|
||||
TokenStatsSummary,
|
||||
ProviderTokenStats,
|
||||
PeriodTokenStats,
|
||||
} from "@/lib/api/telemetry";
|
||||
|
||||
interface TokenStatsProps {
|
||||
summary: TokenStatsSummary;
|
||||
byProvider: Record<string, ProviderTokenStats>;
|
||||
byDay: PeriodTokenStats[];
|
||||
}
|
||||
|
||||
export function TokenStats({ summary, byProvider, byDay }: TokenStatsProps) {
|
||||
const getProviderName = (id: string) => {
|
||||
const names: Record<string, string> = {
|
||||
kiro: "Kiro Claude",
|
||||
gemini: "Gemini",
|
||||
qwen: "通义千问",
|
||||
openai: "OpenAI",
|
||||
claude: "Claude",
|
||||
antigravity: "Antigravity",
|
||||
};
|
||||
return names[id] || id;
|
||||
};
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(2)}M`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "";
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
// 计算每日最大值用于图表缩放
|
||||
const maxDayTokens = Math.max(...byDay.map((d) => d.total_tokens), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Token 总览 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<TokenCard
|
||||
icon={Coins}
|
||||
label="总 Token"
|
||||
value={formatNumber(summary.total_tokens)}
|
||||
subValue={`${summary.record_count} 条记录`}
|
||||
/>
|
||||
<TokenCard
|
||||
icon={TrendingUp}
|
||||
label="输入 Token"
|
||||
value={formatNumber(summary.total_input_tokens)}
|
||||
subValue={`平均 ${Math.round(summary.avg_input_tokens)}/请求`}
|
||||
/>
|
||||
<TokenCard
|
||||
icon={TrendingUp}
|
||||
label="输出 Token"
|
||||
value={formatNumber(summary.total_output_tokens)}
|
||||
subValue={`平均 ${Math.round(summary.avg_output_tokens)}/请求`}
|
||||
/>
|
||||
<TokenCard
|
||||
icon={BarChart3}
|
||||
label="数据来源"
|
||||
value={`${summary.actual_count}/${summary.record_count}`}
|
||||
subValue={`实际值 / 估算值 ${summary.estimated_count}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 按 Provider 统计 */}
|
||||
{Object.keys(byProvider).length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<Coins className="h-4 w-4" />按 Provider Token 使用
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(byProvider).map(([provider, stats]) => (
|
||||
<ProviderTokenRow
|
||||
key={provider}
|
||||
name={getProviderName(provider)}
|
||||
stats={stats}
|
||||
totalTokens={summary.total_tokens}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 每日趋势 */}
|
||||
{byDay.length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
每日 Token 使用趋势
|
||||
</h3>
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{byDay
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((day, index) => (
|
||||
<DayBar
|
||||
key={index}
|
||||
date={formatDate(day.period_start)}
|
||||
tokens={day.total_tokens}
|
||||
maxTokens={maxDayTokens}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: string;
|
||||
subValue?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold">{value}</div>
|
||||
{subValue && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{subValue}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderTokenRow({
|
||||
name,
|
||||
stats,
|
||||
totalTokens,
|
||||
}: {
|
||||
name: string;
|
||||
stats: ProviderTokenStats;
|
||||
totalTokens: number;
|
||||
}) {
|
||||
const percentage =
|
||||
totalTokens > 0 ? (stats.total_tokens / totalTokens) * 100 : 0;
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(2)}M`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="font-medium">{name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{stats.record_count} 条记录
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">输入:</span>{" "}
|
||||
{formatNumber(stats.total_input_tokens)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">输出:</span>{" "}
|
||||
{formatNumber(stats.total_output_tokens)}
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<div className="h-2 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-2 rounded-full bg-primary"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium w-16 text-right">
|
||||
{formatNumber(stats.total_tokens)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayBar({
|
||||
date,
|
||||
tokens,
|
||||
maxTokens,
|
||||
}: {
|
||||
date: string;
|
||||
tokens: number;
|
||||
maxTokens: number;
|
||||
}) {
|
||||
const height = maxTokens > 0 ? (tokens / maxTokens) * 100 : 0;
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`;
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full flex items-end justify-center h-24">
|
||||
<div
|
||||
className="w-full max-w-8 bg-primary/80 rounded-t hover:bg-primary transition-colors"
|
||||
style={{ height: `${Math.max(height, 2)}%` }}
|
||||
title={`${formatNumber(tokens)} tokens`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{date}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { MonitoringPage } from "./MonitoringPage";
|
||||
export { StatsOverview } from "./StatsOverview";
|
||||
export { LogViewer } from "./LogViewer";
|
||||
export { TokenStats } from "./TokenStats";
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from "react";
|
||||
import { Plug, MessageSquare, Boxes, Puzzle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { McpPage } from "../mcp/McpPage";
|
||||
import { PromptsPage } from "../prompts/PromptsPage";
|
||||
import { SkillsPage } from "../skills/SkillsPage";
|
||||
import { PluginManager } from "../plugins/PluginManager";
|
||||
|
||||
type Tab = "mcp" | "prompts" | "skills" | "plugins";
|
||||
|
||||
const tabs = [
|
||||
{ id: "mcp" as Tab, label: "MCP", icon: Plug },
|
||||
{ id: "prompts" as Tab, label: "Prompts", icon: MessageSquare },
|
||||
{ id: "skills" as Tab, label: "Skills", icon: Boxes },
|
||||
{ id: "plugins" as Tab, label: "Plugins", icon: Puzzle },
|
||||
];
|
||||
|
||||
export function ExtensionsSettings() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("mcp");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">扩展管理</h3>
|
||||
<p className="text-muted-foreground">
|
||||
管理 MCP 服务器、Prompts 和 Skills
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<div className="pt-2">
|
||||
{activeTab === "mcp" && <McpPage hideHeader />}
|
||||
{activeTab === "prompts" && <PromptsPage hideHeader />}
|
||||
{activeTab === "skills" && <SkillsPage hideHeader />}
|
||||
{activeTab === "plugins" && <PluginManager />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { Route, Shield, AlertTriangle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RoutingPage } from "../routing/RoutingPage";
|
||||
import { ResiliencePage } from "../resilience/ResiliencePage";
|
||||
|
||||
type Tab = "routing" | "resilience";
|
||||
|
||||
const tabs = [
|
||||
{ id: "routing" as Tab, label: "智能路由", icon: Route },
|
||||
{ id: "resilience" as Tab, label: "容错配置", icon: Shield },
|
||||
];
|
||||
|
||||
export function RoutingSettings() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("routing");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">路由管理</h3>
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-md">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
实验功能
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
配置智能路由规则和容错策略(实验性功能,可能不稳定)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<div className="pt-2">
|
||||
{activeTab === "routing" && <RoutingPageContent />}
|
||||
{activeTab === "resilience" && <ResiliencePageContent />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 路由页面内容(去掉标题)
|
||||
function RoutingPageContent() {
|
||||
return (
|
||||
<div className="routing-content">
|
||||
<RoutingPage hideHeader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 容错页面内容(去掉标题)
|
||||
function ResiliencePageContent() {
|
||||
return (
|
||||
<div className="resilience-content">
|
||||
<ResiliencePage hideHeader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,14 +7,25 @@ import { AboutSection } from "./AboutSection";
|
||||
import { TlsSettings } from "./TlsSettings";
|
||||
import { QuotaSettings } from "./QuotaSettings";
|
||||
import { RemoteManagementSettings } from "./RemoteManagementSettings";
|
||||
import { ExtensionsSettings } from "./ExtensionsSettings";
|
||||
import { RoutingSettings } from "./RoutingSettings";
|
||||
|
||||
type SettingsTab = "general" | "proxy" | "security" | "advanced" | "about";
|
||||
type SettingsTab =
|
||||
| "general"
|
||||
| "proxy"
|
||||
| "security"
|
||||
| "advanced"
|
||||
| "extensions"
|
||||
| "routing"
|
||||
| "about";
|
||||
|
||||
const tabs: { id: SettingsTab; label: string }[] = [
|
||||
{ id: "general", label: "通用" },
|
||||
{ id: "proxy", label: "代理服务" },
|
||||
{ id: "security", label: "安全" },
|
||||
{ id: "advanced", label: "高级" },
|
||||
{ id: "extensions", label: "扩展" },
|
||||
{ id: "routing", label: "路由管理 (实验)" },
|
||||
{ id: "about", label: "关于" },
|
||||
];
|
||||
|
||||
@@ -65,6 +76,8 @@ export function SettingsPage() {
|
||||
<QuotaSettings />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "extensions" && <ExtensionsSettings />}
|
||||
{activeTab === "routing" && <RoutingSettings />}
|
||||
{activeTab === "about" && <AboutSection />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,13 +124,6 @@ export interface TimeRangeParam {
|
||||
preset?: "1h" | "24h" | "7d" | "30d";
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
stats: StatsSummary;
|
||||
tokens: TokenStatsSummary;
|
||||
by_provider: Record<string, ProviderStats>;
|
||||
recent_logs: RequestLog[];
|
||||
}
|
||||
|
||||
// ========== 请求日志 API ==========
|
||||
|
||||
export async function getRequestLogs(params?: {
|
||||
@@ -197,9 +190,3 @@ export async function getTokenStatsByDay(
|
||||
): Promise<PeriodTokenStats[]> {
|
||||
return invoke("get_token_stats_by_day", { days });
|
||||
}
|
||||
|
||||
// ========== 仪表盘 API ==========
|
||||
|
||||
export async function getDashboardData(): Promise<DashboardData> {
|
||||
return invoke("get_dashboard_data");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user