From a5121b8f9fc7c858ebcbcd6181522e3b64ffcaba Mon Sep 17 00:00:00 2001 From: bnvnvnv Date: Wed, 11 Mar 2026 11:59:36 +0000 Subject: [PATCH] fix: address observability review feedback --- .../routes/api/stats.siteStatus.test.ts | 2 + src/server/routes/api/stats.ts | 3 + src/web/index.css | 186 +----------------- src/web/pages/Dashboard.tsx | 27 ++- .../pages/ProxyLogs.server-driven.test.tsx | 6 +- src/web/pages/ProxyLogs.tsx | 10 +- .../dashboard.site-observability.test.tsx | 1 + 7 files changed, 47 insertions(+), 188 deletions(-) diff --git a/src/server/routes/api/stats.siteStatus.test.ts b/src/server/routes/api/stats.siteStatus.test.ts index 6b69609..acb39ee 100644 --- a/src/server/routes/api/stats.siteStatus.test.ts +++ b/src/server/routes/api/stats.siteStatus.test.ts @@ -315,6 +315,7 @@ describe('stats dashboard filters disabled sites', () => { availabilityPercent: number | null; averageLatencyMs: number | null; buckets: Array<{ + startUtc?: string; totalRequests: number; successCount: number; failedCount: number; @@ -343,5 +344,6 @@ describe('stats dashboard filters disabled sites', () => { expect( body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.failedCount, 0), ).toBe(1); + expect(typeof body.siteAvailability?.[0]?.buckets[0]?.startUtc).toBe('string'); }); }); diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index f27da46..3b11cb7 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -183,6 +183,7 @@ type SiteAvailabilityLogRow = { }; type SiteAvailabilityBucketAccumulator = { + startUtc: string; label: string; totalRequests: number; successCount: number; @@ -210,6 +211,7 @@ function buildSiteAvailabilitySummaries( 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, @@ -293,6 +295,7 @@ function buildSiteAvailabilitySummaries( ? Math.round(aggregate.latencyTotalMs / aggregate.latencyCount) : null, buckets: aggregate.buckets.map((bucket) => ({ + startUtc: bucket.startUtc, label: bucket.label, totalRequests: bucket.totalRequests, successCount: bucket.successCount, diff --git a/src/web/index.css b/src/web/index.css index 1b522b3..0179978 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -4200,155 +4200,6 @@ textarea { 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 { @@ -4467,41 +4318,22 @@ textarea { 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; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + } + + .proxy-logs-time-field input { + width: 100%; + min-width: 0; } } diff --git a/src/web/pages/Dashboard.tsx b/src/web/pages/Dashboard.tsx index 56fad5b..5d92775 100644 --- a/src/web/pages/Dashboard.tsx +++ b/src/web/pages/Dashboard.tsx @@ -38,6 +38,7 @@ type SiteSpeedState = | undefined; type SiteAvailabilityBucket = { + startUtc?: string | null; label: string; totalRequests: number; successCount: number; @@ -97,7 +98,15 @@ function buildSiteLast24hLogsRoute(siteId: number): string { return buildSiteLogsRoute(siteId, { from, to }); } -function parseAvailabilityBucketStart(label: string): Date | null { +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; @@ -114,8 +123,14 @@ function parseAvailabilityBucketStart(label: string): Date | null { return parsed; } -function buildAvailabilityBucketLogsRoute(siteId: number, bucketLabel: string): string { - const start = parseAvailabilityBucketStart(bucketLabel); +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 }); @@ -611,21 +626,21 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN {site.buckets.map((bucket, index) => ( 0 ? 1 : 0.35, }} title={[ - bucket.label, + formatAvailabilityBucketLabel(bucket), bucket.totalRequests > 0 ? `可用性 ${formatAvailabilityPercent(bucket.availabilityPercent)}` : '无请求', `${bucket.successCount} 成功 / ${bucket.failedCount} 失败`, bucket.averageLatencyMs != null ? `平均响应 ${bucket.averageLatencyMs}ms` : '平均响应 —', ].join(' | ')} - aria-label={`${site.siteName} ${bucket.label} 使用日志`} + aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} 使用日志`} /> ))} diff --git a/src/web/pages/ProxyLogs.server-driven.test.tsx b/src/web/pages/ProxyLogs.server-driven.test.tsx index f769192..4347bfb 100644 --- a/src/web/pages/ProxyLogs.server-driven.test.tsx +++ b/src/web/pages/ProxyLogs.server-driven.test.tsx @@ -276,14 +276,16 @@ describe('ProxyLogs server-driven page', () => { }); 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: '2026-03-09T08:00:00.000Z', - to: '2026-03-09T09:00:00.000Z', + from: expectedFrom, + to: expectedTo, }); const rendered = JSON.stringify(root!.toJSON()); diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index 3a955d1..e2c57d8 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -1,4 +1,4 @@ -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, @@ -221,6 +221,7 @@ export default function ProxyLogs() { const [sites, setSites] = useState>([]); const isMobile = useIsMobile(768); const toast = useToast(); + const loadSeq = useRef(0); const fromApiBoundary = toApiTimeBoundary(fromInput); const toApiBoundaryValue = toApiTimeBoundary(toInput); const hasInvalidTimeRange = Boolean( @@ -320,11 +321,12 @@ export default function ProxyLogs() { }, [siteFilter, siteOptions]); const load = useCallback(async () => { + const seq = ++loadSeq.current; if (hasInvalidTimeRange) { setLogs([]); setTotal(0); setSummary(EMPTY_SUMMARY); - setLoading(false); + if (seq === loadSeq.current) setLoading(false); return; } setLoading(true); @@ -339,13 +341,15 @@ export default function ProxyLogs() { ...(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, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]); diff --git a/src/web/pages/dashboard.site-observability.test.tsx b/src/web/pages/dashboard.site-observability.test.tsx index bac57d8..4b4644b 100644 --- a/src/web/pages/dashboard.site-observability.test.tsx +++ b/src/web/pages/dashboard.site-observability.test.tsx @@ -57,6 +57,7 @@ describe('Dashboard site observability panel', () => { 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,