feat: 升级管理台与路由能力

- 管理台体验升级与日志分页/搜索/状态码展示
- 令牌过期与允许渠道控制,站点新增/修改自动测试
- 调用令牌按模型匹配选择,移除模型 TTL 并优化冷却逻辑
- 新增迁移与文档同步
This commit is contained in:
lsy committed 2026-03-15 04:26:25 +08:00
1 parent 8af53f34f0
commit f6f98491b3
41 files changed
+2476 -578

No files matched your search

+59
View File
@@ -29,6 +29,9 @@
- **[usage/proxy]**: 使用日志记录上游失败详情并在日志状态列展示
- 方案: [202603141815_usage-log-status](archive/2026-03/202603141815_usage-log-status/)
- 文件: apps/worker/migrations/0005_add_usage_error_fields.sql, apps/worker/src/db/schema.sql, apps/worker/src/services/usage.ts, apps/worker/src/routes/proxy.ts, apps/ui/src/core/types.ts, apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx, tests/ui/usage-status.test.ts, .helloagents/modules/usage.md, .helloagents/modules/admin-ui.md
- **[admin-ui/usage]**: 状态列改为“状态码 + 错误码”,成功展示 `200 OK`
- 方案: [202603141925_usage-status-label](archive/2026-03/202603141925_usage-status-label/)
- 文件: apps/worker/src/routes/proxy.ts, apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx, tests/ui/usage-status.test.ts, .helloagents/modules/usage.md, .helloagents/modules/admin-ui.md
- **[worker/proxy]**: 移除临时调试日志(请求入口/出口与上游响应汇总)
- 类型: 微调(无方案包)
- 文件: apps/worker/src/index.ts, apps/worker/src/routes/proxy.ts
@@ -168,6 +171,62 @@
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/proxy.ts
### 快速修改
- **[admin-ui]**: 修复模型广场渲染语法错误 — by lsy
- 类型: 快速修改(无方案包)
- 文件: apps/ui/src/features/ModelsView.tsx:16
- **[admin-ui]**: 站点搜索加入防抖与使用日志状态码/摘要展示优化 — by lsy
- 类型: 快速修改(无方案包)
- 文件: apps/ui/src/features/SitesView.tsx, apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx
- **[admin-ui]**: 提交操作增加即时防重复锁,避免连点重复创建 — by lsy
- 类型: 快速修改(无方案包)
- 文件: apps/ui/src/App.tsx
## [0.9.4] - 2026-03-15
### 变更
- **[usage/admin-ui]**: 使用日志改为服务端分页并支持渠道/令牌/模型搜索 — by lsy
- 方案: [202603150145_usage-log-pagination-and-channel-disable](archive/2026-03/202603150145_usage-log-pagination-and-channel-disable/)
- 决策: usage-log-pagination-and-channel-disable#D001(使用 offset+limit+total 分页协议)
- **[channels]**: 连通性测试不再覆盖已禁用渠道状态 — by lsy
- 方案: [202603150145_usage-log-pagination-and-channel-disable](archive/2026-03/202603150145_usage-log-pagination-and-channel-disable/)
## [0.9.2] - 2026-03-14
### 变更
- **[usage/proxy]**: 状态列仅展示日志状态码并支持错误详情查看,冷却改为窗口内连续失败触发 — by lsy
- 方案: [202603142337_usage-status-cooldown-tuning](archive/2026-03/202603142337_usage-status-cooldown-tuning/)
- 决策: usage-status-cooldown-tuning#D001(连续失败阈值冷却)
## [0.9.1] - 2026-03-14
### 变更
- **[proxy/models/settings/admin-ui]**: 移除模型能力 TTL,路由不再兜底并支持失败冷却配置 — by lsy
- 方案: [202603142241_remove-model-ttl-fix-routing](archive/2026-03/202603142241_remove-model-ttl-fix-routing/)
- 决策: remove-model-ttl-fix-routing#D001(移除模型能力 TTL)
## [0.9.2] - 2026-03-15
### 变更
- **[proxy/usage/admin-ui]**: 冷却仅对上游失败触发,使用日志列表仅展示状态码 — by lsy
- 方案: [202603150017_usage-status-cooldown-migration](archive/2026-03/202603150017_usage-status-cooldown-migration/)
- 决策: usage-status-cooldown-migration#D001(冷却仅针对上游/网络失败)
## [0.9.3] - 2026-03-15
### 变更
- **[proxy/usage]**: 所有调用写入日志,失败计数仅在成功时清零 — by lsy
- 方案: [202603150017_usage-status-cooldown-migration](archive/2026-03/202603150017_usage-status-cooldown-migration/)
- 决策: usage-status-cooldown-migration#D001(冷却仅针对上游/网络失败)
## [0.9.0] - 2026-03-14
### 新增
- **[admin-ui]**: 管理台体验升级(通知分级、确认弹窗、空状态 CTA、视觉主题与动效) — by lsy
- 方案: [202603142024_admin-ui-ux-upgrade](archive/2026-03/202603142024_admin-ui-ux-upgrade/)
- 决策: admin-ui-ux-upgrade#D001(统一通知与确认对话管理)
## [0.8.4] - 2026-02-25
### 变更
+12 -3
View File
@@ -24,16 +24,25 @@
- openai/Anthropic/gemini 站点支持 base_url 为空时自动填充官方地址
- done-hub 站点仅使用 base_url,不再维护多地址输入
- 站点连通测试提示令牌成功/失败统计
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
- 使用日志状态列展示详细失败原因(状态码/错误码/摘要)
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
- 站点新增/修改完成后自动触发连通测试并提示结果
- 使用日志支持服务端分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
- 使用日志支持按渠道/令牌/模型/状态码搜索并与分页联动
- 使用日志状态列仅展示状态码,错误详情弹窗仅展示元信息(不展示错误片段)
- 令牌管理采用列表视图(默认每页 10 条),生成/编辑使用弹窗表单,支持过期时间(北京时间)与允许渠道(默认全开)并默认复制,列表展示创建时间、前缀、过期时间与渠道限制摘要
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
- 系统设置增加定时签到开关与时间(中国时间)
- 系统设置提供模型失败冷却分钟数配置
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择
- Token 仅本地保存,不回显
- 移动端使用顶部栏 + 抽屉导航,侧边栏在桌面端保持固定
- 渠道/令牌列表在移动端以卡片形式展示,操作区改为网格按钮排列
- 数据面板、模型广场、使用日志表格在移动端支持横向滚动与字号收紧
- 全局通知支持成功/提示/错误/信息分级展示,可关闭并自动消退
- 删除类操作必须经过确认弹窗
- 主要异步操作提供 loading 与按钮禁用态
- 弹窗支持遮罩/ESC 关闭并自动聚焦首字段
- 空状态提供清晰 CTA 引导下一步
- 全局字体通过外部字体源加载,背景包含暖调渐变与轻纹理
## 依赖关系
- `auth` / `channels` / `models` / `tokens` / `usage` / `dashboard` / `settings` 模块
+1
View File
@@ -29,6 +29,7 @@
- 创建渠道时可传入自定义 `id`,未提供则自动生成
- 连通性测试会对调用令牌逐个调用 `/v1/models` 并汇总模型
- 至少一枚令牌测试成功时更新 `models_json` 并返回成功/失败统计
- 连通性测试不会覆盖已禁用渠道的状态(保持 `disabled`)
- New API 兼容层支持 `type`/`group`/`priority` 等字段映射,并保留扩展字段到 `metadata_json`
- New API 标签(tag)存储在 `metadata_json.tag`,标签接口按该字段批量更新
- New API 分组列表从 `group_name` 字段解析,空时返回 `default`
-1
View File
@@ -11,7 +11,6 @@
## 行为规范
- 仅聚合启用状态的渠道
- 使用“模型广场测试结果”作为可用模型来源
- 模型结果带 TTL(默认 2 小时,来自 settings),过期后回退到渠道声明模型列表(避免模型广场清空)
- 模型归一化支持字符串数组与对象数组(对象取 `id` 字段)
## 依赖关系
+8 -3
View File
@@ -14,7 +14,8 @@
## 行为规范
- 基于令牌鉴权
- 按渠道权重随机排序
- 调用令牌按顺序选择,未配置时回退 `channels.api_key`
- 调用令牌按模型列表匹配选择,未配置或无列表时回退 `channels.api_key`
- 调用令牌模型列表来自渠道连通测试写回
- done-hub 仅使用 `base_url`,不再按多地址切换
- 支持从非流式 JSON、响应头与流式 SSE(含 `response.usage` 与 `usageMetadata`)解析 usage 字段
- 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage
@@ -26,8 +27,12 @@
- 允许 `metadata_json.endpoint_overrides` 覆盖 chat/embedding/image 上游地址(可用 `{model}` 占位)
- 允许 `metadata_json.header_override` 与 `metadata_json.query_override` 注入额外上游请求头/查询参数
- Anthropic 默认注入 `anthropic-version=2023-06-01`,可通过 `header_override` 覆盖
- 路由时优先使用模型能力表(TTL 内测试成功的模型),无能力记录时回退渠道声明模型列表
- 上游失败/超时会记录到能力表并进入冷却期,冷却期内跳过对应渠道/模型
- 路由时优先使用模型能力表记录(测试成功的模型),无能力记录时回退渠道声明模型列表
- 当模型在任意渠道中都不被支持时返回 `no_available_channels`,不回退到全部渠道
- 上游失败/超时会记录到能力表并进入冷却期(需在窗口内连续失败达到阈值),冷却期内跳过对应渠道/模型
- 失败计数仅在成功响应时清零
- 仅 429/5xx/408/timeout/exception 计入冷却,4xx 请求错误不触发冷却
- 映射模型场景失败时同时记录下游模型错误,确保冷却窗口生效
- 上游成功响应会刷新能力表,清空错误并延长可用窗口
## 依赖关系
-2
View File
@@ -4,7 +4,6 @@
- 维护系统级配置(如日志保留天数)
- 为使用日志与面板提供统一配置读取
- 维护定时签到执行时间
- 维护模型能力 TTL(模型广场与路由过滤窗口)
- 维护模型失败冷却时间(运行时失败的渠道/模型冷却窗口)
## 接口定义
@@ -17,7 +16,6 @@
- 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间)
- 更新签到时间后会触发 `CheckinScheduler` 重新调度
- 若更新后当前时间已超过新签到时间,当日可再次触发自动签到
- 模型能力 TTL 以小时计(默认 2 小时),用于控制“模型广场测试结果”的有效期
- 模型失败冷却以分钟计(默认 10 分钟),用于跳过最近失败的渠道/模型
## 依赖关系
+6 -1
View File
@@ -15,8 +15,13 @@
## 行为规范
- 令牌可通过 reveal 接口再次查看
- 默认允许所有渠道
- 可选限制允许的渠道列表(`allowed_channels`),为空或未设置表示全开
- 额度消耗由代理调用记录
- 管理台列表展示创建时间与 key_prefix 前缀
- `expires_at` 为可选字段,留空表示永不过期
- 管理台以北京时间输入过期时间,服务端统一转为 UTC 存储
- 令牌过期后直接拒绝请求(`token_expired`),不自动更改状态
- 管理台支持编辑名称/额度/状态/过期时间/允许渠道,新增令牌默认复制到剪贴板
- 管理台列表展示创建时间与 key_prefix 前缀、过期时间
## 依赖关系
- `tokens` 表
+4 -2
View File
@@ -9,12 +9,14 @@
- `GET /api/usage` 使用日志查询
## 行为规范
- 代理调用统一写入 `usage_logs`
- 代理调用统一写入 `usage_logs`(含无可用渠道/冷却提前返回)
- 保留天数可配置,查询时触发清理
- 查询结果附带渠道与令牌名称
- `GET /api/usage` 支持 `offset/limit` 分页并返回 `total`
- 支持按 `model/channel/token/status` 关键词搜索(保留 `channel_id/token_id` 精确过滤)
- usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度
- 推理强度来自请求体 `reasoning` / `reasoning_effort` 字段
- usage_logs 记录上游失败详情(status/code/message),用于日志状态展示
- usage_logs 记录上游失败详情(status/code/message),日志列表仅展示状态码,详情弹窗仅展示元信息(不展示错误片段)
## 依赖关系
- `usage_logs` 表
+7
View File
@@ -6,6 +6,13 @@
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<meta name="theme-color" content="#1C1917" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>api-workers</title>
</head>
<body>
+873 -217
View File
File diff suppressed because it is too large. Load diff
+16 -1
View File
@@ -1,4 +1,10 @@
import type { AdminData, SettingsForm, SiteForm, TabItem } from "./types";
import type {
AdminData,
SettingsForm,
SiteForm,
TabItem,
TokenForm,
} from "./types";
export const apiBase = import.meta.env.VITE_API_BASE ?? "";
@@ -43,4 +49,13 @@ export const initialSettingsForm: SettingsForm = {
session_ttl_hours: "12",
admin_password: "",
checkin_schedule_time: "00:10",
model_failure_cooldown_minutes: "10",
};
export const initialTokenForm: TokenForm = {
name: "",
quota_total: "",
status: "active",
expires_at: "",
allowed_channels: [],
};
+34
View File
@@ -41,6 +41,8 @@ export type Token = {
quota_total: number | null;
quota_used: number;
status: string;
allowed_channels?: string[] | null;
expires_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
@@ -66,6 +68,20 @@ export type UsageLog = {
created_at: string;
};
export type UsageQuery = {
channel: string;
token: string;
model: string;
status: string;
};
export type UsageResponse = {
logs: UsageLog[];
total: number;
limit: number;
offset: number;
};
export type DashboardData = {
summary: {
total_requests: number;
@@ -84,6 +100,7 @@ export type Settings = {
session_ttl_hours: number;
admin_password_set?: boolean;
checkin_schedule_time?: string;
model_failure_cooldown_minutes?: number;
};
export type ModelChannel = {
@@ -142,6 +159,15 @@ export type SettingsForm = {
session_ttl_hours: string;
admin_password: string;
checkin_schedule_time: string;
model_failure_cooldown_minutes: string;
};
export type TokenForm = {
name: string;
quota_total: string;
status: string;
expires_at: string;
allowed_channels: string[];
};
export type CheckinResultItem = {
@@ -158,3 +184,11 @@ export type CheckinSummary = {
failed: number;
skipped: number;
};
export type NoticeTone = "success" | "warning" | "error" | "info";
export type NoticeMessage = {
tone: NoticeTone;
message: string;
id: number;
};
+89 -51
View File
@@ -26,6 +26,87 @@ export const formatDateTime = (value?: string | null) => {
)}`;
};
const formatInTimeZone = (
value: string | null | undefined,
timeZone: string,
parts: Array<"year" | "month" | "day" | "hour" | "minute" | "second">,
separator: { date: string },
): string => {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "-";
}
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const dateParts = formatter.formatToParts(date);
const pick = (type: string) =>
dateParts.find((part) => part.type === type)?.value ?? "00";
const dateText = `${pick("year")}-${pick("month")}-${pick("day")}`;
const timeText = parts.includes("second")
? `${pick("hour")}:${pick("minute")}:${pick("second")}`
: `${pick("hour")}:${pick("minute")}`;
return `${dateText}${separator.date}${timeText}`.trim();
};
export const formatChinaDateTime = (value?: string | null) =>
formatInTimeZone(
value,
"Asia/Shanghai",
["year", "month", "day", "hour", "minute", "second"],
{ date: " " },
);
export const toChinaDateTimeInput = (value?: string | null) => {
if (!value) {
return "";
}
const formatted = formatInTimeZone(
value,
"Asia/Shanghai",
["year", "month", "day", "hour", "minute"],
{ date: "T" },
);
return formatted === "-" ? "" : formatted;
};
export const toChinaIsoFromInput = (value?: string | null) => {
if (!value) {
return null;
}
const raw = value.trim();
if (!raw) {
return null;
}
const [datePart, timePart] = raw.split("T");
if (!datePart || !timePart) {
return null;
}
const [year, month, day] = datePart.split("-").map(Number);
const [hour, minute] = timePart.split(":").map(Number);
if (
Number.isNaN(year) ||
Number.isNaN(month) ||
Number.isNaN(day) ||
Number.isNaN(hour) ||
Number.isNaN(minute)
) {
return null;
}
const utcMillis = Date.UTC(year, month - 1, day, hour - 8, minute);
return new Date(utcMillis).toISOString();
};
/**
* Toggles channel or token status between active and disabled.
*
@@ -79,63 +160,20 @@ export const buildPageItems = (current: number, total: number): PageItem[] => {
});
};
const truncateText = (value: string | null | undefined, max = 80) => {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed;
};
const describeError = (status?: number | null, code?: string | null) => {
const normalized = (code ?? "").toLowerCase();
if (status === 401 || status === 403) {
return "鉴权失败";
}
if (status === 429 || normalized.includes("rate")) {
return "触发限流";
}
if (
normalized.includes("insufficient") ||
normalized.includes("quota") ||
normalized.includes("balance")
) {
return "余额不足";
}
if (normalized.includes("model")) {
return "模型不可用";
}
return null;
};
export type UsageStatusDetail = {
label: string;
message: string | null;
tone: "success" | "error";
};
export const buildUsageStatusDetail = (log: UsageLog): UsageStatusDetail => {
if (log.status === "ok") {
return { label: "成功", message: null, tone: "success" };
}
const parts: string[] = [];
if (log.upstream_status !== null && log.upstream_status !== undefined) {
parts.push(String(log.upstream_status));
}
if (log.error_code) {
parts.push(log.error_code);
}
const hint = describeError(log.upstream_status, log.error_code ?? null);
if (hint) {
parts.push(hint);
}
const suffix = parts.length > 0 ? ` (${parts.join(" / ")})` : "";
const statusCode = log.upstream_status ?? null;
const isOk = log.status === "ok";
const label =
statusCode !== null && statusCode !== undefined
? String(statusCode)
: "-";
return {
label: `失败${suffix}`,
message: truncateText(log.error_message),
tone: "error",
label,
tone: isOk ? "success" : "error",
};
};
+36 -4
View File
@@ -1,11 +1,12 @@
import type { TabId, TabItem } from "../core/types";
import type { NoticeMessage, TabId, TabItem } from "../core/types";
type AppLayoutProps = {
tabs: TabItem[];
activeTab: TabId;
activeLabel: string;
token: string | null;
notice: string;
notice: NoticeMessage | null;
onDismissNotice: () => void;
onTabChange: (tabId: TabId) => void;
onLogout: () => void;
children?: unknown;
@@ -26,10 +27,23 @@ export const AppLayout = ({
activeLabel,
token,
notice,
onDismissNotice,
onTabChange,
onLogout,
children,
}: AppLayoutProps) => {
const noticeToneStyles: Record<NoticeMessage["tone"], string> = {
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
warning: "border-amber-200 bg-amber-50 text-amber-800",
error: "border-rose-200 bg-rose-50 text-rose-700",
info: "border-sky-200 bg-sky-50 text-sky-800",
};
const noticeToneLabel: Record<NoticeMessage["tone"], string> = {
success: "成功",
warning: "提示",
error: "错误",
info: "信息",
};
const closeMobileNav = () => {
const toggle = document.querySelector<HTMLInputElement>("#app-nav-toggle");
if (toggle) {
@@ -153,8 +167,26 @@ export const AppLayout = ({
</div>
</div>
{notice && (
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{notice}
<div
class={`animate-fade-up mt-4 rounded-xl border px-4 py-3 text-sm ${noticeToneStyles[notice.tone]}`}
>
<div class="flex items-start justify-between gap-3">
<div>
<span class="text-[10px] font-semibold uppercase tracking-widest text-stone-500">
{noticeToneLabel[notice.tone]}
</span>
<div class="mt-1 text-sm font-semibold text-stone-900">
{notice.message}
</div>
</div>
<button
class="h-8 rounded-full border border-stone-200 bg-white px-3 text-[11px] font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={onDismissNotice}
>
关闭
</button>
</div>
</div>
)}
{children}
+82 -7
View File
@@ -2,6 +2,8 @@ import type { DashboardData } from "../core/types";
type DashboardViewProps = {
dashboard: DashboardData | null;
onRefresh: () => void;
isRefreshing: boolean;
};
/**
@@ -13,23 +15,83 @@ type DashboardViewProps = {
* Returns:
* Dashboard JSX element.
*/
export const DashboardView = ({ dashboard }: DashboardViewProps) => {
export const DashboardView = ({
dashboard,
onRefresh,
isRefreshing,
}: DashboardViewProps) => {
if (!dashboard) {
return (
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
暂无数据
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
数据面板
</h3>
<p class="text-xs text-stone-500">
查看请求量、消耗与性能趋势。
</p>
</div>
<button
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={isRefreshing}
onClick={onRefresh}
>
{isRefreshing ? "刷新中..." : "刷新"}
</button>
</div>
<div class="mt-6 rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-8 text-center text-sm text-stone-500">
暂无数据,请先产生调用或刷新面板。
<div class="mt-4 flex justify-center">
<button
class="h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={onRefresh}
disabled={isRefreshing}
>
立即刷新
</button>
</div>
</div>
</div>
);
}
const totalRequests = dashboard.summary.total_requests;
const totalErrors = dashboard.summary.total_errors;
const errorRate = dashboard.summary.total_requests
? Math.round(
(dashboard.summary.total_errors / dashboard.summary.total_requests) *
100,
)
: 0;
const successRate = totalRequests
? Math.max(0, Math.round(((totalRequests - totalErrors) / totalRequests) * 100))
: 0;
const avgTokensPerRequest = totalRequests
? Math.round(dashboard.summary.total_tokens / totalRequests)
: 0;
return (
<div class="space-y-5">
<div class="grid grid-cols-1 gap-5 lg:grid-cols-3">
<div class="animate-fade-up space-y-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
数据面板
</h3>
<p class="text-xs text-stone-500">
快速掌握请求表现、性能与消耗概况。
</p>
</div>
<button
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={isRefreshing}
onClick={onRefresh}
>
{isRefreshing ? "刷新中..." : "刷新"}
</button>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-4">
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
总请求
@@ -54,11 +116,24 @@ export const DashboardView = ({ dashboard }: DashboardViewProps) => {
</div>
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
错误率
成功率
</span>
<div class="text-2xl font-semibold text-stone-900">{errorRate}%</div>
<div class="text-2xl font-semibold text-stone-900">
{successRate}%
</div>
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
平均延迟 {Math.round(dashboard.summary.avg_latency)}ms
错误率 {errorRate}%
</span>
</div>
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
单次消耗
</span>
<div class="text-2xl font-semibold text-stone-900">
{avgTokensPerRequest}
</div>
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
平均 {Math.round(dashboard.summary.avg_latency)}ms 延迟
</span>
</div>
</div>
+52 -36
View File
@@ -1,5 +1,8 @@
import type { NoticeMessage } from "../core/types";
type LoginViewProps = {
notice: string;
notice: NoticeMessage | null;
isSubmitting: boolean;
onSubmit: (event: Event) => void;
};
@@ -12,39 +15,52 @@ type LoginViewProps = {
* Returns:
* Login JSX element.
*/
export const LoginView = ({ notice, onSubmit }: LoginViewProps) => (
<div class="mx-auto mt-24 max-w-md rounded-2xl border border-stone-200 bg-white p-8 shadow-lg">
<h1 class="mb-2 font-['Space_Grotesk'] text-2xl tracking-tight text-stone-900">
api-workers
</h1>
<p class="text-sm text-stone-500">请输入管理员密码登录管理台。</p>
<form class="mt-6 grid gap-4" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="password"
export const LoginView = ({ notice, isSubmitting, onSubmit }: LoginViewProps) => {
const toneStyles: Record<NoticeMessage["tone"], string> = {
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
warning: "border-amber-200 bg-amber-50 text-amber-800",
error: "border-rose-200 bg-rose-50 text-rose-700",
info: "border-sky-200 bg-sky-50 text-sky-800",
};
return (
<div class="animate-fade-up mx-auto mt-24 max-w-md rounded-2xl border border-stone-200 bg-white p-8 shadow-lg">
<h1 class="mb-2 font-['Space_Grotesk'] text-2xl tracking-tight text-stone-900">
api-workers
</h1>
<p class="text-sm text-stone-500">请输入管理员密码登录管理台。</p>
<form class="mt-6 grid gap-4" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="password"
>
管理员密码
</label>
<input
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="password"
name="password"
type="password"
required
autoFocus
/>
</div>
<button
class="h-11 rounded-lg bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="submit"
disabled={isSubmitting}
>
管理员密码
</label>
<input
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="password"
name="password"
type="password"
required
/>
</div>
<button
class="h-11 rounded-lg bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="submit"
>
登录
</button>
</form>
{notice && (
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{notice}
</div>
)}
</div>
);
{isSubmitting ? "登录中..." : "登录"}
</button>
</form>
<p class="mt-3 text-xs text-stone-500">按回车键可快速提交。</p>
{notice && (
<div
class={`mt-4 rounded-xl border px-4 py-3 text-sm ${toneStyles[notice.tone]}`}
>
{notice.message}
</div>
)}
</div>
);
};
+58 -37
View File
@@ -13,41 +13,62 @@ type ModelsViewProps = {
* Returns:
* Models JSX element.
*/
export const ModelsView = ({ models }: ModelsViewProps) => (
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<div class="mb-4 flex items-center justify-between">
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
模型广场
</h3>
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
{models.length} 个模型
</span>
export const ModelsView = ({ models }: ModelsViewProps) => {
const channelCount = new Set(
models.flatMap((model) => model.channels.map((channel) => channel.id)),
).size;
return (
<div class="animate-fade-up rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
模型广场
</h3>
<p class="text-xs text-stone-500">
当前聚合的模型与所属渠道清单。
</p>
</div>
<div class="flex items-center gap-2 text-xs text-stone-500">
<span class="rounded-full bg-stone-100 px-2.5 py-1">
{models.length} 个模型
</span>
<span class="rounded-full bg-stone-100 px-2.5 py-1">
{channelCount} 个渠道
</span>
</div>
</div>
{models.length === 0 ? (
<div class="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-8 text-center text-sm text-stone-500">
暂无模型,请先在站点管理配置可用渠道。
</div>
) : (
<div class="overflow-x-auto">
<table class="min-w-105 w-full border-collapse text-xs sm:text-sm">
<thead>
<tr>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
模型
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
渠道
</th>
</tr>
</thead>
<tbody>
{models.map((model) => (
<tr class="hover:bg-stone-50" key={model.id}>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
{model.id}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
{model.channels.map((channel) => channel.name).join(" / ")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div class="overflow-x-auto">
<table class="min-w-105 w-full border-collapse text-xs sm:text-sm">
<thead>
<tr>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
模型
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
渠道
</th>
</tr>
</thead>
<tbody>
{models.map((model) => (
<tr class="hover:bg-stone-50" key={model.id}>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
{model.id}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
{model.channels.map((channel) => channel.name).join(" / ")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
);
};
+30 -2
View File
@@ -3,6 +3,7 @@ import type { SettingsForm } from "../core/types";
type SettingsViewProps = {
settingsForm: SettingsForm;
adminPasswordSet: boolean;
isSaving: boolean;
onSubmit: (event: Event) => void;
onFormChange: (patch: Partial<SettingsForm>) => void;
};
@@ -19,10 +20,11 @@ type SettingsViewProps = {
export const SettingsView = ({
settingsForm,
adminPasswordSet,
isSaving,
onSubmit,
onFormChange,
}: SettingsViewProps) => (
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<div class="animate-fade-up rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<div class="mb-4 flex items-center justify-between">
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
系统设置
@@ -94,6 +96,31 @@ export const SettingsView = ({
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="failure-cooldown"
>
失败冷却(分钟)
</label>
<input
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="failure-cooldown"
name="model_failure_cooldown_minutes"
type="number"
min="1"
value={settingsForm.model_failure_cooldown_minutes}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
model_failure_cooldown_minutes: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-stone-500">
同一模型失败后在该时间内跳过对应渠道。
</p>
</div>
<div class="lg:col-span-2">
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
@@ -127,8 +154,9 @@ export const SettingsView = ({
<button
class="h-11 rounded-lg bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="submit"
disabled={isSaving}
>
保存设置
{isSaving ? "保存中..." : "保存设置"}
</button>
</div>
</form>
+118 -23
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "hono/jsx/dom";
import {
getSiteCheckinLabel,
getSiteTypeLabel,
@@ -24,13 +25,14 @@ type SitesViewProps = {
lastRun: string | null;
siteSearch: string;
siteSort: SiteSortState;
isActionPending: (key: string) => boolean;
onCreate: () => void;
onCloseModal: () => void;
onEdit: (site: Site) => void;
onSubmit: (event: Event) => void;
onTest: (id: string) => void;
onToggle: (id: string, status: string) => void;
onDelete: (id: string) => void;
onDelete: (site: Site) => void;
onPageChange: (next: number) => void;
onPageSizeChange: (next: number) => void;
onSearchChange: (next: string) => void;
@@ -64,6 +66,7 @@ export const SitesView = ({
lastRun,
siteSearch,
siteSort,
isActionPending,
onCreate,
onCloseModal,
onEdit,
@@ -82,6 +85,10 @@ export const SitesView = ({
const isEditing = Boolean(editingSite);
const pageItems = buildPageItems(sitePage, siteTotalPages);
const today = getBeijingDateString();
const isSubmitting = isActionPending("site:submit");
const isTestingAll = isActionPending("site:testAll");
const isCheckinAll = isActionPending("site:checkinAll");
const [localSearch, setLocalSearch] = useState(siteSearch);
const isOfficialType =
siteForm.site_type === "openai" ||
siteForm.site_type === "anthropic" ||
@@ -130,8 +137,32 @@ export const SitesView = ({
}
return siteSort.direction === "asc" ? "▲" : "▼";
};
useEffect(() => {
if (!isSiteModalOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onCloseModal();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isSiteModalOpen, onCloseModal]);
useEffect(() => {
setLocalSearch(siteSearch);
}, [siteSearch]);
useEffect(() => {
const timer = window.setTimeout(() => {
if (localSearch !== siteSearch) {
onSearchChange(localSearch);
}
}, 300);
return () => window.clearTimeout(timer);
}, [localSearch, onSearchChange, siteSearch]);
return (
<div class="space-y-5">
<div class="animate-fade-up space-y-5">
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -152,18 +183,20 @@ export const SitesView = ({
</div>
)}
<button
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={isCheckinAll}
onClick={onRunAll}
>
一键签到
{isCheckinAll ? "签到中..." : "一键签到"}
</button>
<button
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={isTestingAll}
onClick={onTestAll}
>
一键测试
{isTestingAll ? "测试中..." : "一键测试"}
</button>
<button
class="h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
@@ -180,9 +213,11 @@ export const SitesView = ({
<input
class="w-full bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
placeholder="站点名称或 URL"
value={siteSearch}
value={localSearch}
onInput={(event) =>
onSearchChange((event.currentTarget as HTMLInputElement).value)
setLocalSearch(
(event.currentTarget as HTMLInputElement).value,
)
}
/>
</label>
@@ -206,8 +241,15 @@ export const SitesView = ({
<div class="mt-4">
<div class="space-y-3 md:hidden">
{pagedSites.length === 0 ? (
<div class="rounded-xl border border-stone-200 bg-white px-4 py-10 text-center text-sm text-stone-500">
暂无站点,请先创建。
<div class="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-10 text-center text-sm text-stone-500">
<p>暂无站点,请先创建。</p>
<button
class="mt-4 h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={onCreate}
>
新增站点
</button>
</div>
) : (
pagedSites.map((site) => {
@@ -218,6 +260,13 @@ export const SitesView = ({
site.system_token && site.system_userid,
);
const callTokenCount = site.call_tokens?.length ?? 0;
const testPending = isActionPending(`site:test:${site.id}`);
const togglePending = isActionPending(
`site:toggle:${site.id}`,
);
const deletePending = isActionPending(
`site:delete:${site.id}`,
);
return (
<div
class={`rounded-xl border p-4 shadow-sm ${
@@ -297,16 +346,22 @@ export const SitesView = ({
<button
class="h-9 w-full rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={testPending}
onClick={() => onTest(site.id)}
>
连通测试
{testPending ? "测试中..." : "连通测试"}
</button>
<button
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={togglePending}
onClick={() => onToggle(site.id, site.status)}
>
{isActive ? "禁用" : "启用"}
{togglePending
? "处理中..."
: isActive
? "禁用"
: "启用"}
</button>
<button
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
@@ -318,9 +373,10 @@ export const SitesView = ({
<button
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => onDelete(site.id)}
disabled={deletePending}
onClick={() => onDelete(site)}
>
删除
{deletePending ? "删除中..." : "删除"}
</button>
</div>
</div>
@@ -346,13 +402,27 @@ export const SitesView = ({
</div>
{pagedSites.length === 0 ? (
<div class="px-4 py-10 text-center text-sm text-stone-500">
暂无站点,请先创建。
<p>暂无站点,请先创建。</p>
<button
class="mt-4 h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={onCreate}
>
新增站点
</button>
</div>
) : (
<div class="divide-y divide-stone-100">
{pagedSites.map((site) => {
const isActive = site.status === "active";
const callTokenCount = site.call_tokens?.length ?? 0;
const testPending = isActionPending(`site:test:${site.id}`);
const togglePending = isActionPending(
`site:toggle:${site.id}`,
);
const deletePending = isActionPending(
`site:delete:${site.id}`,
);
return (
<div
class={`grid grid-cols-[minmax(0,1.4fr)_minmax(0,0.6fr)_minmax(0,0.6fr)_minmax(0,0.5fr)_minmax(0,0.6fr)_minmax(0,0.6fr)_minmax(0,0.8fr)_minmax(0,1.4fr)] items-center gap-3 px-4 py-4 text-sm ${
@@ -407,16 +477,22 @@ export const SitesView = ({
<button
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={testPending}
onClick={() => onTest(site.id)}
>
连通测试
{testPending ? "测试中..." : "连通测试"}
</button>
<button
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={togglePending}
onClick={() => onToggle(site.id, site.status)}
>
{isActive ? "禁用" : "启用"}
{togglePending
? "处理中..."
: isActive
? "禁用"
: "启用"}
</button>
<button
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
@@ -428,9 +504,10 @@ export const SitesView = ({
<button
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => onDelete(site.id)}
disabled={deletePending}
onClick={() => onDelete(site)}
>
删除
{deletePending ? "删除中..." : "删除"}
</button>
</div>
</div>
@@ -510,11 +587,23 @@ export const SitesView = ({
)}
</div>
{isSiteModalOpen && (
<div class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8">
<div class="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl">
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8"
onClick={onCloseModal}
>
<div
aria-labelledby="site-modal-title"
aria-modal="true"
class="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl"
role="dialog"
onClick={(event) => event.stopPropagation()}
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
<h3
class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900"
id="site-modal-title"
>
{isEditing ? "编辑站点" : "新增站点"}
</h3>
<p class="text-xs text-stone-500">
@@ -549,6 +638,7 @@ export const SitesView = ({
name="name"
value={siteForm.name}
required
autoFocus
onInput={(event) =>
onFormChange({
name: (event.currentTarget as HTMLInputElement).value,
@@ -835,8 +925,13 @@ export const SitesView = ({
<button
class="h-10 rounded-full bg-stone-900 px-5 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="submit"
disabled={isSubmitting}
>
{isEditing ? "保存修改" : "创建站点"}
{isSubmitting
? "保存中..."
: isEditing
? "保存修改"
: "创建站点"}
</button>
</div>
</form>
+276 -23
View File
@@ -1,5 +1,10 @@
import type { Token } from "../core/types";
import { buildPageItems, formatDateTime } from "../core/utils";
import { useEffect } from "hono/jsx/dom";
import type { Site, Token, TokenForm } from "../core/types";
import {
buildPageItems,
formatChinaDateTime,
formatDateTime,
} from "../core/utils";
type TokensViewProps = {
pagedTokens: Token[];
@@ -8,14 +13,20 @@ type TokensViewProps = {
tokenTotal: number;
tokenTotalPages: number;
isTokenModalOpen: boolean;
isActionPending: (key: string) => boolean;
sites: Site[];
tokenForm: TokenForm;
editingToken: Token | null;
onCreate: () => void;
onCloseModal: () => void;
onPageChange: (next: number) => void;
onPageSizeChange: (next: number) => void;
onSubmit: (event: Event) => void;
onFormChange: (patch: Partial<TokenForm>) => void;
onEdit: (token: Token) => void;
onReveal: (id: string) => void;
onToggle: (id: string, status: string) => void;
onDelete: (id: string) => void;
onDelete: (token: Token) => void;
};
const pageSizeOptions = [10, 20, 50];
@@ -36,18 +47,56 @@ export const TokensView = ({
tokenTotal,
tokenTotalPages,
isTokenModalOpen,
isActionPending,
sites,
tokenForm,
editingToken,
onCreate,
onCloseModal,
onPageChange,
onPageSizeChange,
onSubmit,
onFormChange,
onEdit,
onReveal,
onToggle,
onDelete,
}: TokensViewProps) => {
const pageItems = buildPageItems(tokenPage, tokenTotalPages);
const isSubmitting = isActionPending("token:submit");
const isEditing = Boolean(editingToken);
const modalTitle = isEditing ? "编辑令牌" : "生成令牌";
const modalDescription = isEditing
? "更新令牌名称、额度、状态与过期时间。"
: "创建后会自动复制令牌,请妥善保存。";
const submitLabel = isEditing ? "保存修改" : "生成令牌";
const selectedChannels = new Set(tokenForm.allowed_channels);
const toggleChannel = (channelId: string) => {
const next = new Set(selectedChannels);
if (next.has(channelId)) {
next.delete(channelId);
} else {
next.add(channelId);
}
onFormChange({ allowed_channels: Array.from(next) });
};
const clearChannels = () => onFormChange({ allowed_channels: [] });
useEffect(() => {
if (!isTokenModalOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onCloseModal();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isTokenModalOpen, onCloseModal]);
return (
<div class="space-y-5">
<div class="animate-fade-up space-y-5">
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -71,12 +120,28 @@ export const TokensView = ({
<div class="mt-4">
<div class="space-y-3 md:hidden">
{pagedTokens.length === 0 ? (
<div class="rounded-xl border border-stone-200 bg-white px-4 py-10 text-center text-sm text-stone-500">
暂无令牌,请先创建。
<div class="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-10 text-center text-sm text-stone-500">
<p>暂无令牌,请先创建。</p>
<button
class="mt-4 h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={onCreate}
>
生成令牌
</button>
</div>
) : (
pagedTokens.map((tokenItem) => {
const isActive = tokenItem.status === "active";
const revealPending = isActionPending(
`token:reveal:${tokenItem.id}`,
);
const togglePending = isActionPending(
`token:toggle:${tokenItem.id}`,
);
const deletePending = isActionPending(
`token:delete:${tokenItem.id}`,
);
return (
<div
class="rounded-xl border border-stone-200 bg-white p-4 shadow-sm"
@@ -115,28 +180,55 @@ export const TokensView = ({
{formatDateTime(tokenItem.created_at)}
</p>
</div>
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
<p>过期时间</p>
<p class="mt-1 font-semibold text-stone-700">
{tokenItem.expires_at
? formatChinaDateTime(tokenItem.expires_at)
: "永不过期"}
</p>
</div>
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
<p>渠道限制</p>
<p class="mt-1 font-semibold text-stone-700">
{tokenItem.allowed_channels &&
tokenItem.allowed_channels.length > 0
? `${tokenItem.allowed_channels.length} 个`
: "全开"}
</p>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2">
<button
class="col-span-2 h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={() => onEdit(tokenItem)}
>
编辑
</button>
<button
class="h-9 w-full rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={revealPending}
onClick={() => onReveal(tokenItem.id)}
>
查看
{revealPending ? "查看中..." : "查看"}
</button>
<button
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={togglePending}
onClick={() => onToggle(tokenItem.id, tokenItem.status)}
>
切换
{togglePending ? "处理中..." : "切换"}
</button>
<button
class="col-span-2 h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => onDelete(tokenItem.id)}
disabled={deletePending}
onClick={() => onDelete(tokenItem)}
>
删除
{deletePending ? "删除中..." : "删除"}
</button>
</div>
</div>
@@ -145,25 +237,43 @@ export const TokensView = ({
)}
</div>
<div class="hidden overflow-hidden rounded-xl border border-stone-200 md:block">
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.6fr)_minmax(0,0.9fr)_minmax(0,0.6fr)_minmax(0,1fr)_minmax(0,1.2fr)] gap-3 bg-stone-50 px-4 py-3 text-xs uppercase tracking-widest text-stone-500">
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.6fr)_minmax(0,0.9fr)_minmax(0,0.6fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.3fr)] gap-3 bg-stone-50 px-4 py-3 text-xs uppercase tracking-widest text-stone-500">
<div>名称</div>
<div>状态</div>
<div>已用/额度</div>
<div>前缀</div>
<div>创建时间</div>
<div>过期时间</div>
<div>渠道限制</div>
<div>操作</div>
</div>
{pagedTokens.length === 0 ? (
<div class="px-4 py-10 text-center text-sm text-stone-500">
暂无令牌,请先创建。
<p>暂无令牌,请先创建。</p>
<button
class="mt-4 h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={onCreate}
>
生成令牌
</button>
</div>
) : (
<div class="divide-y divide-stone-100">
{pagedTokens.map((tokenItem) => {
const isActive = tokenItem.status === "active";
const revealPending = isActionPending(
`token:reveal:${tokenItem.id}`,
);
const togglePending = isActionPending(
`token:toggle:${tokenItem.id}`,
);
const deletePending = isActionPending(
`token:delete:${tokenItem.id}`,
);
return (
<div
class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.6fr)_minmax(0,0.9fr)_minmax(0,0.6fr)_minmax(0,1fr)_minmax(0,1.2fr)] items-center gap-3 px-4 py-4 text-sm"
class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.6fr)_minmax(0,0.9fr)_minmax(0,0.6fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.3fr)] items-center gap-3 px-4 py-4 text-sm"
key={tokenItem.id}
>
<div class="flex min-w-0 flex-col">
@@ -191,29 +301,50 @@ export const TokensView = ({
<div class="text-sm text-stone-700">
{formatDateTime(tokenItem.created_at)}
</div>
<div class="text-sm text-stone-700">
{tokenItem.expires_at
? formatChinaDateTime(tokenItem.expires_at)
: "永不过期"}
</div>
<div class="text-sm text-stone-700">
{tokenItem.allowed_channels &&
tokenItem.allowed_channels.length > 0
? `${tokenItem.allowed_channels.length} 个`
: "全开"}
</div>
<div class="flex flex-wrap gap-2">
<button
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={() => onEdit(tokenItem)}
>
编辑
</button>
<button
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={revealPending}
onClick={() => onReveal(tokenItem.id)}
>
查看
{revealPending ? "查看中..." : "查看"}
</button>
<button
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={togglePending}
onClick={() =>
onToggle(tokenItem.id, tokenItem.status)
}
>
切换
{togglePending ? "处理中..." : "切换"}
</button>
<button
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => onDelete(tokenItem.id)}
disabled={deletePending}
onClick={() => onDelete(tokenItem)}
>
删除
{deletePending ? "删除中..." : "删除"}
</button>
</div>
</div>
@@ -288,15 +419,27 @@ export const TokensView = ({
</div>
</div>
{isTokenModalOpen && (
<div class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8">
<div class="w-full max-w-xl rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl">
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8"
onClick={onCloseModal}
>
<div
aria-labelledby="token-modal-title"
aria-modal="true"
class="w-full max-w-xl rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl"
role="dialog"
onClick={(event) => event.stopPropagation()}
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
生成令牌
<h3
class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900"
id="token-modal-title"
>
{modalTitle}
</h3>
<p class="text-xs text-stone-500">
创建后可在列表中查看与管理。
{modalDescription}
</p>
</div>
<button
@@ -320,6 +463,13 @@ export const TokensView = ({
id="token-name"
name="name"
required
autoFocus
value={tokenForm.name}
onInput={(event) =>
onFormChange({
name: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
@@ -336,8 +486,106 @@ export const TokensView = ({
type="number"
min="0"
placeholder="留空表示无限"
value={tokenForm.quota_total}
onInput={(event) =>
onFormChange({
quota_total: (
event.currentTarget as HTMLInputElement
).value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="token-status"
>
状态
</label>
<select
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="token-status"
name="status"
value={tokenForm.status}
onChange={(event) =>
onFormChange({
status: (
event.currentTarget as HTMLSelectElement
).value,
})
}
>
<option value="active">启用</option>
<option value="disabled">禁用</option>
</select>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="token-expires"
>
过期时间(北京时间)
</label>
<input
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="token-expires"
name="expires_at"
type="datetime-local"
step="60"
placeholder="留空表示不过期"
value={tokenForm.expires_at}
onInput={(event) =>
onFormChange({
expires_at: (
event.currentTarget as HTMLInputElement
).value,
})
}
/>
<p class="mt-1 text-xs text-stone-400">
留空表示不过期。
</p>
</div>
<div>
<div class="flex items-center justify-between">
<label class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500">
允许渠道
</label>
<button
class="text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:text-stone-900"
type="button"
onClick={clearChannels}
>
全开
</button>
</div>
<p class="text-xs text-stone-400">
未选择表示全开。
</p>
<div class="mt-2 max-h-36 space-y-2 overflow-auto rounded-lg border border-stone-200 bg-stone-50 p-3 text-xs text-stone-600">
{sites.length === 0 ? (
<p class="text-stone-400">暂无渠道,请先创建。</p>
) : (
sites.map((site) => (
<label
class="flex items-center gap-2"
key={site.id}
>
<input
class="h-3 w-3 rounded border-stone-300 text-stone-900 focus:ring-amber-300"
type="checkbox"
checked={selectedChannels.has(site.id)}
onChange={() => toggleChannel(site.id)}
/>
<span class="truncate">
{site.name ?? site.id}
</span>
</label>
))
)}
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<button
class="h-10 rounded-full border border-stone-200 bg-stone-50 px-4 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
@@ -349,8 +597,13 @@ export const TokensView = ({
<button
class="h-10 rounded-full bg-stone-900 px-5 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="submit"
disabled={isSubmitting}
>
生成令牌
{isSubmitting
? isEditing
? "保存中..."
: "生成中..."
: submitLabel}
</button>
</div>
</form>
+251 -40
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import type { UsageLog } from "../core/types";
import type { UsageLog, UsageQuery } from "../core/types";
import {
buildPageItems,
buildUsageStatusDetail,
@@ -8,7 +8,17 @@ import {
type UsageViewProps = {
usage: UsageLog[];
total: number;
page: number;
pageSize: number;
filters: UsageQuery;
isRefreshing: boolean;
onRefresh: () => void;
onPageChange: (next: number) => void;
onPageSizeChange: (next: number) => void;
onFiltersChange: (patch: Partial<UsageQuery>) => void;
onSearch: () => void;
onClear: () => void;
};
const pageSizeOptions = [50, 100, 200];
@@ -42,34 +52,61 @@ const formatStream = (value: boolean | number | null | undefined) => {
* Returns:
* Usage JSX element.
*/
export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
const [pageSize, setPageSize] = useState(50);
const [page, setPage] = useState(1);
const total = usage.length;
export const UsageView = ({
usage,
total,
page,
pageSize,
filters,
isRefreshing,
onRefresh,
onPageChange,
onPageSizeChange,
onFiltersChange,
onSearch,
onClear,
}: UsageViewProps) => {
const [activeErrorLog, setActiveErrorLog] = useState<UsageLog | null>(null);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / pageSize)),
[total, pageSize],
);
useEffect(() => {
setPage(1);
}, [pageSize]);
useEffect(() => {
setPage((prev) => Math.min(prev, totalPages));
}, [totalPages]);
const pagedUsage = useMemo(() => {
const start = (page - 1) * pageSize;
return usage.slice(start, start + pageSize);
}, [usage, page, pageSize]);
const pageItems = useMemo(
() => buildPageItems(page, totalPages),
[page, totalPages],
);
const closeErrorModal = () => setActiveErrorLog(null);
const hasFilters =
filters.channel.trim() ||
filters.token.trim() ||
filters.model.trim() ||
filters.status.trim();
const handleSearchKey = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
onSearch();
}
};
useEffect(() => {
if (!activeErrorLog) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
closeErrorModal();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [activeErrorLog]);
return (
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<div class="animate-fade-up rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
@@ -83,9 +120,87 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
<button
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={isRefreshing}
onClick={onRefresh}
>
刷新
{isRefreshing ? "刷新中..." : "刷新"}
</button>
</div>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2 rounded-2xl border border-stone-200 bg-stone-50 p-3">
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
渠道
<input
class="w-36 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
placeholder="渠道名称"
value={filters.channel}
onInput={(event) =>
onFiltersChange({
channel: (event.currentTarget as HTMLInputElement).value,
})
}
onKeyDown={handleSearchKey}
/>
</label>
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
令牌
<input
class="w-36 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
placeholder="令牌名称"
value={filters.token}
onInput={(event) =>
onFiltersChange({
token: (event.currentTarget as HTMLInputElement).value,
})
}
onKeyDown={handleSearchKey}
/>
</label>
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
模型
<input
class="w-36 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
placeholder="模型名称"
value={filters.model}
onInput={(event) =>
onFiltersChange({
model: (event.currentTarget as HTMLInputElement).value,
})
}
onKeyDown={handleSearchKey}
/>
</label>
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
状态码
<input
class="w-28 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
placeholder="200/503"
inputMode="numeric"
value={filters.status}
onInput={(event) =>
onFiltersChange({
status: (event.currentTarget as HTMLInputElement).value,
})
}
onKeyDown={handleSearchKey}
/>
</label>
<div class="flex flex-wrap items-center gap-2">
<button
class="h-8 rounded-full bg-stone-900 px-4 text-[11px] font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={isRefreshing}
onClick={onSearch}
>
搜索
</button>
<button
class="h-8 rounded-full border border-stone-200 bg-white px-4 text-[11px] font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={isRefreshing || !hasFilters}
onClick={onClear}
>
清空
</button>
</div>
</div>
@@ -113,10 +228,10 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
输出 Tokens
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
用时
用时 (s)
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
首 token 延迟
首 token 延迟 (s)
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
流式
@@ -125,23 +240,36 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
推理强度
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
状态
状态码
</th>
</tr>
</thead>
<tbody>
{pagedUsage.length === 0 ? (
{usage.length === 0 ? (
<tr>
<td
class="px-3 py-10 text-center text-sm text-stone-500"
colSpan={11}
>
暂无日志。
<div class="flex flex-col items-center gap-3">
<span>暂无日志,先完成一次调用吧。</span>
<button
class="h-8 rounded-full bg-stone-900 px-4 text-[11px] font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={onRefresh}
disabled={isRefreshing}
>
立即刷新
</button>
</div>
</td>
</tr>
) : (
pagedUsage.map((log) => {
usage.map((log) => {
const statusDetail = buildUsageStatusDetail(log);
const hasDetail = Boolean(
log.error_message || log.error_code,
);
return (
<tr class="hover:bg-stone-50" key={log.id}>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
@@ -184,10 +312,14 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
>
{statusDetail.label}
</div>
{statusDetail.message ? (
<div class="mt-1 text-[11px] text-stone-500">
{statusDetail.message}
</div>
{hasDetail ? (
<button
class="mt-2 rounded-full border border-stone-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
type="button"
onClick={() => setActiveErrorLog(log)}
>
查看详情
</button>
) : null}
</td>
</tr>
@@ -200,12 +332,14 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
</div>
<div class="mt-4 flex flex-col gap-3 text-xs text-stone-500 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-stone-500">共 {totalPages} 页</span>
<span class="text-xs text-stone-500">
共 {totalPages} 页 · {total} 条
</span>
<button
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={page <= 1}
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1 || isRefreshing}
onClick={() => onPageChange(Math.max(1, page - 1))}
>
&lt;
</button>
@@ -223,7 +357,8 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
}`}
type="button"
key={item}
onClick={() => setPage(item)}
disabled={isRefreshing}
onClick={() => onPageChange(item)}
>
{item}
</button>
@@ -232,8 +367,8 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
<button
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={page >= totalPages}
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages || isRefreshing}
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
>
&gt;
</button>
@@ -243,11 +378,12 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
<select
class="rounded-full border border-stone-200 bg-white px-2 py-0.5 text-xs text-stone-700 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
value={pageSize}
onChange={(event) => {
setPageSize(
disabled={isRefreshing}
onChange={(event) =>
onPageSizeChange(
Number((event.currentTarget as HTMLSelectElement).value),
);
}}
)
}
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
@@ -257,6 +393,81 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
</select>
</label>
</div>
{activeErrorLog ? (
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/50 px-4 py-8"
onClick={closeErrorModal}
>
<div
aria-labelledby="usage-error-title"
aria-modal="true"
class="w-full max-w-2xl rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl"
role="dialog"
onClick={(event) => event.stopPropagation()}
>
<div class="flex items-start justify-between gap-4">
<div>
<h3
class="font-['Space_Grotesk'] text-lg tracking-tight text-stone-900"
id="usage-error-title"
>
错误详情
</h3>
<p class="mt-2 text-xs text-stone-500">
状态码 {activeErrorLog.upstream_status ?? "-"}
</p>
{activeErrorLog.error_code ? (
<p class="mt-1 text-xs text-stone-500">
错误码: {activeErrorLog.error_code}
</p>
) : null}
</div>
<button
class="h-8 rounded-full border border-stone-200 bg-stone-50 px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={closeErrorModal}
>
关闭
</button>
</div>
<div class="mt-4 rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-700">
<div class="grid gap-2">
<div class="flex items-center justify-between gap-3">
<span class="text-stone-400">时间</span>
<span>{formatDateTime(activeErrorLog.created_at)}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-stone-400">模型</span>
<span>{activeErrorLog.model ?? "-"}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-stone-400">渠道</span>
<span>
{activeErrorLog.channel_name ??
activeErrorLog.channel_id ??
"-"}
</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-stone-400">令牌</span>
<span>
{activeErrorLog.token_name ??
activeErrorLog.token_id ??
"-"}
</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-stone-400">耗时</span>
<span>{formatSeconds(activeErrorLog.latency_ms)}</span>
</div>
</div>
<p class="mt-3 text-[11px] text-stone-400">
错误片段已省略,请结合状态码与错误码排查。
</p>
</div>
</div>
</div>
) : null}
</div>
);
};
+56
View File
@@ -1 +1,57 @@
@import "tailwindcss";
:root {
color-scheme: light;
--app-ink: #1c1917;
--app-ink-muted: #78716c;
--app-amber: #f59e0b;
--app-amber-soft: #fde68a;
--app-bg: #f8f5f0;
--app-surface: #ffffff;
--app-border: rgba(28, 25, 23, 0.12);
}
body {
background-color: var(--app-bg);
}
.app-background {
position: fixed;
inset: 0;
z-index: -1;
background:
radial-gradient(circle at 8% 12%, rgba(245, 158, 11, 0.16), transparent 45%),
radial-gradient(circle at 90% 8%, rgba(120, 113, 108, 0.18), transparent 40%),
linear-gradient(180deg, #fff7ed 0%, #f8f5f0 38%, #f1f5f9 100%);
}
.app-background::after {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(rgba(12, 10, 9, 0.06) 0.6px, transparent 0);
background-size: 3px 3px;
opacity: 0.18;
pointer-events: none;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-up {
animation: fadeUp 0.35s ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-up {
animation: none;
}
}
@@ -0,0 +1 @@
ALTER TABLE channel_model_capabilities ADD COLUMN last_err_count INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1 @@
ALTER TABLE tokens ADD COLUMN expires_at TEXT;
@@ -0,0 +1 @@
ALTER TABLE channel_call_tokens ADD COLUMN models_json TEXT;
+20
View File
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS channel_call_tokens (
channel_id TEXT NOT NULL,
name TEXT NOT NULL,
api_key TEXT NOT NULL,
models_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -46,6 +47,7 @@ CREATE TABLE IF NOT EXISTS tokens (
quota_used INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
allowed_channels TEXT,
expires_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -85,3 +87,21 @@ CREATE TABLE IF NOT EXISTS admin_sessions (
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS channel_model_capabilities (
channel_id TEXT NOT NULL,
model TEXT NOT NULL,
last_ok_at INTEGER NOT NULL,
last_err_at INTEGER,
last_err_code TEXT,
last_err_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (channel_id, model)
);
CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_model
ON channel_model_capabilities (model);
CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_channel
ON channel_model_capabilities (channel_id);
+9 -1
View File
@@ -12,6 +12,7 @@ export type TokenRecord = {
quota_used: number;
status: string;
allowed_channels: string | null;
expires_at?: string | null;
};
/**
@@ -25,7 +26,7 @@ export const tokenAuth = createMiddleware<AppEnv>(async (c, next) => {
const tokenHash = await sha256Hex(token);
const record = await c.env.DB.prepare(
"SELECT id, name, quota_total, quota_used, status, allowed_channels FROM tokens WHERE key_hash = ?",
"SELECT id, name, quota_total, quota_used, status, allowed_channels, expires_at FROM tokens WHERE key_hash = ?",
)
.bind(tokenHash)
.first<TokenRecord>();
@@ -34,6 +35,13 @@ export const tokenAuth = createMiddleware<AppEnv>(async (c, next) => {
return jsonError(c, 401, "invalid_token", "invalid_token");
}
if (record.expires_at) {
const parsed = Date.parse(record.expires_at);
if (!Number.isNaN(parsed) && Date.now() >= parsed) {
return jsonError(c, 403, "token_expired", "token_expired");
}
}
if (record.status !== "active") {
return jsonError(c, 403, "token_disabled", "token_disabled");
}
+12 -1
View File
@@ -1,6 +1,9 @@
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { listCallTokens } from "../services/channel-call-token-repo";
import {
listCallTokens,
updateCallTokenModels,
} from "../services/channel-call-token-repo";
import { modelsToJson } from "../services/channel-models";
import {
channelExists,
@@ -197,6 +200,14 @@ channels.post("/:id/test", async (c) => {
return jsonError(c, 502, "channel_unreachable", "channel_unreachable");
}
const tokenIdSet = new Set(callTokenRows.map((row) => row.id));
for (const item of summary.items) {
if (!item.ok || !item.tokenId || !tokenIdSet.has(item.tokenId)) {
continue;
}
await updateCallTokenModels(c.env.DB, item.tokenId, item.models, nowIso());
}
const models = summary.models.map((id) => ({ id }));
await updateChannelTestResult(c.env.DB, id, {
ok: true,
-4
View File
@@ -2,7 +2,6 @@ import { Hono } from "hono";
import type { AppEnv } from "../env";
import { listModelEntriesWithFallback } from "../services/channel-model-capabilities";
import { listActiveChannels } from "../services/channel-repo";
import { getModelCapabilityTtlHours } from "../services/settings";
const models = new Hono<AppEnv>();
@@ -11,8 +10,6 @@ const models = new Hono<AppEnv>();
*/
models.get("/", async (c) => {
const channels = await listActiveChannels(c.env.DB);
const ttlHours = await getModelCapabilityTtlHours(c.env.DB);
const ttlSeconds = Math.max(1, Math.floor(ttlHours)) * 60 * 60;
const entries = await listModelEntriesWithFallback(
c.env.DB,
channels.map((channel) => ({
@@ -20,7 +17,6 @@ models.get("/", async (c) => {
name: channel.name,
models_json: channel.models_json,
})),
ttlSeconds,
);
const map = new Map<
-4
View File
@@ -5,7 +5,6 @@ import {
listModelsByChannelWithFallback,
} from "../services/channel-model-capabilities";
import { listActiveChannels } from "../services/channel-repo";
import { getModelCapabilityTtlHours } from "../services/settings";
import { newApiSuccess } from "../utils/newapi-response";
const users = new Hono<AppEnv>({ strict: false });
@@ -13,8 +12,6 @@ users.use("*", newApiAuth);
users.get("/models", async (c) => {
const channels = await listActiveChannels(c.env.DB);
const ttlHours = await getModelCapabilityTtlHours(c.env.DB);
const ttlSeconds = Math.max(1, Math.floor(ttlHours)) * 60 * 60;
const map = await listModelsByChannelWithFallback(
c.env.DB,
channels.map((channel) => ({
@@ -22,7 +19,6 @@ users.get("/models", async (c) => {
name: channel.name,
models_json: channel.models_json,
})),
ttlSeconds,
);
const modelSet = new Set<string>();
for (const models of map.values()) {
+186 -35
View File
@@ -3,7 +3,6 @@ import type { AppEnv } from "../env";
import { type TokenRecord, tokenAuth } from "../middleware/tokenAuth";
import {
type CallTokenItem,
selectCallToken,
} from "../services/call-token-selector";
import { listCallTokens } from "../services/channel-call-token-repo";
import {
@@ -24,7 +23,6 @@ import {
upsertChannelModelCapabilities,
} from "../services/channel-model-capabilities";
import {
getModelCapabilityTtlHours,
getModelFailureCooldownMinutes,
} from "../services/settings";
import {
@@ -80,7 +78,9 @@ type ErrorDetails = {
errorMessage: string | null;
};
function truncateMessage(value: string | null, max = 240): string | null {
const FAILURE_COUNT_THRESHOLD = 2;
function normalizeMessage(value: string | null): string | null {
if (!value) {
return null;
}
@@ -88,7 +88,7 @@ function truncateMessage(value: string | null, max = 240): string | null {
if (!trimmed) {
return null;
}
return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed;
return trimmed;
}
async function extractErrorDetails(
@@ -114,12 +114,28 @@ async function extractErrorDetails(
: null;
return {
errorCode,
errorMessage: truncateMessage(errorMessage),
errorMessage: normalizeMessage(errorMessage),
};
}
}
const text = await response.clone().text().catch(() => "");
return { errorCode: null, errorMessage: truncateMessage(text) };
return { errorCode: null, errorMessage: normalizeMessage(text) };
}
export function shouldCooldown(
upstreamStatus: number | null,
errorCode: string | null,
): boolean {
if (errorCode === "timeout" || errorCode === "exception") {
return true;
}
if (upstreamStatus === 429 || upstreamStatus === 408) {
return true;
}
if (upstreamStatus !== null && upstreamStatus >= 500) {
return true;
}
return false;
}
function channelSupportsModel(
@@ -134,37 +150,46 @@ function channelSupportsModel(
const declaredModels = extractModels(channel).map((entry) => entry.id);
const metadata = parseChannelMetadata(channel.metadata_json);
const mapped = resolveMappedModel(metadata.model_mapping, model);
const hasExplicitMapping = hasExplicitModelMapping(metadata, model);
const declaredAllows =
declaredModels.length === 0
? true
: (mapped ? declaredModels.includes(mapped) : false) ||
declaredModels.includes(model);
if (!declaredAllows) {
declaredModels.length > 0
? (mapped ? declaredModels.includes(mapped) : false) ||
declaredModels.includes(model)
: null;
const verifiedAllows =
verified && verified.size > 0
? (mapped ? verified.has(mapped) : false) || verified.has(model)
: null;
if (hasExplicitMapping) {
if (verified && verified.size > 0) {
return Boolean(verifiedAllows);
}
if (declaredModels.length > 0) {
return Boolean(declaredAllows);
}
return true;
}
if (declaredModels.length > 0 && !declaredAllows) {
return false;
}
if (verified && verified.size > 0) {
if (mapped && verified.has(mapped)) {
return true;
}
return verified.has(model);
return Boolean(verifiedAllows);
}
return true;
if (declaredModels.length > 0) {
return true;
}
return false;
}
export function selectCandidateChannels(
allowedChannels: ChannelRecord[],
downstreamModel: string | null,
verifiedModelsByChannel: Map<string, Set<string>>,
verifiedModelsByChannel: Map<string, Set<string>> = new Map(),
): ChannelRecord[] {
const modelChannels = allowedChannels.filter((channel) =>
channelSupportsModel(channel, downstreamModel, verifiedModelsByChannel),
);
if (modelChannels.length > 0) {
return modelChannels;
}
// Fallback to all allowed channels. Downstream protocol should not restrict
// upstream channel selection.
return allowedChannels;
return modelChannels;
}
function hasExplicitModelMapping(
@@ -184,7 +209,7 @@ export function resolveUpstreamModelForChannel(
channel: ChannelRecord,
metadata: ChannelMetadata,
downstreamModel: string | null,
verifiedModelsByChannel: Map<string, Set<string>>,
verifiedModelsByChannel: Map<string, Set<string>> = new Map(),
): { model: string | null; autoMapped: boolean } {
const mapped = resolveMappedModel(metadata.model_mapping, downstreamModel);
if (!downstreamModel || hasExplicitModelMapping(metadata, downstreamModel)) {
@@ -219,6 +244,36 @@ function filterAllowedChannels(
return channels.filter((channel) => allowedSet.has(channel.id));
}
const normalizeTokenModels = (raw?: string | null): string[] | null => {
const parsed = safeJsonParse<string[] | null>(raw ?? null, null);
if (!parsed || !Array.isArray(parsed) || parsed.length === 0) {
return null;
}
return parsed.map((item) => String(item));
};
const selectTokenForModel = (
tokens: CallTokenItem[],
model: string | null,
): { token: CallTokenItem | null; hasModelList: boolean } => {
if (tokens.length === 0) {
return { token: null, hasModelList: false };
}
if (!model) {
return { token: tokens[0], hasModelList: false };
}
const tokensWithModels = tokens.map((token) => ({
token,
models: normalizeTokenModels(token.models_json),
}));
const hasModelList = tokensWithModels.some((entry) => entry.models);
if (!hasModelList) {
return { token: tokens[0], hasModelList: false };
}
const match = tokensWithModels.find((entry) => entry.models?.includes(model));
return { token: match?.token ?? null, hasModelList };
};
function resolveChannelBaseUrl(channel: ChannelRecord): string {
return normalizeBaseUrl(channel.base_url);
}
@@ -297,6 +352,7 @@ async function fetchWithTimeout(
*/
proxy.all("/*", tokenAuth, async (c) => {
const tokenRecord = c.get("tokenRecord") as TokenRecord;
const requestStart = Date.now();
let requestText = await c.req.text();
const parsedBody = requestText
? safeJsonParse<Record<string, unknown> | null>(requestText, null)
@@ -361,6 +417,33 @@ proxy.all("/*", tokenAuth, async (c) => {
);
}
const recordEarlyUsage = (options: {
status: number;
code: string;
message?: string | null;
}) => {
const latencyMs = Date.now() - requestStart;
const errorMessage = options.message ?? options.code;
scheduleDbWrite(
c,
recordUsage(c.env.DB, {
tokenId: tokenRecord.id,
channelId: null,
model: downstreamModel,
requestPath: c.req.path,
totalTokens: 0,
latencyMs,
firstTokenLatencyMs: isStream ? null : latencyMs,
stream: isStream,
reasoningEffort,
status: "error",
upstreamStatus: options.status,
errorCode: options.code,
errorMessage,
}),
);
};
const channelResult = await c.env.DB.prepare(
"SELECT * FROM channels WHERE status = ?",
)
@@ -377,18 +460,16 @@ proxy.all("/*", tokenAuth, async (c) => {
channel_id: row.channel_id,
name: row.name,
api_key: row.api_key,
models_json: row.models_json ?? null,
};
const list = callTokenMap.get(row.channel_id) ?? [];
list.push(entry);
callTokenMap.set(row.channel_id, list);
}
const allowedChannels = filterAllowedChannels(activeChannels, tokenRecord);
const ttlHours = await getModelCapabilityTtlHours(c.env.DB);
const ttlSeconds = Math.max(1, Math.floor(ttlHours)) * 60 * 60;
const verifiedModelsByChannel = await listVerifiedModelsByChannel(
c.env.DB,
allowedChannels.map((channel) => channel.id),
ttlSeconds,
);
let candidates = selectCandidateChannels(
allowedChannels,
@@ -403,6 +484,7 @@ proxy.all("/*", tokenAuth, async (c) => {
candidates.map((channel) => channel.id),
downstreamModel,
cooldownSeconds,
FAILURE_COUNT_THRESHOLD,
);
if (coolingChannels.size > 0) {
candidates = candidates.filter(
@@ -415,6 +497,11 @@ proxy.all("/*", tokenAuth, async (c) => {
cooldown_minutes: cooldownMinutes,
blocked_channels: coolingChannels.size,
});
recordEarlyUsage({
status: 503,
code: "upstream_cooldown",
message: "upstream_cooldown",
});
return jsonError(
c,
503,
@@ -434,6 +521,11 @@ proxy.all("/*", tokenAuth, async (c) => {
});
}
if (candidates.length === 0) {
recordEarlyUsage({
status: 503,
code: "no_available_channels",
message: "no_available_channels",
});
return jsonError(c, 503, "no_available_channels", "no_available_channels");
}
const targetPath = c.req.path;
@@ -451,7 +543,6 @@ proxy.all("/*", tokenAuth, async (c) => {
let lastChannel: ChannelRecord | null = null;
let lastRequestPath = targetPath;
let lastErrorDetails: ErrorDetails | null = null;
const start = Date.now();
let selectedChannel: ChannelRecord | null = null;
let selectedUpstreamProvider: ProviderType | null = null;
let selectedUpstreamModel: string | null = null;
@@ -476,8 +567,11 @@ proxy.all("/*", tokenAuth, async (c) => {
}
const baseUrl = resolveChannelBaseUrl(channel);
const tokens = callTokenMap.get(channel.id) ?? [];
const selectedToken = tokens.length > 0 ? selectCallToken(tokens) : null;
const apiKey = selectedToken?.api_key ?? channel.api_key;
const selection = selectTokenForModel(tokens, recordModel);
if (!selection.token && selection.hasModelList && recordModel) {
continue;
}
const apiKey = selection.token?.api_key ?? channel.api_key;
const headers = buildUpstreamHeaders(
new Headers(c.req.header()),
upstreamProvider,
@@ -588,6 +682,11 @@ proxy.all("/*", tokenAuth, async (c) => {
} | null = null;
if (endpointType === "chat" || endpointType === "responses") {
if (!normalizedChat) {
recordEarlyUsage({
status: 400,
code: "invalid_body",
message: "invalid_body",
});
return jsonError(c, 400, "invalid_body", "invalid_body");
}
const request = buildUpstreamChatRequest(
@@ -607,6 +706,11 @@ proxy.all("/*", tokenAuth, async (c) => {
};
} else if (endpointType === "embeddings") {
if (!normalizedEmbedding) {
recordEarlyUsage({
status: 400,
code: "invalid_body",
message: "invalid_body",
});
return jsonError(c, 400, "invalid_body", "invalid_body");
}
const request = buildUpstreamEmbeddingRequest(
@@ -624,6 +728,11 @@ proxy.all("/*", tokenAuth, async (c) => {
};
} else if (endpointType === "images") {
if (!normalizedImage) {
recordEarlyUsage({
status: 400,
code: "invalid_body",
message: "invalid_body",
});
return jsonError(c, 400, "invalid_body", "invalid_body");
}
const request = buildUpstreamImageRequest(
@@ -718,7 +827,11 @@ proxy.all("/*", tokenAuth, async (c) => {
errorCode: errorInfo.errorCode,
errorMessage: errorInfo.errorMessage,
};
if (recordModel) {
const cooldownEligible = shouldCooldown(
response.status,
errorInfo.errorCode,
);
if (recordModel && cooldownSeconds > 0 && cooldownEligible) {
scheduleDbWrite(
c,
recordChannelModelError(
@@ -730,6 +843,23 @@ proxy.all("/*", tokenAuth, async (c) => {
),
);
}
if (
downstreamModel &&
downstreamModel !== recordModel &&
cooldownSeconds > 0 &&
cooldownEligible
) {
scheduleDbWrite(
c,
recordChannelModelError(
c.env.DB,
channel.id,
downstreamModel,
String(response.status),
nowSeconds,
),
);
}
} catch (error) {
const isTimeout =
error instanceof Error &&
@@ -747,11 +877,15 @@ proxy.all("/*", tokenAuth, async (c) => {
lastErrorDetails = {
upstreamStatus: null,
errorCode: isTimeout ? "timeout" : "exception",
errorMessage: truncateMessage(
errorMessage: normalizeMessage(
error instanceof Error ? error.message : String(error),
),
};
if (recordModel) {
const cooldownEligible = shouldCooldown(
null,
isTimeout ? "timeout" : "exception",
);
if (recordModel && cooldownSeconds > 0 && cooldownEligible) {
scheduleDbWrite(
c,
recordChannelModelError(
@@ -763,11 +897,28 @@ proxy.all("/*", tokenAuth, async (c) => {
),
);
}
if (
downstreamModel &&
downstreamModel !== recordModel &&
cooldownSeconds > 0 &&
cooldownEligible
) {
scheduleDbWrite(
c,
recordChannelModelError(
c.env.DB,
channel.id,
downstreamModel,
isTimeout ? "timeout" : "exception",
nowSeconds,
),
);
}
lastResponse = null;
}
}
const latencyMs = Date.now() - start;
const latencyMs = Date.now() - requestStart;
if (!selectedChannel && lastResponse && !lastResponse.ok) {
console.warn("[proxy:upstream_exhausted]", {
path: targetPath,
@@ -838,7 +989,7 @@ proxy.all("/*", tokenAuth, async (c) => {
reasoningEffort,
status: lastResponse.ok ? "ok" : "error",
upstreamStatus: lastResponse.ok
? null
? lastResponse.status
: errorDetails?.upstreamStatus ?? lastResponse.status,
errorCode: lastResponse.ok ? null : errorDetails?.errorCode ?? null,
errorMessage: lastResponse.ok
-18
View File
@@ -8,14 +8,12 @@ import {
getCheckinScheduleTime,
getRetentionDays,
getSessionTtlHours,
getModelCapabilityTtlHours,
getModelFailureCooldownMinutes,
isAdminPasswordSet,
setAdminPasswordHash,
setCheckinScheduleTime,
setRetentionDays,
setSessionTtlHours,
setModelCapabilityTtlHours,
setModelFailureCooldownMinutes,
} from "../services/settings";
import { sha256Hex } from "../utils/crypto";
@@ -31,7 +29,6 @@ settings.get("/", async (c) => {
const sessionTtlHours = await getSessionTtlHours(c.env.DB);
const adminPasswordSet = await isAdminPasswordSet(c.env.DB);
const checkinScheduleTime = await getCheckinScheduleTime(c.env.DB);
const modelCapabilityTtlHours = await getModelCapabilityTtlHours(c.env.DB);
const modelFailureCooldownMinutes = await getModelFailureCooldownMinutes(
c.env.DB,
);
@@ -40,7 +37,6 @@ settings.get("/", async (c) => {
session_ttl_hours: sessionTtlHours,
admin_password_set: adminPasswordSet,
checkin_schedule_time: checkinScheduleTime,
model_capability_ttl_hours: modelCapabilityTtlHours,
model_failure_cooldown_minutes: modelFailureCooldownMinutes,
});
});
@@ -86,20 +82,6 @@ settings.put("/", async (c) => {
touched = true;
}
if (body.model_capability_ttl_hours !== undefined) {
const hours = Number(body.model_capability_ttl_hours);
if (Number.isNaN(hours) || hours < 1) {
return jsonError(
c,
400,
"invalid_model_capability_ttl_hours",
"invalid_model_capability_ttl_hours",
);
}
await setModelCapabilityTtlHours(c.env.DB, hours);
touched = true;
}
if (body.model_failure_cooldown_minutes !== undefined) {
const minutes = Number(body.model_failure_cooldown_minutes);
if (Number.isNaN(minutes) || minutes < 1) {
+59 -4
View File
@@ -16,6 +16,43 @@ type TokenRow = {
status: string;
allowed_channels: string | null;
token_plain?: string | null;
expires_at?: string | null;
};
const normalizeAllowedChannels = (
raw: string | null,
): string[] | null => {
if (!raw) {
return null;
}
const parsed = safeJsonParse<string[] | null>(raw, null);
if (!Array.isArray(parsed) || parsed.length === 0) {
return null;
}
return parsed.map((item) => String(item));
};
const normalizeExpiresAt = (
value: unknown,
): { value: string | null; valid: boolean } => {
if (value === undefined) {
return { value: null, valid: true };
}
if (value === null) {
return { value: null, valid: true };
}
if (typeof value !== "string") {
return { value: null, valid: false };
}
const trimmed = value.trim();
if (!trimmed) {
return { value: null, valid: true };
}
const parsed = Date.parse(trimmed);
if (Number.isNaN(parsed)) {
return { value: null, valid: false };
}
return { value: new Date(parsed).toISOString(), valid: true };
};
/**
@@ -23,9 +60,13 @@ type TokenRow = {
*/
tokens.get("/", async (c) => {
const result = await c.env.DB.prepare(
"SELECT id, name, key_prefix, quota_total, quota_used, status, allowed_channels, created_at, updated_at FROM tokens ORDER BY created_at DESC",
"SELECT id, name, key_prefix, quota_total, quota_used, status, allowed_channels, expires_at, created_at, updated_at FROM tokens ORDER BY created_at DESC",
).all();
return c.json({ tokens: result.results ?? [] });
const tokens = (result.results ?? []).map((row) => ({
...row,
allowed_channels: normalizeAllowedChannels(row.allowed_channels ?? null),
}));
return c.json({ tokens });
});
/**
@@ -46,9 +87,13 @@ tokens.post("/", async (c) => {
body.quota_total === null || body.quota_total === undefined
? null
: Number(body.quota_total);
const expiresAt = normalizeExpiresAt(body.expires_at);
if (!expiresAt.valid) {
return jsonError(c, 400, "invalid_expires_at", "invalid_expires_at");
}
await c.env.DB.prepare(
"INSERT INTO tokens (id, name, key_hash, key_prefix, token_plain, quota_total, quota_used, status, allowed_channels, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO tokens (id, name, key_hash, key_prefix, token_plain, quota_total, quota_used, status, allowed_channels, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(
id,
@@ -60,6 +105,7 @@ tokens.post("/", async (c) => {
0,
body.status ?? "active",
JSON.stringify(body.allowed_channels ?? null),
expiresAt.value,
now,
now,
)
@@ -97,9 +143,17 @@ tokens.patch("/:id", async (c) => {
body.quota_used === null || body.quota_used === undefined
? existing.quota_used
: Number(body.quota_used);
let expiresAtUpdate: string | null | undefined = existing.expires_at ?? null;
if (Object.hasOwn(body, "expires_at")) {
const normalized = normalizeExpiresAt(body.expires_at);
if (!normalized.valid) {
return jsonError(c, 400, "invalid_expires_at", "invalid_expires_at");
}
expiresAtUpdate = normalized.value;
}
await c.env.DB.prepare(
"UPDATE tokens SET name = ?, quota_total = ?, quota_used = ?, status = ?, allowed_channels = ?, updated_at = ? WHERE id = ?",
"UPDATE tokens SET name = ?, quota_total = ?, quota_used = ?, status = ?, allowed_channels = ?, expires_at = ?, updated_at = ? WHERE id = ?",
)
.bind(
body.name ?? existing.name,
@@ -107,6 +161,7 @@ tokens.patch("/:id", async (c) => {
Number.isNaN(quotaUsedUpdate) ? existing.quota_used : quotaUsedUpdate,
body.status ?? existing.status,
JSON.stringify(body.allowed_channels ?? existingAllowed ?? null),
expiresAtUpdate,
nowIso(),
id,
)
+63 -13
View File
@@ -10,42 +10,92 @@ const usage = new Hono<AppEnv>();
*/
usage.get("/", async (c) => {
const query = c.req.query();
const filters: string[] = [];
const params: Array<string | number> = [];
let sql =
"SELECT usage_logs.*, channels.name as channel_name, tokens.name as token_name FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id LEFT JOIN tokens ON tokens.id = usage_logs.token_id WHERE 1=1";
if (query.from) {
sql += " AND usage_logs.created_at >= ?";
filters.push("usage_logs.created_at >= ?");
params.push(query.from);
}
if (query.to) {
sql += " AND usage_logs.created_at <= ?";
filters.push("usage_logs.created_at <= ?");
params.push(query.to);
}
if (query.model) {
sql += " AND usage_logs.model = ?";
params.push(query.model);
const model = String(query.model).trim();
if (model) {
filters.push("usage_logs.model LIKE ? COLLATE NOCASE");
params.push(`%${model}%`);
}
}
if (query.channel_id) {
sql += " AND usage_logs.channel_id = ?";
filters.push("usage_logs.channel_id = ?");
params.push(query.channel_id);
}
if (query.token_id) {
sql += " AND usage_logs.token_id = ?";
filters.push("usage_logs.token_id = ?");
params.push(query.token_id);
}
if (query.channel) {
const channel = String(query.channel).trim();
if (channel) {
filters.push("channels.name LIKE ? COLLATE NOCASE");
params.push(`%${channel}%`);
}
}
if (query.token) {
const token = String(query.token).trim();
if (token) {
filters.push("tokens.name LIKE ? COLLATE NOCASE");
params.push(`%${token}%`);
}
}
if (query.status) {
const rawStatus = String(query.status).trim();
if (rawStatus) {
const numericStatus = Number(rawStatus);
if (Number.isNaN(numericStatus)) {
filters.push("usage_logs.status LIKE ? COLLATE NOCASE");
params.push(`%${rawStatus}%`);
} else {
filters.push("usage_logs.upstream_status = ?");
params.push(numericStatus);
}
}
}
const limit = Math.min(Number(query.limit ?? 50), 200);
sql += " ORDER BY usage_logs.created_at DESC LIMIT ?";
params.push(Number.isNaN(limit) ? 50 : limit);
const rawLimit = Number(query.limit ?? 50);
const normalizedLimit = Number.isNaN(rawLimit) ? 50 : Math.floor(rawLimit);
const limit = Math.min(Math.max(normalizedLimit, 1), 200);
const rawOffset = Number(query.offset ?? 0);
const normalizedOffset = Number.isNaN(rawOffset)
? 0
: Math.max(0, Math.floor(rawOffset));
const whereSql = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
const baseSql =
"FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id LEFT JOIN tokens ON tokens.id = usage_logs.token_id";
const retention = await getRetentionDays(c.env.DB);
await pruneUsageLogs(c.env.DB, retention);
const result = await c.env.DB.prepare(sql)
const countRow = await c.env.DB.prepare(
`SELECT COUNT(*) as total ${baseSql} ${whereSql}`,
)
.bind(...params)
.first<{ total: number }>();
const total = Number(countRow?.total ?? 0);
const listSql = `SELECT usage_logs.*, channels.name as channel_name, tokens.name as token_name ${baseSql} ${whereSql} ORDER BY usage_logs.created_at DESC LIMIT ? OFFSET ?`;
const result = await c.env.DB.prepare(listSql)
.bind(...params, limit, normalizedOffset)
.all();
return c.json({ logs: result.results ?? [] });
return c.json({
logs: result.results ?? [],
total,
limit,
offset: normalizedOffset,
});
});
export default usage;
@@ -3,6 +3,7 @@ export type CallTokenItem = {
channel_id: string;
name: string;
api_key: string;
models_json?: string | null;
};
export const selectCallToken = (
@@ -79,3 +79,18 @@ export async function replaceCallTokensForChannel(
await insertCallToken(db, token);
}
}
export async function updateCallTokenModels(
db: D1Database,
tokenId: string,
models: string[],
updatedAt: string,
): Promise<void> {
const modelsJson = models.length > 0 ? JSON.stringify(models) : null;
await db
.prepare(
"UPDATE channel_call_tokens SET models_json = ?, updated_at = ? WHERE id = ?",
)
.bind(modelsJson, updatedAt, tokenId)
.run();
}
@@ -3,6 +3,7 @@ export type ChannelCallTokenRow = {
channel_id: string;
name: string;
api_key: string;
models_json?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
@@ -3,17 +3,15 @@ import { nowIso } from "../utils/time";
import { extractModelIds } from "./channel-models";
import type { ModelEntry } from "./channel-models";
export const DEFAULT_MODEL_CAPABILITY_TTL_SEC = 2 * 60 * 60;
export type CapabilityRow = {
channel_id: string;
model: string;
last_ok_at: number | null;
last_err_count?: number | null;
};
export function buildCapabilityMap(
rows: CapabilityRow[],
cutoff: number,
): Map<string, Set<string>> {
const map = new Map<string, Set<string>>();
for (const row of rows) {
@@ -21,7 +19,7 @@ export function buildCapabilityMap(
continue;
}
const lastOk = Number(row.last_ok_at ?? 0);
if (!lastOk || lastOk < cutoff) {
if (!lastOk || lastOk <= 0) {
continue;
}
const set = map.get(row.channel_id) ?? new Set<string>();
@@ -34,30 +32,27 @@ export function buildCapabilityMap(
export async function listVerifiedModelsByChannel(
db: D1Database,
channelIds: string[],
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
): Promise<Map<string, Set<string>>> {
if (channelIds.length === 0) {
return new Map();
}
const cutoff = Math.floor(Date.now() / 1000) - ttlSeconds;
const placeholders = channelIds.map(() => "?").join(", ");
const rows = await db
.prepare(
`SELECT channel_id, model, last_ok_at FROM channel_model_capabilities WHERE channel_id IN (${placeholders}) AND last_ok_at >= ?`,
`SELECT channel_id, model, last_ok_at FROM channel_model_capabilities WHERE channel_id IN (${placeholders}) AND last_ok_at > 0`,
)
.bind(...channelIds, cutoff)
.bind(...channelIds)
.all<CapabilityRow>();
return buildCapabilityMap(rows.results ?? [], cutoff);
return buildCapabilityMap(rows.results ?? []);
}
export async function listVerifiedModelEntries(
db: D1Database,
channels: Array<{ id: string; name: string }>,
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
): Promise<ModelEntry[]> {
const ids = channels.map((channel) => channel.id);
const nameMap = new Map(channels.map((channel) => [channel.id, channel.name]));
const map = await listVerifiedModelsByChannel(db, ids, ttlSeconds);
const map = await listVerifiedModelsByChannel(db, ids);
const entries: ModelEntry[] = [];
for (const [channelId, models] of map.entries()) {
const channelName = nameMap.get(channelId) ?? channelId;
@@ -71,10 +66,9 @@ export async function listVerifiedModelEntries(
export async function listModelsByChannelWithFallback(
db: D1Database,
channels: Array<{ id: string; name: string; models_json?: string | null }>,
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
): Promise<Map<string, Set<string>>> {
const ids = channels.map((channel) => channel.id);
const verified = await listVerifiedModelsByChannel(db, ids, ttlSeconds);
const verified = await listVerifiedModelsByChannel(db, ids);
const map = new Map<string, Set<string>>();
for (const channel of channels) {
const verifiedModels = verified.get(channel.id);
@@ -93,9 +87,8 @@ export async function listModelsByChannelWithFallback(
export async function listModelEntriesWithFallback(
db: D1Database,
channels: Array<{ id: string; name: string; models_json?: string | null }>,
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
): Promise<ModelEntry[]> {
const map = await listModelsByChannelWithFallback(db, channels, ttlSeconds);
const map = await listModelsByChannelWithFallback(db, channels);
const entries: ModelEntry[] = [];
for (const channel of channels) {
const models = map.get(channel.id);
@@ -114,6 +107,7 @@ export async function listCoolingDownChannelsForModel(
channelIds: string[],
model: string | null,
cooldownSeconds: number,
minErrorCount: number = 1,
): Promise<Set<string>> {
if (!model || channelIds.length === 0 || cooldownSeconds <= 0) {
return new Set();
@@ -123,15 +117,21 @@ export async function listCoolingDownChannelsForModel(
const placeholders = channelIds.map(() => "?").join(", ");
const rows = await db
.prepare(
`SELECT channel_id, last_err_at, last_ok_at FROM channel_model_capabilities WHERE model = ? AND channel_id IN (${placeholders}) AND last_err_at IS NOT NULL AND last_err_at >= ?`,
`SELECT channel_id, last_err_at, last_ok_at, last_err_count FROM channel_model_capabilities WHERE model = ? AND channel_id IN (${placeholders}) AND last_err_at IS NOT NULL AND last_err_at >= ?`,
)
.bind(model, ...channelIds, cutoff)
.all<{ channel_id: string; last_err_at: number | null; last_ok_at: number | null }>();
.all<{
channel_id: string;
last_err_at: number | null;
last_ok_at: number | null;
last_err_count?: number | null;
}>();
const blocked = new Set<string>();
for (const row of rows.results ?? []) {
const lastErr = Number(row.last_err_at ?? 0);
const lastOk = Number(row.last_ok_at ?? 0);
if (lastErr && lastErr >= lastOk) {
const errCount = Number(row.last_err_count ?? 0);
if (lastErr && lastErr >= lastOk && errCount >= minErrorCount) {
blocked.add(row.channel_id);
}
}
@@ -151,9 +151,16 @@ export async function recordChannelModelError(
const timestamp = nowIso();
await db
.prepare(
"INSERT INTO channel_model_capabilities (channel_id, model, last_ok_at, last_err_at, last_err_code, created_at, updated_at) VALUES (?, ?, 0, ?, ?, ?, ?) ON CONFLICT(channel_id, model) DO UPDATE SET last_err_at = excluded.last_err_at, last_err_code = excluded.last_err_code, updated_at = excluded.updated_at",
"INSERT INTO channel_model_capabilities (channel_id, model, last_ok_at, last_err_at, last_err_code, last_err_count, created_at, updated_at) VALUES (?, ?, 0, ?, ?, 1, ?, ?) ON CONFLICT(channel_id, model) DO UPDATE SET last_err_at = excluded.last_err_at, last_err_code = excluded.last_err_code, last_err_count = COALESCE(channel_model_capabilities.last_err_count, 0) + 1, updated_at = excluded.updated_at",
)
.bind(
channelId,
model,
nowSeconds,
errorCode,
timestamp,
timestamp,
)
.bind(channelId, model, nowSeconds, errorCode, timestamp, timestamp)
.run();
}
@@ -168,7 +175,7 @@ export async function upsertChannelModelCapabilities(
}
const timestamp = nowIso();
const stmt = db.prepare(
"INSERT INTO channel_model_capabilities (channel_id, model, last_ok_at, last_err_at, last_err_code, created_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?) ON CONFLICT(channel_id, model) DO UPDATE SET last_ok_at = excluded.last_ok_at, last_err_at = NULL, last_err_code = NULL, updated_at = excluded.updated_at",
"INSERT INTO channel_model_capabilities (channel_id, model, last_ok_at, last_err_at, last_err_code, last_err_count, created_at, updated_at) VALUES (?, ?, ?, NULL, NULL, 0, ?, ?) ON CONFLICT(channel_id, model) DO UPDATE SET last_ok_at = excluded.last_ok_at, last_err_at = NULL, last_err_code = NULL, last_err_count = 0, updated_at = excluded.updated_at",
);
const statements = models.map((model) =>
stmt.bind(channelId, model, nowSeconds, timestamp, timestamp),
+7 -1
View File
@@ -144,7 +144,13 @@ export async function updateChannelTestResult(
},
): Promise<void> {
const now = Math.floor(Date.now() / 1000);
const status = result.ok ? "active" : "error";
const current = await db
.prepare("SELECT status FROM channels WHERE id = ?")
.bind(id)
.first<{ status: string }>();
const currentStatus = current?.status ?? "active";
const status =
currentStatus === "disabled" ? "disabled" : result.ok ? "active" : "error";
const modelsJson =
result.modelsJson ??
(result.models ? modelsToJson(result.models) : undefined);
-17
View File
@@ -4,13 +4,11 @@ import { nowIso } from "../utils/time";
const DEFAULT_LOG_RETENTION_DAYS = 30;
const DEFAULT_SESSION_TTL_HOURS = 12;
const DEFAULT_CHECKIN_SCHEDULE_TIME = "00:10";
const DEFAULT_MODEL_CAPABILITY_TTL_HOURS = 2;
const DEFAULT_MODEL_FAILURE_COOLDOWN_MINUTES = 10;
const RETENTION_KEY = "log_retention_days";
const SESSION_TTL_KEY = "session_ttl_hours";
const ADMIN_PASSWORD_HASH_KEY = "admin_password_hash";
const CHECKIN_SCHEDULE_TIME_KEY = "checkin_schedule_time";
const MODEL_CAPABILITY_TTL_KEY = "model_capability_ttl_hours";
const MODEL_FAILURE_COOLDOWN_KEY = "model_failure_cooldown_minutes";
async function readSetting(
@@ -130,21 +128,6 @@ export async function setCheckinScheduleTime(
await upsertSetting(db, CHECKIN_SCHEDULE_TIME_KEY, time);
}
export async function getModelCapabilityTtlHours(
db: D1Database,
): Promise<number> {
const value = await readSetting(db, MODEL_CAPABILITY_TTL_KEY);
return parsePositiveNumber(value, DEFAULT_MODEL_CAPABILITY_TTL_HOURS);
}
export async function setModelCapabilityTtlHours(
db: D1Database,
hours: number,
): Promise<void> {
const value = Math.max(1, Math.floor(hours)).toString();
await upsertSetting(db, MODEL_CAPABILITY_TTL_KEY, value);
}
export async function getModelFailureCooldownMinutes(
db: D1Database,
): Promise<number> {
+4 -6
View File
@@ -18,11 +18,11 @@ const buildLog = (partial: Partial<{ status: string; upstream_status: number | n
describe("usage status detail", () => {
it("renders success label", () => {
const detail = buildUsageStatusDetail(buildLog({ status: "ok" }) as any);
expect(detail.label).toBe("成功");
expect(detail.label).toBe("-");
expect(detail.tone).toBe("success");
});
it("renders failure with status and code", () => {
it("renders failure with status code", () => {
const detail = buildUsageStatusDetail(
buildLog({
status: "error",
@@ -31,9 +31,7 @@ describe("usage status detail", () => {
error_message: "Invalid API key",
}) as any,
);
expect(detail.label).toContain("失败");
expect(detail.label).toContain("403");
expect(detail.label).toContain("authentication_error");
expect(detail.message).toBe("Invalid API key");
expect(detail.label).toBe("403");
expect(detail.tone).toBe("error");
});
});