From 8773357ec0f92ead3ce462f82418eb0736c20003 Mon Sep 17 00:00:00 2001
From: JavaZero <71128095+JavaZeroo@users.noreply.github.com>
Date: Fri, 29 Aug 2025 17:57:18 +0800
Subject: [PATCH 1/2] fix: respect per-file metric config
---
src/components/ChartContainer.jsx | 26 ++++++++++----
.../__tests__/ChartContainer.test.jsx | 34 +++++++++++++++++++
2 files changed, 54 insertions(+), 6 deletions(-)
diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx
index 8a6c5e3..c24e04d 100644
--- a/src/components/ChartContainer.jsx
+++ b/src/components/ChartContainer.jsx
@@ -260,12 +260,13 @@ export default function ChartContainer({
return results;
};
- metrics.forEach(metric => {
+ metrics.forEach((metric, idx) => {
+ const fileMetric = file.config?.metrics?.[idx] || metric;
let points = [];
- if (metric.mode === 'keyword') {
- points = extractByKeyword(lines, metric.keyword);
- } else if (metric.regex) {
- const reg = new RegExp(metric.regex);
+ if (fileMetric.mode === 'keyword') {
+ points = extractByKeyword(lines, fileMetric.keyword);
+ } else if (fileMetric.regex) {
+ const reg = new RegExp(fileMetric.regex);
lines.forEach(line => {
reg.lastIndex = 0;
const m = reg.exec(line);
@@ -278,7 +279,20 @@ export default function ChartContainer({
}
});
}
- metricsData[metric.name || metric.keyword] = points;
+
+ let key = '';
+ if (metric.name && metric.name.trim()) {
+ key = metric.name.trim();
+ } else if (metric.keyword) {
+ key = metric.keyword.replace(/[::]/g, '').trim();
+ } else if (metric.regex) {
+ const sanitized = metric.regex.replace(/[^a-zA-Z0-9_]/g, '').trim();
+ key = sanitized || `metric${idx + 1}`;
+ } else {
+ key = `metric${idx + 1}`;
+ }
+
+ metricsData[key] = points;
});
const range = file.config?.dataRange;
diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx
index 378703a..04b1785 100644
--- a/src/components/__tests__/ChartContainer.test.jsx
+++ b/src/components/__tests__/ChartContainer.test.jsx
@@ -175,4 +175,38 @@ describe('ChartContainer', () => {
opts.plugins.zoom.pan.onPanComplete({ chart: { scales: { x: { min: 0, max: 10 } } } });
opts.plugins.zoom.zoom.onZoomComplete({ chart: { scales: { x: { min: 2, max: 4 } } } });
});
+
+ it('uses per-file metric configuration when provided', () => {
+ const onXRangeChange = vi.fn();
+ const onMaxStepChange = vi.fn();
+ const files = [
+ { name: 'a.log', enabled: true, content: 'loss: 1\nloss: 2' },
+ {
+ name: 'b.log',
+ enabled: true,
+ content: 'train_loss: 3\ntrain_loss: 4',
+ config: { metrics: [{ mode: 'keyword', keyword: 'train_loss:' }] }
+ }
+ ];
+ const metrics = [{ name: 'loss', mode: 'keyword', keyword: 'loss:' }];
+
+ render(
+
+ );
+
+ const mainChart = [...__lineProps].reverse().find(p =>
+ p.data.datasets && p.data.datasets.some(d => d.label === 'b')
+ );
+ const ds = mainChart.data.datasets.find(d => d.label === 'b');
+ expect(ds.data).toEqual([
+ { x: 0, y: 3 },
+ { x: 1, y: 4 }
+ ]);
+ });
});
From e3ebb13a764cc7d34336949a5b6b5144b7bd4a01 Mon Sep 17 00:00:00 2001
From: JavaZero <71128095+JavaZeroo@users.noreply.github.com>
Date: Fri, 29 Aug 2025 19:55:36 +0800
Subject: [PATCH 2/2] test: ensure custom metrics survive global updates
---
src/App.jsx | 30 +++---
.../__tests__/GlobalConfigOverride.test.jsx | 94 +++++++++++++++++++
2 files changed, 113 insertions(+), 11 deletions(-)
create mode 100644 src/components/__tests__/GlobalConfigOverride.test.jsx
diff --git a/src/App.jsx b/src/App.jsx
index 4a942e6..b12b4ee 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -171,17 +171,25 @@ function App() {
const handleGlobalParsingConfigChange = useCallback((newConfig) => {
setGlobalParsingConfig(newConfig);
- // Sync parsing config to all files
- setUploadedFiles(prev => prev.map(file => ({
- ...file,
- config: {
- ...file.config,
- metrics: newConfig.metrics.map(m => ({ ...m })),
- useStepKeyword: newConfig.useStepKeyword,
- stepKeyword: newConfig.stepKeyword
- }
- })));
- }, []);
+ // Sync parsing config to files that still use the global metrics
+ setUploadedFiles(prev => prev.map(file => {
+ const fileConfig = file.config || {};
+ const usesGlobalMetrics = !fileConfig.metrics ||
+ JSON.stringify(fileConfig.metrics) === JSON.stringify(globalParsingConfig.metrics);
+
+ return {
+ ...file,
+ config: {
+ ...fileConfig,
+ ...(usesGlobalMetrics && {
+ metrics: newConfig.metrics.map(m => ({ ...m }))
+ }),
+ useStepKeyword: newConfig.useStepKeyword,
+ stepKeyword: newConfig.stepKeyword
+ }
+ };
+ }));
+ }, [globalParsingConfig]);
// Reset configuration
const handleResetConfig = useCallback(() => {
diff --git a/src/components/__tests__/GlobalConfigOverride.test.jsx b/src/components/__tests__/GlobalConfigOverride.test.jsx
new file mode 100644
index 0000000..1dafdb8
--- /dev/null
+++ b/src/components/__tests__/GlobalConfigOverride.test.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { render, screen, within, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import '@testing-library/jest-dom/vitest';
+import App from '../../App.jsx';
+import i18n from '../../i18n';
+
+// Mock chart.js and react-chartjs-2 to avoid canvas requirements
+vi.mock('chart.js', () => {
+ const Chart = {
+ register: vi.fn(),
+ defaults: { plugins: { legend: { labels: { generateLabels: vi.fn(() => []) } } } }
+ };
+ return {
+ Chart,
+ ChartJS: Chart,
+ CategoryScale: {},
+ LinearScale: {},
+ PointElement: {},
+ LineElement: {},
+ Title: {},
+ Tooltip: {},
+ Legend: {},
+ };
+});
+
+vi.mock('react-chartjs-2', async () => {
+ const React = await import('react');
+ return {
+ Line: React.forwardRef(() =>
)
+ };
+});
+
+vi.mock('chartjs-plugin-zoom', () => ({ default: {} }));
+
+function stubFileReader(result) {
+ class FileReaderMock {
+ constructor() {
+ this.onload = null;
+ }
+ readAsText() {
+ this.onload({ target: { result } });
+ }
+ }
+ global.FileReader = FileReaderMock;
+}
+
+describe('Global config override', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('retains file metric config after global change', async () => {
+ stubFileReader('train_loss: 1');
+ const user = userEvent.setup();
+
+ render();
+
+ const input = screen.getByLabelText(i18n.t('fileUpload.aria'));
+ const file = new File(['train_loss: 1'], 'a.log', { type: 'text/plain' });
+ await user.upload(input, file);
+
+ await screen.findByText('a.log');
+
+ const configBtn = screen.getByLabelText(i18n.t('fileList.config', { name: 'a.log' }));
+ await user.click(configBtn);
+
+ const modal = screen.getByRole('dialog');
+ const keywordInputs = within(modal).getAllByPlaceholderText('keyword');
+ await user.clear(keywordInputs[0]);
+ await user.type(keywordInputs[0], 'train_loss:');
+
+ const saveBtn = screen.getByText(i18n.t('configModal.save'));
+ await user.click(saveBtn);
+
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+
+ // Update global config
+ const globalKeyword = screen.getAllByPlaceholderText('keyword')[0];
+ await user.clear(globalKeyword);
+ await user.type(globalKeyword, 'val_loss:');
+ expect(globalKeyword.value).toBe('val_loss:');
+
+ // Reopen file config to verify keyword remains
+ const configBtn2 = screen.getByLabelText(i18n.t('fileList.config', { name: 'a.log' }));
+ await user.click(configBtn2);
+
+ const modal2 = screen.getByRole('dialog');
+ const updatedInputs = within(modal2).getAllByPlaceholderText('keyword');
+ expect(updatedInputs[0].value).toBe('train_loss:');
+ });
+});
+