Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions src/app/api/admin/cliproxy/instances/[id]/logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,35 @@ 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<Response> {
if (!validateAdminAuth(request.headers.get("authorization"))) {
return errorResponse("Unauthorized", 401);
}

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) {
Expand Down
60 changes: 24 additions & 36 deletions src/components/admin/cliproxy-instance-logs-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Card variant="outlined">
Expand Down Expand Up @@ -93,7 +82,7 @@ export function CliproxyInstanceLogsPanel({ instance }: CliproxyInstanceLogsPane
<p className="py-8 text-center type-body-medium text-destructive">
{t("logsLoadFailed")}
</p>
) : !logs || logs.length === 0 ? (
) : !result || result.lines.length === 0 ? (
<p className="py-8 text-center type-body-medium text-muted-foreground">
{t("logsEmpty")}
</p>
Expand All @@ -104,13 +93,12 @@ export function CliproxyInstanceLogsPanel({ instance }: CliproxyInstanceLogsPane
) : (
<div className="max-h-[28rem] overflow-y-auto rounded-cf-sm border border-border bg-surface-200 p-3 font-mono">
<ul className="space-y-1 type-body-small">
{filtered.map((entry, index) => (
<li key={`${entry.timestamp}-${index}`} className="flex gap-3">
<span className="shrink-0 text-muted-foreground">{entry.timestamp}</span>
<span className={cn("shrink-0 font-semibold", levelClassName(entry.level))}>
[{entry.level.toUpperCase()}]
</span>
<span className="min-w-0 break-words">{entry.message}</span>
{filtered.map((line, index) => (
<li
key={`${index}-${line.slice(0, 32)}`}
className={cn("break-words", classifyLineLevel(line))}
>
{line}
</li>
))}
</ul>
Expand Down
31 changes: 24 additions & 7 deletions src/hooks/use-cliproxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
CliproxyUpstreamProvider,
CliproxyOAuthInitiateResult,
CliproxyOAuthStatusResult,
CliproxyLogEntry,
CliproxyLogsResult,
CliproxyAuthFileModel,
CliproxyLinkedUpstream,
} from "@/types/cliproxy";
Expand Down Expand Up @@ -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;
},
Expand Down
12 changes: 8 additions & 4 deletions src/lib/services/cliproxy-instance-logs-service.ts
Original file line number Diff line number Diff line change
@@ -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<CliproxyLogEntry[]> {
query: CliproxyLogsQuery = {}
): Promise<CliproxyLogsResult> {
const target = await resolveCliproxyManagementTarget(instanceId);
return getLogs(target, since);
return getLogs(target, query);
}
101 changes: 82 additions & 19 deletions src/lib/services/cliproxy-management-client.ts
Original file line number Diff line number Diff line change
@@ -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");

Expand Down Expand Up @@ -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
);
}
Expand Down Expand Up @@ -156,7 +187,15 @@ async function requestManagementApi<T>(
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();
Expand Down Expand Up @@ -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<CliproxyLogEntry[]> {
const query = since ? `?since=${encodeURIComponent(since)}` : "";
const result = await requestManagementApi<CliproxyLogEntry[] | { logs?: CliproxyLogEntry[] }>(
query: CliproxyLogsQuery = {}
): Promise<CliproxyLogsResult> {
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<CliproxyLogsWire>(
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 服务商。 */
Expand Down
26 changes: 20 additions & 6 deletions src/types/cliproxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 模型条目。 */
Expand Down
Loading
Loading