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:
kate522 committed 2026-10-18 17:19:13 +08:00
1 parent 8ee5e98ea3
commit f8acf31ebc
71 files changed
+15727 -1

No files matched your search

+170
View File
@@ -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>
);
}