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,