forked from boos/BoosAPI
fix: address observability review feedback
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
+9
-177
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<Link
|
||||
key={`${site.siteId}-${index}`}
|
||||
to={buildAvailabilityBucketLogsRoute(site.siteId, bucket.label)}
|
||||
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={[
|
||||
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)} 使用日志`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<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(
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user