diff --git a/src/app/api/admin/cliproxy/instances/[id]/logs/route.ts b/src/app/api/admin/cliproxy/instances/[id]/logs/route.ts index fa28cb2a..203684fd 100644 --- a/src/app/api/admin/cliproxy/instances/[id]/logs/route.ts +++ b/src/app/api/admin/cliproxy/instances/[id]/logs/route.ts @@ -10,10 +10,11 @@ const log = createLogger("admin-cliproxy-logs"); type RouteContext = { params: Promise<{ id: string }> }; /** - * GET /api/admin/cliproxy/instances/:id/logs?since=ISO_TIMESTAMP - 查询实例日志。 + * GET /api/admin/cliproxy/instances/:id/logs?limit&after - 查询实例日志。 * - * 透传到 CLIProxyAPI 的 `/v0/management/logs` 端点。`since` 为可选的 ISO 时间戳, - * 用于增量拉取;未传时返回上游默认窗口内的全部日志。 + * 透传到 CLIProxyAPI 的 `/v0/management/logs` 端点。`limit` 限制单次返回行数, + * `after` 为 Unix 秒,仅返回时间戳大于该值的行(用于增量轮询)。 + * 上游要求开启 `LoggingToFile`,否则返回 400。 */ export async function GET(request: NextRequest, context: RouteContext): Promise { if (!validateAdminAuth(request.headers.get("authorization"))) { @@ -21,11 +22,23 @@ export async function GET(request: NextRequest, context: RouteContext): Promise< } const { id } = await context.params; - const since = new URL(request.url).searchParams.get("since") ?? undefined; + const searchParams = new URL(request.url).searchParams; + const rawLimit = searchParams.get("limit"); + const rawAfter = searchParams.get("after"); + + const limit = rawLimit !== null ? Number(rawLimit) : undefined; + const after = rawAfter !== null ? Number(rawAfter) : undefined; + + if (limit !== undefined && !Number.isFinite(limit)) { + return errorResponse("limit must be a finite number", 400); + } + if (after !== undefined && !Number.isFinite(after)) { + return errorResponse("after must be a finite Unix timestamp", 400); + } try { - const entries = await listCliproxyInstanceLogs(id, since); - return NextResponse.json({ data: entries }); + const result = await listCliproxyInstanceLogs(id, { limit, after }); + return NextResponse.json({ data: result }); } catch (err) { const mapped = handleCliproxyRouteError(err); if (mapped) { diff --git a/src/components/admin/cliproxy-instance-logs-panel.tsx b/src/components/admin/cliproxy-instance-logs-panel.tsx index 8a32461b..692cd07d 100644 --- a/src/components/admin/cliproxy-instance-logs-panel.tsx +++ b/src/components/admin/cliproxy-instance-logs-panel.tsx @@ -15,51 +15,40 @@ interface CliproxyInstanceLogsPanelProps { instance: CliproxyInstance; } -/** 日志级别对应的色调,未识别级别使用 muted。 */ -function levelClassName(level: string): string { - switch (level.toLowerCase()) { - case "error": - return "text-destructive"; - case "warn": - case "warning": - return "text-amber-500"; - case "info": - return "text-emerald-500"; - case "debug": - return "text-muted-foreground"; - default: - return "text-foreground"; - } +/** 启发式识别行内日志级别用于上色;未识别时落到 muted 色。 */ +function classifyLineLevel(line: string): string { + const normalized = line.toUpperCase(); + if (/\b(ERROR|ERR|FATAL|PANIC)\b/.test(normalized)) return "text-destructive"; + if (/\b(WARN|WARNING)\b/.test(normalized)) return "text-amber-500"; + if (/\b(INFO|NOTICE)\b/.test(normalized)) return "text-emerald-500"; + if (/\b(DEBUG|TRACE)\b/.test(normalized)) return "text-muted-foreground"; + return "text-foreground"; } /** * 实例日志查看面板。 * - * 首次显示时拉取一次日志,提供刷新按钮与前端关键词过滤。 - * 单次拉取行数受 `CLIPROXY_LOGS_DEFAULT_LIMIT` 上限,超出部分由后端控制。 + * 首次显示时拉取一次 CLIProxyAPI 上 `LoggingToFile` 输出的最近 N 行原始日志, + * 提供刷新按钮与前端关键词过滤。上游若未启用 `LoggingToFile` 会返回 400, + * 经管理 API 客户端透传后展示具体错误原因。 */ export function CliproxyInstanceLogsPanel({ instance }: CliproxyInstanceLogsPanelProps) { const t = useTranslations("cliproxy"); const { - data: logs, + data: result, isLoading, isError, refetch, isFetching, - } = useCliproxyInstanceLogs(instance.id); + } = useCliproxyInstanceLogs(instance.id, { limit: CLIPROXY_LOGS_DEFAULT_LIMIT }); const [keyword, setKeyword] = useState(""); const filtered = useMemo(() => { - if (!logs) return []; - const limited = logs.slice(0, CLIPROXY_LOGS_DEFAULT_LIMIT); - if (!keyword.trim()) return limited; + const lines = result?.lines ?? []; + if (!keyword.trim()) return lines; const needle = keyword.trim().toLowerCase(); - return limited.filter( - (entry) => - (entry.message ?? "").toLowerCase().includes(needle) || - (entry.level ?? "").toLowerCase().includes(needle) - ); - }, [logs, keyword]); + return lines.filter((line) => line.toLowerCase().includes(needle)); + }, [result, keyword]); return ( @@ -93,7 +82,7 @@ export function CliproxyInstanceLogsPanel({ instance }: CliproxyInstanceLogsPane

{t("logsLoadFailed")}

- ) : !logs || logs.length === 0 ? ( + ) : !result || result.lines.length === 0 ? (

{t("logsEmpty")}

@@ -104,13 +93,12 @@ export function CliproxyInstanceLogsPanel({ instance }: CliproxyInstanceLogsPane ) : (
    - {filtered.map((entry, index) => ( -
  • - {entry.timestamp} - - [{entry.level.toUpperCase()}] - - {entry.message} + {filtered.map((line, index) => ( +
  • + {line}
  • ))}
diff --git a/src/hooks/use-cliproxy.ts b/src/hooks/use-cliproxy.ts index af84e9bb..11bb6556 100644 --- a/src/hooks/use-cliproxy.ts +++ b/src/hooks/use-cliproxy.ts @@ -14,7 +14,7 @@ import type { CliproxyUpstreamProvider, CliproxyOAuthInitiateResult, CliproxyOAuthStatusResult, - CliproxyLogEntry, + CliproxyLogsResult, CliproxyAuthFileModel, CliproxyLinkedUpstream, } from "@/types/cliproxy"; @@ -366,16 +366,33 @@ export function useCliproxyAccountModels(instanceId: string, authFileName: strin }); } -/** 拉取实例的 CLIProxyAPI 运行日志,支持可选 since 时间戳过滤。 */ -export function useCliproxyInstanceLogs(instanceId: string | null, since?: string) { +/** + * 拉取实例的 CLIProxyAPI 运行日志。 + * + * 接受可选的 `limit`(单次行数上限)与 `after`(Unix 秒,增量起点), + * 返回 `{ lines, line_count, latest_timestamp }`;下次轮询可把 `latest_timestamp` + * 作为 `after` 传入以实现增量拉取。 + */ +export function useCliproxyInstanceLogs( + instanceId: string | null, + options: { limit?: number; after?: number } = {} +) { const { apiClient } = useAuth(); + const { limit, after } = options; return useQuery({ - queryKey: ["cliproxy", "logs", instanceId, since ?? null], + queryKey: ["cliproxy", "logs", instanceId, limit ?? null, after ?? null], queryFn: async () => { - const sinceParam = since ? `?since=${encodeURIComponent(since)}` : ""; - const response = await apiClient.get<{ data: CliproxyLogEntry[] }>( - `/admin/cliproxy/instances/${instanceId}/logs${sinceParam}` + const search = new URLSearchParams(); + if (typeof limit === "number" && Number.isFinite(limit)) { + search.set("limit", String(limit)); + } + if (typeof after === "number" && Number.isFinite(after)) { + search.set("after", String(after)); + } + const qs = search.toString(); + const response = await apiClient.get<{ data: CliproxyLogsResult }>( + `/admin/cliproxy/instances/${instanceId}/logs${qs ? `?${qs}` : ""}` ); return response.data; }, diff --git a/src/lib/services/cliproxy-instance-logs-service.ts b/src/lib/services/cliproxy-instance-logs-service.ts index e93c94b1..c6d44275 100644 --- a/src/lib/services/cliproxy-instance-logs-service.ts +++ b/src/lib/services/cliproxy-instance-logs-service.ts @@ -1,11 +1,15 @@ -import { getLogs, type CliproxyLogEntry } from "./cliproxy-management-client"; +import { + getLogs, + type CliproxyLogsQuery, + type CliproxyLogsResult, +} from "./cliproxy-management-client"; import { resolveCliproxyManagementTarget } from "./cliproxy-instance-crud"; /** 从 CLIProxyAPI 拉取实例日志。 */ export async function listCliproxyInstanceLogs( instanceId: string, - since?: string -): Promise { + query: CliproxyLogsQuery = {} +): Promise { const target = await resolveCliproxyManagementTarget(instanceId); - return getLogs(target, since); + return getLogs(target, query); } diff --git a/src/lib/services/cliproxy-management-client.ts b/src/lib/services/cliproxy-management-client.ts index 960ec663..3d6dbfdf 100644 --- a/src/lib/services/cliproxy-management-client.ts +++ b/src/lib/services/cliproxy-management-client.ts @@ -1,7 +1,11 @@ import { createLogger } from "../utils/logger"; -import type { CliproxyAuthFileModel, CliproxyLogEntry } from "@/types/cliproxy"; +import type { + CliproxyAuthFileModel, + CliproxyLogsQuery, + CliproxyLogsResult, +} from "@/types/cliproxy"; -export type { CliproxyAuthFileModel, CliproxyLogEntry }; +export type { CliproxyAuthFileModel, CliproxyLogsQuery, CliproxyLogsResult }; const log = createLogger("cliproxy-management-client"); @@ -99,18 +103,45 @@ function buildManagementUrl(managementUrl: string, path: string): string { return `${base}${path}`; } -/** 按响应状态码归类错误。 */ -function classifyHttpError(statusCode: number): CliproxyManagementApiError { +/** + * 提取上游错误响应中可读的描述,方便在 message 中透传给用户。 + * + * 兼容 CLIProxyAPI 常见的两种错误形态:纯文本(如 "logging to file is disabled") + * 以及 `{ error?: string; message?: string }` 包装。截断到 200 字符避免噪音。 + */ +function extractUpstreamErrorDetail(body: string | null): string | null { + if (!body) return null; + const trimmed = body.trim(); + if (!trimmed) return null; + try { + const parsed: unknown = JSON.parse(trimmed); + if (parsed && typeof parsed === "object") { + const obj = parsed as { error?: unknown; message?: unknown }; + const candidate = typeof obj.error === "string" ? obj.error : obj.message; + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim().slice(0, 200); + } + } + } catch { + // 不是 JSON,按纯文本处理 + } + return trimmed.slice(0, 200); +} + +/** 按响应状态码归类错误。`body` 为上游响应正文,存在时拼接到 message 末尾。 */ +function classifyHttpError(statusCode: number, body: string | null): CliproxyManagementApiError { + const detail = extractUpstreamErrorDetail(body); + const suffix = detail ? `:${detail}` : ""; if (statusCode === 401 || statusCode === 403) { return new CliproxyManagementApiError( "auth_failed", - "CLIProxyAPI 管理 API 鉴权失败,管理密钥无效", + `CLIProxyAPI 管理 API 鉴权失败,管理密钥无效${suffix}`, statusCode ); } return new CliproxyManagementApiError( "service_error", - `CLIProxyAPI 管理 API 返回异常状态码 ${statusCode}`, + `CLIProxyAPI 管理 API 返回异常状态码 ${statusCode}${suffix}`, statusCode ); } @@ -156,7 +187,15 @@ async function requestManagementApi( clearTimeout(timeoutId); if (!response.ok) { - throw classifyHttpError(response.status); + // 读取上游 body 以便把 "logging to file is disabled" 等具体原因透传给用户, + // 失败时降级为不带 detail 的状态码描述。 + let errorBody: string | null = null; + try { + errorBody = await response.text(); + } catch { + errorBody = null; + } + throw classifyHttpError(response.status, errorBody); } const text = await response.text(); @@ -347,27 +386,51 @@ export async function submitOAuthCallback( }); } +/** + * CLIProxyAPI 原始 `/v0/management/logs` 响应。 + * + * CLIProxyAPI 返回的字段使用 kebab-case,本类型如实声明上游 wire 形态, + * 由 {@link getLogs} 转换为 snake_case 后再向上层透出。 + */ +interface CliproxyLogsWire { + lines?: string[]; + "line-count"?: number; + "latest-timestamp"?: number; +} + /** * 查询 CLIProxyAPI 管理日志。 * - * `since` 为可选的 ISO 8601 时间戳字符串,传入时仅返回该时刻之后的条目。 - * 兼容上游返回直接数组与 `{ logs: [...] }` 包装两种格式。 + * 端点 `/v0/management/logs` 接受 `limit` 与 `after` 两个可选参数: + * `limit` 限制单次返回的行数,`after` 为 Unix 秒,仅返回时间戳大于该值的行。 + * 要求 CLIProxyAPI 已启用 `LoggingToFile`,否则上游会返回 400。 + * + * 上游返回的字段使用 kebab-case(`line-count` / `latest-timestamp`), + * 这里转换为 snake_case 以贴合 AutoRouter 其它管理 API 的命名约定。 */ export async function getLogs( target: CliproxyManagementTarget, - since?: string -): Promise { - const query = since ? `?since=${encodeURIComponent(since)}` : ""; - const result = await requestManagementApi( + query: CliproxyLogsQuery = {} +): Promise { + const params = new URLSearchParams(); + if (typeof query.limit === "number" && Number.isFinite(query.limit)) { + params.set("limit", String(Math.max(0, Math.floor(query.limit)))); + } + if (typeof query.after === "number" && Number.isFinite(query.after)) { + params.set("after", String(Math.max(0, Math.floor(query.after)))); + } + const search = params.toString(); + const result = await requestManagementApi( target, - `/logs${query}`, + `/logs${search ? `?${search}` : ""}`, { method: "GET" } ); - if (Array.isArray(result)) { - return result; - } - const wrapped = result as { logs?: CliproxyLogEntry[] }; - return Array.isArray(wrapped.logs) ? wrapped.logs : []; + return { + lines: Array.isArray(result.lines) ? result.lines : [], + line_count: typeof result["line-count"] === "number" ? result["line-count"] : 0, + latest_timestamp: + typeof result["latest-timestamp"] === "number" ? result["latest-timestamp"] : 0, + }; } /** 判断给定值是否为受支持的 OAuth 服务商。 */ diff --git a/src/types/cliproxy.ts b/src/types/cliproxy.ts index 5e8b1102..f0879b07 100644 --- a/src/types/cliproxy.ts +++ b/src/types/cliproxy.ts @@ -138,12 +138,26 @@ export interface CliproxyOAuthStatusResult { syncResult?: CliproxyAuthAccountSyncResult; } -/** CLIProxyAPI 管理日志条目。 */ -export interface CliproxyLogEntry { - timestamp: string; - level: string; - message: string; - [key: string]: unknown; +/** + * CLIProxyAPI 管理日志查询结果。 + * + * CLIProxyAPI `/v0/management/logs` 端点返回的形态为 + * `{ lines: string[], "line-count": number, "latest-timestamp": number }`, + * `lines` 是已格式化的日志行字符串,由 CLIProxyAPI 的 logger 直接生成。 + * `latestTimestamp` 为最后一行的 Unix 秒时间戳,调用方可用于下一次的 `after` 增量参数。 + */ +export interface CliproxyLogsResult { + lines: string[]; + line_count: number; + latest_timestamp: number; +} + +/** CLIProxyAPI 管理日志查询参数。 */ +export interface CliproxyLogsQuery { + /** 单次返回的日志行数上限,未传则由 CLIProxyAPI 决定。 */ + limit?: number; + /** 仅返回时间戳大于该值的日志行(Unix 秒),用于增量轮询。 */ + after?: number; } /** CLIProxyAPI auth-file 模型条目。 */ diff --git a/tests/components/cliproxy-instance-logs-panel.test.tsx b/tests/components/cliproxy-instance-logs-panel.test.tsx index af3bdc06..aed7d791 100644 --- a/tests/components/cliproxy-instance-logs-panel.test.tsx +++ b/tests/components/cliproxy-instance-logs-panel.test.tsx @@ -29,6 +29,14 @@ const instance: CliproxyInstance = { updated_at: "2025-05-30T12:00:00.000Z", }; +function logsResult(lines: string[]) { + return { + lines, + line_count: lines.length, + latest_timestamp: lines.length ? 1748685600 : 0, + }; +} + describe("CliproxyInstanceLogsPanel", () => { beforeEach(() => { vi.clearAllMocks(); @@ -58,7 +66,7 @@ describe("CliproxyInstanceLogsPanel", () => { it("空日志展示提示", () => { useCliproxyInstanceLogsMock.mockReturnValue({ - data: [], + data: logsResult([]), isLoading: false, refetch: vi.fn(), isFetching: false, @@ -67,29 +75,38 @@ describe("CliproxyInstanceLogsPanel", () => { expect(screen.getByText("logsEmpty")).toBeInTheDocument(); }); - it("渲染日志条目", () => { + it("渲染原始日志行字符串", () => { useCliproxyInstanceLogsMock.mockReturnValue({ - data: [ - { timestamp: "2025-05-31T10:00:00Z", level: "info", message: "started" }, - { timestamp: "2025-05-31T10:00:01Z", level: "warn", message: "slow request" }, - ], + data: logsResult([ + "2026-05-31 10:00:00 INFO server started", + "2026-05-31 10:00:01 WARN slow upstream request", + ]), isLoading: false, refetch: vi.fn(), isFetching: false, }); render(); - expect(screen.getByText("started")).toBeInTheDocument(); - expect(screen.getByText("slow request")).toBeInTheDocument(); - expect(screen.getByText("[INFO]")).toBeInTheDocument(); - expect(screen.getByText("[WARN]")).toBeInTheDocument(); + expect(screen.getByText("2026-05-31 10:00:00 INFO server started")).toBeInTheDocument(); + expect(screen.getByText("2026-05-31 10:00:01 WARN slow upstream request")).toBeInTheDocument(); }); - it("关键词过滤后只保留匹配条目", () => { + it("hook 使用默认 limit 调用,便于上游裁剪行数", () => { useCliproxyInstanceLogsMock.mockReturnValue({ - data: [ - { timestamp: "2025-05-31T10:00:00Z", level: "info", message: "started" }, - { timestamp: "2025-05-31T10:00:01Z", level: "warn", message: "slow request" }, - ], + data: logsResult([]), + isLoading: false, + refetch: vi.fn(), + isFetching: false, + }); + render(); + expect(useCliproxyInstanceLogsMock).toHaveBeenCalledWith("instance-1", { limit: 200 }); + }); + + it("关键词过滤后只保留匹配的日志行", () => { + useCliproxyInstanceLogsMock.mockReturnValue({ + data: logsResult([ + "2026-05-31 10:00:00 INFO server started", + "2026-05-31 10:00:01 WARN slow upstream request", + ]), isLoading: false, refetch: vi.fn(), isFetching: false, @@ -100,7 +117,23 @@ describe("CliproxyInstanceLogsPanel", () => { target: { value: "slow" }, }); - expect(screen.queryByText("started")).not.toBeInTheDocument(); - expect(screen.getByText("slow request")).toBeInTheDocument(); + expect(screen.queryByText("2026-05-31 10:00:00 INFO server started")).not.toBeInTheDocument(); + expect(screen.getByText("2026-05-31 10:00:01 WARN slow upstream request")).toBeInTheDocument(); + }); + + it("有日志但全部被过滤掉时展示 logsNoMatches", () => { + useCliproxyInstanceLogsMock.mockReturnValue({ + data: logsResult(["2026-05-31 10:00:00 INFO server started"]), + isLoading: false, + refetch: vi.fn(), + isFetching: false, + }); + render(); + + fireEvent.change(screen.getByPlaceholderText("logsSearchPlaceholder"), { + target: { value: "no-such-token" }, + }); + + expect(screen.getByText("logsNoMatches")).toBeInTheDocument(); }); }); diff --git a/tests/unit/api/admin/cliproxy/logs.test.ts b/tests/unit/api/admin/cliproxy/logs.test.ts index 317505bb..abcab548 100644 --- a/tests/unit/api/admin/cliproxy/logs.test.ts +++ b/tests/unit/api/admin/cliproxy/logs.test.ts @@ -39,11 +39,13 @@ describe("Admin CLIProxyAPI logs API", () => { expect(res.status).toBe(401); }); - it("返回日志数组", async () => { + it("返回 lines / line_count / latest_timestamp 三元组", async () => { const { GET } = await import("@/app/api/admin/cliproxy/instances/[id]/logs/route"); - listCliproxyInstanceLogsMock.mockResolvedValueOnce([ - { timestamp: "2025-05-31T10:00:00Z", level: "info", message: "ok" }, - ]); + listCliproxyInstanceLogsMock.mockResolvedValueOnce({ + lines: ["2026-05-31 10:00:00 INFO server started"], + line_count: 1, + latest_timestamp: 1748685600, + }); const res = await GET( new NextRequest("http://localhost/api/admin/cliproxy/instances/instance-1/logs", { @@ -55,24 +57,49 @@ describe("Admin CLIProxyAPI logs API", () => { const body = await res.json(); expect(res.status).toBe(200); - expect(body.data).toHaveLength(1); - expect(body.data[0].message).toBe("ok"); - expect(listCliproxyInstanceLogsMock).toHaveBeenCalledWith("instance-1", undefined); + expect(body.data.lines).toEqual(["2026-05-31 10:00:00 INFO server started"]); + expect(body.data.line_count).toBe(1); + expect(body.data.latest_timestamp).toBe(1748685600); + expect(listCliproxyInstanceLogsMock).toHaveBeenCalledWith("instance-1", { + limit: undefined, + after: undefined, + }); }); - it("透传 since 查询参数", async () => { + it("透传 limit 与 after 查询参数到服务层", async () => { const { GET } = await import("@/app/api/admin/cliproxy/instances/[id]/logs/route"); - listCliproxyInstanceLogsMock.mockResolvedValueOnce([]); + listCliproxyInstanceLogsMock.mockResolvedValueOnce({ + lines: [], + line_count: 0, + latest_timestamp: 0, + }); await GET( new NextRequest( - "http://localhost/api/admin/cliproxy/instances/instance-1/logs?since=2025-05-31T09:00:00Z", + "http://localhost/api/admin/cliproxy/instances/instance-1/logs?limit=200&after=1748685000", { method: "GET", headers: { authorization: AUTH } } ), ctx({ id: "instance-1" }) ); - expect(listCliproxyInstanceLogsMock).toHaveBeenCalledWith("instance-1", "2025-05-31T09:00:00Z"); + expect(listCliproxyInstanceLogsMock).toHaveBeenCalledWith("instance-1", { + limit: 200, + after: 1748685000, + }); + }); + + it("limit 不是数字时返回 400", async () => { + const { GET } = await import("@/app/api/admin/cliproxy/instances/[id]/logs/route"); + + const res = await GET( + new NextRequest("http://localhost/api/admin/cliproxy/instances/instance-1/logs?limit=abc", { + method: "GET", + headers: { authorization: AUTH }, + }), + ctx({ id: "instance-1" }) + ); + expect(res.status).toBe(400); + expect(listCliproxyInstanceLogsMock).not.toHaveBeenCalled(); }); it("实例不存在返回 404", async () => { diff --git a/tests/unit/hooks/use-cliproxy.test.ts b/tests/unit/hooks/use-cliproxy.test.ts index 40127848..d7dbdf54 100644 --- a/tests/unit/hooks/use-cliproxy.test.ts +++ b/tests/unit/hooks/use-cliproxy.test.ts @@ -496,18 +496,37 @@ describe("use-cliproxy 关联上游与日志 hooks", () => { expect(mockGet).not.toHaveBeenCalled(); }); - it("useCliproxyInstanceLogs 按可选 since 透传查询参数", async () => { - mockGet.mockResolvedValueOnce({ data: [] }); + it("useCliproxyInstanceLogs 透传 limit / after 到查询字符串并返回 lines 结构", async () => { + mockGet.mockResolvedValueOnce({ + data: { lines: ["INFO ok"], line_count: 1, latest_timestamp: 1748685600 }, + }); const { useCliproxyInstanceLogs } = await import("@/hooks/use-cliproxy"); const { wrapper } = createWrapper(); const { result } = renderHook( - () => useCliproxyInstanceLogs("instance-1", "2026-05-30T00:00:00Z"), + () => useCliproxyInstanceLogs("instance-1", { limit: 200, after: 1748685000 }), { wrapper } ); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(mockGet).toHaveBeenCalledWith( - "/admin/cliproxy/instances/instance-1/logs?since=2026-05-30T00%3A00%3A00Z" + "/admin/cliproxy/instances/instance-1/logs?limit=200&after=1748685000" ); + expect(result.current.data).toEqual({ + lines: ["INFO ok"], + line_count: 1, + latest_timestamp: 1748685600, + }); + }); + + it("useCliproxyInstanceLogs 未传查询参数时不附加 query string", async () => { + mockGet.mockResolvedValueOnce({ + data: { lines: [], line_count: 0, latest_timestamp: 0 }, + }); + const { useCliproxyInstanceLogs } = await import("@/hooks/use-cliproxy"); + const { wrapper } = createWrapper(); + + const { result } = renderHook(() => useCliproxyInstanceLogs("instance-1"), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockGet).toHaveBeenCalledWith("/admin/cliproxy/instances/instance-1/logs"); }); }); diff --git a/tests/unit/services/cliproxy-instance-logs-service.test.ts b/tests/unit/services/cliproxy-instance-logs-service.test.ts index 7089a063..0e0a9688 100644 --- a/tests/unit/services/cliproxy-instance-logs-service.test.ts +++ b/tests/unit/services/cliproxy-instance-logs-service.test.ts @@ -20,36 +20,43 @@ const target = { managementKey: "mgmt-key", }; +const sampleResult = { + lines: ["2026-05-31 10:00:00 INFO server started"], + line_count: 1, + latest_timestamp: 1748685600, +}; + describe("cliproxy-instance-logs-service", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("listCliproxyInstanceLogs 透传 since 参数到管理客户端", async () => { + it("listCliproxyInstanceLogs 透传 limit / after 查询参数到管理客户端", async () => { const { listCliproxyInstanceLogs } = await import("@/lib/services/cliproxy-instance-logs-service"); resolveTargetMock.mockResolvedValueOnce(target); - getLogsMock.mockResolvedValueOnce([ - { timestamp: "2025-05-31T10:00:00Z", level: "info", message: "ok" }, - ]); + getLogsMock.mockResolvedValueOnce(sampleResult); - const result = await listCliproxyInstanceLogs("instance-1", "2025-05-31T09:00:00Z"); + const result = await listCliproxyInstanceLogs("instance-1", { + limit: 200, + after: 1748685000, + }); - expect(result).toHaveLength(1); - expect(getLogsMock).toHaveBeenCalledWith(target, "2025-05-31T09:00:00Z"); + expect(result).toEqual(sampleResult); + expect(getLogsMock).toHaveBeenCalledWith(target, { limit: 200, after: 1748685000 }); }); - it("listCliproxyInstanceLogs 不传 since 时也不向客户端传递", async () => { + it("listCliproxyInstanceLogs 不传参数时默认传空对象", async () => { const { listCliproxyInstanceLogs } = await import("@/lib/services/cliproxy-instance-logs-service"); resolveTargetMock.mockResolvedValueOnce(target); - getLogsMock.mockResolvedValueOnce([]); + getLogsMock.mockResolvedValueOnce({ lines: [], line_count: 0, latest_timestamp: 0 }); await listCliproxyInstanceLogs("instance-1"); - expect(getLogsMock).toHaveBeenCalledWith(target, undefined); + expect(getLogsMock).toHaveBeenCalledWith(target, {}); }); it("listCliproxyInstanceLogs 实例不存在时抛出 CliproxyInstanceNotFoundError", async () => { diff --git a/tests/unit/services/cliproxy-management-client.test.ts b/tests/unit/services/cliproxy-management-client.test.ts index 4fd13475..37a54086 100644 --- a/tests/unit/services/cliproxy-management-client.test.ts +++ b/tests/unit/services/cliproxy-management-client.test.ts @@ -331,48 +331,64 @@ describe("cliproxy-management-client", () => { // ── getLogs ───────────────────────────────────────────────────────────── - it("getLogs 返回日志数组(上游直接返回数组)", async () => { - const entries = [ - { timestamp: "2025-05-31T10:00:00Z", level: "info", message: "started" }, - { timestamp: "2025-05-31T10:00:01Z", level: "warn", message: "slow" }, - ]; - const fetchMock = stubFetchOnce(new Response(JSON.stringify(entries), { status: 200 })); + it("getLogs 按 CLIProxyAPI wire 格式解析 lines / line-count / latest-timestamp", async () => { + const wire = { + lines: ["2026-05-31 10:00:00 INFO server started", "2026-05-31 10:00:01 WARN slow upstream"], + "line-count": 2, + "latest-timestamp": 1748685601, + }; + const fetchMock = stubFetchOnce(new Response(JSON.stringify(wire), { status: 200 })); const result = await getLogs(TARGET); - expect(result).toHaveLength(2); - expect(result[0].message).toBe("started"); + expect(result.lines).toEqual(wire.lines); + expect(result.line_count).toBe(2); + expect(result.latest_timestamp).toBe(1748685601); expect(fetchMock.mock.calls[0][0]).toBe("http://cliproxyapi:8317/v0/management/logs"); }); - it("getLogs 支持 since 参数并 URL 编码", async () => { - const fetchMock = stubFetchOnce(new Response(JSON.stringify([]), { status: 200 })); + it("getLogs 把 limit / after 参数拼到 query string 上", async () => { + const fetchMock = stubFetchOnce( + new Response(JSON.stringify({ lines: [], "line-count": 0, "latest-timestamp": 0 }), { + status: 200, + }) + ); - await getLogs(TARGET, "2025-05-31T10:00:00Z"); + await getLogs(TARGET, { limit: 200, after: 1748685000 }); - expect(fetchMock.mock.calls[0][0]).toContain("since=2025-05-31T10%3A00%3A00Z"); + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain("limit=200"); + expect(url).toContain("after=1748685000"); }); - it("getLogs 不传 since 时不附加查询参数", async () => { - const fetchMock = stubFetchOnce(new Response(JSON.stringify([]), { status: 200 })); + it("getLogs 不传参数时不附加 query string", async () => { + const fetchMock = stubFetchOnce( + new Response(JSON.stringify({ lines: [], "line-count": 0, "latest-timestamp": 0 }), { + status: 200, + }) + ); await getLogs(TARGET); - expect(fetchMock.mock.calls[0][0]).not.toContain("since"); + expect(fetchMock.mock.calls[0][0]).toBe("http://cliproxyapi:8317/v0/management/logs"); }); - it("getLogs 兼容上游返回 {logs:[]} 包装格式", async () => { - const entries = [{ timestamp: "2025-05-31T10:00:00Z", level: "info", message: "ok" }]; - stubFetchOnce(new Response(JSON.stringify({ logs: entries }), { status: 200 })); - - const result = await getLogs(TARGET); - - expect(result).toHaveLength(1); - expect(result[0].level).toBe("info"); + it("getLogs 上游返回空对象时落到 0 / 空数组兜底", async () => { + stubFetchOnce(new Response(JSON.stringify({}), { status: 200 })); + expect(await getLogs(TARGET)).toEqual({ + lines: [], + line_count: 0, + latest_timestamp: 0, + }); }); - it("getLogs 上游返回空对象时返回空数组", async () => { - stubFetchOnce(new Response(JSON.stringify({}), { status: 200 })); - expect(await getLogs(TARGET)).toEqual([]); + it("getLogs 上游返回 400 时把错误正文透传到 message", async () => { + stubFetchOnce(new Response("logging to file is disabled", { status: 400 })); + + await expect(getLogs(TARGET)).rejects.toMatchObject({ + kind: "service_error", + statusCode: 400, + message: expect.stringContaining("logging to file is disabled"), + }); }); });