Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,12 @@ specs
.omc
.playwright-mcp
.jqwik-database
.DS_Store
.DS_Store

# Eclipse / m2e IDE files
.classpath
.project
.settings/
backend/.classpath
backend/.project
backend/.settings/
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
package org.apache.tsfile.viewer.config;

import java.io.IOException;
import java.time.Duration;

import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
Expand All @@ -44,8 +46,18 @@ public class WebConfig implements WebMvcConfigurer {
* Configure resource handlers for serving static frontend assets.
*
* <p>Maps /view/** requests to classpath:/static/view/ directory. Implements SPA routing by
* falling back to index.html for all non-existent resources (client-side routes). Enables caching
* with 1-hour cache period.
* falling back to index.html for all non-existent resources (client-side routes).
*
* <p>Caching strategy follows the standard hashed-SPA pattern:
*
* <ul>
* <li>{@code /view/assets/**} — build output with content-hashed filenames (e.g. {@code
* index-BXJsXIsM.js}). Safe to cache forever ({@code max-age=1y, immutable}); a content
* change produces a new filename, so it can never go stale.
* <li>{@code index.html} and SPA routes — the entry point that references those hashes. Served
* {@code no-cache} (revalidate every load) so a new deployment is picked up immediately
* instead of being masked by a cached entry point for up to the cache period.
* </ul>
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
Expand All @@ -55,11 +67,17 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) {
.addResourceLocations("classpath:/static/view/")
.setCachePeriod(86400); // 24 hours cache

// Handle frontend SPA routes
// Content-hashed build assets: immutable, cache for a year.
registry
.addResourceHandler("/view/assets/**")
.addResourceLocations("classpath:/static/view/assets/")
.setCacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic().immutable());

// Frontend SPA entry + client-side routes: always revalidate so deploys take effect at once.
registry
.addResourceHandler("/view", "/view/", "/view/**")
.addResourceLocations("classpath:/static/view/")
.setCachePeriod(3600) // 1 hour cache
.setCacheControl(CacheControl.noCache())
.resourceChain(true)
.addResolver(
new PathResourceResolver() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, List<DataRow>> deviceEntry : deviceGroups.entrySet()) {
String device = deviceEntry.getKey();
List<DataRow> 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<double[]> dataPoints = extractMeasurementData(deviceData, measurement);
if (dataPoints.isEmpty()) continue;

Expand All @@ -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);

Expand Down
22 changes: 20 additions & 2 deletions frontend/src/components/tsfile/ChartPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ const props = withDefaults(defineProps<Props>(), {

const { t } = useI18n();

// 单图最多渲染的序列数,超过则拒绝绘制并提示(防止数千条折线卡死浏览器)
const MAX_CHART_SERIES = 50;
const tooManySeries = computed(() => props.series.length > MAX_CHART_SERIES);

const chartRef = ref<HTMLDivElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
const chartHeight = ref(500);
Expand Down Expand Up @@ -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);
}

Expand All @@ -147,6 +152,11 @@ function updateChart() {
initChart();
return;
}
// 序列过多时不渲染,仅清空,交由模板展示告警
if (tooManySeries.value) {
chartInstance.clear();
return;
}
chartInstance.setOption(chartOption.value, true);
nextTick(() => chartInstance?.resize());
}
Expand Down Expand Up @@ -215,15 +225,23 @@ onUnmounted(() => {
show-icon
class="mb-2 flex-shrink-0"
/>
<Alert
v-else-if="tooManySeries"
type="warning"
:message="t('tsfile.chart.tooManySeries', { count: series.length, max: MAX_CHART_SERIES })"
:description="t('tsfile.chart.tooManySeriesDescription', { max: MAX_CHART_SERIES })"
show-icon
class="mb-2 flex-shrink-0"
/>

<Spin :spinning="loading">
<div
v-show="!error && series.length > 0"
v-show="!error && !tooManySeries && series.length > 0"
ref="chartRef"
:style="{ width: '100%', height: chartHeight + 'px' }"
></div>
<div
v-if="!loading && !error && series.length === 0"
v-if="!loading && !error && !tooManySeries && series.length === 0"
class="flex items-center justify-center text-gray-500"
:style="{ height: chartHeight + 'px' }"
>
Expand Down
111 changes: 103 additions & 8 deletions frontend/src/components/tsfile/DataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -59,11 +59,21 @@ const internalLimit = ref(props.limit);
const sortColumn = ref<string | null>(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;
Expand All @@ -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<TableColumnType[]>(() => {
const cols: TableColumnType[] = [
Expand Down Expand Up @@ -124,8 +168,8 @@ const columns = computed<TableColumnType[]>(() => {
});
}

// 字段列(可滚动)
for (const fieldCol of fieldColumnNames.value) {
// 字段列(可滚动)—— 仅渲染当前列分页窗口内的列
for (const fieldCol of visibleFieldColumns.value) {
cols.push({
title: fieldCol,
dataIndex: fieldCol,
Expand All @@ -138,15 +182,22 @@ const columns = computed<TableColumnType[]>(() => {
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<string, unknown> = {
_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;
Expand Down Expand Up @@ -192,6 +243,11 @@ function handleLimitChange(limit: number) {
emit("limitChange", limit);
}

// 处理字段列分页变化
function handleColumnPageChange(page: number) {
columnPage.value = page;
}

// 处理排序
function handleTableChange(
_pagination: unknown,
Expand Down Expand Up @@ -254,14 +310,53 @@ function formatValue(value: unknown): number | string {
class="mb-4"
/>

<!-- 字段列控制条:搜索 + 列分页(避免一次性渲染数千列) -->
<div
v-if="!error && fieldColumnNames.length > columnPageSize"
class="mb-3 flex flex-wrap items-center gap-3"
>
<Input
:value="columnSearch"
:placeholder="t('tsfile.data.searchColumn')"
allow-clear
size="small"
style="width: 220px"
@update:value="(v: string) => (columnSearch = v ?? '')"
/>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">{{ t("tsfile.data.columnsPerPage") }}:</span>
<Select
:value="columnPageSize"
:options="columnPageSizeOptions"
size="small"
style="width: 80px"
@change="(v: number) => (columnPageSize = v)"
/>
</div>
<span class="text-sm text-gray-500">
{{ t("tsfile.data.columns") }} {{ fieldColumnRange.start }}–{{ fieldColumnRange.end }} /
{{ fieldColumnRange.total }}
</span>
<Pagination
:current="columnPage"
:page-size="columnPageSize"
:total="filteredFieldColumns.length"
:show-size-changer="false"
size="small"
simple
@change="handleColumnPageChange"
/>
</div>

<!-- 数据表格 -->
<Table
v-else
v-if="!error"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="false"
:scroll="{ x: 'max-content', y: 'calc(100vh - 500px)' }"
:scroll="{ x: scrollX, y: 480 }"
:virtual="true"
bordered
size="middle"
row-key="_key"
Expand Down
Loading