mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 18:26:36 +08:00
feat(rate-limits): add RPS analysis tab with dual-axis chart
Split rate-limits into analysis/config tabs; reuse access-log overview filters; chart hourly RPS vs visits with dataZoom; rank top hosts/IPs by window-average RPS.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { Gauge } from 'lucide-react';
|
||||
|
||||
import { RankCard } from '@/components/data/rank-card';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import type {
|
||||
AccessLogOverview,
|
||||
DistributionItem,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
formatOverviewRangeHint,
|
||||
type OverviewRangeHours,
|
||||
} from '../../access-logs/components/access-log-utils';
|
||||
import { OverviewToolbar } from '../../access-logs/components/overview-toolbar';
|
||||
import { RatePressureChart } from './rate-pressure-chart';
|
||||
|
||||
function toAvgRpsItems(items: DistributionItem[] | undefined, hours: number) {
|
||||
const windowSeconds = Math.max(hours, 1) * 3600;
|
||||
return (items ?? []).map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value / windowSeconds,
|
||||
}));
|
||||
}
|
||||
|
||||
function formatRps(value: number) {
|
||||
if (!Number.isFinite(value)) return '—';
|
||||
if (value >= 100) {
|
||||
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
|
||||
}
|
||||
return value.toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 3,
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function AnalysisTab({
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
hours,
|
||||
hosts,
|
||||
onHoursChange,
|
||||
onHostsChange,
|
||||
onRetry,
|
||||
}: {
|
||||
data?: AccessLogOverview;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
hours: OverviewRangeHours;
|
||||
hosts: string[];
|
||||
onHoursChange: (hours: OverviewRangeHours) => void;
|
||||
onHostsChange: (hosts: string[]) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const rangeHint = formatOverviewRangeHint(hours);
|
||||
const hostItems = toAvgRpsItems(data?.top_hosts, hours);
|
||||
const ipItems = toAvgRpsItems(data?.top_ips, hours);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<OverviewToolbar
|
||||
hours={hours}
|
||||
hosts={hosts}
|
||||
onHoursChange={onHoursChange}
|
||||
onHostsChange={onHostsChange}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<LoadingStateWithBorder icon={Gauge} description='加载请求压力...' />
|
||||
) : error ? (
|
||||
<ErrorInline message={error.message || '加载失败'} onRetry={onRetry} />
|
||||
) : !data ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Gauge}
|
||||
title='暂无数据'
|
||||
description='当前时间范围内没有可用的访问日志。'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<RatePressureChart data={data} hours={hours} />
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
<RankCard
|
||||
title='平均 RPS 最高域名'
|
||||
description={`${rangeHint}平均请求速率`}
|
||||
items={hostItems}
|
||||
color='#38bdf8'
|
||||
valueFormatter={(value) => `${formatRps(value)} req/s`}
|
||||
/>
|
||||
<RankCard
|
||||
title='平均 RPS 最高 IP'
|
||||
description={`${rangeHint}平均请求速率`}
|
||||
items={ipItems}
|
||||
color='#a78bfa'
|
||||
valueFormatter={(value) => `${formatRps(value)} req/s`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ExternalLink, Gauge, Loader2, Save } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { OptionService } from '@/lib/services/openflare';
|
||||
|
||||
const optionsQueryKey = ['openflare', 'options'] as const;
|
||||
|
||||
const KEY_CONN_PER_SERVER = 'openresty_default_limit_conn_per_server';
|
||||
const KEY_CONN_PER_IP = 'openresty_default_limit_conn_per_ip';
|
||||
const KEY_LIMIT_RATE = 'openresty_default_limit_rate';
|
||||
|
||||
const limitRatePattern = /^\d+(?:[kKmM])?$/;
|
||||
|
||||
type RateLimitFields = {
|
||||
openresty_default_limit_conn_per_server: string;
|
||||
openresty_default_limit_conn_per_ip: string;
|
||||
openresty_default_limit_rate: string;
|
||||
};
|
||||
|
||||
const defaultFields: RateLimitFields = {
|
||||
openresty_default_limit_conn_per_server: '0',
|
||||
openresty_default_limit_conn_per_ip: '0',
|
||||
openresty_default_limit_rate: '',
|
||||
};
|
||||
|
||||
function optionsToMap(options: Array<{ key: string; value: string }>) {
|
||||
return options.reduce<Record<string, string>>((acc, option) => {
|
||||
acc[option.key] = option.value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function mapOptionsToFields(
|
||||
optionMap: Record<string, string>,
|
||||
): RateLimitFields {
|
||||
return {
|
||||
openresty_default_limit_conn_per_server:
|
||||
optionMap[KEY_CONN_PER_SERVER] ?? '0',
|
||||
openresty_default_limit_conn_per_ip: optionMap[KEY_CONN_PER_IP] ?? '0',
|
||||
openresty_default_limit_rate: optionMap[KEY_LIMIT_RATE] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function validateFields(fields: RateLimitFields) {
|
||||
for (const key of [KEY_CONN_PER_SERVER, KEY_CONN_PER_IP] as const) {
|
||||
const raw = fields[key].trim();
|
||||
if (!raw) continue;
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
throw new Error('并发限制请输入非负整数,或留空表示关闭');
|
||||
}
|
||||
}
|
||||
|
||||
const rate = fields.openresty_default_limit_rate.trim();
|
||||
if (rate && rate !== '0' && !limitRatePattern.test(rate)) {
|
||||
throw new Error('限速格式不合法,请使用 512k、1m、纯数字,或留空关闭');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConnValue(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '0';
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeRateValue(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized || normalized === '0') return '';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function ConfigTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [fields, setFields] = useState<RateLimitFields>(defaultFields);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: optionsQueryKey,
|
||||
queryFn: () => OptionService.list(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!optionsQuery.data) return;
|
||||
setFields(mapOptionsToFields(optionsToMap(optionsQuery.data)));
|
||||
}, [optionsQuery.data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
validateFields(fields);
|
||||
setSaving(true);
|
||||
await OptionService.updateBatch([
|
||||
{
|
||||
key: KEY_CONN_PER_SERVER,
|
||||
value: normalizeConnValue(
|
||||
fields.openresty_default_limit_conn_per_server,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: KEY_CONN_PER_IP,
|
||||
value: normalizeConnValue(fields.openresty_default_limit_conn_per_ip),
|
||||
},
|
||||
{
|
||||
key: KEY_LIMIT_RATE,
|
||||
value: normalizeRateValue(fields.openresty_default_limit_rate),
|
||||
},
|
||||
]);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('限流参数已保存');
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: optionsQueryKey }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'config-preview'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'config-versions'],
|
||||
}),
|
||||
]);
|
||||
setSaving(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
setSaving(false);
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
},
|
||||
});
|
||||
|
||||
const updateField = <K extends keyof RateLimitFields>(
|
||||
key: K,
|
||||
value: RateLimitFields[K],
|
||||
) => {
|
||||
setFields((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
if (optionsQuery.isLoading) {
|
||||
return (
|
||||
<LoadingStateWithBorder icon={Gauge} description='加载限流参数...' />
|
||||
);
|
||||
}
|
||||
|
||||
if (optionsQuery.isError) {
|
||||
return (
|
||||
<ErrorInline
|
||||
message={
|
||||
optionsQuery.error instanceof Error
|
||||
? optionsQuery.error.message
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void optionsQuery.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex justify-end'>
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<Link href='/config-versions'>
|
||||
<ExternalLink className='size-3.5 mr-1' />
|
||||
查看配置预览
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>全局默认限流</CardTitle>
|
||||
<CardDescription>
|
||||
0
|
||||
或空表示默认关闭;站点未单独配置时继承此处设置。修改后需在版本发布中生效。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
disabled={saving}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
) : (
|
||||
<Save className='size-3.5 mr-1' />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs text-muted-foreground'>
|
||||
默认并发限制(每站点)
|
||||
</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
value={fields.openresty_default_limit_conn_per_server}
|
||||
placeholder='0'
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'openresty_default_limit_conn_per_server',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
站点最大并发连接数默认值
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs text-muted-foreground'>
|
||||
默认单 IP 并发
|
||||
</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
value={fields.openresty_default_limit_conn_per_ip}
|
||||
placeholder='0'
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'openresty_default_limit_conn_per_ip',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
单个 IP 最大并发连接数默认值
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs text-muted-foreground'>默认限速</Label>
|
||||
<Input
|
||||
value={fields.openresty_default_limit_rate}
|
||||
placeholder='512k / 1m'
|
||||
onChange={(e) =>
|
||||
updateField('openresty_default_limit_rate', e.target.value)
|
||||
}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
单请求带宽默认值,例如 512k 或 1m
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { Clock } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { AccessLogOverview } from '@/lib/services/openflare';
|
||||
import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics';
|
||||
|
||||
import {
|
||||
formatOverviewTrendLabel,
|
||||
type OverviewRangeHours,
|
||||
} from '../../access-logs/components/access-log-utils';
|
||||
|
||||
const BUCKET_SECONDS = 3600;
|
||||
const RPS_COLOR = '#38bdf8';
|
||||
const VISITS_COLOR = '#a78bfa';
|
||||
|
||||
function formatDateTime(date: Date) {
|
||||
const y = date.getFullYear();
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const d = `${date.getDate()}`.padStart(2, '0');
|
||||
const h = `${date.getHours()}`.padStart(2, '0');
|
||||
const min = `${date.getMinutes()}`.padStart(2, '0');
|
||||
const s = `${date.getSeconds()}`.padStart(2, '0');
|
||||
return `${y}-${m}-${d} ${h}:${min}:${s}`;
|
||||
}
|
||||
|
||||
function formatAxisTime(value: string, hours: number) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—\n—';
|
||||
}
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
||||
if (hours <= 24) {
|
||||
return `${hour}:${minute}\n`;
|
||||
}
|
||||
return `${month}-${day}\n${hour}:${minute}`;
|
||||
}
|
||||
|
||||
function formatRps(value: number) {
|
||||
if (!Number.isFinite(value)) return '—';
|
||||
if (value >= 100) return formatCompactNumber(value);
|
||||
return value.toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
type RatePressureChartProps = {
|
||||
data?: AccessLogOverview;
|
||||
hours: OverviewRangeHours;
|
||||
};
|
||||
|
||||
export function RatePressureChart({ data, hours }: RatePressureChartProps) {
|
||||
const rangeLabel = useMemo(() => {
|
||||
const end = data?.generated_at ? new Date(data.generated_at) : new Date();
|
||||
const start = new Date(end.getTime() - hours * 3600 * 1000);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return `${formatDateTime(start)} — ${formatDateTime(end)}`;
|
||||
}, [data?.generated_at, hours]);
|
||||
|
||||
const chartModel = useMemo(() => {
|
||||
const requests = data?.trends.requests ?? [];
|
||||
const visits = data?.trends.visits ?? [];
|
||||
const length = Math.max(requests.length, visits.length);
|
||||
const labels: string[] = [];
|
||||
const rpsValues: number[] = [];
|
||||
const visitValues: number[] = [];
|
||||
const rawTimes: string[] = [];
|
||||
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const req = requests[i];
|
||||
const visit = visits[i];
|
||||
const time = req?.bucket_started_at ?? visit?.bucket_started_at ?? '';
|
||||
rawTimes.push(time);
|
||||
labels.push(formatAxisTime(time, hours));
|
||||
rpsValues.push((req?.value ?? 0) / BUCKET_SECONDS);
|
||||
visitValues.push(visit?.value ?? 0);
|
||||
}
|
||||
|
||||
return { labels, rpsValues, visitValues, rawTimes };
|
||||
}, [data?.trends.requests, data?.trends.visits, hours]);
|
||||
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const rpsMax = calculateNiceAxisMax(chartModel.rpsValues);
|
||||
const visitMax = calculateNiceAxisMax(chartModel.visitValues);
|
||||
|
||||
return {
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicOut',
|
||||
grid: {
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 48,
|
||||
bottom: 72,
|
||||
containLabel: true,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#e2e8f0',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const items = Array.isArray(params) ? params : [];
|
||||
if (items.length === 0) return '';
|
||||
const first = items[0] as {
|
||||
dataIndex?: number;
|
||||
axisValueLabel?: string;
|
||||
};
|
||||
const index =
|
||||
typeof first.dataIndex === 'number' ? first.dataIndex : 0;
|
||||
const rawTime = chartModel.rawTimes[index] ?? '';
|
||||
const header = rawTime
|
||||
? formatOverviewTrendLabel(rawTime, hours)
|
||||
: (first.axisValueLabel ?? '');
|
||||
const rows = items.map((item) => {
|
||||
const row = item as {
|
||||
seriesName?: string;
|
||||
color?: string;
|
||||
value?: number | string;
|
||||
};
|
||||
const numeric =
|
||||
typeof row.value === 'number'
|
||||
? row.value
|
||||
: Number(row.value ?? 0);
|
||||
const isRps = row.seriesName === '请求速率';
|
||||
const formatted = isRps
|
||||
? `${formatRps(numeric)} req/s`
|
||||
: formatCompactNumber(numeric);
|
||||
return [
|
||||
'<span style="display:inline-flex;align-items:center;gap:8px;">',
|
||||
`<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${row.color ?? '#94a3b8'};"></span>`,
|
||||
`<span>${row.seriesName ?? ''}</span>`,
|
||||
`<strong style="margin-left:8px;">${formatted}</strong>`,
|
||||
'</span>',
|
||||
].join('');
|
||||
});
|
||||
return [header, ...rows].join('<br/>');
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
top: 8,
|
||||
right: 80,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
icon: 'circle',
|
||||
textStyle: {
|
||||
color: '#94a3b8',
|
||||
fontSize: 12,
|
||||
},
|
||||
data: ['请求速率', '独立访客'],
|
||||
},
|
||||
graphic: [
|
||||
{
|
||||
type: 'text',
|
||||
left: 16,
|
||||
top: 12,
|
||||
style: {
|
||||
text: 'RPS',
|
||||
fill: '#94a3b8',
|
||||
fontSize: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
right: 16,
|
||||
top: 12,
|
||||
style: {
|
||||
text: '访客 / 桶',
|
||||
fill: '#94a3b8',
|
||||
fontSize: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: chartModel.labels,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.24)',
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
margin: 14,
|
||||
lineHeight: 16,
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: rpsMax,
|
||||
splitNumber: 4,
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
formatter: (value: number) => formatRps(value),
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.16)',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: visitMax,
|
||||
splitNumber: 4,
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
formatter: (value: number) => formatCompactNumber(value),
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'slider',
|
||||
height: 28,
|
||||
bottom: 8,
|
||||
borderColor: 'rgba(148, 163, 184, 0.2)',
|
||||
backgroundColor: 'rgba(148, 163, 184, 0.06)',
|
||||
fillerColor: 'rgba(56, 189, 248, 0.12)',
|
||||
handleStyle: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
textStyle: {
|
||||
color: '#94a3b8',
|
||||
fontSize: 10,
|
||||
},
|
||||
dataBackground: {
|
||||
lineStyle: {
|
||||
color: RPS_COLOR,
|
||||
width: 1,
|
||||
},
|
||||
areaStyle: {
|
||||
color: `${RPS_COLOR}33`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inside',
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '请求速率',
|
||||
type: 'line',
|
||||
yAxisIndex: 0,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: {
|
||||
color: RPS_COLOR,
|
||||
width: 2.5,
|
||||
},
|
||||
itemStyle: {
|
||||
color: RPS_COLOR,
|
||||
},
|
||||
areaStyle: {
|
||||
color: `${RPS_COLOR}33`,
|
||||
},
|
||||
data: chartModel.rpsValues,
|
||||
},
|
||||
{
|
||||
name: '独立访客',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: {
|
||||
color: VISITS_COLOR,
|
||||
width: 2,
|
||||
},
|
||||
itemStyle: {
|
||||
color: VISITS_COLOR,
|
||||
},
|
||||
areaStyle: {
|
||||
color: `${VISITS_COLOR}22`,
|
||||
},
|
||||
data: chartModel.visitValues,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [chartModel, hours]);
|
||||
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between pb-2'>
|
||||
<CardTitle className='text-base font-semibold tracking-tight'>
|
||||
请求压力
|
||||
</CardTitle>
|
||||
<div className='flex items-center gap-1.5 text-xs text-muted-foreground font-mono'>
|
||||
<Clock className='size-3.5 shrink-0' />
|
||||
<span className='break-all'>{rangeLabel}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
{chartModel.labels.length === 0 ? (
|
||||
<div className='flex h-[360px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground'>
|
||||
暂无请求压力数据
|
||||
</div>
|
||||
) : (
|
||||
<ReactECharts
|
||||
option={option}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
style={{ height: 360, width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user