- ContextSwitchCard: CTX/IRQ/次 → t() - ProcessTable: agentWaitData/cpuUsagePercent → t() - FDUsageCard: fdHandles → t() - PortMonitorTab: 黑名单/白名单/策略配置/暂无端口数据 → t() - PortPolicyDialog: 白名单全量上报/黑名单仅上报/端口列表/逗号分隔/白名单无需配置/已输入端口 → t() - HostTimeRangePicker: 时间范围/自定义/取消/应用 → t() - DualLineChart: 接收/发送 → t(), i18n.t→useTranslation hook - ZoomableAreaChart: 暂无数据 → t() - SiteResultsPanel: 选择任务/次/可用率/立即拨测/SSL证书预警/到期/剩余/阈值/天 → t() - MiddlewareMonitor: Redis单机集群/上次更新/添加平台默认/暂无实例 → t() - SiteMonitor: subtitle → t() - SNMPMonitor: 标题/采集间隔/IP地址/OID模板/SNMP版本/确认/暂无设备/采集指标/暂无指标 → t() - HealthProbeSection: TCP目标地址/TCP端口 → t() - AgentAlerts: Agent告警/刷新/未恢复/已恢复/恢复/新建规则/创建第一条规则 → i18n.t() 新增 monitor.lbl 46 语义化翻译键 + agent.alerts 6 键 zh/en 7762→7814 对齐 tsc -b=0, npm build✓ (16.88s), ESLint=0/43/any=0, go build=0, 17/17 测试通过
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
// 可缩放面积图 — 光标拖拽选区 / 宽屏弹框展开 // Logan.Li
|
|
import {
|
|
useState, useMemo, useCallback, useId, useEffect,
|
|
forwardRef, useImperativeHandle,
|
|
} from 'react';
|
|
import {
|
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
|
ResponsiveContainer, ReferenceArea, ReferenceLine,
|
|
} from 'recharts';
|
|
import {
|
|
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { ChartToolbar } from './ChartToolbar';
|
|
import { MonitorChartTooltip } from '../shared/monitorChartTheme';
|
|
import { ChartRangeHint } from '../shared/ChartRangeHint';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
export interface ChartSeries {
|
|
dataKey: string;
|
|
color: string;
|
|
label: string;
|
|
}
|
|
|
|
export interface ChartReferenceLine {
|
|
value: number;
|
|
label?: string;
|
|
color?: string;
|
|
}
|
|
|
|
export interface ZoomableChartHandle {
|
|
expand: () => void;
|
|
resetZoom: () => void;
|
|
}
|
|
|
|
interface ZoomRange {
|
|
left: string;
|
|
right: string;
|
|
}
|
|
|
|
interface Props {
|
|
data: Record<string, unknown>[];
|
|
series: ChartSeries[];
|
|
xDataKey?: string;
|
|
height?: number | string;
|
|
unit?: string;
|
|
yDomain?: [number, number | 'auto'];
|
|
chartId: string;
|
|
title?: string;
|
|
subtitle?: string;
|
|
showHint?: boolean;
|
|
/** header=按钮由父组件放在卡片标题栏;overlay=浮在图表右上角(无卡片场景) // Logan.Li */
|
|
toolbarPlacement?: 'header' | 'overlay';
|
|
onZoomedChange?: (zoomed: boolean) => void;
|
|
referenceLines?: ChartReferenceLine[];
|
|
}
|
|
|
|
const dialogClass =
|
|
'!max-w-[min(96vw,88rem)] sm:!max-w-[min(96vw,88rem)] w-[min(96vw,88rem)] max-h-[92vh] flex flex-col gap-2 p-4 sm:p-5 bg-[var(--dg-bg-primary)] border-[var(--dg-border)] overflow-hidden';
|
|
|
|
function ChartBody({
|
|
displayData,
|
|
series,
|
|
xDataKey,
|
|
height,
|
|
unit,
|
|
yDomain,
|
|
gradPrefix,
|
|
refLeft,
|
|
refRight,
|
|
onMouseDown,
|
|
onMouseMove,
|
|
onMouseUp,
|
|
compact,
|
|
referenceLines,
|
|
}: {
|
|
displayData: Record<string, unknown>[];
|
|
series: ChartSeries[];
|
|
xDataKey: string;
|
|
height: number | string;
|
|
unit: string;
|
|
yDomain: [number, number | 'auto'];
|
|
gradPrefix: string;
|
|
zoomRange: ZoomRange | null;
|
|
refLeft: string;
|
|
refRight: string;
|
|
onMouseDown: (e: { activeLabel?: string }) => void;
|
|
onMouseMove: (e: { activeLabel?: string }) => void;
|
|
onMouseUp: () => void;
|
|
compact?: boolean;
|
|
referenceLines?: ChartReferenceLine[];
|
|
}) {
|
|
return (
|
|
<div style={{ height }} className="select-none w-full min-h-0">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart
|
|
data={displayData}
|
|
margin={{ top: 6, right: compact ? 12 : 8, bottom: compact ? 12 : 8, left: 2 }}
|
|
onMouseDown={onMouseDown}
|
|
onMouseMove={onMouseMove}
|
|
onMouseUp={onMouseUp}
|
|
style={{ cursor: 'crosshair' }}
|
|
>
|
|
<defs>
|
|
{series.map((s) => (
|
|
<linearGradient key={s.dataKey} id={`${gradPrefix}-${s.dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={s.color} stopOpacity={0.35} />
|
|
<stop offset="95%" stopColor={s.color} stopOpacity={0} />
|
|
</linearGradient>
|
|
))}
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" strokeOpacity={0.6} />
|
|
<XAxis
|
|
dataKey={xDataKey}
|
|
tick={{ fontSize: compact ? 11 : 10, fill: 'var(--dg-text-muted)' }}
|
|
tickLine={false}
|
|
axisLine={{ stroke: 'var(--dg-border)' }}
|
|
interval="preserveStartEnd"
|
|
minTickGap={compact ? 56 : 40}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: compact ? 11 : 10, fill: 'var(--dg-text-muted)' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
width={compact ? 44 : 36}
|
|
domain={yDomain[1] === 'auto' ? [0, (max: number) => max * 1.15] : yDomain as [number, number]}
|
|
tickFormatter={(v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(Math.round(v * 10) / 10))}
|
|
/>
|
|
<Tooltip
|
|
content={(
|
|
<MonitorChartTooltip
|
|
nameFormatter={(name) => series.find((s) => s.dataKey === name)?.label || name}
|
|
valueFormatter={(v) => `${Number(v).toFixed(2)}${unit ? ` ${unit}` : ''}`}
|
|
/>
|
|
)}
|
|
/>
|
|
{referenceLines?.map((line) => (
|
|
<ReferenceLine
|
|
key={`ref-${line.value}-${line.label || ''}`}
|
|
y={line.value}
|
|
stroke={line.color || 'var(--dg-yellow)'}
|
|
strokeDasharray="4 4"
|
|
strokeOpacity={0.75}
|
|
label={{
|
|
value: line.label || String(line.value),
|
|
position: 'insideTopRight',
|
|
fontSize: compact ? 10 : 9,
|
|
fill: line.color || 'var(--dg-yellow)',
|
|
}}
|
|
/>
|
|
))}
|
|
{series.map((s) => (
|
|
<Area
|
|
key={s.dataKey}
|
|
type="monotone"
|
|
dataKey={s.dataKey}
|
|
stroke={s.color}
|
|
strokeWidth={compact ? 2 : 1.5}
|
|
fill={`url(#${gradPrefix}-${s.dataKey})`}
|
|
name={s.dataKey}
|
|
activeDot={{ r: compact ? 5 : 4, strokeWidth: 0 }}
|
|
isAnimationActive={false}
|
|
/>
|
|
))}
|
|
{refLeft && refRight ? (
|
|
<ReferenceArea
|
|
x1={refLeft}
|
|
x2={refRight}
|
|
strokeOpacity={0.4}
|
|
fill="var(--dg-blue)"
|
|
fillOpacity={0.15}
|
|
stroke="var(--dg-blue)"
|
|
/>
|
|
) : null}
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const ZoomableAreaChart = forwardRef<ZoomableChartHandle, Props>(function ZoomableAreaChart({
|
|
data,
|
|
series,
|
|
xDataKey = 'time',
|
|
height = 192,
|
|
unit = '',
|
|
yDomain = [0, 'auto'],
|
|
chartId,
|
|
title,
|
|
subtitle,
|
|
showHint = true,
|
|
toolbarPlacement = 'header',
|
|
onZoomedChange,
|
|
referenceLines,
|
|
}, ref) {
|
|
const { t } = useTranslation();
|
|
const uid = useId().replace(/:/g, '');
|
|
const gradPrefix = `grad-${chartId}-${uid}`;
|
|
|
|
const [zoomRange, setZoomRange] = useState<ZoomRange | null>(null);
|
|
const [refLeft, setRefLeft] = useState('');
|
|
const [refRight, setRefRight] = useState('');
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const dataFingerprint = useMemo(() => {
|
|
if (data.length === 0) return 'empty';
|
|
const first = data[0]?.[xDataKey];
|
|
const last = data[data.length - 1]?.[xDataKey];
|
|
return `${data.length}|${String(first)}|${String(last)}`;
|
|
}, [data, xDataKey]);
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setZoomRange(null);
|
|
setRefLeft('');
|
|
setRefRight('');
|
|
setExpanded(false);
|
|
}, [dataFingerprint]);
|
|
|
|
useEffect(() => {
|
|
onZoomedChange?.(!!zoomRange);
|
|
}, [zoomRange, onZoomedChange]);
|
|
|
|
const displayData = useMemo(() => {
|
|
if (!zoomRange || data.length === 0) return data;
|
|
const li = data.findIndex((d) => d[xDataKey] === zoomRange.left);
|
|
const ri = data.findIndex((d) => d[xDataKey] === zoomRange.right);
|
|
if (li < 0 || ri < 0) return data;
|
|
const start = Math.min(li, ri);
|
|
const end = Math.max(li, ri);
|
|
if (end - start < 2) return data;
|
|
return data.slice(start, end + 1);
|
|
}, [data, zoomRange, xDataKey]);
|
|
|
|
const zoomOut = useCallback(() => {
|
|
setZoomRange(null);
|
|
setRefLeft('');
|
|
setRefRight('');
|
|
}, []);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
expand: () => setExpanded(true),
|
|
resetZoom: zoomOut,
|
|
}), [zoomOut]);
|
|
|
|
const onMouseDown = useCallback((e: { activeLabel?: string }) => {
|
|
if (!e?.activeLabel) return;
|
|
setRefLeft(String(e.activeLabel));
|
|
setRefRight(String(e.activeLabel));
|
|
}, []);
|
|
|
|
const onMouseMove = useCallback((e: { activeLabel?: string }) => {
|
|
if (!refLeft || !e?.activeLabel) return;
|
|
setRefRight(String(e.activeLabel));
|
|
}, [refLeft]);
|
|
|
|
const onMouseUp = useCallback(() => {
|
|
if (!refLeft || !refRight || refLeft === refRight) {
|
|
setRefLeft('');
|
|
setRefRight('');
|
|
return;
|
|
}
|
|
setZoomRange({ left: refLeft, right: refRight });
|
|
setRefLeft('');
|
|
setRefRight('');
|
|
}, [refLeft, refRight]);
|
|
|
|
const chartProps = {
|
|
displayData,
|
|
series,
|
|
xDataKey,
|
|
unit,
|
|
yDomain,
|
|
gradPrefix,
|
|
zoomRange,
|
|
refLeft,
|
|
refRight,
|
|
onMouseDown,
|
|
onMouseMove,
|
|
onMouseUp,
|
|
referenceLines,
|
|
};
|
|
|
|
if (data.length === 0) {
|
|
return (
|
|
<div
|
|
className="flex items-center justify-center text-xs dg-text-muted border border-dashed border-[var(--dg-border)] rounded-md w-full"
|
|
style={{ height }}
|
|
>
|
|
{t('monitor.lbl.noData')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="relative w-full">
|
|
{toolbarPlacement === 'overlay' && (
|
|
<div className="absolute top-1 right-1 z-10">
|
|
<ChartToolbar
|
|
zoomed={!!zoomRange}
|
|
onExpand={() => setExpanded(true)}
|
|
onResetZoom={zoomOut}
|
|
/>
|
|
</div>
|
|
)}
|
|
<ChartBody {...chartProps} height={height} />
|
|
{showHint && toolbarPlacement === 'header' && (
|
|
<ChartRangeHint
|
|
label={zoomRange ? `${zoomRange.left} — ${zoomRange.right}` : null}
|
|
zoomed={!!zoomRange}
|
|
onReset={zoomOut}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<Dialog open={expanded} onOpenChange={setExpanded}>
|
|
<DialogContent className={dialogClass}>
|
|
<DialogHeader className="shrink-0 flex-row items-start justify-between gap-4 space-y-0 pr-10">
|
|
<div className="min-w-0">
|
|
<DialogTitle className="text-base truncate">{title || t('monitor.lbl.metricDetail')}</DialogTitle>
|
|
{subtitle && (
|
|
<p className="text-xs dg-text-muted mt-0.5 truncate">{subtitle}</p>
|
|
)}
|
|
<DialogDescription className="sr-only"> {t('monitor.lbl.selectHost')} </DialogDescription>
|
|
</div>
|
|
<ChartToolbar
|
|
size="md"
|
|
zoomed={!!zoomRange}
|
|
onExpand={() => setExpanded(true)}
|
|
onResetZoom={zoomOut}
|
|
/>
|
|
</DialogHeader>
|
|
|
|
{series.length > 1 && (
|
|
<div className="flex flex-wrap items-center gap-4 text-xs shrink-0 px-0.5">
|
|
{series.map((s) => (
|
|
<span key={s.dataKey} className="inline-flex items-center gap-1.5 dg-text-secondary">
|
|
<span className="w-3 h-0.5 rounded-full" style={{ backgroundColor: s.color }} />
|
|
{s.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 宽屏趋势区:21:8 比例,监控弹框标准形态 // Logan.Li */}
|
|
<div className="w-full flex-1 min-h-[260px] max-h-[min(48vh,480px)] aspect-[21/8]">
|
|
<ChartBody {...chartProps} height="100%" compact />
|
|
</div>
|
|
|
|
{zoomRange && (
|
|
<div className="shrink-0 border-t border-[var(--dg-border)] pt-2">
|
|
<ChartRangeHint
|
|
label={`${zoomRange.left} — ${zoomRange.right}`}
|
|
zoomed
|
|
onReset={zoomOut}
|
|
/>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}); |