From 28e33557ae9569043a1b60033d23261227ee2f64 Mon Sep 17 00:00:00 2001 From: CritasWang Date: Wed, 17 Jun 2026 12:29:26 +0800 Subject: [PATCH 1/2] fix: paginate wide TsFile columns and cap chart series to prevent UI crash Wide table-model TsFiles (thousands of measurements) crashed the browser in both the Data Preview and Chart views by rendering every column/series at once. Data Preview (DataTable.vue): - Paginate field columns (default 50/page) with a column search box; render only the current column window and flatten only visible columns per row. - Enable virtual row scrolling. Chart (ChartPanel.vue, TableFilterPanel.vue, TreeFilterPanel.vue): - Default-select only the first 8 numeric fields instead of all fields on load. - Refuse to render and show a warning when a query returns more than 50 series. Backend (DataService.java): - Hard-cap chart series at 200 per request as a performance safety net. i18n: add column-pagination and chart-series-limit strings (zh-CN, en-US). --- .gitignore | 10 +- .../tsfile/viewer/service/DataService.java | 25 ++++ frontend/src/components/tsfile/ChartPanel.vue | 22 +++- frontend/src/components/tsfile/DataTable.vue | 111 ++++++++++++++++-- .../components/tsfile/TableFilterPanel.vue | 27 +++-- .../src/components/tsfile/TreeFilterPanel.vue | 23 ++-- frontend/src/i18n/locales/en-US.json | 9 +- frontend/src/i18n/locales/zh-CN.json | 9 +- 8 files changed, 203 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 394984f..679187f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,12 @@ specs .omc .playwright-mcp .jqwik-database -.DS_Store \ No newline at end of file +.DS_Store + +# Eclipse / m2e IDE files +.classpath +.project +.settings/ +backend/.classpath +backend/.project +backend/.settings/ \ No newline at end of file diff --git a/backend/src/main/java/org/apache/tsfile/viewer/service/DataService.java b/backend/src/main/java/org/apache/tsfile/viewer/service/DataService.java index b73f7a7..50362c9 100644 --- a/backend/src/main/java/org/apache/tsfile/viewer/service/DataService.java +++ b/backend/src/main/java/org/apache/tsfile/viewer/service/DataService.java @@ -65,6 +65,14 @@ public class DataService { private static final Logger logger = LoggerFactory.getLogger(DataService.class); private static final int DEFAULT_MAX_POINTS = 1000; + /** + * Hard upper bound on the number of chart series built per request. Acts as a performance safety + * net so a request that selects thousands of measurements (e.g. a wide table model file) cannot + * make the backend build and serialize an unbounded number of series. The frontend additionally + * refuses to render beyond its own (lower) display threshold. + */ + private static final int MAX_CHART_SERIES = 200; + private final TsFileProperties tsFileProperties; private final FileService fileService; private final TsFileDataReader dataReader; @@ -229,11 +237,19 @@ public ChartDataResponse queryChartData(ChartDataRequest request) int maxPoints = request.getMaxPoints() != null ? request.getMaxPoints() : DEFAULT_MAX_POINTS; + boolean seriesCapped = false; + outer: for (Map.Entry> deviceEntry : deviceGroups.entrySet()) { String device = deviceEntry.getKey(); List deviceData = deviceEntry.getValue(); for (String measurement : request.getMeasurements()) { + // Performance safety net: never build more than MAX_CHART_SERIES series. + if (series.size() >= MAX_CHART_SERIES) { + seriesCapped = true; + break outer; + } + List dataPoints = extractMeasurementData(deviceData, measurement); if (dataPoints.isEmpty()) continue; @@ -260,6 +276,15 @@ public ChartDataResponse queryChartData(ChartDataRequest request) } } + if (seriesCapped) { + logger.warn( + "Chart series capped at {} for fileId={} (requested {} measurements across {} devices)", + MAX_CHART_SERIES, + request.getFileId(), + request.getMeasurements().size(), + deviceGroups.size()); + } + // Calculate time range TimeRange timeRange = calculateTimeRange(allData); diff --git a/frontend/src/components/tsfile/ChartPanel.vue b/frontend/src/components/tsfile/ChartPanel.vue index 905e4fc..c4756b4 100644 --- a/frontend/src/components/tsfile/ChartPanel.vue +++ b/frontend/src/components/tsfile/ChartPanel.vue @@ -66,6 +66,10 @@ const props = withDefaults(defineProps(), { const { t } = useI18n(); +// 单图最多渲染的序列数,超过则拒绝绘制并提示(防止数千条折线卡死浏览器) +const MAX_CHART_SERIES = 50; +const tooManySeries = computed(() => props.series.length > MAX_CHART_SERIES); + const chartRef = ref(null); let chartInstance: echarts.ECharts | null = null; const chartHeight = ref(500); @@ -138,6 +142,7 @@ const chartOption = computed(() => { function initChart() { if (!chartRef.value) return; chartInstance = echarts.init(chartRef.value); + if (tooManySeries.value) return; chartInstance.setOption(chartOption.value); } @@ -147,6 +152,11 @@ function updateChart() { initChart(); return; } + // 序列过多时不渲染,仅清空,交由模板展示告警 + if (tooManySeries.value) { + chartInstance.clear(); + return; + } chartInstance.setOption(chartOption.value, true); nextTick(() => chartInstance?.resize()); } @@ -215,15 +225,23 @@ onUnmounted(() => { show-icon class="mb-2 flex-shrink-0" /> +
diff --git a/frontend/src/components/tsfile/DataTable.vue b/frontend/src/components/tsfile/DataTable.vue index 85ebbd4..33f650d 100644 --- a/frontend/src/components/tsfile/DataTable.vue +++ b/frontend/src/components/tsfile/DataTable.vue @@ -28,7 +28,7 @@ import type { TableColumnType } from "antdv-next"; import { computed, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; -import { Alert, Button, Card, Pagination, Select, Spin, Table } from "antdv-next"; +import { Alert, Button, Card, Input, Pagination, Select, Spin, Table } from "antdv-next"; import { DownloadOutlined } from "@antdv-next/icons"; interface Props { @@ -59,11 +59,21 @@ const internalLimit = ref(props.limit); const sortColumn = ref(null); const sortDirection = ref<"asc" | "desc">("asc"); +// 字段列分页(避免一次性渲染数千列导致页面卡死) +const columnPage = ref(1); +const columnPageSize = ref(50); +const columnSearch = ref(""); + const limitOptions = [10, 20, 50, 100, 200, 500, 1000].map((v) => ({ label: String(v), value: v, })); +const columnPageSizeOptions = [20, 50, 100, 200].map((v) => ({ + label: String(v), + value: v, +})); + // 当前页码 const currentPage = computed(() => { return Math.floor(props.offset / internalLimit.value) + 1; @@ -85,12 +95,46 @@ const measurementColumns = computed(() => { return [...columns].toSorted(); }); -// 字段列(非标签列) +// 字段列(非标签列)—— 全量 const fieldColumnNames = computed(() => { const tagSet = new Set(props.tagColumns); return measurementColumns.value.filter((col) => !tagSet.has(col)); }); +// 按搜索关键字过滤字段列 +const filteredFieldColumns = computed(() => { + const q = columnSearch.value.trim().toLowerCase(); + if (!q) return fieldColumnNames.value; + return fieldColumnNames.value.filter((col) => col.toLowerCase().includes(q)); +}); + +// 字段列总页数 +const fieldColumnTotalPages = computed(() => + Math.max(1, Math.ceil(filteredFieldColumns.value.length / columnPageSize.value)), +); + +// 当前页可见的字段列 +const visibleFieldColumns = computed(() => { + const start = (columnPage.value - 1) * columnPageSize.value; + return filteredFieldColumns.value.slice(start, start + columnPageSize.value); +}); + +// 当前可见字段列区间(用于展示 "X–Y / 总数") +const fieldColumnRange = computed(() => { + const totalCols = filteredFieldColumns.value.length; + if (totalCols === 0) return { start: 0, end: 0, total: 0 }; + const start = (columnPage.value - 1) * columnPageSize.value; + return { start: start + 1, end: Math.min(start + columnPageSize.value, totalCols), total: totalCols }; +}); + +// 当列搜索 / 每页列数 / 数据变化时,重置到第一页并防止页码越界 +watch([columnSearch, columnPageSize, fieldColumnNames], () => { + columnPage.value = 1; +}); +watch(fieldColumnTotalPages, (pages) => { + if (columnPage.value > pages) columnPage.value = pages; +}); + // 动态列定义 const columns = computed(() => { const cols: TableColumnType[] = [ @@ -124,8 +168,8 @@ const columns = computed(() => { }); } - // 字段列(可滚动) - for (const fieldCol of fieldColumnNames.value) { + // 字段列(可滚动)—— 仅渲染当前列分页窗口内的列 + for (const fieldCol of visibleFieldColumns.value) { cols.push({ title: fieldCol, dataIndex: fieldCol, @@ -138,15 +182,22 @@ const columns = computed(() => { return cols; }); -// 表格数据(扁平化) +// 表格横向滚动宽度(用于虚拟滚动,需为数字) +const scrollX = computed(() => { + const fixedWidth = 210 + 180 + props.tagColumns.length * 120; + return fixedWidth + visibleFieldColumns.value.length * 120; +}); + +// 表格数据(扁平化)—— 仅展开当前可见列,避免每行生成数千个属性 const tableData = computed(() => { + const visibleMeasurements = [...props.tagColumns, ...visibleFieldColumns.value]; let data = props.data.map((row, index) => { const flatRow: Record = { _key: `${row.timestamp}-${row.device}-${index}`, timestamp: row.timestamp, device: row.device, }; - for (const measurement of measurementColumns.value) { + for (const measurement of visibleMeasurements) { flatRow[measurement] = formatValue(row.measurements[measurement]); } return flatRow; @@ -192,6 +243,11 @@ function handleLimitChange(limit: number) { emit("limitChange", limit); } +// 处理字段列分页变化 +function handleColumnPageChange(page: number) { + columnPage.value = page; +} + // 处理排序 function handleTableChange( _pagination: unknown, @@ -254,14 +310,53 @@ function formatValue(value: unknown): number | string { class="mb-4" /> + +
+ +
+ {{ t("tsfile.data.columnsPerPage") }}: +