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;
|
availabilityPercent: number | null;
|
||||||
averageLatencyMs: number | null;
|
averageLatencyMs: number | null;
|
||||||
buckets: Array<{
|
buckets: Array<{
|
||||||
|
startUtc?: string;
|
||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
@@ -343,5 +344,6 @@ describe('stats dashboard filters disabled sites', () => {
|
|||||||
expect(
|
expect(
|
||||||
body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.failedCount, 0),
|
body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.failedCount, 0),
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
|
expect(typeof body.siteAvailability?.[0]?.buckets[0]?.startUtc).toBe('string');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ type SiteAvailabilityLogRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SiteAvailabilityBucketAccumulator = {
|
type SiteAvailabilityBucketAccumulator = {
|
||||||
|
startUtc: string;
|
||||||
label: string;
|
label: string;
|
||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
@@ -210,6 +211,7 @@ function buildSiteAvailabilitySummaries(
|
|||||||
Array.from({ length: SITE_AVAILABILITY_BUCKET_COUNT }, (_, index) => {
|
Array.from({ length: SITE_AVAILABILITY_BUCKET_COUNT }, (_, index) => {
|
||||||
const bucketStart = new Date(startMs + index * SITE_AVAILABILITY_BUCKET_MS);
|
const bucketStart = new Date(startMs + index * SITE_AVAILABILITY_BUCKET_MS);
|
||||||
return {
|
return {
|
||||||
|
startUtc: bucketStart.toISOString(),
|
||||||
label: formatLocalDateTime(bucketStart),
|
label: formatLocalDateTime(bucketStart),
|
||||||
totalRequests: 0,
|
totalRequests: 0,
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
@@ -293,6 +295,7 @@ function buildSiteAvailabilitySummaries(
|
|||||||
? Math.round(aggregate.latencyTotalMs / aggregate.latencyCount)
|
? Math.round(aggregate.latencyTotalMs / aggregate.latencyCount)
|
||||||
: null,
|
: null,
|
||||||
buckets: aggregate.buckets.map((bucket) => ({
|
buckets: aggregate.buckets.map((bucket) => ({
|
||||||
|
startUtc: bucket.startUtc,
|
||||||
label: bucket.label,
|
label: bucket.label,
|
||||||
totalRequests: bucket.totalRequests,
|
totalRequests: bucket.totalRequests,
|
||||||
successCount: bucket.successCount,
|
successCount: bucket.successCount,
|
||||||
|
|||||||
+9
-177
@@ -4200,155 +4200,6 @@ textarea {
|
|||||||
color: var(--color-text-muted);
|
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 ===== */
|
/* ===== Responsive Adjustments ===== */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.topbar-search-trigger {
|
.topbar-search-trigger {
|
||||||
@@ -4467,41 +4318,22 @@ textarea {
|
|||||||
min-height: 460px;
|
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-filter-select,
|
||||||
.proxy-logs-time-field,
|
.proxy-logs-time-field,
|
||||||
.proxy-logs-time-field input,
|
|
||||||
.proxy-logs-filter-reset {
|
.proxy-logs-filter-reset {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.proxy-logs-time-field {
|
.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;
|
| undefined;
|
||||||
|
|
||||||
type SiteAvailabilityBucket = {
|
type SiteAvailabilityBucket = {
|
||||||
|
startUtc?: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
@@ -97,7 +98,15 @@ function buildSiteLast24hLogsRoute(siteId: number): string {
|
|||||||
return buildSiteLogsRoute(siteId, { from, to });
|
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}))?$/);
|
const match = label.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})(?::(\d{2}))?$/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const [, year, month, day, hour, minute, second = '0'] = match;
|
const [, year, month, day, hour, minute, second = '0'] = match;
|
||||||
@@ -114,8 +123,14 @@ function parseAvailabilityBucketStart(label: string): Date | null {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAvailabilityBucketLogsRoute(siteId: number, bucketLabel: string): string {
|
function formatAvailabilityBucketLabel(bucket: SiteAvailabilityBucket): string {
|
||||||
const start = parseAvailabilityBucketStart(bucketLabel);
|
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);
|
if (!start) return buildSiteLast24hLogsRoute(siteId);
|
||||||
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||||
return buildSiteLogsRoute(siteId, { from: start, to: end });
|
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) => (
|
{site.buckets.map((bucket, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={`${site.siteId}-${index}`}
|
key={`${site.siteId}-${index}`}
|
||||||
to={buildAvailabilityBucketLogsRoute(site.siteId, bucket.label)}
|
to={buildAvailabilityBucketLogsRoute(site.siteId, bucket)}
|
||||||
className="site-availability-cell site-availability-cell-link"
|
className="site-availability-cell site-availability-cell-link"
|
||||||
style={{
|
style={{
|
||||||
background: getAvailabilityColor(bucket.availabilityPercent),
|
background: getAvailabilityColor(bucket.availabilityPercent),
|
||||||
opacity: bucket.totalRequests > 0 ? 1 : 0.35,
|
opacity: bucket.totalRequests > 0 ? 1 : 0.35,
|
||||||
}}
|
}}
|
||||||
title={[
|
title={[
|
||||||
bucket.label,
|
formatAvailabilityBucketLabel(bucket),
|
||||||
bucket.totalRequests > 0
|
bucket.totalRequests > 0
|
||||||
? `可用性 ${formatAvailabilityPercent(bucket.availabilityPercent)}`
|
? `可用性 ${formatAvailabilityPercent(bucket.availabilityPercent)}`
|
||||||
: '无请求',
|
: '无请求',
|
||||||
`${bucket.successCount} 成功 / ${bucket.failedCount} 失败`,
|
`${bucket.successCount} 成功 / ${bucket.failedCount} 失败`,
|
||||||
bucket.averageLatencyMs != null ? `平均响应 ${bucket.averageLatencyMs}ms` : '平均响应 —',
|
bucket.averageLatencyMs != null ? `平均响应 ${bucket.averageLatencyMs}ms` : '平均响应 —',
|
||||||
].join(' | ')}
|
].join(' | ')}
|
||||||
aria-label={`${site.siteName} ${bucket.label} 使用日志`}
|
aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} 使用日志`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -276,14 +276,16 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
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({
|
expect(apiMock.getProxyLogs).toHaveBeenCalledWith({
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
status: 'all',
|
status: 'all',
|
||||||
search: '',
|
search: '',
|
||||||
siteId: 9,
|
siteId: 9,
|
||||||
from: '2026-03-09T08:00:00.000Z',
|
from: expectedFrom,
|
||||||
to: '2026-03-09T09:00:00.000Z',
|
to: expectedTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rendered = JSON.stringify(root!.toJSON());
|
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 { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -221,6 +221,7 @@ export default function ProxyLogs() {
|
|||||||
const [sites, setSites] = useState<Array<{ id: number; name: string; status?: string | null }>>([]);
|
const [sites, setSites] = useState<Array<{ id: number; name: string; status?: string | null }>>([]);
|
||||||
const isMobile = useIsMobile(768);
|
const isMobile = useIsMobile(768);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const loadSeq = useRef(0);
|
||||||
const fromApiBoundary = toApiTimeBoundary(fromInput);
|
const fromApiBoundary = toApiTimeBoundary(fromInput);
|
||||||
const toApiBoundaryValue = toApiTimeBoundary(toInput);
|
const toApiBoundaryValue = toApiTimeBoundary(toInput);
|
||||||
const hasInvalidTimeRange = Boolean(
|
const hasInvalidTimeRange = Boolean(
|
||||||
@@ -320,11 +321,12 @@ export default function ProxyLogs() {
|
|||||||
}, [siteFilter, siteOptions]);
|
}, [siteFilter, siteOptions]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
const seq = ++loadSeq.current;
|
||||||
if (hasInvalidTimeRange) {
|
if (hasInvalidTimeRange) {
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
setSummary(EMPTY_SUMMARY);
|
setSummary(EMPTY_SUMMARY);
|
||||||
setLoading(false);
|
if (seq === loadSeq.current) setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -339,13 +341,15 @@ export default function ProxyLogs() {
|
|||||||
...(toApiBoundaryValue ? { to: toApiBoundaryValue } : {}),
|
...(toApiBoundaryValue ? { to: toApiBoundaryValue } : {}),
|
||||||
};
|
};
|
||||||
const data = await api.getProxyLogs(params);
|
const data = await api.getProxyLogs(params);
|
||||||
|
if (seq !== loadSeq.current) return;
|
||||||
setLogs(Array.isArray(data.items) ? data.items : []);
|
setLogs(Array.isArray(data.items) ? data.items : []);
|
||||||
setTotal(Number(data.total || 0));
|
setTotal(Number(data.total || 0));
|
||||||
setSummary(data.summary || EMPTY_SUMMARY);
|
setSummary(data.summary || EMPTY_SUMMARY);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (seq !== loadSeq.current) return;
|
||||||
toast.error(e.message || '加载日志失败');
|
toast.error(e.message || '加载日志失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (seq === loadSeq.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
|
}, [currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ describe('Dashboard site observability panel', () => {
|
|||||||
availabilityPercent: 75,
|
availabilityPercent: 75,
|
||||||
averageLatencyMs: 320,
|
averageLatencyMs: 320,
|
||||||
buckets: Array.from({ length: 24 }, (_, index) => ({
|
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`,
|
label: `2026-03-11 ${String(index).padStart(2, '0')}:00:00`,
|
||||||
totalRequests: index < 8 ? 1 : 0,
|
totalRequests: index < 8 ? 1 : 0,
|
||||||
successCount: index < 6 ? 1 : 0,
|
successCount: index < 6 ? 1 : 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user