feat: add proxy log client detection filters

This commit is contained in:
cita
2026-03-20 12:25:30 +08:00
parent 2de5eebf29
commit 4e60290838
34 changed files with 3511 additions and 266 deletions
+12
View File
@@ -157,6 +157,7 @@ export type ProxyTestJobResponse = {
};
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
export type ProxyLogBillingDetails = {
quotaType: number;
@@ -208,6 +209,10 @@ export type ProxyLogListItem = {
downstreamKeyName?: string | null;
downstreamKeyGroupName?: string | null;
downstreamKeyTags?: string[];
clientFamily?: string | null;
clientAppId?: string | null;
clientAppName?: string | null;
clientConfidence?: ProxyLogClientConfidence;
promptTokens?: number | null;
completionTokens?: number | null;
estimatedCost?: number | null;
@@ -233,16 +238,23 @@ export type ProxyLogsQuery = {
offset?: number;
status?: ProxyLogStatusFilter;
search?: string;
client?: string;
siteId?: number;
from?: string;
to?: string;
};
export type ProxyLogClientOption = {
value: string;
label: string;
};
export type ProxyLogsResponse = {
items: ProxyLogListItem[];
total: number;
page: number;
pageSize: number;
clientOptions: ProxyLogClientOption[];
summary: ProxyLogsSummary;
};
+36 -2
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import ModernSelect from '../components/ModernSelect.js';
import { ToastProvider } from '../components/Toast.js';
import ProxyLogs from './ProxyLogs.js';
@@ -62,6 +63,10 @@ function buildListResponse(overrides?: Partial<{
username: 'tester',
siteName: 'main-site',
siteUrl: 'https://main-site.example.com',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'heuristic',
downstreamKeyName: '移动端灰度',
downstreamKeyGroupName: '项目A',
downstreamKeyTags: ['VIP', '灰度'],
@@ -77,6 +82,10 @@ function buildListResponse(overrides?: Partial<{
totalCost: 1.23,
totalTokensAll: 15,
},
clientOptions: [
{ value: 'app:cherry_studio', label: '应用 · Cherry Studio' },
{ value: 'family:codex', label: '协议 · Codex' },
],
...overrides,
};
}
@@ -105,6 +114,10 @@ describe('ProxyLogs server-driven page', () => {
username: 'tester',
siteName: 'main-site',
siteUrl: 'https://main-site.example.com',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'heuristic',
downstreamKeyName: '移动端灰度',
downstreamKeyGroupName: '项目A',
downstreamKeyTags: ['VIP', '灰度'],
@@ -171,13 +184,16 @@ describe('ProxyLogs server-driven page', () => {
expect(text).toContain('全部 12');
expect(text).toContain('成功 8');
expect(text).toContain('失败 4');
expect(text).toContain('Cherry Studio');
expect(text).toContain('Codex');
expect(text).toContain('推测');
expect(text).toContain('下游 Key: 移动端灰度');
} finally {
root?.unmount();
}
});
it('re-queries the server for status and search changes instead of filtering locally', async () => {
it('re-queries the server for status, client, and search changes instead of filtering locally', async () => {
let root: ReturnType<typeof create> | null = null;
try {
@@ -200,6 +216,15 @@ describe('ProxyLogs server-driven page', () => {
});
await flushMicrotasks();
const selects = root!.root.findAllByType(ModernSelect);
const clientSelect = selects.find((node) => node.props.placeholder === '全部客户端');
expect(clientSelect).toBeDefined();
await act(async () => {
clientSelect!.props.onChange('app:cherry_studio');
});
await flushMicrotasks();
const searchInput = root!.root.find((node) => (
node.type === 'input' && node.props.placeholder === '搜索模型、下游 Key、主分组、标签...'
));
@@ -214,11 +239,19 @@ describe('ProxyLogs server-driven page', () => {
status: 'failed',
search: '',
});
expect(apiMock.getProxyLogs).toHaveBeenNthCalledWith(3, {
limit: 50,
offset: 0,
status: 'failed',
search: '',
client: 'app:cherry_studio',
});
expect(apiMock.getProxyLogs).toHaveBeenLastCalledWith({
limit: 50,
offset: 0,
status: 'failed',
search: 'mini',
client: 'app:cherry_studio',
});
} finally {
root?.unmount();
@@ -274,7 +307,7 @@ describe('ProxyLogs server-driven page', () => {
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/logs?siteId=9&from=2026-03-09T08:00&to=2026-03-09T09:00']}>
<MemoryRouter initialEntries={['/logs?siteId=9&client=family%3Acodex&from=2026-03-09T08:00&to=2026-03-09T09:00']}>
<ToastProvider>
<ProxyLogs />
</ToastProvider>
@@ -291,6 +324,7 @@ describe('ProxyLogs server-driven page', () => {
status: 'all',
search: '',
siteId: 9,
client: 'family:codex',
from: expectedFrom,
to: expectedTo,
});
+124 -3
View File
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import {
api,
type ProxyLogBillingDetails,
type ProxyLogClientOption,
type ProxyLogDetail,
type ProxyLogListItem,
type ProxyLogsSummary,
@@ -35,6 +36,12 @@ type ProxyLogDetailState = {
const PAGE_SIZES = [20, 50, 100];
const DEFAULT_PAGE_SIZE = 50;
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
codex: 'Codex',
claude_code: 'Claude Code',
gemini_cli: 'Gemini CLI',
generic: '通用',
};
const EMPTY_SUMMARY: ProxyLogsSummary = {
totalCount: 0,
successCount: 0,
@@ -153,6 +160,12 @@ function normalizeRouteSearch(raw: string | null): string {
return (raw || '').trim();
}
function normalizeRouteClient(raw: string | null): string {
const text = (raw || '').trim();
if (!text) return '';
return /^((app|family):)/i.test(text) ? text : '';
}
function normalizeRouteSiteId(raw: string | null): number | null {
const parsed = Number.parseInt(raw || '', 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
@@ -174,6 +187,7 @@ function readProxyLogsRouteState(search: string) {
pageSize: normalizeRoutePageSize(params.get('pageSize')),
status: normalizeRouteStatus(params.get('status')),
search: normalizeRouteSearch(params.get('q')),
client: normalizeRouteClient(params.get('client')),
siteId: normalizeRouteSiteId(params.get('siteId')),
from: normalizeRouteDateTimeInput(params.get('from')),
to: normalizeRouteDateTimeInput(params.get('to')),
@@ -185,6 +199,7 @@ function buildProxyLogsRouteSearch(input: {
pageSize: number;
status: ProxyLogStatusFilter;
search: string;
client: string;
siteId: number | null;
from: string;
to: string;
@@ -194,6 +209,7 @@ function buildProxyLogsRouteSearch(input: {
if (input.pageSize !== DEFAULT_PAGE_SIZE) params.set('pageSize', String(input.pageSize));
if (input.status !== 'all') params.set('status', input.status);
if (input.search.trim()) params.set('q', input.search.trim());
if (input.client.trim()) params.set('client', input.client.trim());
if (input.siteId) params.set('siteId', String(input.siteId));
if (input.from.trim()) params.set('from', input.from.trim());
if (input.to.trim()) params.set('to', input.to.trim());
@@ -201,6 +217,66 @@ function buildProxyLogsRouteSearch(input: {
return next ? `?${next}` : '';
}
function formatProxyLogClientFamilyLabel(clientFamily?: string | null, options?: { includeGeneric?: boolean }) {
const normalized = typeof clientFamily === 'string' ? clientFamily.trim().toLowerCase() : '';
if (!normalized) return null;
if (!options?.includeGeneric && normalized === 'generic') return null;
return PROXY_LOG_CLIENT_FAMILY_LABELS[normalized] || clientFamily || null;
}
function resolveProxyLogClientDisplay(
log: Pick<ProxyLogRenderItem, 'clientFamily' | 'clientAppName' | 'clientConfidence'>,
options?: { includeGeneric?: boolean },
) {
const familyLabel = formatProxyLogClientFamilyLabel(log.clientFamily, options);
const appName = typeof log.clientAppName === 'string' ? log.clientAppName.trim() : '';
if (appName) {
return {
primary: appName,
secondary: familyLabel,
heuristic: log.clientConfidence === 'heuristic',
};
}
return {
primary: familyLabel,
secondary: null,
heuristic: false,
};
}
function renderProxyLogClientCell(
log: Pick<ProxyLogRenderItem, 'clientFamily' | 'clientAppName' | 'clientConfidence'>,
options?: { includeGeneric?: boolean },
) {
const display = resolveProxyLogClientDisplay(log, options);
if (!display.primary) {
return <span style={{ color: 'var(--color-text-muted)' }}>-</span>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span>{display.primary}</span>
{display.heuristic ? (
<span
className="badge"
style={{
fontSize: 10,
color: 'var(--color-text-muted)',
borderColor: 'var(--color-border)',
}}
>
推测
</span>
) : null}
</div>
{display.secondary ? (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{display.secondary}</span>
) : null}
</div>
);
}
function toApiTimeBoundary(value: string): string | undefined {
const text = value.trim();
if (!text) return undefined;
@@ -220,6 +296,7 @@ export default function ProxyLogs() {
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>(initialRouteState.status);
const [searchInput, setSearchInput] = useState(initialRouteState.search);
const deferredSearchInput = useDeferredValue(searchInput.trim());
const [clientFilter, setClientFilter] = useState(initialRouteState.client);
const [siteFilter, setSiteFilter] = useState<number | null>(initialRouteState.siteId);
const [fromInput, setFromInput] = useState(initialRouteState.from);
const [toInput, setToInput] = useState(initialRouteState.to);
@@ -229,6 +306,7 @@ export default function ProxyLogs() {
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
const [showFilters, setShowFilters] = useState(false);
const [sites, setSites] = useState<Array<{ id: number; name: string; status?: string | null }>>([]);
const [clientOptions, setClientOptions] = useState<ProxyLogClientOption[]>([]);
const [autoRefresh, setAutoRefresh] = useState(false);
const isMobile = useIsMobile(768);
const toast = useToast();
@@ -245,6 +323,7 @@ export default function ProxyLogs() {
const next = readProxyLogsRouteState(location.search);
setStatusFilter((current) => (current === next.status ? current : next.status));
setSearchInput((current) => (current === next.search ? current : next.search));
setClientFilter((current) => (current === next.client ? current : next.client));
setSiteFilter((current) => (current === next.siteId ? current : next.siteId));
setFromInput((current) => (current === next.from ? current : next.from));
setToInput((current) => (current === next.to ? current : next.to));
@@ -258,13 +337,14 @@ export default function ProxyLogs() {
pageSize,
status: statusFilter,
search: searchInput,
client: clientFilter,
siteId: siteFilter,
from: fromInput,
to: toInput,
});
if (nextSearch === location.search) return;
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
}, [fromInput, location.pathname, location.search, navigate, page, pageSize, searchInput, siteFilter, statusFilter, toInput]);
}, [clientFilter, fromInput, location.pathname, location.search, navigate, page, pageSize, searchInput, siteFilter, statusFilter, toInput]);
useEffect(() => {
let cancelled = false;
@@ -326,6 +406,20 @@ export default function ProxyLogs() {
];
}, [siteFilter, sites]);
const resolvedClientOptions = useMemo(() => {
const options = [...clientOptions];
if (clientFilter && !options.some((option) => option.value === clientFilter)) {
options.unshift({
value: clientFilter,
label: clientFilter,
});
}
return [
{ value: '', label: '全部客户端' },
...options,
];
}, [clientFilter, clientOptions]);
const activeSiteLabel = useMemo(() => {
if (!siteFilter) return '全部站点';
return siteOptions.find((option) => option.value === String(siteFilter))?.label || `站点 #${siteFilter}`;
@@ -357,6 +451,7 @@ export default function ProxyLogs() {
offset: currentOffset,
status: statusFilter,
search: deferredSearchInput,
...(clientFilter ? { client: clientFilter } : {}),
...(siteFilter ? { siteId: siteFilter } : {}),
...(fromApiBoundary ? { from: fromApiBoundary } : {}),
...(toApiBoundaryValue ? { to: toApiBoundaryValue } : {}),
@@ -366,13 +461,14 @@ export default function ProxyLogs() {
setLogs(Array.isArray(data.items) ? data.items : []);
setTotal(Number(data.total || 0));
setSummary(data.summary || EMPTY_SUMMARY);
setClientOptions(Array.isArray(data.clientOptions) ? data.clientOptions : []);
} catch (e: any) {
if (seq !== loadSeq.current) return;
if (!silent) toast.error(e.message || '加载日志失败');
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, [currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
}, [clientFilter, currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
useEffect(() => {
void load();
@@ -450,6 +546,18 @@ export default function ProxyLogs() {
</button>
))}
</div>
<div className="proxy-logs-filter-select">
<ModernSelect
size="sm"
value={clientFilter}
onChange={(nextValue) => {
setClientFilter(nextValue);
setPage(1);
}}
options={resolvedClientOptions}
placeholder="全部客户端"
/>
</div>
<div className="proxy-logs-filter-select">
<ModernSelect
size="sm"
@@ -504,6 +612,7 @@ export default function ProxyLogs() {
className="btn btn-ghost proxy-logs-filter-reset"
onClick={() => {
setStatusFilter('all');
setClientFilter('');
setSiteFilter(null);
setFromInput('');
setToInput('');
@@ -620,6 +729,7 @@ export default function ProxyLogs() {
>
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
<MobileField label="客户端" value={renderProxyLogClientCell(detailLog)} />
{downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null}
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
@@ -634,6 +744,7 @@ export default function ProxyLogs() {
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
{billingDetailSummary && <div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>}
<MobileField label="客户端详情" value={renderProxyLogClientCell(detailLog, { includeGeneric: true })} />
{downstreamKeySummary && <div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>}
{billingProcessLines.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
@@ -668,6 +779,7 @@ export default function ProxyLogs() {
<th>时间</th>
<th>模型</th>
<th>站点</th>
<th>客户端</th>
<th>{tr('状态')}</th>
<th style={{ textAlign: 'center' }}>用时</th>
<th style={{ textAlign: 'right' }}>输入</th>
@@ -722,6 +834,9 @@ export default function ProxyLogs() {
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{renderProxyLogClientCell(detailLog)}
</td>
<td>
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
<span style={{
@@ -763,7 +878,7 @@ export default function ProxyLogs() {
</tr>
{expanded === log.id && (
<tr style={{ background: 'var(--color-bg)' }}>
<td colSpan={10} style={{ padding: 0 }}>
<td colSpan={11} style={{ padding: 0 }}>
<div className="anim-collapse is-open">
<div className="anim-collapse-inner">
<div className="animate-fade-in" style={{
@@ -796,6 +911,12 @@ export default function ProxyLogs() {
{billingDetailSummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
)}
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>
<span style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>客户端</span>
<div style={{ minWidth: 0 }}>
{renderProxyLogClientCell(detailLog, { includeGeneric: true })}
</div>
</div>
{downstreamKeySummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>
)}
@@ -16,6 +16,14 @@ describe('parseProxyLogPathMeta', () => {
expect(parsed.errorMessage).toBe('messages is required');
});
it('strips legacy client and session prefixes while keeping them available for display', () => {
const parsed = parseProxyLogPathMeta('[client:codex] [session:turn-123] [downstream:/v1/responses] boom');
expect(parsed.clientFamily).toBe('codex');
expect(parsed.sessionId).toBe('turn-123');
expect(parsed.downstreamPath).toBe('/v1/responses');
expect(parsed.errorMessage).toBe('boom');
});
it('keeps plain message when no metadata exists', () => {
const parsed = parseProxyLogPathMeta('network timeout');
expect(parsed.downstreamPath).toBe(null);
+10 -1
View File
@@ -1,4 +1,6 @@
type ProxyLogPathMeta = {
clientFamily: string | null;
sessionId: string | null;
downstreamPath: string | null;
upstreamPath: string | null;
errorMessage: string;
@@ -6,11 +8,18 @@ type ProxyLogPathMeta = {
export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
const raw = typeof message === 'string' ? message.trim() : '';
const clientMatch = raw.match(/\[client:([^\]]+)\]/i);
const sessionMatch = raw.match(/\[session:([^\]]+)\]/i);
const downstreamMatch = raw.match(/\[downstream:([^\]]+)\]/i);
const upstreamMatch = raw.match(/\[upstream:([^\]]+)\]/i);
const stripped = raw.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim();
const stripped = raw.replace(
/^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i,
'',
).trim();
return {
clientFamily: clientMatch?.[1]?.trim() || null,
sessionId: sessionMatch?.[1]?.trim() || null,
downstreamPath: downstreamMatch?.[1]?.trim() || null,
upstreamPath: upstreamMatch?.[1]?.trim() || null,
errorMessage: stripped,