1
0
forked from boos/BoosAPI

feat: add site observability log drilldown

This commit is contained in:
bnvnvnv
2026-03-11 11:03:57 +00:00
parent 921c551a3a
commit bc3c731ed2
9 changed files with 1508 additions and 31 deletions
+4
View File
@@ -184,6 +184,7 @@ export type ProxyLogListItem = {
totalTokens: number | null;
retryCount: number;
accountId?: number | null;
siteId?: number | null;
username?: string | null;
siteName?: string | null;
siteUrl?: string | null;
@@ -213,6 +214,9 @@ export type ProxyLogsQuery = {
offset?: number;
status?: ProxyLogStatusFilter;
search?: string;
siteId?: number;
from?: string;
to?: string;
};
export type ProxyLogsResponse = {
+444
View File
@@ -3533,6 +3533,43 @@ textarea {
color: var(--color-text-muted);
}
.proxy-logs-filter-select {
min-width: 180px;
}
.proxy-logs-time-field {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-text-muted);
}
.proxy-logs-time-field span {
white-space: nowrap;
}
.proxy-logs-time-field input {
min-width: 170px;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 13px;
background: var(--color-bg-card);
color: var(--color-text-primary);
outline: none;
}
.proxy-logs-time-field input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.proxy-logs-filter-reset {
border: 1px solid var(--color-border);
padding: 8px 12px;
}
/* ===== View Toggle ===== */
.view-toggle {
display: inline-flex;
@@ -3968,6 +4005,350 @@ textarea {
}
}
.site-observability-panel {
margin-bottom: 24px;
}
.site-observability-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.site-observability-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 4px;
}
.site-observability-subtitle {
font-size: 12px;
color: var(--color-text-muted);
}
.site-observability-legend {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--color-text-muted);
}
.site-observability-legend-text {
line-height: 1;
}
.site-observability-legend-chip {
width: 14px;
height: 14px;
border-radius: 4px;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 80%, transparent);
}
.site-observability-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.site-observability-row {
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
background: color-mix(in srgb, var(--color-bg) 88%, white);
}
.site-observability-row-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.site-observability-row-actions {
display: flex;
align-items: center;
flex-shrink: 0;
}
.site-observability-site-meta {
min-width: 0;
flex: 1;
}
.site-observability-site-name-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.site-observability-site-name {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
}
.site-observability-platform-badge {
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-info-soft) 72%, white);
color: var(--color-info);
font-size: 11px;
font-weight: 600;
line-height: 1.6;
}
.site-observability-link {
font-size: 11px;
color: var(--color-info);
text-decoration: none;
}
.site-observability-link:hover {
text-decoration: underline;
}
.site-observability-site-stats {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 12px;
color: var(--color-text-secondary);
}
.site-observability-log-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
color: var(--color-text-primary);
font-size: 12px;
font-weight: 600;
text-decoration: none;
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.site-observability-log-link:hover {
border-color: var(--color-primary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.site-availability-strip {
display: grid;
grid-template-columns: repeat(24, minmax(0, 1fr));
gap: 4px;
margin-top: 10px;
}
.site-availability-cell {
width: 100%;
height: 12px;
border-radius: 4px;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 78%, transparent);
}
.site-availability-cell-link {
display: block;
text-decoration: none;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.site-availability-cell-link:hover {
transform: translateY(-1px);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--color-border) 78%, transparent),
0 2px 6px rgba(15, 23, 42, 0.12);
}
.site-availability-cell-link:focus-visible,
.site-observability-log-link:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 16%, white);
}
.site-observability-empty {
padding: 28px 16px;
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-bg) 90%, white);
}
.site-observability-empty-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 4px;
}
.site-observability-empty-note {
font-size: 12px;
color: var(--color-text-muted);
}
.site-observability-panel {
margin-bottom: 24px;
}
.site-observability-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.site-observability-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 4px;
}
.site-observability-subtitle {
font-size: 12px;
color: var(--color-text-muted);
}
.site-observability-legend {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--color-text-muted);
}
.site-observability-legend-text {
line-height: 1;
}
.site-observability-legend-chip {
width: 14px;
height: 14px;
border-radius: 4px;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 80%, transparent);
}
.site-observability-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.site-observability-row {
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
background: color-mix(in srgb, var(--color-bg) 88%, white);
}
.site-observability-row-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.site-observability-site-meta {
min-width: 0;
flex: 1;
}
.site-observability-site-name-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.site-observability-site-name {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
}
.site-observability-platform-badge {
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-info-soft) 72%, white);
color: var(--color-info);
font-size: 11px;
font-weight: 600;
line-height: 1.6;
}
.site-observability-link {
font-size: 11px;
color: var(--color-info);
text-decoration: none;
}
.site-observability-link:hover {
text-decoration: underline;
}
.site-observability-site-stats {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 12px;
color: var(--color-text-secondary);
}
.site-availability-strip {
display: grid;
grid-template-columns: repeat(24, minmax(0, 1fr));
gap: 4px;
margin-top: 10px;
}
.site-availability-cell {
width: 100%;
height: 12px;
border-radius: 4px;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 78%, transparent);
}
.site-observability-empty {
padding: 28px 16px;
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-bg) 90%, white);
}
.site-observability-empty-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 4px;
}
.site-observability-empty-note {
font-size: 12px;
color: var(--color-text-muted);
}
/* ===== Responsive Adjustments ===== */
@media (max-width: 900px) {
.topbar-search-trigger {
@@ -4055,10 +4436,73 @@ textarea {
display: none;
}
.site-observability-row {
padding: 12px;
}
.site-observability-row-header {
flex-direction: column;
}
.site-observability-row-actions {
width: 100%;
justify-content: flex-end;
}
.site-observability-site-stats {
gap: 8px;
}
.site-availability-strip {
gap: 3px;
}
.site-availability-cell {
height: 10px;
border-radius: 3px;
}
.monitor-iframe {
height: calc(100vh - 320px);
min-height: 460px;
}
.site-observability-row {
padding: 12px;
}
.site-observability-row-header {
flex-direction: column;
}
.site-observability-row-actions {
width: 100%;
justify-content: flex-end;
}
.site-observability-site-stats {
gap: 8px;
}
.site-availability-strip {
gap: 3px;
}
.site-availability-cell {
height: 10px;
border-radius: 3px;
}
.proxy-logs-filter-select,
.proxy-logs-time-field,
.proxy-logs-time-field input,
.proxy-logs-filter-reset {
width: 100%;
}
.proxy-logs-time-field {
justify-content: space-between;
}
}
@media (max-width: 640px) {
+179
View File
@@ -1,4 +1,5 @@
import { Suspense, lazy, useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { formatCompactTokenMetric } from '../numberFormat.js';
@@ -36,6 +37,90 @@ type SiteSpeedState =
| { status: 'done'; ms: number }
| undefined;
type SiteAvailabilityBucket = {
label: string;
totalRequests: number;
successCount: number;
failedCount: number;
availabilityPercent: number | null;
averageLatencyMs: number | null;
};
type SiteAvailabilitySummary = {
siteId: number;
siteName: string;
siteUrl?: string | null;
platform?: string | null;
totalRequests: number;
successCount: number;
failedCount: number;
availabilityPercent: number | null;
averageLatencyMs: number | null;
buckets: SiteAvailabilityBucket[];
};
function formatAvailabilityPercent(value: number | null | undefined): string {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return '—';
return `${Math.round(value)}%`;
}
function getAvailabilityColor(value: number | null | undefined): string {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) {
return 'var(--color-border-light)';
}
const hue = Math.max(0, Math.min(120, value * 1.2));
return `hsl(${hue}, 72%, 45%)`;
}
function padDateTimeSegment(value: number): string {
return String(value).padStart(2, '0');
}
function formatDateTimeRouteValue(value: Date): string {
return `${value.getFullYear()}-${padDateTimeSegment(value.getMonth() + 1)}-${padDateTimeSegment(value.getDate())}T${padDateTimeSegment(value.getHours())}:${padDateTimeSegment(value.getMinutes())}`;
}
function buildSiteLogsRoute(siteId: number, range?: { from: Date; to: Date }): string {
const params = new URLSearchParams();
params.set('siteId', String(siteId));
if (range) {
params.set('from', formatDateTimeRouteValue(range.from));
params.set('to', formatDateTimeRouteValue(range.to));
}
return `/logs?${params.toString()}`;
}
function buildSiteLast24hLogsRoute(siteId: number): string {
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() - 23, 0, 0, 0);
const to = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() + 1, 0, 0, 0);
return buildSiteLogsRoute(siteId, { from, to });
}
function parseAvailabilityBucketStart(label: string): Date | null {
const match = label.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})(?::(\d{2}))?$/);
if (!match) return null;
const [, year, month, day, hour, minute, second = '0'] = match;
const parsed = new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second),
0,
);
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
function buildAvailabilityBucketLogsRoute(siteId: number, bucketLabel: string): string {
const start = parseAvailabilityBucketStart(bucketLabel);
if (!start) return buildSiteLast24hLogsRoute(siteId);
const end = new Date(start.getTime() + 60 * 60 * 1000);
return buildSiteLogsRoute(siteId, { from: start, to: end });
}
export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminName?: string }) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
@@ -213,6 +298,9 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
const performanceWindowSeconds = Math.max(1, safeNumber(data?.performance?.windowSeconds) || 60);
const requestsPerMinute = safeNumber(data?.performance?.requestsPerMinute);
const tokensPerMinute = safeNumber(data?.performance?.tokensPerMinute);
const siteAvailability: SiteAvailabilitySummary[] = Array.isArray(data?.siteAvailability)
? data.siteAvailability
: [];
const getLatencyColor = (ms: number) => (
ms <= 500
@@ -461,6 +549,97 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</div>
</div>
<div className="chart-container animate-slide-up stagger-8 site-observability-panel">
<div className="site-observability-header">
<div>
<div className="site-observability-title">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12h4l3 8 4-16 3 8h4" /></svg>
站点可用性观测
</div>
<div className="site-observability-subtitle">最近 24 小时,每个色块代表 1 小时,按单次上游请求统计</div>
</div>
<div className="site-observability-legend">
<span className="site-observability-legend-text">低</span>
<span className="site-observability-legend-chip" style={{ background: getAvailabilityColor(0) }} />
<span className="site-observability-legend-chip" style={{ background: getAvailabilityColor(50) }} />
<span className="site-observability-legend-chip" style={{ background: getAvailabilityColor(100) }} />
<span className="site-observability-legend-text">高</span>
</div>
</div>
{siteAvailability.length > 0 ? (
<div className="site-observability-list">
{siteAvailability.map((site) => (
<div key={site.siteId} className="site-observability-row">
<div className="site-observability-row-header">
<div className="site-observability-site-meta">
<div className="site-observability-site-name-row">
<span className="site-observability-site-name">{site.siteName}</span>
{site.platform && (
<span className="site-observability-platform-badge">{site.platform}</span>
)}
{site.siteUrl && (
<a
href={site.siteUrl}
target="_blank"
rel="noopener noreferrer"
className="site-observability-link"
>
打开
</a>
)}
</div>
<div className="site-observability-site-stats">
<span>{formatAvailabilityPercent(site.availabilityPercent)} 可用性</span>
<span style={site.averageLatencyMs != null ? { color: getLatencyColor(site.averageLatencyMs) } : undefined}>
{site.averageLatencyMs != null ? `${site.averageLatencyMs}ms` : '—'} 平均响应
</span>
<span>{Math.round(site.totalRequests || 0)} 次请求</span>
<span>{Math.round(site.successCount || 0)} 成功 / {Math.round(site.failedCount || 0)} 失败</span>
</div>
</div>
<div className="site-observability-row-actions">
<Link
to={buildSiteLast24hLogsRoute(site.siteId)}
className="site-observability-log-link"
>
查看日志
</Link>
</div>
</div>
<div className="site-availability-strip">
{site.buckets.map((bucket, index) => (
<Link
key={`${site.siteId}-${index}`}
to={buildAvailabilityBucketLogsRoute(site.siteId, bucket.label)}
className="site-availability-cell site-availability-cell-link"
style={{
background: getAvailabilityColor(bucket.availabilityPercent),
opacity: bucket.totalRequests > 0 ? 1 : 0.35,
}}
title={[
bucket.label,
bucket.totalRequests > 0
? `可用性 ${formatAvailabilityPercent(bucket.availabilityPercent)}`
: '无请求',
`${bucket.successCount} 成功 / ${bucket.failedCount} 失败`,
bucket.averageLatencyMs != null ? `平均响应 ${bucket.averageLatencyMs}ms` : '平均响应 —',
].join(' | ')}
aria-label={`${site.siteName} ${bucket.label} 使用日志`}
/>
))}
</div>
</div>
))}
</div>
) : (
<div className="site-observability-empty">
<div className="site-observability-empty-title">暂无站点观测数据</div>
<div className="site-observability-empty-note">有代理请求后,这里会自动生成每个站点的可用性条和平均响应速度。</div>
</div>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16 }}>
<div className="chart-container animate-slide-up stagger-8">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
+53 -9
View File
@@ -1,5 +1,6 @@
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 { ToastProvider } from '../components/Toast.js';
import ProxyLogs from './ProxyLogs.js';
@@ -7,6 +8,7 @@ const { apiMock } = vi.hoisted(() => ({
apiMock: {
getProxyLogs: vi.fn(),
getProxyLogDetail: vi.fn(),
getSites: vi.fn(),
},
}));
@@ -79,6 +81,10 @@ function buildListResponse(overrides?: Partial<{
describe('ProxyLogs server-driven page', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getSites.mockResolvedValue([
{ id: 9, name: 'main-site', status: 'active' },
{ id: 12, name: 'backup-site', status: 'active' },
]);
apiMock.getProxyLogs.mockResolvedValue(buildListResponse());
apiMock.getProxyLogDetail.mockResolvedValue({
id: 101,
@@ -138,9 +144,11 @@ describe('ProxyLogs server-driven page', () => {
try {
await act(async () => {
root = create(
<ToastProvider>
<ProxyLogs />
</ToastProvider>,
<MemoryRouter initialEntries={['/logs']}>
<ToastProvider>
<ProxyLogs />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
@@ -168,9 +176,11 @@ describe('ProxyLogs server-driven page', () => {
try {
await act(async () => {
root = create(
<ToastProvider>
<ProxyLogs />
</ToastProvider>,
<MemoryRouter initialEntries={['/logs']}>
<ToastProvider>
<ProxyLogs />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
@@ -214,9 +224,11 @@ describe('ProxyLogs server-driven page', () => {
try {
await act(async () => {
root = create(
<ToastProvider>
<ProxyLogs />
</ToastProvider>,
<MemoryRouter initialEntries={['/logs']}>
<ToastProvider>
<ProxyLogs />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
@@ -248,4 +260,36 @@ describe('ProxyLogs server-driven page', () => {
root?.unmount();
}
});
it('hydrates site and time filters from the route query', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/logs?siteId=9&from=2026-03-09T08:00&to=2026-03-09T09:00']}>
<ToastProvider>
<ProxyLogs />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(apiMock.getProxyLogs).toHaveBeenCalledWith({
limit: 50,
offset: 0,
status: 'all',
search: '',
siteId: 9,
from: '2026-03-09T08:00:00.000Z',
to: '2026-03-09T09:00:00.000Z',
});
const rendered = JSON.stringify(root!.toJSON());
expect(rendered).toContain('main-site');
} finally {
root?.unmount();
}
});
});
+265 -14
View File
@@ -1,4 +1,5 @@
import React, { useDeferredValue, useEffect, useMemo, useState, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
api,
type ProxyLogBillingDetails,
@@ -32,6 +33,7 @@ type ProxyLogDetailState = {
};
const PAGE_SIZES = [20, 50, 100];
const DEFAULT_PAGE_SIZE = 50;
const EMPTY_SUMMARY: ProxyLogsSummary = {
totalCount: 0,
successCount: 0,
@@ -113,27 +115,172 @@ function buildBillingProcessLines(log: ProxyLogRenderItem) {
return lines;
}
function padDateTimeSegment(value: number) {
return String(value).padStart(2, '0');
}
function formatDateTimeInputValue(value: Date) {
return `${value.getFullYear()}-${padDateTimeSegment(value.getMonth() + 1)}-${padDateTimeSegment(value.getDate())}T${padDateTimeSegment(value.getHours())}:${padDateTimeSegment(value.getMinutes())}`;
}
function normalizeRoutePage(raw: string | null): number {
const parsed = Number.parseInt(raw || '', 10);
if (!Number.isFinite(parsed) || parsed <= 0) return 1;
return parsed;
}
function normalizeRoutePageSize(raw: string | null): number {
const parsed = Number.parseInt(raw || '', 10);
return PAGE_SIZES.includes(parsed) ? parsed : DEFAULT_PAGE_SIZE;
}
function normalizeRouteStatus(raw: string | null): ProxyLogStatusFilter {
if (raw === 'success' || raw === 'failed') return raw;
return 'all';
}
function normalizeRouteSearch(raw: string | null): string {
return (raw || '').trim();
}
function normalizeRouteSiteId(raw: string | null): number | null {
const parsed = Number.parseInt(raw || '', 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
}
function normalizeRouteDateTimeInput(raw: string | null): string {
const text = (raw || '').trim();
if (!text) return '';
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) return '';
return formatDateTimeInputValue(parsed);
}
function readProxyLogsRouteState(search: string) {
const params = new URLSearchParams(search);
return {
page: normalizeRoutePage(params.get('page')),
pageSize: normalizeRoutePageSize(params.get('pageSize')),
status: normalizeRouteStatus(params.get('status')),
search: normalizeRouteSearch(params.get('q')),
siteId: normalizeRouteSiteId(params.get('siteId')),
from: normalizeRouteDateTimeInput(params.get('from')),
to: normalizeRouteDateTimeInput(params.get('to')),
};
}
function buildProxyLogsRouteSearch(input: {
page: number;
pageSize: number;
status: ProxyLogStatusFilter;
search: string;
siteId: number | null;
from: string;
to: string;
}) {
const params = new URLSearchParams();
if (input.page > 1) params.set('page', String(input.page));
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.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());
const next = params.toString();
return next ? `?${next}` : '';
}
function toApiTimeBoundary(value: string): string | undefined {
const text = value.trim();
if (!text) return undefined;
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) return undefined;
return parsed.toISOString();
}
export default function ProxyLogs() {
const location = useLocation();
const navigate = useNavigate();
const initialRouteState = useMemo(() => readProxyLogsRouteState(location.search), [location.search]);
const [logs, setLogs] = useState<ProxyLogListItem[]>([]);
const [summary, setSummary] = useState<ProxyLogsSummary>(EMPTY_SUMMARY);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>('all');
const [searchInput, setSearchInput] = useState('');
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>(initialRouteState.status);
const [searchInput, setSearchInput] = useState(initialRouteState.search);
const deferredSearchInput = useDeferredValue(searchInput.trim());
const [searchQuery, setSearchQuery] = useState('');
const [siteFilter, setSiteFilter] = useState<number | null>(initialRouteState.siteId);
const [fromInput, setFromInput] = useState(initialRouteState.from);
const [toInput, setToInput] = useState(initialRouteState.to);
const [expanded, setExpanded] = useState<number | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [page, setPage] = useState(initialRouteState.page);
const [pageSize, setPageSize] = useState(initialRouteState.pageSize);
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 isMobile = useIsMobile(768);
const toast = useToast();
const fromApiBoundary = toApiTimeBoundary(fromInput);
const toApiBoundaryValue = toApiTimeBoundary(toInput);
const hasInvalidTimeRange = Boolean(
fromApiBoundary
&& toApiBoundaryValue
&& new Date(fromApiBoundary).getTime() >= new Date(toApiBoundaryValue).getTime(),
);
useEffect(() => {
setPage(1);
setSearchQuery(deferredSearchInput);
}, [deferredSearchInput]);
const next = readProxyLogsRouteState(location.search);
setStatusFilter((current) => (current === next.status ? current : next.status));
setSearchInput((current) => (current === next.search ? current : next.search));
setSiteFilter((current) => (current === next.siteId ? current : next.siteId));
setFromInput((current) => (current === next.from ? current : next.from));
setToInput((current) => (current === next.to ? current : next.to));
setPage((current) => (current === next.page ? current : next.page));
setPageSize((current) => (current === next.pageSize ? current : next.pageSize));
}, [location.search]);
useEffect(() => {
const nextSearch = buildProxyLogsRouteSearch({
page,
pageSize,
status: statusFilter,
search: searchInput,
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]);
useEffect(() => {
let cancelled = false;
const loadSites = async () => {
try {
const result = await api.getSites();
const rows = Array.isArray(result) ? result : (result?.sites || []);
const normalized = rows
.map((site: any) => ({
id: Number(site?.id || 0),
name: String(site?.name || '').trim() || `站点 #${site?.id ?? ''}`,
status: typeof site?.status === 'string' ? site.status : null,
}))
.filter((site) => site.id > 0)
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'));
if (!cancelled) setSites(normalized);
} catch (error) {
console.error('Failed to load sites for proxy log filters:', error);
}
};
void loadSites();
return () => {
cancelled = true;
};
}, []);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
@@ -150,15 +297,48 @@ export default function ProxyLogs() {
})
), [safePage, totalPages]);
const siteOptions = useMemo(() => {
const options = sites.map((site) => ({
value: String(site.id),
label: site.status === 'disabled' ? `${site.name}(已禁用)` : site.name,
}));
if (siteFilter && !options.some((option) => option.value === String(siteFilter))) {
options.unshift({
value: String(siteFilter),
label: `站点 #${siteFilter}(已删除)`,
});
}
return [
{ value: '', label: '全部站点' },
...options,
];
}, [siteFilter, sites]);
const activeSiteLabel = useMemo(() => {
if (!siteFilter) return '全部站点';
return siteOptions.find((option) => option.value === String(siteFilter))?.label || `站点 #${siteFilter}`;
}, [siteFilter, siteOptions]);
const load = useCallback(async () => {
if (hasInvalidTimeRange) {
setLogs([]);
setTotal(0);
setSummary(EMPTY_SUMMARY);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await api.getProxyLogs({
const params = {
limit: pageSize,
offset: currentOffset,
status: statusFilter,
search: searchQuery,
});
search: deferredSearchInput,
...(siteFilter ? { siteId: siteFilter } : {}),
...(fromApiBoundary ? { from: fromApiBoundary } : {}),
...(toApiBoundaryValue ? { to: toApiBoundaryValue } : {}),
};
const data = await api.getProxyLogs(params);
setLogs(Array.isArray(data.items) ? data.items : []);
setTotal(Number(data.total || 0));
setSummary(data.summary || EMPTY_SUMMARY);
@@ -167,7 +347,7 @@ export default function ProxyLogs() {
} finally {
setLoading(false);
}
}, [currentOffset, pageSize, searchQuery, statusFilter, toast]);
}, [currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
useEffect(() => {
void load();
@@ -239,12 +419,69 @@ export default function ProxyLogs() {
</button>
))}
</div>
<div className="proxy-logs-filter-select">
<ModernSelect
size="sm"
value={siteFilter ? String(siteFilter) : ''}
onChange={(nextValue) => {
setSiteFilter(nextValue ? Number(nextValue) : null);
setPage(1);
}}
options={siteOptions}
placeholder="全部站点"
/>
</div>
<label className="proxy-logs-time-field">
<span>开始</span>
<input
type="datetime-local"
value={fromInput}
max={toInput || undefined}
onChange={(e) => {
setFromInput(e.target.value);
setPage(1);
}}
/>
</label>
<label className="proxy-logs-time-field">
<span>结束</span>
<input
type="datetime-local"
value={toInput}
min={fromInput || undefined}
onChange={(e) => {
setToInput(e.target.value);
setPage(1);
}}
/>
</label>
<div className="toolbar-search" style={{ maxWidth: 280 }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="搜索模型名称..." />
<input
value={searchInput}
onChange={(e) => {
setSearchInput(e.target.value);
setPage(1);
}}
placeholder="搜索模型名称..."
/>
</div>
<button
type="button"
className="btn btn-ghost proxy-logs-filter-reset"
onClick={() => {
setStatusFilter('all');
setSiteFilter(null);
setFromInput('');
setToInput('');
setSearchInput('');
setPage(1);
}}
>
清空筛选
</button>
</>
);
@@ -253,6 +490,9 @@ export default function ProxyLogs() {
<div className="page-header" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<h2 className="page-title">{tr('使用日志')}</h2>
<span className="kpi-chip">
{activeSiteLabel}
</span>
<span className="kpi-chip kpi-chip-success">
消耗总额 ${summary.totalCost.toFixed(4)}
</span>
@@ -292,6 +532,12 @@ export default function ProxyLogs() {
</div>
)}
{hasInvalidTimeRange && (
<div className="alert alert-error" style={{ marginBottom: 12 }}>
结束时间必须晚于开始时间
</div>
)}
<div className="card" style={{ overflowX: 'auto' }}>
{loading ? (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 10 }}>
@@ -328,6 +574,7 @@ export default function ProxyLogs() {
)}
>
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
<MobileField label="站点" value={log.siteName || '-'} />
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
<MobileField label="输出" value={log.completionTokens?.toLocaleString() || '-'} />
@@ -373,6 +620,7 @@ export default function ProxyLogs() {
<th style={{ width: 28 }} />
<th>时间</th>
<th>模型</th>
<th>站点</th>
<th>{tr('状态')}</th>
<th style={{ textAlign: 'center' }}>用时</th>
<th style={{ textAlign: 'right' }}>输入</th>
@@ -416,6 +664,9 @@ export default function ProxyLogs() {
<td>
<ModelBadge model={log.modelRequested} />
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{log.siteName || '-'}
</td>
<td>
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
<span style={{
@@ -457,7 +708,7 @@ export default function ProxyLogs() {
</tr>
{expanded === log.id && (
<tr style={{ background: 'var(--color-bg)' }}>
<td colSpan={9} style={{ padding: 0 }}>
<td colSpan={10} style={{ padding: 0 }}>
<div className="anim-collapse is-open">
<div className="anim-collapse-inner">
<div className="animate-fade-in" style={{
@@ -0,0 +1,131 @@
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 { ToastProvider } from '../components/Toast.js';
import Dashboard from './Dashboard.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDashboard: vi.fn(),
getSiteDistribution: vi.fn(),
getSiteTrend: vi.fn(),
getSites: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Dashboard site observability panel', () => {
const originalDocument = globalThis.document;
beforeEach(() => {
vi.clearAllMocks();
apiMock.getDashboard.mockResolvedValue({
totalBalance: 0,
totalUsed: 0,
todaySpend: 0,
todayReward: 0,
activeAccounts: 0,
totalAccounts: 0,
todayCheckin: { success: 0, total: 0 },
proxy24h: { success: 0, total: 0, totalTokens: 0 },
performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 },
siteAvailability: [{
siteId: 1,
siteName: 'Demo Site',
siteUrl: 'https://example.com',
platform: 'new-api',
totalRequests: 8,
successCount: 6,
failedCount: 2,
availabilityPercent: 75,
averageLatencyMs: 320,
buckets: Array.from({ length: 24 }, (_, index) => ({
label: `2026-03-11 ${String(index).padStart(2, '0')}:00:00`,
totalRequests: index < 8 ? 1 : 0,
successCount: index < 6 ? 1 : 0,
failedCount: index >= 6 && index < 8 ? 1 : 0,
availabilityPercent: index < 6 ? 100 : index < 8 ? 0 : null,
averageLatencyMs: index < 8 ? 320 : null,
})),
}],
modelAnalysis: null,
});
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
apiMock.getSites.mockResolvedValue([]);
globalThis.document = {
visibilityState: 'visible',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getElementById: vi.fn(() => null),
} as unknown as Document;
});
afterEach(() => {
globalThis.document = originalDocument;
vi.clearAllMocks();
});
it('renders site availability strips and summary metrics', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<ToastProvider>
<Dashboard />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const panel = root!.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('site-observability-panel')
));
const cells = panel.findAll((node) => (
node.type === 'a'
&& typeof node.props.className === 'string'
&& node.props.className.includes('site-availability-cell')
));
const logLink = panel.find((node) => (
node.type === 'a'
&& typeof node.props.className === 'string'
&& node.props.className.includes('site-observability-log-link')
));
expect(collectText(panel)).toContain('站点可用性观测');
expect(collectText(panel)).toContain('Demo Site');
expect(collectText(panel)).toContain('75%');
expect(collectText(panel)).toContain('320ms');
expect(collectText(panel)).toContain('查看日志');
expect(cells).toHaveLength(24);
expect(String(cells[0]?.props.title || '')).toContain('可用性 100%');
expect(String(cells[7]?.props.title || '')).toContain('可用性 0%');
expect(String(logLink.props.href || logLink.props.to || '')).toContain('/logs?siteId=1');
} finally {
root?.unmount();
}
});
});