CrossRelay stage 0+1: Go gateway with Anthropic<->Chat Completions relay, Tauri shell, bilingual management UI
- Go gateway (stdlib only): /v1/messages non-stream + SSE, /v1/messages/count_tokens, management API /api/* (providers CRUD, apply/restore, settings, backups, logs) - Protocol conversion: Anthropic Messages <-> Chat Completions with a streaming state machine keyed by content block index (text, tool_use, parallel calls) - ~/.claude/settings.json guarded read/write: timestamped backups, managed env keys only, one-click restore - Tauri 2 shell: spawns the gateway sidecar, parses the random port from stdout, stdin pipe watchdog prevents orphans, kills the child on exit - React frontend: Dashboard/Providers/Settings, zh-CN + en-US, TanStack Query - Tests: Go table-driven + httptest integration (all green), vitest + RTL
This commit is contained in:
1 parent
8ee5e98ea3
commit
f8acf31ebc
71 files changed
+15727
-1
No files matched your search
@@ -0,0 +1,170 @@
|
||||
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(undefined, {
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user