1
0
forked from boos/BoosAPI

Merge pull request #54 from bnvnvnv/feat/site-observability-proxy-logs

feat: add site observability drilldown for proxy logs
This commit is contained in:
Cita
2026-03-11 21:43:20 +08:00
committed by GitHub
9 changed files with 1369 additions and 33 deletions
@@ -199,4 +199,99 @@ describe('stats proxy logs routes', () => {
usage: { promptTokens: 100, completionTokens: 20 },
});
});
it('filters proxy logs by site and time range', async () => {
const alphaSite = await db.insert(schema.sites).values({
name: 'alpha-site',
url: 'https://alpha.example.com',
platform: 'new-api',
}).returning().get();
const betaSite = await db.insert(schema.sites).values({
name: 'beta-site',
url: 'https://beta.example.com',
platform: 'new-api',
}).returning().get();
const alphaAccount = await db.insert(schema.accounts).values({
siteId: alphaSite.id,
username: 'alpha-user',
accessToken: 'alpha-token',
status: 'active',
}).returning().get();
const betaAccount = await db.insert(schema.accounts).values({
siteId: betaSite.id,
username: 'beta-user',
accessToken: 'beta-token',
status: 'active',
}).returning().get();
await db.insert(schema.proxyLogs).values([
{
accountId: alphaAccount.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
totalTokens: 10,
estimatedCost: 0.11,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T08:15:00.000Z')),
},
{
accountId: alphaAccount.id,
modelRequested: 'gpt-4.1-mini',
modelActual: 'gpt-4.1-mini',
status: 'failed',
totalTokens: 20,
estimatedCost: 0.22,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T08:45:00.000Z')),
},
{
accountId: alphaAccount.id,
modelRequested: 'gpt-4.1',
modelActual: 'gpt-4.1',
status: 'success',
totalTokens: 30,
estimatedCost: 0.33,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T09:15:00.000Z')),
},
{
accountId: betaAccount.id,
modelRequested: 'claude-3-7-sonnet',
modelActual: 'claude-3-7-sonnet',
status: 'success',
totalTokens: 40,
estimatedCost: 0.44,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T08:30:00.000Z')),
},
]).run();
const response = await app.inject({
method: 'GET',
url: `/api/stats/proxy-logs?siteId=${alphaSite.id}&from=${encodeURIComponent('2026-03-09T08:00:00.000Z')}&to=${encodeURIComponent('2026-03-09T09:00:00.000Z')}`,
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
items: Array<Record<string, unknown>>;
total: number;
summary: {
totalCount: number;
successCount: number;
failedCount: number;
totalCost: number;
totalTokensAll: number;
};
};
expect(body.total).toBe(2);
expect(body.items).toHaveLength(2);
expect(body.items.map((item) => item.siteId)).toEqual([alphaSite.id, alphaSite.id]);
expect(body.items.map((item) => item.siteName)).toEqual(['alpha-site', 'alpha-site']);
expect(body.summary).toEqual({
totalCount: 2,
successCount: 1,
failedCount: 1,
totalCost: 0.33,
totalTokensAll: 30,
});
});
});
@@ -242,4 +242,108 @@ describe('stats dashboard filters disabled sites', () => {
tokensPerMinute: 1800,
});
});
it('returns site availability buckets and average latency from per-request proxy logs', async () => {
const activeSite = await db.insert(schema.sites).values({
name: 'availability-site',
url: 'https://availability.example.com',
platform: 'new-api',
}).returning().get();
const disabledSite = await db.insert(schema.sites).values({
name: 'disabled-availability-site',
url: 'https://disabled-availability.example.com',
platform: 'new-api',
}).returning().get();
await db.run(sql`update sites set status = 'disabled' where id = ${disabledSite.id}`);
const activeAccount = await db.insert(schema.accounts).values({
siteId: activeSite.id,
username: 'availability-user',
accessToken: 'availability-token',
balance: 20,
status: 'active',
}).returning().get();
const disabledAccount = await db.insert(schema.accounts).values({
siteId: disabledSite.id,
username: 'disabled-availability-user',
accessToken: 'disabled-availability-token',
balance: 20,
status: 'active',
}).returning().get();
const now = Date.now();
const recentSuccess = formatUtcSqlDateTime(new Date(now - 5 * 60_000));
const recentFailure = formatUtcSqlDateTime(new Date(now - 65 * 60_000));
await db.insert(schema.proxyLogs).values([
{
accountId: activeAccount.id,
status: 'success',
latencyMs: 120,
createdAt: recentSuccess,
},
{
accountId: activeAccount.id,
status: 'failed',
latencyMs: 280,
createdAt: recentFailure,
},
{
accountId: disabledAccount.id,
status: 'success',
latencyMs: 999,
createdAt: recentSuccess,
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/stats/dashboard',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
siteAvailability?: Array<{
siteId: number;
siteName: string;
totalRequests: number;
successCount: number;
failedCount: number;
availabilityPercent: number | null;
averageLatencyMs: number | null;
buckets: Array<{
startUtc?: string;
totalRequests: number;
successCount: number;
failedCount: number;
}>;
}>;
};
expect(Array.isArray(body.siteAvailability)).toBe(true);
expect(body.siteAvailability).toHaveLength(1);
expect(body.siteAvailability?.[0]).toMatchObject({
siteId: activeSite.id,
siteName: 'availability-site',
totalRequests: 2,
successCount: 1,
failedCount: 1,
availabilityPercent: 50,
averageLatencyMs: 200,
});
expect(body.siteAvailability?.[0]?.buckets).toHaveLength(24);
expect(
body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.totalRequests, 0),
).toBe(2);
expect(
body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.successCount, 0),
).toBe(1);
expect(
body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.failedCount, 0),
).toBe(1);
expect(typeof body.siteAvailability?.[0]?.buckets[0]?.startUtc).toBe('string');
});
});
+238 -8
View File
@@ -19,9 +19,11 @@ import {
} from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import {
formatLocalDateTime,
formatUtcSqlDateTime,
getLocalDayRangeUtc,
getLocalRangeStartUtc,
parseStoredUtcDateTime,
toLocalDayKeyFromStoredUtc,
} from '../../services/localTimeService.js';
@@ -105,6 +107,20 @@ function normalizeProxyLogSearch(raw?: string): string {
return (raw || '').trim().toLowerCase();
}
function normalizeProxyLogSiteId(raw?: string): number | null {
const parsed = Number.parseInt(raw || '', 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
}
function normalizeProxyLogTimeBoundary(raw?: string): string | null {
const text = (raw || '').trim();
if (!text) return null;
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) return null;
return formatUtcSqlDateTime(parsed);
}
function buildProxyLogSearchCondition(search: string) {
if (!search) return null;
const likeTerm = `%${search}%`;
@@ -127,10 +143,16 @@ function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
function buildProxyLogWhereClause(params: {
status?: ProxyLogStatusFilter;
search?: string;
siteId?: number | null;
fromUtc?: string | null;
toUtc?: string | null;
}) {
const conditions = [
params.status ? buildProxyLogStatusCondition(params.status) : null,
params.search ? buildProxyLogSearchCondition(params.search) : null,
params.siteId ? eq(schema.sites.id, params.siteId) : null,
params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null,
params.toUtc ? lt(schema.proxyLogs.createdAt, params.toUtc) : null,
].filter((condition): condition is NonNullable<typeof condition> => condition !== null);
if (conditions.length === 0) return undefined;
@@ -141,11 +163,159 @@ function toRoundedMicroNumber(value: number | null | undefined): number {
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
}
const SITE_AVAILABILITY_BUCKET_COUNT = 24;
const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000;
type SiteAvailabilitySiteRow = {
id: number;
name: string;
url: string | null;
platform: string | null;
sortOrder: number | null;
isPinned: boolean | null;
};
type SiteAvailabilityLogRow = {
siteId: number | null;
createdAt: string | null;
status: string | null;
latencyMs: number | null;
};
type SiteAvailabilityBucketAccumulator = {
startUtc: string;
label: string;
totalRequests: number;
successCount: number;
failedCount: number;
latencyTotalMs: number;
latencyCount: number;
};
function roundPercent(value: number | null): number | null {
if (value == null || !Number.isFinite(value)) return null;
return Math.round(value * 10) / 10;
}
function buildSiteAvailabilitySummaries(
sites: SiteAvailabilitySiteRow[],
logs: SiteAvailabilityLogRow[],
now = new Date(),
) {
const endLocal = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0);
const startLocal = new Date(endLocal.getTime() - (SITE_AVAILABILITY_BUCKET_COUNT - 1) * SITE_AVAILABILITY_BUCKET_MS);
const startMs = startLocal.getTime();
const rangeMs = SITE_AVAILABILITY_BUCKET_COUNT * SITE_AVAILABILITY_BUCKET_MS;
const createBucketTemplate = (): SiteAvailabilityBucketAccumulator[] => (
Array.from({ length: SITE_AVAILABILITY_BUCKET_COUNT }, (_, index) => {
const bucketStart = new Date(startMs + index * SITE_AVAILABILITY_BUCKET_MS);
return {
startUtc: bucketStart.toISOString(),
label: formatLocalDateTime(bucketStart),
totalRequests: 0,
successCount: 0,
failedCount: 0,
latencyTotalMs: 0,
latencyCount: 0,
};
})
);
const siteMap = new Map<number, {
site: SiteAvailabilitySiteRow;
totalRequests: number;
successCount: number;
failedCount: number;
latencyTotalMs: number;
latencyCount: number;
buckets: SiteAvailabilityBucketAccumulator[];
}>();
for (const site of sites) {
siteMap.set(site.id, {
site,
totalRequests: 0,
successCount: 0,
failedCount: 0,
latencyTotalMs: 0,
latencyCount: 0,
buckets: createBucketTemplate(),
});
}
for (const log of logs) {
if (log.siteId == null) continue;
const target = siteMap.get(log.siteId);
if (!target) continue;
const parsed = parseStoredUtcDateTime(log.createdAt);
if (!parsed) continue;
const timestampMs = parsed.getTime();
const diffMs = timestampMs - startMs;
if (diffMs < 0 || diffMs >= rangeMs) continue;
const bucketIndex = Math.floor(diffMs / SITE_AVAILABILITY_BUCKET_MS);
const bucket = target.buckets[bucketIndex];
const isSuccess = (log.status || '').trim().toLowerCase() === 'success';
target.totalRequests += 1;
bucket.totalRequests += 1;
if (isSuccess) {
target.successCount += 1;
bucket.successCount += 1;
} else {
target.failedCount += 1;
bucket.failedCount += 1;
}
const latencyMs = Number(log.latencyMs);
if (Number.isFinite(latencyMs) && latencyMs >= 0) {
target.latencyTotalMs += latencyMs;
target.latencyCount += 1;
bucket.latencyTotalMs += latencyMs;
bucket.latencyCount += 1;
}
}
return sites.map((site) => {
const aggregate = siteMap.get(site.id)!;
return {
siteId: site.id,
siteName: site.name,
siteUrl: site.url,
platform: site.platform,
totalRequests: aggregate.totalRequests,
successCount: aggregate.successCount,
failedCount: aggregate.failedCount,
availabilityPercent: aggregate.totalRequests > 0
? roundPercent((aggregate.successCount / aggregate.totalRequests) * 100)
: null,
averageLatencyMs: aggregate.latencyCount > 0
? Math.round(aggregate.latencyTotalMs / aggregate.latencyCount)
: null,
buckets: aggregate.buckets.map((bucket) => ({
startUtc: bucket.startUtc,
label: bucket.label,
totalRequests: bucket.totalRequests,
successCount: bucket.successCount,
failedCount: bucket.failedCount,
availabilityPercent: bucket.totalRequests > 0
? roundPercent((bucket.successCount / bucket.totalRequests) * 100)
: null,
averageLatencyMs: bucket.latencyCount > 0
? Math.round(bucket.latencyTotalMs / bucket.latencyCount)
: null,
})),
};
});
}
function mapProxyLogRow(
row: {
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { name?: string | null; url?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
},
options?: { includeBillingDetails?: boolean },
) {
@@ -155,6 +325,7 @@ function mapProxyLogRow(
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
: {}),
username: row.accounts?.username || null,
siteId: row.sites?.id || null,
siteName: row.sites?.name || null,
siteUrl: row.sites?.url || null,
};
@@ -271,6 +442,49 @@ export async function statsRoutes(app: FastifyInstance) {
extraConfig: account.extraConfig,
}), 0);
const modelAnalysis = buildModelAnalysis(recentProxyLogs, { days: 7 });
const activeSites = (await db.select({
id: schema.sites.id,
name: schema.sites.name,
url: schema.sites.url,
platform: schema.sites.platform,
sortOrder: schema.sites.sortOrder,
isPinned: schema.sites.isPinned,
}).from(schema.sites)
.where(eq(schema.sites.status, 'active'))
.all())
.sort((left: SiteAvailabilitySiteRow, right: SiteAvailabilitySiteRow) => {
const leftPinned = left.isPinned ? 1 : 0;
const rightPinned = right.isPinned ? 1 : 0;
if (leftPinned !== rightPinned) return rightPinned - leftPinned;
const leftOrder = Number(left.sortOrder || 0);
const rightOrder = Number(right.sortOrder || 0);
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(left.name || '').localeCompare(String(right.name || ''));
});
const siteAvailabilityNow = new Date();
const siteAvailabilitySinceUtc = formatUtcSqlDateTime(new Date(
siteAvailabilityNow.getFullYear(),
siteAvailabilityNow.getMonth(),
siteAvailabilityNow.getDate(),
siteAvailabilityNow.getHours() - (SITE_AVAILABILITY_BUCKET_COUNT - 1),
0,
0,
0,
));
const siteAvailabilityLogs = await db.select({
siteId: schema.sites.id,
createdAt: schema.proxyLogs.createdAt,
status: schema.proxyLogs.status,
latencyMs: schema.proxyLogs.latencyMs,
}).from(schema.proxyLogs)
.innerJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(
gte(schema.proxyLogs.createdAt, siteAvailabilitySinceUtc),
eq(schema.sites.status, 'active'),
))
.all();
const siteAvailability = buildSiteAvailabilitySummaries(activeSites, siteAvailabilityLogs, siteAvailabilityNow);
return {
totalBalance,
@@ -286,18 +500,30 @@ export async function statsRoutes(app: FastifyInstance) {
requestsPerMinute,
tokensPerMinute,
},
siteAvailability,
modelAnalysis,
};
});
// Proxy logs
app.get<{ Querystring: { limit?: string; offset?: string; status?: string; search?: string } }>('/api/stats/proxy-logs', async (request) => {
app.get<{ Querystring: {
limit?: string;
offset?: string;
status?: string;
search?: string;
siteId?: string;
from?: string;
to?: string;
} }>('/api/stats/proxy-logs', async (request) => {
const limit = normalizeProxyLogPageSize(request.query.limit);
const offset = normalizeProxyLogOffset(request.query.offset);
const status = normalizeProxyLogStatusFilter(request.query.status);
const search = normalizeProxyLogSearch(request.query.search);
const listWhere = buildProxyLogWhereClause({ status, search });
const summaryWhere = buildProxyLogWhereClause({ search });
const siteId = normalizeProxyLogSiteId(request.query.siteId);
const fromUtc = normalizeProxyLogTimeBoundary(request.query.from);
const toUtc = normalizeProxyLogTimeBoundary(request.query.to);
const listWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
const summaryWhere = buildProxyLogWhereClause({ search, siteId, fromUtc, toUtc });
const listRows = await withProxyLogSelectFields(({ fields }) => {
let query = db.select({
@@ -320,12 +546,14 @@ export async function statsRoutes(app: FastifyInstance) {
}, { includeBillingDetails: false }) as Array<{
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { name?: string | null; url?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
}>;
let totalQuery = db.select({
total: sql<number>`count(*)`,
}).from(schema.proxyLogs);
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
if (listWhere) {
totalQuery = totalQuery.where(listWhere) as typeof totalQuery;
}
@@ -337,7 +565,9 @@ export async function statsRoutes(app: FastifyInstance) {
failedCount: sql<number>`coalesce(sum(case when coalesce(${schema.proxyLogs.status}, '') <> 'success' then 1 else 0 end), 0)`,
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
totalTokensAll: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
}).from(schema.proxyLogs);
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
if (summaryWhere) {
summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery;
}
@@ -377,7 +607,7 @@ export async function statsRoutes(app: FastifyInstance) {
), { includeBillingDetails: true }) as {
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { name?: string | null; url?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
} | undefined;
if (!row) {
+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 = {
+276
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,201 @@ 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);
}
/* ===== Responsive Adjustments ===== */
@media (max-width: 900px) {
.topbar-search-trigger {
@@ -4055,10 +4287,54 @@ 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;
}
.proxy-logs-filter-select,
.proxy-logs-time-field,
.proxy-logs-filter-reset {
width: 100%;
}
.proxy-logs-time-field {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.proxy-logs-time-field input {
width: 100%;
min-width: 0;
}
}
@media (max-width: 640px) {
+194
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,105 @@ type SiteSpeedState =
| { status: 'done'; ms: number }
| undefined;
type SiteAvailabilityBucket = {
startUtc?: string | null;
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(startUtc?: string | null): Date | null {
const text = (startUtc || '').trim();
if (!text) return null;
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
function parseAvailabilityBucketLabel(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 formatAvailabilityBucketLabel(bucket: SiteAvailabilityBucket): string {
const parsed = parseAvailabilityBucketStart(bucket.startUtc) || parseAvailabilityBucketLabel(bucket.label);
if (!parsed) return bucket.label;
return `${parsed.getFullYear()}-${padDateTimeSegment(parsed.getMonth() + 1)}-${padDateTimeSegment(parsed.getDate())} ${padDateTimeSegment(parsed.getHours())}:${padDateTimeSegment(parsed.getMinutes())}:${padDateTimeSegment(parsed.getSeconds())}`;
}
function buildAvailabilityBucketLogsRoute(siteId: number, bucket: SiteAvailabilityBucket): string {
const start = parseAvailabilityBucketStart(bucket.startUtc) || parseAvailabilityBucketLabel(bucket.label);
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 +313,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 +564,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)}
className="site-availability-cell site-availability-cell-link"
style={{
background: getAvailabilityColor(bucket.availabilityPercent),
opacity: bucket.totalRequests > 0 ? 1 : 0.35,
}}
title={[
formatAvailabilityBucketLabel(bucket),
bucket.totalRequests > 0
? `可用性 ${formatAvailabilityPercent(bucket.availabilityPercent)}`
: '无请求',
`${bucket.successCount} 成功 / ${bucket.failedCount} 失败`,
bucket.averageLatencyMs != null ? `平均响应 ${bucket.averageLatencyMs}ms` : '平均响应 —',
].join(' | ')}
aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} 使用日志`}
/>
))}
</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 }}>
+49 -3
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(
<MemoryRouter initialEntries={['/logs']}>
<ToastProvider>
<ProxyLogs />
</ToastProvider>,
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
@@ -168,9 +176,11 @@ describe('ProxyLogs server-driven page', () => {
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/logs']}>
<ToastProvider>
<ProxyLogs />
</ToastProvider>,
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
@@ -214,9 +224,11 @@ describe('ProxyLogs server-driven page', () => {
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/logs']}>
<ToastProvider>
<ProxyLogs />
</ToastProvider>,
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
@@ -248,4 +260,38 @@ 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();
const expectedFrom = new Date(2026, 2, 9, 8, 0).toISOString();
const expectedTo = new Date(2026, 2, 9, 9, 0).toISOString();
expect(apiMock.getProxyLogs).toHaveBeenCalledWith({
limit: 50,
offset: 0,
status: 'all',
search: '',
siteId: 9,
from: expectedFrom,
to: expectedTo,
});
const rendered = JSON.stringify(root!.toJSON());
expect(rendered).toContain('main-site');
} finally {
root?.unmount();
}
});
});
+271 -16
View File
@@ -1,4 +1,5 @@
import React, { useDeferredValue, useEffect, useMemo, useState, useCallback } from 'react';
import React, { useDeferredValue, useEffect, useMemo, useRef, 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,173 @@ 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 loadSeq = useRef(0);
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,24 +298,60 @@ 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 () => {
const seq = ++loadSeq.current;
if (hasInvalidTimeRange) {
setLogs([]);
setTotal(0);
setSummary(EMPTY_SUMMARY);
if (seq === loadSeq.current) 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);
if (seq !== loadSeq.current) return;
setLogs(Array.isArray(data.items) ? data.items : []);
setTotal(Number(data.total || 0));
setSummary(data.summary || EMPTY_SUMMARY);
} catch (e: any) {
if (seq !== loadSeq.current) return;
toast.error(e.message || '加载日志失败');
} finally {
setLoading(false);
if (seq === loadSeq.current) setLoading(false);
}
}, [currentOffset, pageSize, searchQuery, statusFilter, toast]);
}, [currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
useEffect(() => {
void load();
@@ -239,12 +423,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 +494,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 +536,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 +578,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 +624,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 +668,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 +712,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,132 @@
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) => ({
startUtc: new Date(Date.UTC(2026, 2, 11, index, 0, 0)).toISOString(),
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();
}
});
});