修复负载UI问题

This commit is contained in:
imengying committed 2026-08-30 10:34:50 +08:00
1 parent 6b4ee11cbf
commit aededd1362
3 files changed
+42 -91

No files matched your search

+1 -2
View File
@@ -127,7 +127,7 @@ function HomeView() {
}
function DetailView() {
const { config, goHome, liveMetrics, loading, selectedId, servers } = useApp();
const { config, goHome, loading, selectedId, servers } = useApp();
const locale = config.locale;
const selected = servers.find((server) => server.id === selectedId) ?? null;
@@ -136,7 +136,6 @@ function DetailView() {
<Suspense fallback={<div className="chart-loading">{ui(locale, "正在加载节点", "Loading server")}</div>}>
<NodeDetails
server={selected}
liveLatencyResults={liveMetrics[selected.id]?.latencyResults ?? []}
threshold={config.offline_threshold_seconds}
retentionDays={config.history_retention_days}
locale={locale}
+31 -68
View File
@@ -25,7 +25,7 @@ import { chartGapLimit, chartTimeLabel, chartTimeRange, insertTimelineGaps } fro
import { demoHistory, demoLatencyHistory, demoLatencyTasks } from "../demo";
import { averageOf } from "../latency";
import { displayGpuDevices, formatBytes, formatCpuName, formatSpeed, formatUptime, isOnline, number } from "../format";
import type { HistoryPoint, LatencySample, LatencyTestPoint, LiveLatencyResult, Server } from "../types";
import type { HistoryPoint, LatencySample, LatencyTestPoint, Server } from "../types";
import { Flag, regionDisplayName } from "./Flag";
import { OSIcon } from "./OSIcon";
import { ui } from "../locale";
@@ -186,9 +186,8 @@ const tooltipStyle = {
fontSize: 12,
};
export function NodeDetails({ server, liveLatencyResults, threshold, retentionDays, locale, demo = false, onClose }: {
export function NodeDetails({ server, threshold, retentionDays, locale, demo = false, onClose }: {
server: Server;
liveLatencyResults: LiveLatencyResult[];
threshold: number;
retentionDays: number;
locale: "zh-CN" | "en";
@@ -247,50 +246,13 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
api.latencyHistory(server.id, latencyHours)
.then((latency) => { if (active) {
setLatencyTasks(latency.tasks);
setLatencyPoints((current) => mergeLatencySamples(
current,
latency.points,
latencyHours,
latency.tasks.length,
));
setLatencyPoints(mergeLatencySamples([], latency.points, latencyHours, latency.tasks.length));
} })
.catch((reason) => { if (active) { setLatencyTasks([]); setLatencyPoints([]); setLatencyError(reason instanceof Error ? reason.message : ui(locale, "延迟数据加载失败", "Unable to load latency")); } })
.finally(() => { if (active) setLatencyLoading(false); });
return () => { active = false; };
}, [server.id, latencyHours, demo, locale]);
useEffect(() => {
if (demo || !server.latency.length) return;
setLatencyPoints((current) => mergeLatencySamples(
current,
server.latency,
latencyHours,
Math.max(latencyTasks.length, server.latency.length),
));
}, [demo, latencyHours, latencyTasks.length, server.latency]);
useEffect(() => {
if (demo || !liveLatencyResults.length || !latencyTasks.length) return;
const definitions = new Map(latencyTasks.map((task) => [task.id, task]));
const samples = liveLatencyResults.flatMap((result): LatencySample[] => {
const task = definitions.get(result.task_id);
if (!task || !Number.isFinite(result.timestamp) || result.timestamp <= 0) return [];
return [{
task_id: task.id,
server_id: server.id,
name: task.name,
task_type: task.task_type,
target: task.target,
port: task.port,
timestamp: result.timestamp,
latency_ms: result.latency_ms,
packet_loss: result.packet_loss,
}];
});
if (!samples.length) return;
setLatencyPoints((current) => mergeLatencySamples(current, samples, latencyHours, latencyTasks.length));
}, [demo, latencyHours, latencyTasks, liveLatencyResults, server.id]);
useEffect(() => {
setHiddenLatencyTaskIds((current) => {
const available = new Set(latencyTasks.map((task) => task.id));
@@ -317,8 +279,8 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
() => insertLoadGaps(points, loadChartHours),
[loadChartHours, points],
);
// 有时间范围时钉住整段窗口,部分数据也保持真实比例;实时档跟随数据自适应
// (与延迟图、Komari 原版一致)。points 进 deps 是为了在数据落位后重算
// 有时间范围时钉住整段窗口,部分数据也保持真实比例;实时档用 dataMin/dataMax
// 贴合数据(与延迟图、Komari 原版一致)。points 进 deps 是为了在数据落位后重算
// domain,慢查询时末端不会被裁掉。
const loadTimeRange = useMemo(
() => (loadHours === 0 ? null : chartTimeRange(loadChartHours)),
@@ -350,14 +312,15 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
[hiddenLatencyTaskIds, latencySeries],
);
const latencyData = useMemo(() => {
// 共享时间轴取全部任务(含已隐藏),全部隐藏时图表仍渲染空轴 + 覆盖提示。
const timestamps = new Set<number>();
for (const series of visibleLatencySeries) {
for (const series of latencySeries) {
for (const point of series.points) timestamps.add(point.timestamp);
}
return Array.from(timestamps)
.sort((left, right) => left - right)
.map((timestamp) => ({ timestamp, label: chartTimeLabel(timestamp, hours, locale) }));
}, [hours, locale, visibleLatencySeries]);
}, [hours, locale, latencySeries]);
const latencyTimeRange = useMemo(() => chartTimeRange(latencyHours), [latencyHours, latencyPoints]);
const last = data[data.length - 1];
const pingEnabled = latencyTasks.length > 0;
@@ -416,53 +379,53 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
{chartType === "load" && loadLoading ? <div className="chart-loading">{ui(locale, "正在读取历史数据", "Loading history")}</div> : chartType === "latency" && latencyLoading ? <div className="chart-loading">{ui(locale, "正在读取延迟数据", "Loading latency")}</div> : chartType === "load" && !data.length ? <div className="chart-loading">{ui(locale, "暂无历史数据", "No history")}</div> : chartType === "load" ? (
<div className="detail-charts-grid">
<ChartCard title="CPU" value={`${number(last.cpu).toFixed(1)}%`}>
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : undefined} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, 100]} tick={{ fontSize: 10 }} width={36} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [`${Number(value).toFixed(1)}%`, "CPU"]} /><Area type="monotone" dataKey="cpu" stroke="var(--danger-bar)" fill="var(--danger-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : ["dataMin", "dataMax"]} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, 100]} tick={{ fontSize: 10 }} width={36} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [`${Number(value).toFixed(1)}%`, "CPU"]} /><Area type="monotone" dataKey="cpu" stroke="var(--danger-bar)" fill="var(--danger-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
</ChartCard>
<ChartCard title={ui(locale, "内存", "Memory")} value={`${formatBytes(last.mem_used)} / ${formatBytes(last.mem_total)}`}>
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : undefined} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, last.mem_total]} ticks={resourceTicks(last.mem_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "内存"]} /><Area type="monotone" dataKey="mem_used" stroke="var(--primary)" fill="var(--primary)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : ["dataMin", "dataMax"]} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, last.mem_total]} ticks={resourceTicks(last.mem_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "内存"]} /><Area type="monotone" dataKey="mem_used" stroke="var(--primary)" fill="var(--primary)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
</ChartCard>
<ChartCard title={ui(locale, "硬盘", "Disk")} value={`${formatBytes(last.disk_used)} / ${formatBytes(last.disk_total)}`}>
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : undefined} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, last.disk_total]} ticks={resourceTicks(last.disk_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "硬盘"]} /><Area type="monotone" dataKey="disk_used" stroke="var(--warning-bar)" fill="var(--warning-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : ["dataMin", "dataMax"]} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, last.disk_total]} ticks={resourceTicks(last.disk_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "硬盘"]} /><Area type="monotone" dataKey="disk_used" stroke="var(--warning-bar)" fill="var(--warning-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
</ChartCard>
<ChartCard title={ui(locale, "网络", "Network")} value={`↑ ${formatSpeed(last.net_out)} ↓ ${formatSpeed(last.net_in)}`}>
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : undefined} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis tick={{ fontSize: 10 }} width={48} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} /><Area type="monotone" dataKey="net_out" stroke="var(--success-bar)" fill="var(--success-bar)" fillOpacity={0.12} strokeWidth={2} dot={false} isAnimationActive={false} /><Area type="monotone" dataKey="net_in" stroke="var(--info)" fill="var(--info)" fillOpacity={0.1} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : ["dataMin", "dataMax"]} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis tick={{ fontSize: 10 }} width={48} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} /><Area type="monotone" dataKey="net_out" stroke="var(--success-bar)" fill="var(--success-bar)" fillOpacity={0.12} strokeWidth={2} dot={false} isAnimationActive={false} /><Area type="monotone" dataKey="net_in" stroke="var(--info)" fill="var(--info)" fillOpacity={0.1} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
</ChartCard>
</div>
) : (
<section className="latency-overview glass-panel">
<div className="latency-summary-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, latencySeries.length)}, minmax(180px, 1fr))` }}>
{latencySeries.map((series) => <div className={`latency-summary ${hiddenLatencyTaskIds.has(series.id) ? "hidden" : ""}`} key={series.id}>
<span><i style={{ backgroundColor: series.color }} />{series.name}</span>
<strong>{series.latency == null ? "--" : `${series.latency.toFixed(2)} ms`}</strong>
<small>{series.loss == null ? "--" : `${series.loss.toFixed(2)}%`} 平均丢包</small>
</div>)}
</div>
<div className="latency-task-legend" role="group" aria-label={ui(locale, "延迟任务", "Latency tasks")}>
{latencySeries.length ? <div className="latency-task-legend" role="group" aria-label={ui(locale, "延迟任务", "Latency tasks")}>
{latencySeries.map((series) => {
const hidden = hiddenLatencyTaskIds.has(series.id);
const values = `${ui(locale, "延迟", "Latency")} ${series.latency == null ? "--" : `${series.latency.toFixed(1)} ms`} · ${ui(locale, "丢包", "Loss")} ${series.loss == null ? "--" : `${series.loss.toFixed(1)}%`}`;
return <button
className={`latency-task-toggle ${hidden ? "hidden" : ""}`}
key={series.id}
type="button"
aria-pressed={!hidden}
title={hidden ? ui(locale, "显示此任务", "Show this task") : ui(locale, "隐藏此任务", "Hide this task")}
aria-label={`${hidden ? ui(locale, "显示", "Show") : ui(locale, "隐藏", "Hide")} ${series.name}`}
title={`${hidden ? ui(locale, "点击显示", "Click to show") : ui(locale, "点击隐藏", "Click to hide")} ${series.name}\n${values}`}
onClick={() => toggleLatencyTask(series.id)}
>
<i style={{ backgroundColor: series.color }} />
<span>{series.name}</span>
<em>{series.latency == null ? "--" : `${series.latency.toFixed(1)} ms`}</em>
<em>{ui(locale, "丢包", "Loss")} {series.loss == null ? "--" : `${series.loss.toFixed(1)}%`}</em>
</button>;
})}
</div>
</div> : null}
<div className="latency-line-chart">
{latencyData.length && visibleLatencySeries.length ? <ResponsiveContainer width="100%" height="100%">
<LineChart data={latencyData} margin={{ top: 10, right: 12, left: 4, bottom: 2 }}>
<CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" />
<XAxis dataKey="timestamp" type="number" domain={latencyTimeRange.domain} ticks={latencyTimeRange.ticks} allowDataOverflow tick={{ fontSize: 10 }} minTickGap={40} tickFormatter={(value) => chartTimeLabel(Number(value), latencyHours, locale)} />
<YAxis tick={{ fontSize: 10 }} width={48} unit="ms" />
<Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), latencyHours, locale)} formatter={(value, name) => [value == null ? "--" : `${Number(value).toFixed(1)} ms`, name]} />
{visibleLatencySeries.map((series) => <Line key={series.id} data={series.points} type="monotone" dataKey="value" name={series.name} stroke={series.color} strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} />)}
</LineChart>
</ResponsiveContainer> : <div className="latency-empty">{visibleLatencySeries.length ? ui(locale, "暂无延迟数据", "No latency data") : ui(locale, "已隐藏全部任务", "All tasks are hidden")}</div>}
{latencyData.length ? <>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={latencyData} margin={{ top: 10, right: 12, left: 4, bottom: 2 }}>
<CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" />
<XAxis dataKey="timestamp" type="number" domain={latencyTimeRange.domain} ticks={latencyTimeRange.ticks} allowDataOverflow tick={{ fontSize: 10 }} minTickGap={40} tickFormatter={(value) => chartTimeLabel(Number(value), latencyHours, locale)} />
<YAxis tick={{ fontSize: 10 }} width={48} unit="ms" />
<Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), latencyHours, locale)} formatter={(value, name) => [value == null ? "--" : `${Number(value).toFixed(1)} ms`, name]} />
{visibleLatencySeries.map((series) => <Line key={series.id} data={series.points} type="monotone" dataKey="value" name={series.name} stroke={series.color} strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} />)}
</LineChart>
</ResponsiveContainer>
{visibleLatencySeries.length ? null : <div className="latency-empty-overlay">{ui(locale, "已隐藏全部延迟任务", "All latency tasks are hidden")}</div>}
</> : <div className="latency-empty">{ui(locale, "暂无延迟数据", "No latency data")}</div>}
</div>
</section>
)}
+10 -21
View File
@@ -186,23 +186,18 @@ html[data-blur="on"] .node-offline-overlay { backdrop-filter: blur(2px); }
.detail-chart header span { overflow: hidden; color: var(--muted-foreground); font-size: 12px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
.detail-chart > div { width: 100%; height: 208px; }
.latency-overview { min-width: 0; overflow: hidden; border-radius: 8px; }
.latency-summary-grid { display: grid; min-width: 100%; overflow-x: auto; border-bottom: 1px solid var(--border); }
.latency-summary { min-width: 180px; border-right: 1px solid var(--border); padding: 17px 20px; }
.latency-summary:last-child { border-right: 0; }
.latency-summary > span { display: flex; min-width: 0; align-items: center; gap: 7px; overflow: hidden; color: var(--muted-foreground); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.latency-summary > span i { width: 9px; height: 9px; flex: 0 0 auto; border-radius: 2px; }
.latency-summary strong { display: block; margin-top: 6px; font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; letter-spacing: 0; }
.latency-summary small { display: block; margin-top: 5px; color: var(--muted-foreground); font-size: 12px; font-variant-numeric: tabular-nums; }
.latency-summary.hidden { opacity: .42; }
.latency-task-legend { display: flex; min-width: 0; align-items: center; gap: 6px; overflow-x: auto; border-bottom: 1px solid var(--border); padding: 10px 16px; }
.latency-task-toggle { display: inline-flex; min-width: 0; min-height: 28px; flex: 0 0 auto; align-items: center; gap: 6px; border: 1px solid transparent; border-radius: 6px; padding: 4px 8px; color: var(--muted-foreground); font-size: 11px; white-space: nowrap; }
.latency-task-toggle:hover { color: var(--foreground); background: color-mix(in srgb, var(--foreground) 6%, transparent); }
.latency-task-legend { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px 20px; border-bottom: 1px solid var(--border); padding: 10px 16px; }
.latency-task-toggle { display: inline-flex; min-width: 0; max-width: 100%; min-height: 28px; flex: 0 0 auto; align-items: center; gap: 7px; border: 0; border-radius: 6px; padding: 4px 6px; color: var(--foreground); font-size: 12px; white-space: nowrap; transition: opacity 150ms ease; }
.latency-task-toggle:hover { background: color-mix(in srgb, var(--foreground) 6%, transparent); }
.latency-task-toggle:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
.latency-task-toggle i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 2px; }
.latency-task-toggle.hidden { border-color: var(--border); color: var(--muted-foreground); opacity: .58; }
.latency-task-toggle.hidden i { opacity: .38; }
.latency-line-chart { width: 100%; height: 320px; padding: 16px 18px 10px 10px; }
.latency-task-toggle i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; }
.latency-task-toggle span { min-width: 0; overflow: hidden; max-width: 160px; font-weight: 500; text-overflow: ellipsis; }
.latency-task-toggle em { flex: 0 0 auto; color: var(--muted-foreground); font-style: normal; font-variant-numeric: tabular-nums; }
.latency-task-toggle.hidden { opacity: .35; }
.latency-task-toggle.hidden:hover { opacity: .65; }
.latency-line-chart { position: relative; width: 100%; height: 288px; padding: 16px 18px 10px 10px; }
.latency-empty { display: grid; width: 100%; height: 100%; place-items: center; color: var(--muted-foreground); font-size: 13px; }
.latency-empty-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted-foreground); font-size: 12px; pointer-events: none; }
@media (min-width: 640px) {
.brand strong { font-size: 18px; }
@@ -245,11 +240,5 @@ html[data-blur="on"] .node-offline-overlay { backdrop-filter: blur(2px); }
.range-control { width: 100%; }
.range-control button { flex: 0 0 auto; }
.detail-chart > div { height: 192px; }
.latency-summary { padding: 14px 16px; }
.latency-summary strong { font-size: 19px; }
.latency-summary-grid { overflow: visible; grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
.latency-summary { min-width: 0; }
.latency-summary:nth-child(2n) { border-right: 0; }
.latency-summary:nth-child(n+3) { border-top: 1px solid var(--border); }
.latency-line-chart { height: 270px; padding: 12px 10px 8px 4px; }
}