174 lines
6.3 KiB
TypeScript
174 lines
6.3 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import { RotateCcw, Zap } from "lucide-react";
|
|
import { useRestore, useStatus, useLogs } from "@/lib/queries";
|
|
import { useToast } from "@/components/toast";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table";
|
|
|
|
function formatUptime(seconds: number): string {
|
|
if (seconds < 60) return `${seconds}s`;
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
}
|
|
|
|
function formatTime(iso: string): string {
|
|
const date = new Date(iso);
|
|
return date.toLocaleTimeString();
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const { t } = useTranslation();
|
|
const status = useStatus();
|
|
const logs = useLogs();
|
|
const restore = useRestore();
|
|
const { show } = useToast();
|
|
|
|
if (status.isPending) {
|
|
return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
|
|
}
|
|
if (status.isError || !status.data) {
|
|
return (
|
|
<div className="flex flex-col items-start gap-3">
|
|
<span className="text-sm text-red-600">{t("common.error")}</span>
|
|
<Button variant="outline" size="sm" onClick={() => status.refetch()}>
|
|
{t("common.retry")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const s = status.data;
|
|
const active = s.active_claude_provider;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t("dashboard.gateway")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<Badge tone={s.running ? "success" : "danger"}>
|
|
{s.running ? t("dashboard.running") : t("dashboard.stopped")}
|
|
</Badge>
|
|
<span className="text-xs text-zinc-500">
|
|
{t("dashboard.port")} {s.port}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-zinc-500">
|
|
{t("dashboard.uptime")} {formatUptime(s.uptime_sec)} · {t("dashboard.version")}{" "}
|
|
{s.version}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t("dashboard.activeProvider")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-2">
|
|
{active ? (
|
|
<>
|
|
<div className="text-sm font-medium">{active.name}</div>
|
|
<div className="truncate text-xs text-zinc-500" title={active.base_url}>
|
|
{active.base_url}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-sm text-zinc-500">{t("dashboard.noActiveProvider")}</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t("dashboard.statsTitle")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<dl className="grid grid-cols-2 gap-2 text-xs">
|
|
<div>
|
|
<dt className="text-zinc-500">{t("dashboard.requests")}</dt>
|
|
<dd className="text-base font-semibold">{s.stats.requests_total}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-zinc-500">{t("dashboard.failed")}</dt>
|
|
<dd className="text-base font-semibold">{s.stats.requests_failed}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-zinc-500">{t("dashboard.tokensIn")}</dt>
|
|
<dd className="text-base font-semibold">{s.stats.input_tokens}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-zinc-500">{t("dashboard.tokensOut")}</dt>
|
|
<dd className="text-base font-semibold">{s.stats.output_tokens}</dd>
|
|
</div>
|
|
</dl>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="flex-row items-center justify-between">
|
|
<CardTitle>{t("dashboard.recentRequests")}</CardTitle>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={restore.isPending}
|
|
onClick={() =>
|
|
restore.mutate(
|
|
{ target: "claude" },
|
|
{
|
|
onSuccess: () => show(t("dashboard.restoreDone")),
|
|
onError: (err) => show(err.message, "error"),
|
|
},
|
|
)
|
|
}
|
|
>
|
|
{restore.isPending ? (
|
|
<RotateCcw size={14} className="animate-spin" aria-hidden />
|
|
) : (
|
|
<Zap size={14} aria-hidden />
|
|
)}
|
|
{restore.isPending ? t("common.restoring") : t("common.restore")}
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{logs.data && logs.data.items.length > 0 ? (
|
|
<Table>
|
|
<THead>
|
|
<TR>
|
|
<TH>{t("dashboard.tableTime")}</TH>
|
|
<TH>{t("dashboard.tableModel")}</TH>
|
|
<TH>{t("dashboard.tableStatus")}</TH>
|
|
<TH>{t("dashboard.tableDuration")}</TH>
|
|
<TH>{t("dashboard.tableTokens")}</TH>
|
|
</TR>
|
|
</THead>
|
|
<TBody>
|
|
{logs.data.items.slice(0, 12).map((entry, index) => (
|
|
<TR key={`${entry.time}-${index}`}>
|
|
<TD className="text-xs text-zinc-500">{formatTime(entry.time)}</TD>
|
|
<TD className="font-mono text-xs">{entry.model ?? entry.path}</TD>
|
|
<TD>
|
|
<Badge tone={entry.status < 400 ? "success" : "danger"}>{entry.status}</Badge>
|
|
</TD>
|
|
<TD className="text-xs">{entry.duration_ms} ms</TD>
|
|
<TD className="text-xs">
|
|
{entry.input_tokens ?? 0} / {entry.output_tokens ?? 0}
|
|
</TD>
|
|
</TR>
|
|
))}
|
|
</TBody>
|
|
</Table>
|
|
) : (
|
|
<div className="text-sm text-zinc-500">{t("dashboard.emptyLogs")}</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|