From bc3c731ed26cc5338d92e3697da52addc2cd1d41 Mon Sep 17 00:00:00 2001 From: bnvnvnv Date: Wed, 11 Mar 2026 11:03:57 +0000 Subject: [PATCH] feat: add site observability log drilldown --- .../routes/api/stats.proxy-logs.test.ts | 95 ++++ .../routes/api/stats.siteStatus.test.ts | 102 ++++ src/server/routes/api/stats.ts | 243 +++++++++- src/web/api.ts | 4 + src/web/index.css | 444 ++++++++++++++++++ src/web/pages/Dashboard.tsx | 179 +++++++ .../pages/ProxyLogs.server-driven.test.tsx | 62 ++- src/web/pages/ProxyLogs.tsx | 279 ++++++++++- .../dashboard.site-observability.test.tsx | 131 ++++++ 9 files changed, 1508 insertions(+), 31 deletions(-) create mode 100644 src/web/pages/dashboard.site-observability.test.tsx diff --git a/src/server/routes/api/stats.proxy-logs.test.ts b/src/server/routes/api/stats.proxy-logs.test.ts index 39a4179..3fe554a 100644 --- a/src/server/routes/api/stats.proxy-logs.test.ts +++ b/src/server/routes/api/stats.proxy-logs.test.ts @@ -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>; + 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, + }); + }); }); diff --git a/src/server/routes/api/stats.siteStatus.test.ts b/src/server/routes/api/stats.siteStatus.test.ts index 75ca6c5..6b69609 100644 --- a/src/server/routes/api/stats.siteStatus.test.ts +++ b/src/server/routes/api/stats.siteStatus.test.ts @@ -242,4 +242,106 @@ 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<{ + 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); + }); }); diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 696304f..f27da46 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -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 => condition !== null); if (conditions.length === 0) return undefined; @@ -141,11 +163,156 @@ 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 = { + 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 { + label: formatLocalDateTime(bucketStart), + totalRequests: 0, + successCount: 0, + failedCount: 0, + latencyTotalMs: 0, + latencyCount: 0, + }; + }) + ); + + const siteMap = new Map(); + + 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) => ({ + 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 & { 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 +322,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 +439,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 +497,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 +543,14 @@ export async function statsRoutes(app: FastifyInstance) { }, { includeBillingDetails: false }) as Array<{ proxy_logs: Record & { 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`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 +562,9 @@ export async function statsRoutes(app: FastifyInstance) { failedCount: sql`coalesce(sum(case when coalesce(${schema.proxyLogs.status}, '') <> 'success' then 1 else 0 end), 0)`, totalCost: sql`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, totalTokensAll: sql`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 +604,7 @@ export async function statsRoutes(app: FastifyInstance) { ), { includeBillingDetails: true }) as { proxy_logs: Record & { 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) { diff --git a/src/web/api.ts b/src/web/api.ts index 7663238..cb93360 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -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 = { diff --git a/src/web/index.css b/src/web/index.css index 30574a1..d8a85f4 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -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) { diff --git a/src/web/pages/Dashboard.tsx b/src/web/pages/Dashboard.tsx index 5a3779f..56fad5b 100644 --- a/src/web/pages/Dashboard.tsx +++ b/src/web/pages/Dashboard.tsx @@ -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(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 +
+
+
+
+ + 站点可用性观测 +
+
最近 24 小时,每个色块代表 1 小时,按单次上游请求统计
+
+
+ 低 + + + + 高 +
+
+ + {siteAvailability.length > 0 ? ( +
+ {siteAvailability.map((site) => ( +
+
+
+
+ {site.siteName} + {site.platform && ( + {site.platform} + )} + {site.siteUrl && ( + + 打开 + + )} +
+
+ {formatAvailabilityPercent(site.availabilityPercent)} 可用性 + + {site.averageLatencyMs != null ? `${site.averageLatencyMs}ms` : '—'} 平均响应 + + {Math.round(site.totalRequests || 0)} 次请求 + {Math.round(site.successCount || 0)} 成功 / {Math.round(site.failedCount || 0)} 失败 +
+
+
+ + 查看日志 + +
+
+
+ {site.buckets.map((bucket, index) => ( + 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} 使用日志`} + /> + ))} +
+
+ ))} +
+ ) : ( +
+
暂无站点观测数据
+
有代理请求后,这里会自动生成每个站点的可用性条和平均响应速度。
+
+ )} +
+
diff --git a/src/web/pages/ProxyLogs.server-driven.test.tsx b/src/web/pages/ProxyLogs.server-driven.test.tsx index 0d6e231..f769192 100644 --- a/src/web/pages/ProxyLogs.server-driven.test.tsx +++ b/src/web/pages/ProxyLogs.server-driven.test.tsx @@ -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( - - - , + + + + + , ); }); await flushMicrotasks(); @@ -168,9 +176,11 @@ describe('ProxyLogs server-driven page', () => { try { await act(async () => { root = create( - - - , + + + + + , ); }); await flushMicrotasks(); @@ -214,9 +224,11 @@ describe('ProxyLogs server-driven page', () => { try { await act(async () => { root = create( - - - , + + + + + , ); }); 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 | null = null; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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(); + } + }); }); diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index dd880b8..3a955d1 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -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([]); const [summary, setSummary] = useState(EMPTY_SUMMARY); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); - const [statusFilter, setStatusFilter] = useState('all'); - const [searchInput, setSearchInput] = useState(''); + const [statusFilter, setStatusFilter] = useState(initialRouteState.status); + const [searchInput, setSearchInput] = useState(initialRouteState.search); const deferredSearchInput = useDeferredValue(searchInput.trim()); - const [searchQuery, setSearchQuery] = useState(''); + const [siteFilter, setSiteFilter] = useState(initialRouteState.siteId); + const [fromInput, setFromInput] = useState(initialRouteState.from); + const [toInput, setToInput] = useState(initialRouteState.to); const [expanded, setExpanded] = useState(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>({}); const [showFilters, setShowFilters] = useState(false); + const [sites, setSites] = useState>([]); 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() { ))}
+
+ { + setSiteFilter(nextValue ? Number(nextValue) : null); + setPage(1); + }} + options={siteOptions} + placeholder="全部站点" + /> +
+ +
- setSearchInput(e.target.value)} placeholder="搜索模型名称..." /> + { + setSearchInput(e.target.value); + setPage(1); + }} + placeholder="搜索模型名称..." + />
+ ); @@ -253,6 +490,9 @@ export default function ProxyLogs() {

{tr('使用日志')}

+ + {activeSiteLabel} + 消耗总额 ${summary.totalCost.toFixed(4)} @@ -292,6 +532,12 @@ export default function ProxyLogs() {
)} + {hasInvalidTimeRange && ( +
+ 结束时间必须晚于开始时间 +
+ )} +
{loading ? (
@@ -328,6 +574,7 @@ export default function ProxyLogs() { )} > + @@ -373,6 +620,7 @@ export default function ProxyLogs() { 时间 模型 + 站点 {tr('状态')} 用时 输入 @@ -416,6 +664,9 @@ export default function ProxyLogs() { + + {log.siteName || '-'} + {expanded === log.id && ( - +
({ + 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 | null = null; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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(); + } + }); +});