Files
aiops/frontend/src/pages/monitor/host-monitor/ZoomableAreaChart.tsx
T
attacker 4e2ba77aa8 refactor(i18n): U40 Monitor + Agent 子组件残余硬编码中文 i18n 补全 — 14 文件 ~50 处→0
- 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 测试通过
2026-07-13 19:45:45 +08:00

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>
</>
);
});