feat: add site observability log drilldown
This commit is contained in:
@@ -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 }}>
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user