feat: 动态粒度趋势图,支持 auto bucket 选择 + P95 延迟 + 状态条
This commit is contained in:
@@ -551,7 +551,7 @@ if (r.body) {
|
|||||||
**轻数据库指标计算规范**:
|
**轻数据库指标计算规范**:
|
||||||
|
|
||||||
- 数据库只负责存储、筛选、排序、分页、LIMIT 和标准 SQL 基础聚合(如 `COUNT`、`SUM(CASE)`、`AVG`、`MIN`、`MAX`、`GROUP BY`),用于减少应用层输入数据量
|
- 数据库只负责存储、筛选、排序、分页、LIMIT 和标准 SQL 基础聚合(如 `COUNT`、`SUM(CASE)`、`AVG`、`MIN`、`MAX`、`GROUP BY`),用于减少应用层输入数据量
|
||||||
- 指标语义必须在后端应用层实现,包括可用率舍入、百分位、状态翻转、故障段识别、MTTR、最长故障、连续状态、趋势 UTC 小时分桶和窗口边界处理
|
- 指标语义必须在后端应用层实现,包括可用率舍入、百分位、状态翻转、故障段识别、MTTR、最长故障、连续状态、趋势按标准 bucket 聚合(`resolveAutoBucket` 自动选择粒度,`buildTrend` 按 UTC 整点/整分钟对齐)和窗口边界处理
|
||||||
- 禁止用 SQLite 专有时间函数承载趋势分桶语义,禁止用复杂 SQL/window function 承载故障事件或恢复时长等业务规则
|
- 禁止用 SQLite 专有时间函数承载趋势分桶语义,禁止用复杂 SQL/window function 承载故障事件或恢复时长等业务规则
|
||||||
|
|
||||||
**Schema**:
|
**Schema**:
|
||||||
@@ -791,7 +791,7 @@ hooks/use-theme-preference.ts(浏览器 UI 偏好)
|
|||||||
const queryKeys = {
|
const queryKeys = {
|
||||||
dashboard: () => ["dashboard", "24h", 30] as const,
|
dashboard: () => ["dashboard", "24h", 30] as const,
|
||||||
meta: () => ["meta"] as const,
|
meta: () => ["meta"] as const,
|
||||||
metrics: (targetId: number, from: string, to: string, bucket: "1h") =>
|
metrics: (targetId: number, from: string, to: string, bucket: "auto" | MetricsBucket) =>
|
||||||
["metrics", targetId, from, to, bucket] as const,
|
["metrics", targetId, from, to, bucket] as const,
|
||||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**
|
|||||||
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、ICMP(存活检测、延迟、丢包率)、LLM(大模型服务应用层健康检查)
|
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、ICMP(存活检测、延迟、丢包率)、LLM(大模型服务应用层健康检查)
|
||||||
- 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
|
- 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
|
||||||
- 结构化观测数据:检查结果保留按需读取的 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation,便于排障和后续分析
|
- 结构化观测数据:检查结果保留按需读取的 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation,便于排障和后续分析
|
||||||
- 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新、版本号展示
|
- 响应式 Dashboard:实时状态、可用率统计、动态粒度趋势图(avg/P95 + 状态条)、手动/自动刷新、版本号展示
|
||||||
- 多主题支持:系统、明亮、黑暗三种主题模式
|
- 多主题支持:系统、明亮、黑暗三种主题模式
|
||||||
- 零外部依赖:数据存储使用 SQLite,无需额外数据库服务
|
- 零外部依赖:数据存储使用 SQLite,无需额外数据库服务
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
schema: spec-driven
|
schema: fast-drive
|
||||||
|
|
||||||
context: |
|
context: |
|
||||||
- 使用中文(注释、文档、交流),面向中文开发者
|
- 使用中文(注释、文档、交流),面向中文开发者
|
||||||
@@ -20,8 +20,6 @@ context: |
|
|||||||
- (当前项目未上线,不需要考虑向前兼容)
|
- (当前项目未上线,不需要考虑向前兼容)
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
proposal:
|
|
||||||
- 仔细审查每一个过往spec判断是否存在Modified Capabilities
|
|
||||||
design:
|
design:
|
||||||
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
||||||
tasks:
|
tasks:
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
import type { CurrentStreak, TrendPoint } from "../shared/api";
|
import type { CurrentStreak, MetricsBucket, TrendPoint } from "../shared/api";
|
||||||
|
|
||||||
|
const BUCKET_STEPS: Array<{ label: MetricsBucket; ms: number }> = [
|
||||||
|
{ label: "30s", ms: 30_000 },
|
||||||
|
{ label: "1m", ms: 60_000 },
|
||||||
|
{ label: "5m", ms: 300_000 },
|
||||||
|
{ label: "15m", ms: 900_000 },
|
||||||
|
{ label: "30m", ms: 1_800_000 },
|
||||||
|
{ label: "1h", ms: 3_600_000 },
|
||||||
|
{ label: "3h", ms: 10_800_000 },
|
||||||
|
{ label: "6h", ms: 21_600_000 },
|
||||||
|
{ label: "12h", ms: 43_200_000 },
|
||||||
|
{ label: "1d", ms: 86_400_000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DESIRED_POINTS = 168;
|
||||||
|
|
||||||
export interface IncidentAnalysis {
|
export interface IncidentAnalysis {
|
||||||
incidentCount: number;
|
incidentCount: number;
|
||||||
@@ -58,9 +73,20 @@ export function analyzeIncidentSequence(checkpoints: MetricCheckpoint[], from: s
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHourlyTrend(checkpoints: MetricCheckpoint[]): TrendPoint[] {
|
export function buildTrend(
|
||||||
|
checkpoints: MetricCheckpoint[],
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
bucket: MetricsBucket,
|
||||||
|
): TrendPoint[] {
|
||||||
|
const bucketMs = getBucketDurationMs(bucket);
|
||||||
|
const fromTime = new Date(from).getTime();
|
||||||
|
const toTime = new Date(to).getTime();
|
||||||
|
|
||||||
|
const firstBucketStart = alignToBucketBoundary(fromTime, bucketMs);
|
||||||
|
|
||||||
const buckets = new Map<
|
const buckets = new Map<
|
||||||
string,
|
number,
|
||||||
{
|
{
|
||||||
downChecks: number;
|
downChecks: number;
|
||||||
durations: number[];
|
durations: number[];
|
||||||
@@ -70,34 +96,60 @@ export function buildHourlyTrend(checkpoints: MetricCheckpoint[]): TrendPoint[]
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
for (const checkpoint of checkpoints) {
|
for (const checkpoint of checkpoints) {
|
||||||
const bucketStart = getUtcHourStart(checkpoint.timestamp);
|
const ts = new Date(checkpoint.timestamp).getTime();
|
||||||
const bucket = buckets.get(bucketStart) ?? { downChecks: 0, durations: [], totalChecks: 0, upChecks: 0 };
|
const offset = ts - firstBucketStart;
|
||||||
|
const bucketIndex = Math.max(0, Math.floor(offset / bucketMs));
|
||||||
|
const bucketStart = firstBucketStart + bucketIndex * bucketMs;
|
||||||
|
|
||||||
bucket.totalChecks++;
|
const b = buckets.get(bucketStart) ?? { downChecks: 0, durations: [], totalChecks: 0, upChecks: 0 };
|
||||||
|
b.totalChecks++;
|
||||||
if (checkpoint.matched) {
|
if (checkpoint.matched) {
|
||||||
bucket.upChecks++;
|
b.upChecks++;
|
||||||
if (checkpoint.durationMs !== null) {
|
if (checkpoint.durationMs !== null) {
|
||||||
bucket.durations.push(checkpoint.durationMs);
|
b.durations.push(checkpoint.durationMs);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bucket.downChecks++;
|
b.downChecks++;
|
||||||
}
|
}
|
||||||
|
buckets.set(bucketStart, b);
|
||||||
buckets.set(bucketStart, bucket);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...buckets.entries()]
|
const result: TrendPoint[] = [];
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
for (let start = firstBucketStart; start < toTime; start += bucketMs) {
|
||||||
.map(([bucketStart, bucket]) => ({
|
if (start + bucketMs <= fromTime) continue;
|
||||||
availability: calculateAvailability(bucket.upChecks, bucket.totalChecks),
|
const effectiveStart = Math.max(start, fromTime);
|
||||||
avgDurationMs: calculateAverageDuration(bucket.durations),
|
const effectiveEnd = Math.min(start + bucketMs, toTime);
|
||||||
bucketStart,
|
const b = buckets.get(start);
|
||||||
downChecks: bucket.downChecks,
|
if (b) {
|
||||||
maxDurationMs: bucket.durations.length > 0 ? Math.max(...bucket.durations) : null,
|
result.push({
|
||||||
minDurationMs: bucket.durations.length > 0 ? Math.min(...bucket.durations) : null,
|
availability: calculateAvailability(b.upChecks, b.totalChecks),
|
||||||
totalChecks: bucket.totalChecks,
|
avgDurationMs: calculateAverageDuration(b.durations),
|
||||||
upChecks: bucket.upChecks,
|
bucketEnd: new Date(effectiveEnd).toISOString(),
|
||||||
}));
|
bucketStart: new Date(effectiveStart).toISOString(),
|
||||||
|
downChecks: b.downChecks,
|
||||||
|
maxDurationMs: b.durations.length > 0 ? Math.max(...b.durations) : null,
|
||||||
|
minDurationMs: b.durations.length > 0 ? Math.min(...b.durations) : null,
|
||||||
|
p95DurationMs: calculatePercentile(b.durations, 95),
|
||||||
|
totalChecks: b.totalChecks,
|
||||||
|
upChecks: b.upChecks,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.push({
|
||||||
|
availability: 0,
|
||||||
|
avgDurationMs: null,
|
||||||
|
bucketEnd: new Date(effectiveEnd).toISOString(),
|
||||||
|
bucketStart: new Date(effectiveStart).toISOString(),
|
||||||
|
downChecks: 0,
|
||||||
|
maxDurationMs: null,
|
||||||
|
minDurationMs: null,
|
||||||
|
p95DurationMs: null,
|
||||||
|
totalChecks: 0,
|
||||||
|
upChecks: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateAvailability(upChecks: number, totalChecks: number): number {
|
export function calculateAvailability(upChecks: number, totalChecks: number): number {
|
||||||
@@ -138,10 +190,20 @@ export function calculatePercentile(durations: number[], percentile: number): nu
|
|||||||
return sorted[index] ?? null;
|
return sorted[index] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUtcHourStart(timestamp: string): string {
|
export function getBucketDurationMs(bucket: MetricsBucket): number {
|
||||||
const date = new Date(timestamp);
|
return BUCKET_STEPS.find((s) => s.label === bucket)?.ms ?? 3_600_000;
|
||||||
date.setUTCMinutes(0, 0, 0);
|
}
|
||||||
return date.toISOString();
|
|
||||||
|
export function resolveAutoBucket(intervalMs: number, windowMs: number): MetricsBucket {
|
||||||
|
const roughBucket = Math.max(intervalMs, windowMs / DESIRED_POINTS);
|
||||||
|
for (const step of BUCKET_STEPS) {
|
||||||
|
if (step.ms >= roughBucket) return step.label;
|
||||||
|
}
|
||||||
|
return "1d";
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignToBucketBoundary(timestampMs: number, bucketMs: number): number {
|
||||||
|
return Math.floor(timestampMs / bucketMs) * bucketMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function maxNullable(left: null | number, right: number): number {
|
function maxNullable(left: null | number, right: number): number {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { RuntimeMode } from "../shared/api";
|
import type { MetricsBucket, RuntimeMode } from "../shared/api";
|
||||||
|
|
||||||
|
const STANDARD_BUCKETS = new Set<string>(["1d", "1h", "1m", "3h", "5m", "6h", "12h", "15m", "30m", "30s"]);
|
||||||
|
|
||||||
import { createApiError, jsonResponse } from "./helpers";
|
import { createApiError, jsonResponse } from "./helpers";
|
||||||
|
|
||||||
@@ -19,13 +21,16 @@ export function validateDashboardWindow(
|
|||||||
return { from: from.toISOString(), label: window, to: to.toISOString() };
|
return { from: from.toISOString(), label: window, to: to.toISOString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateMetricsBucket(bucketParam: null | string, mode: RuntimeMode): Response | { bucket: "1h" } {
|
export function validateMetricsBucket(
|
||||||
const bucket = bucketParam ?? "1h";
|
bucketParam: null | string,
|
||||||
if (bucket !== "1h") {
|
mode: RuntimeMode,
|
||||||
|
): Response | { bucket: "auto" | MetricsBucket } {
|
||||||
|
const bucket = bucketParam ?? "auto";
|
||||||
|
if (bucket !== "auto" && !STANDARD_BUCKETS.has(bucket)) {
|
||||||
return jsonResponse(createApiError("Unsupported bucket parameter", 400), { mode, status: 400 });
|
return jsonResponse(createApiError("Unsupported bucket parameter", 400), { mode, status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { bucket };
|
return { bucket: bucket as "auto" | MetricsBucket };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validatePagination(
|
export function validatePagination(
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { RuntimeMode, TargetMetricsResponse } from "../../shared/api";
|
import type { MetricsBucket, RuntimeMode, TargetMetricsResponse } from "../../shared/api";
|
||||||
import type { ProbeStore } from "../checker/store";
|
import type { ProbeStore } from "../checker/store";
|
||||||
|
|
||||||
import { jsonResponse } from "../helpers";
|
import { jsonResponse } from "../helpers";
|
||||||
import {
|
import {
|
||||||
analyzeIncidentSequence,
|
analyzeIncidentSequence,
|
||||||
buildHourlyTrend,
|
buildTrend,
|
||||||
calculateAverageDuration,
|
calculateAverageDuration,
|
||||||
calculateCurrentStreak,
|
calculateCurrentStreak,
|
||||||
calculatePercentile,
|
calculatePercentile,
|
||||||
type MetricCheckpoint,
|
type MetricCheckpoint,
|
||||||
|
resolveAutoBucket,
|
||||||
} from "../metrics";
|
} from "../metrics";
|
||||||
import { validateMetricsBucket, validateTargetId, validateTimeRange } from "../middleware";
|
import { validateMetricsBucket, validateTargetId, validateTimeRange } from "../middleware";
|
||||||
|
|
||||||
@@ -27,6 +28,15 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
|
|||||||
const bucketResult = validateMetricsBucket(url.searchParams.get("bucket"), mode);
|
const bucketResult = validateMetricsBucket(url.searchParams.get("bucket"), mode);
|
||||||
if (bucketResult instanceof Response) return bucketResult;
|
if (bucketResult instanceof Response) return bucketResult;
|
||||||
|
|
||||||
|
const requestedBucket = bucketResult.bucket;
|
||||||
|
let resolvedBucket: MetricsBucket;
|
||||||
|
if (requestedBucket === "auto") {
|
||||||
|
const windowMs = new Date(timeResult.to).getTime() - new Date(timeResult.from).getTime();
|
||||||
|
resolvedBucket = resolveAutoBucket(target.interval_ms, windowMs);
|
||||||
|
} else {
|
||||||
|
resolvedBucket = requestedBucket;
|
||||||
|
}
|
||||||
|
|
||||||
const checkpoints = store
|
const checkpoints = store
|
||||||
.getTargetCheckpoints(idResult.id, timeResult.from, timeResult.to)
|
.getTargetCheckpoints(idResult.id, timeResult.from, timeResult.to)
|
||||||
.map((checkpoint): MetricCheckpoint => {
|
.map((checkpoint): MetricCheckpoint => {
|
||||||
@@ -55,9 +65,9 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
|
|||||||
upChecks: stats.upChecks,
|
upChecks: stats.upChecks,
|
||||||
},
|
},
|
||||||
targetId: idResult.id,
|
targetId: idResult.id,
|
||||||
trend: buildHourlyTrend(checkpoints),
|
trend: buildTrend(checkpoints, timeResult.from, timeResult.to, resolvedBucket),
|
||||||
window: {
|
window: {
|
||||||
bucket: bucketResult.bucket,
|
bucket: resolvedBucket,
|
||||||
from: timeResult.from,
|
from: timeResult.from,
|
||||||
to: timeResult.to,
|
to: timeResult.to,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export interface MetaResponse {
|
|||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MetricsBucket = "1d" | "1h" | "1m" | "3h" | "5m" | "6h" | "12h" | "15m" | "30m" | "30s";
|
||||||
|
|
||||||
export interface RecentSample {
|
export interface RecentSample {
|
||||||
durationMs: null | number;
|
durationMs: null | number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -86,7 +88,7 @@ export interface TargetMetricsResponse {
|
|||||||
targetId: string;
|
targetId: string;
|
||||||
trend: TrendPoint[];
|
trend: TrendPoint[];
|
||||||
window: {
|
window: {
|
||||||
bucket: "1h";
|
bucket: MetricsBucket;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
};
|
};
|
||||||
@@ -116,10 +118,12 @@ export interface TargetStatus {
|
|||||||
export interface TrendPoint {
|
export interface TrendPoint {
|
||||||
availability: number;
|
availability: number;
|
||||||
avgDurationMs: null | number;
|
avgDurationMs: null | number;
|
||||||
|
bucketEnd: string;
|
||||||
bucketStart: string;
|
bucketStart: string;
|
||||||
downChecks: number;
|
downChecks: number;
|
||||||
maxDurationMs: null | number;
|
maxDurationMs: null | number;
|
||||||
minDurationMs: null | number;
|
minDurationMs: null | number;
|
||||||
|
p95DurationMs: null | number;
|
||||||
totalChecks: number;
|
totalChecks: number;
|
||||||
upChecks: number;
|
upChecks: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
|
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
|
||||||
|
|
||||||
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
|
import type { MetricsBucket, TargetMetricsResponse, TargetStatus } from "../../shared/api";
|
||||||
|
|
||||||
import { formatDurationUnit } from "../utils/time";
|
import { formatDurationUnit } from "../utils/time";
|
||||||
import { TrendChart } from "./TrendChart";
|
import { TrendChart } from "./TrendChart";
|
||||||
@@ -78,14 +78,14 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
|
|||||||
<OverviewStatItem color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
|
<OverviewStatItem color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={3}>
|
<Col span={3}>
|
||||||
<OverviewStatItem color="green" suffix="次" title="连续正常" value={currentUpStreak} />
|
<OverviewStatItem color="green" suffix="次" title="窗口内连续正常" value={currentUpStreak} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
) : (
|
) : (
|
||||||
<div className="trend-empty">暂无指标数据</div>
|
<div className="trend-empty">暂无指标数据</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider align="left">趋势</Divider>
|
<Divider align="left">趋势{metricsData ? ` · ${formatBucketLabel(metricsData.window.bucket)}` : ""}</Divider>
|
||||||
{metricsLoading ? (
|
{metricsLoading ? (
|
||||||
<Skeleton animation="gradient" />
|
<Skeleton animation="gradient" />
|
||||||
) : metricsData ? (
|
) : metricsData ? (
|
||||||
@@ -104,3 +104,20 @@ function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BUCKET_LABELS: Record<MetricsBucket, string> = {
|
||||||
|
"1d": "1天",
|
||||||
|
"1h": "1小时",
|
||||||
|
"1m": "1分钟",
|
||||||
|
"3h": "3小时",
|
||||||
|
"5m": "5分钟",
|
||||||
|
"6h": "6小时",
|
||||||
|
"12h": "12小时",
|
||||||
|
"15m": "15分钟",
|
||||||
|
"30m": "30分钟",
|
||||||
|
"30s": "30秒",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatBucketLabel(bucket: MetricsBucket): string {
|
||||||
|
return BUCKET_LABELS[bucket] ?? bucket;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,28 @@
|
|||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { Area, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
|
||||||
import type { TrendPoint } from "../../shared/api";
|
import type { TrendPoint } from "../../shared/api";
|
||||||
|
|
||||||
interface IncidentDotProps {
|
|
||||||
cx?: number | string;
|
|
||||||
cy?: number | string;
|
|
||||||
payload?: TrendPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrendChartProps {
|
interface TrendChartProps {
|
||||||
data: TrendPoint[];
|
data: TrendPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) {
|
export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) {
|
||||||
|
const windowMs = useMemo(() => {
|
||||||
|
if (data.length < 2) return 0;
|
||||||
|
const first = new Date(data[0]!.bucketStart).getTime();
|
||||||
|
const last = new Date(data[data.length - 1]!.bucketEnd).getTime();
|
||||||
|
return last - first;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
data.map((point) => ({
|
data.map((point) => ({
|
||||||
...point,
|
...point,
|
||||||
durationRange:
|
label: formatBucketLabel(point.bucketStart, windowMs),
|
||||||
point.minDurationMs !== null && point.maxDurationMs !== null
|
statusLevel: getStatusLevel(point),
|
||||||
? [point.minDurationMs, point.maxDurationMs]
|
|
||||||
: null,
|
|
||||||
label: formatBucketLabel(point.bucketStart),
|
|
||||||
})),
|
})),
|
||||||
[data],
|
[data, windowMs],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
@@ -33,7 +31,7 @@ export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="trend-chart">
|
<div className="trend-chart">
|
||||||
<ResponsiveContainer height={240} width="100%">
|
<ResponsiveContainer height={280} width="100%">
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid stroke="var(--td-border-level-2-color)" strokeDasharray="3 3" />
|
<CartesianGrid stroke="var(--td-border-level-2-color)" strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="label" stroke="var(--td-text-color-secondary)" tick={{ fontSize: 12 }} />
|
<XAxis dataKey="label" stroke="var(--td-text-color-secondary)" tick={{ fontSize: 12 }} />
|
||||||
@@ -43,57 +41,98 @@ export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) {
|
|||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 12 }}
|
||||||
yAxisId="duration"
|
yAxisId="duration"
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip content={<TrendTooltip />} />
|
||||||
formatter={(value: unknown, name: unknown) => {
|
|
||||||
const nameStr = String(name);
|
|
||||||
if (nameStr === "durationRange" && Array.isArray(value)) {
|
|
||||||
return [`${Math.round(Number(value[0]))}ms - ${Math.round(Number(value[1]))}ms`, "延迟范围"];
|
|
||||||
}
|
|
||||||
const num = Number(value);
|
|
||||||
if (nameStr === "avgDurationMs") return [`${Math.round(num)}ms`, "平均耗时"];
|
|
||||||
return [String(value), nameStr];
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="durationRange"
|
|
||||||
fill="var(--td-brand-color-light)"
|
|
||||||
fillOpacity={0.2}
|
|
||||||
name="durationRange"
|
|
||||||
stroke="var(--td-brand-color-light)"
|
|
||||||
type="monotone"
|
|
||||||
yAxisId="duration"
|
|
||||||
/>
|
|
||||||
<Line
|
<Line
|
||||||
|
connectNulls={false}
|
||||||
dataKey="avgDurationMs"
|
dataKey="avgDurationMs"
|
||||||
dot={renderIncidentDot}
|
dot={false}
|
||||||
name="avgDurationMs"
|
name="avgDurationMs"
|
||||||
stroke="var(--td-brand-color)"
|
stroke="var(--td-brand-color)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
yAxisId="duration"
|
yAxisId="duration"
|
||||||
/>
|
/>
|
||||||
|
<Line
|
||||||
|
connectNulls={false}
|
||||||
|
dataKey="p95DurationMs"
|
||||||
|
dot={false}
|
||||||
|
name="p95DurationMs"
|
||||||
|
stroke="var(--td-warning-color)"
|
||||||
|
strokeDasharray="4 2"
|
||||||
|
strokeWidth={1}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="duration"
|
||||||
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
<div className="trend-status-bar">
|
||||||
|
{chartData.map((point) => (
|
||||||
|
<div
|
||||||
|
className={`trend-status-block trend-status-block--${point.statusLevel}`}
|
||||||
|
key={point.bucketStart}
|
||||||
|
title={`${point.label}: ${getStatusText(point)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatBucketLabel(bucketStart: string): string {
|
function formatBucketLabel(bucketStart: string, windowMs: number): string {
|
||||||
return new Date(bucketStart).toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" });
|
const date = new Date(bucketStart);
|
||||||
|
if (windowMs > 24 * 60 * 60 * 1000) {
|
||||||
|
return date.toLocaleString("zh-CN", {
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
minute: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderIncidentDot(props: IncidentDotProps) {
|
function formatTime(iso: string): string {
|
||||||
const { cx, cy, payload } = props;
|
return new Date(iso).toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" });
|
||||||
if (!payload || payload.availability >= 100 || payload.avgDurationMs === null) return <></>;
|
}
|
||||||
|
|
||||||
|
function getStatusLevel(point: TrendPoint): "down" | "empty" | "ok" | "partial" {
|
||||||
|
if (point.totalChecks === 0) return "empty";
|
||||||
|
if (point.availability >= 100) return "ok";
|
||||||
|
if (point.availability > 0) return "partial";
|
||||||
|
return "down";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(point: TrendPoint): string {
|
||||||
|
const level = getStatusLevel(point);
|
||||||
|
if (level === "empty") return "无检查数据";
|
||||||
|
if (level === "ok") return "正常";
|
||||||
|
if (level === "partial") return "部分异常";
|
||||||
|
return "全异常";
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: TrendPoint }> }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const point = payload[0]!.payload;
|
||||||
|
const level = getStatusLevel(point);
|
||||||
|
const timeRange = `${formatTime(point.bucketStart)} - ${formatTime(point.bucketEnd)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<circle
|
<div className="trend-tooltip">
|
||||||
cx={Number(cx)}
|
<div className="trend-tooltip-time">{timeRange}</div>
|
||||||
cy={Number(cy)}
|
{level === "empty" ? (
|
||||||
fill="var(--td-error-color)"
|
<div className="trend-tooltip-empty">无检查数据</div>
|
||||||
r={4}
|
) : (
|
||||||
stroke="var(--td-bg-color-container)"
|
<>
|
||||||
strokeWidth={2}
|
<div>可用率:{point.availability.toFixed(1)}%</div>
|
||||||
/>
|
<div>
|
||||||
|
成功/总数:{point.upChecks}/{point.totalChecks}
|
||||||
|
</div>
|
||||||
|
<div>失败数:{point.downChecks}</div>
|
||||||
|
{point.avgDurationMs !== null && <div>平均耗时:{Math.round(point.avgDurationMs)}ms</div>}
|
||||||
|
{point.p95DurationMs !== null && <div>P95:{Math.round(point.p95DurationMs)}ms</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { DashboardResponse, MetaResponse, TargetMetricsResponse } from "../../shared/api";
|
import type { DashboardResponse, MetaResponse, MetricsBucket, TargetMetricsResponse } from "../../shared/api";
|
||||||
|
|
||||||
const queryKeys = {
|
const queryKeys = {
|
||||||
dashboard: () => ["dashboard", "24h", 30] as const,
|
dashboard: () => ["dashboard", "24h", 30] as const,
|
||||||
meta: () => ["meta"] as const,
|
meta: () => ["meta"] as const,
|
||||||
metrics: (targetId: string, from: string, to: string, bucket: "1h") =>
|
metrics: (targetId: string, from: string, to: string, bucket: "auto" | MetricsBucket) =>
|
||||||
["metrics", targetId, from, to, bucket] as const,
|
["metrics", targetId, from, to, bucket] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export function useMeta() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTargetMetrics(targetId: null | string, from: string, to: string, bucket: "1h") {
|
export function useTargetMetrics(targetId: null | string, from: string, to: string, bucket: "auto" | MetricsBucket) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
enabled: targetId !== null && !!from && !!to,
|
enabled: targetId !== null && !!from && !!to,
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function useTargetDetail() {
|
|||||||
? (dashboardData?.targets.find((target) => target.id === selectedTargetId) ?? null)
|
? (dashboardData?.targets.find((target) => target.id === selectedTargetId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const metrics = useTargetMetrics(selectedTargetId, timeFrom, timeTo, "1h");
|
const metrics = useTargetMetrics(selectedTargetId, timeFrom, timeTo, "auto");
|
||||||
|
|
||||||
const history = useQuery({
|
const history = useQuery({
|
||||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo && activeTab === "history",
|
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo && activeTab === "history",
|
||||||
|
|||||||
@@ -135,6 +135,57 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trend-status-bar {
|
||||||
|
display: flex;
|
||||||
|
height: 6px;
|
||||||
|
gap: 1px;
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-status-block {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-status-block--ok {
|
||||||
|
background: var(--td-success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-status-block--partial {
|
||||||
|
background: var(--td-warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-status-block--down {
|
||||||
|
background: var(--td-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-status-block--empty {
|
||||||
|
background: var(--td-bg-color-component-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-tooltip {
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
border: 1px solid var(--td-border-level-2-color);
|
||||||
|
border-radius: var(--td-radius-default);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
box-shadow: var(--td-shadow-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-tooltip-time {
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-tooltip-empty {
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
.trend-loading,
|
.trend-loading,
|
||||||
.trend-empty {
|
.trend-empty {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|||||||
@@ -319,10 +319,12 @@ describe("API 路由", () => {
|
|||||||
expect(body.trend[0]).toMatchObject({
|
expect(body.trend[0]).toMatchObject({
|
||||||
availability: 50,
|
availability: 50,
|
||||||
avgDurationMs: 150,
|
avgDurationMs: 150,
|
||||||
|
bucketEnd: "2025-01-01T01:00:00.000Z",
|
||||||
bucketStart: "2025-01-01T00:00:00.000Z",
|
bucketStart: "2025-01-01T00:00:00.000Z",
|
||||||
downChecks: 2,
|
downChecks: 2,
|
||||||
maxDurationMs: 200,
|
maxDurationMs: 200,
|
||||||
minDurationMs: 100,
|
minDurationMs: 100,
|
||||||
|
p95DurationMs: 200,
|
||||||
totalChecks: 4,
|
totalChecks: 4,
|
||||||
upChecks: 2,
|
upChecks: 2,
|
||||||
});
|
});
|
||||||
@@ -350,7 +352,11 @@ describe("API 路由", () => {
|
|||||||
totalChecks: 0,
|
totalChecks: 0,
|
||||||
upChecks: 0,
|
upChecks: 0,
|
||||||
});
|
});
|
||||||
expect(body.trend).toEqual([]);
|
expect(body.trend.length).toBeGreaterThan(0);
|
||||||
|
body.trend.forEach((point: { availability: number; totalChecks: number }) => {
|
||||||
|
expect(point.totalChecks).toBe(0);
|
||||||
|
expect(point.availability).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("查询不存在的目标返回 404", async () => {
|
test("查询不存在的目标返回 404", async () => {
|
||||||
@@ -394,7 +400,7 @@ describe("API 路由", () => {
|
|||||||
test("metrics 无效 bucket 和不存在目标返回错误", async () => {
|
test("metrics 无效 bucket 和不存在目标返回错误", async () => {
|
||||||
const targets = store.getTargets();
|
const targets = store.getTargets();
|
||||||
const invalidBucket = await fetch(
|
const invalidBucket = await fetch(
|
||||||
`${baseUrl}/api/targets/${targets[0]!.id}/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z&bucket=5m`,
|
`${baseUrl}/api/targets/${targets[0]!.id}/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z&bucket=invalid`,
|
||||||
);
|
);
|
||||||
const missingTarget = await fetch(
|
const missingTarget = await fetch(
|
||||||
`${baseUrl}/api/targets/99999/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z`,
|
`${baseUrl}/api/targets/99999/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z`,
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
analyzeIncidentSequence,
|
analyzeIncidentSequence,
|
||||||
buildHourlyTrend,
|
buildTrend,
|
||||||
calculateAvailability,
|
calculateAvailability,
|
||||||
calculateCurrentStreak,
|
calculateCurrentStreak,
|
||||||
calculatePercentile,
|
calculatePercentile,
|
||||||
type MetricCheckpoint,
|
type MetricCheckpoint,
|
||||||
|
resolveAutoBucket,
|
||||||
} from "../../src/server/metrics";
|
} from "../../src/server/metrics";
|
||||||
|
|
||||||
describe("后端指标计算", () => {
|
describe("后端指标计算", () => {
|
||||||
@@ -90,35 +91,47 @@ describe("后端指标计算", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("UTC 小时趋势分桶返回 up/down 和延迟范围", () => {
|
test("UTC 小时趋势分桶返回 up/down 和延迟范围", () => {
|
||||||
const trend = buildHourlyTrend([
|
const checkpoints = [
|
||||||
checkpoint("2025-01-01T00:10:00.000Z", true, 100),
|
checkpoint("2025-01-01T00:10:00.000Z", true, 100),
|
||||||
checkpoint("2025-01-01T00:40:00.000Z", false, null),
|
checkpoint("2025-01-01T00:40:00.000Z", false, null),
|
||||||
checkpoint("2025-01-01T01:05:00.000Z", true, 300),
|
checkpoint("2025-01-01T01:05:00.000Z", true, 300),
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
const trend = buildTrend(checkpoints, "2025-01-01T00:00:00.000Z", "2025-01-01T01:59:59.999Z", "1h");
|
||||||
|
|
||||||
expect(trend).toEqual([
|
expect(trend).toEqual([
|
||||||
{
|
{
|
||||||
availability: 50,
|
availability: 50,
|
||||||
avgDurationMs: 100,
|
avgDurationMs: 100,
|
||||||
|
bucketEnd: "2025-01-01T01:00:00.000Z",
|
||||||
bucketStart: "2025-01-01T00:00:00.000Z",
|
bucketStart: "2025-01-01T00:00:00.000Z",
|
||||||
downChecks: 1,
|
downChecks: 1,
|
||||||
maxDurationMs: 100,
|
maxDurationMs: 100,
|
||||||
minDurationMs: 100,
|
minDurationMs: 100,
|
||||||
|
p95DurationMs: 100,
|
||||||
totalChecks: 2,
|
totalChecks: 2,
|
||||||
upChecks: 1,
|
upChecks: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
availability: 100,
|
availability: 100,
|
||||||
avgDurationMs: 300,
|
avgDurationMs: 300,
|
||||||
|
bucketEnd: "2025-01-01T01:59:59.999Z",
|
||||||
bucketStart: "2025-01-01T01:00:00.000Z",
|
bucketStart: "2025-01-01T01:00:00.000Z",
|
||||||
downChecks: 0,
|
downChecks: 0,
|
||||||
maxDurationMs: 300,
|
maxDurationMs: 300,
|
||||||
minDurationMs: 300,
|
minDurationMs: 300,
|
||||||
|
p95DurationMs: 300,
|
||||||
totalChecks: 1,
|
totalChecks: 1,
|
||||||
upChecks: 1,
|
upChecks: 1,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resolveAutoBucket 按窗口大小选择合适桶", () => {
|
||||||
|
expect(resolveAutoBucket(30_000, 7 * 24 * 60 * 60 * 1000)).toBe("1h");
|
||||||
|
expect(resolveAutoBucket(30_000, 60 * 60 * 1000)).toBe("30s");
|
||||||
|
expect(resolveAutoBucket(30_000, 24 * 60 * 60 * 1000)).toBe("15m");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function checkpoint(timestamp: string, matched: boolean, durationMs: null | number = null): MetricCheckpoint {
|
function checkpoint(timestamp: string, matched: boolean, durationMs: null | number = null): MetricCheckpoint {
|
||||||
|
|||||||
@@ -154,9 +154,9 @@ describe("validateDashboardWindow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("validateMetricsBucket", () => {
|
describe("validateMetricsBucket", () => {
|
||||||
test("默认值:bucket=1h", () => {
|
test("默认值:bucket=auto", () => {
|
||||||
const result = validateMetricsBucket(null, "production");
|
const result = validateMetricsBucket(null, "production");
|
||||||
expect(result).toEqual({ bucket: "1h" });
|
expect(result).toEqual({ bucket: "auto" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bucket=1h 返回成功", () => {
|
test("bucket=1h 返回成功", () => {
|
||||||
@@ -165,7 +165,7 @@ describe("validateMetricsBucket", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("不支持的 bucket 参数返回 400", () => {
|
test("不支持的 bucket 参数返回 400", () => {
|
||||||
const result = validateMetricsBucket("5m", "production");
|
const result = validateMetricsBucket("invalid", "production");
|
||||||
expect(result).toHaveProperty("status", 400);
|
expect(result).toHaveProperty("status", 400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,20 +11,24 @@ describe("TrendChart", () => {
|
|||||||
{
|
{
|
||||||
availability: 100,
|
availability: 100,
|
||||||
avgDurationMs: 100,
|
avgDurationMs: 100,
|
||||||
|
bucketEnd: "2025-01-15T11:00:00.000Z",
|
||||||
bucketStart: "2025-01-15T10:00:00.000Z",
|
bucketStart: "2025-01-15T10:00:00.000Z",
|
||||||
downChecks: 0,
|
downChecks: 0,
|
||||||
maxDurationMs: 150,
|
maxDurationMs: 150,
|
||||||
minDurationMs: 50,
|
minDurationMs: 50,
|
||||||
|
p95DurationMs: 120,
|
||||||
totalChecks: 10,
|
totalChecks: 10,
|
||||||
upChecks: 10,
|
upChecks: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
availability: 95,
|
availability: 95,
|
||||||
avgDurationMs: 120,
|
avgDurationMs: 120,
|
||||||
|
bucketEnd: "2025-01-15T12:00:00.000Z",
|
||||||
bucketStart: "2025-01-15T11:00:00.000Z",
|
bucketStart: "2025-01-15T11:00:00.000Z",
|
||||||
downChecks: 1,
|
downChecks: 1,
|
||||||
maxDurationMs: 200,
|
maxDurationMs: 200,
|
||||||
minDurationMs: 80,
|
minDurationMs: 80,
|
||||||
|
p95DurationMs: 180,
|
||||||
totalChecks: 20,
|
totalChecks: 20,
|
||||||
upChecks: 19,
|
upChecks: 19,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user