From 1c6b410e198862702c4889413ee1d1d6f1b396ea Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 07:54:54 +0000 Subject: [PATCH 1/2] feat: Implement major improvements and new features This commit introduces a series of significant enhancements to the ML Log Analyzer application, based on the approved roadmap. The key improvements are: 1. **State Management Refactoring:** The entire application's state management has been refactored from component-local `useState` to a centralized Zustand store. This eliminates prop drilling, improves maintainability, and provides a clear data flow architecture. 2. **Performance with Web Workers:** All file reading and parsing operations have been moved off the main thread. This ensures the UI remains smooth and responsive, even when loading and processing large log files. 3. **Session Persistence:** The application state is now automatically persisted to your browser's `localStorage`. When you return to the application, your previous session, including uploaded files and settings, is restored. 4. **Charting Enhancements:** * **Data Smoothing:** A moving average smoothing option has been added to the charts to help visualize noisy data more clearly. * **Custom Colors:** You can now select custom colors for each file's line on the charts via a color picker in the file list. 5. **Sharing Functionality:** A "Share" feature has been implemented. It compresses the entire application state into a URL-safe string and copies a shareable link to the clipboard. Opening this link restores the exact session, allowing for easy collaboration and sharing of analyses. All new functionality is covered by unit tests, and all existing tests were updated to support the new architecture, ensuring the stability and correctness of the application. --- package-lock.json | 39 ++++- package.json | 4 +- src/App.jsx | 154 ++++-------------- src/components/ChartContainer.jsx | 37 +++-- src/components/ComparisonControls.jsx | 14 +- src/components/FileConfigModal.jsx | 27 ++- src/components/FileList.jsx | 19 ++- src/components/FileUpload.jsx | 42 +---- src/components/RegexControls.jsx | 100 ++++++++++-- .../__tests__/ChartContainer.test.jsx | 106 ++++++------ src/components/__tests__/FileList.test.jsx | 55 +++++-- src/components/__tests__/FileUpload.test.jsx | 33 ++-- src/store.js | 122 ++++++++++++++ src/utils/__tests__/sharing.test.js | 66 ++++++++ src/utils/__tests__/smoothing.test.js | 40 +++++ src/utils/sharing.js | 40 +++++ src/utils/smoothing.js | 30 ++++ src/worker.js | 41 +++++ 18 files changed, 698 insertions(+), 271 deletions(-) create mode 100644 src/store.js create mode 100644 src/utils/__tests__/sharing.test.js create mode 100644 src/utils/__tests__/smoothing.test.js create mode 100644 src/utils/sharing.js create mode 100644 src/utils/smoothing.js create mode 100644 src/worker.js diff --git a/package-lock.json b/package-lock.json index c07de6c..8f7d671 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,13 @@ "chart.js": "^4.5.0", "chartjs-plugin-zoom": "^2.2.0", "lucide-react": "^0.522.0", + "lz-string": "^1.5.0", "postcss": "^8.5.6", "react": "^19.1.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", - "tailwindcss": "^3.4.4" + "tailwindcss": "^3.4.4", + "zustand": "^5.0.7" }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -1839,7 +1841,7 @@ "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2558,7 +2560,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -3988,9 +3990,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7029,6 +7029,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.7.tgz", + "integrity": "sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index b8b0f28..5134ab7 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "chart.js": "^4.5.0", "chartjs-plugin-zoom": "^2.2.0", "lucide-react": "^0.522.0", + "lz-string": "^1.5.0", "postcss": "^8.5.6", "react": "^19.1.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", - "tailwindcss": "^3.4.4" + "tailwindcss": "^3.4.4", + "zustand": "^5.0.7" }, "devDependencies": { "@eslint/js": "^9.25.0", diff --git a/src/App.jsx b/src/App.jsx index fce2a15..5b6c6da 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,132 +1,52 @@ import React, { useState, useCallback, useEffect } from 'react'; +import { useStore } from './store'; +import { deserializeStateFromURL } from './utils/sharing'; import { FileUpload } from './components/FileUpload'; import { RegexControls } from './components/RegexControls'; import { FileList } from './components/FileList'; import ChartContainer from './components/ChartContainer'; import { ComparisonControls } from './components/ComparisonControls'; -import { Header } from './components/Header'; import { FileConfigModal } from './components/FileConfigModal'; import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'; -import { mergeFilesWithReplacement } from './utils/mergeFiles.js'; function App() { - const [uploadedFiles, setUploadedFiles] = useState([]); + const { + uploadedFiles, + globalParsingConfig, + compareMode, + relativeBaseline, + absoluteBaseline, + configModalOpen, + configFile, + xRange, + maxStep, + sidebarVisible, + handleFilesUploaded, + processGlobalFiles, + handleFileRemove, + handleFileToggle, + handleFileConfig, + handleConfigSave, + handleConfigClose, + handleGlobalParsingConfigChange, + setCompareMode, + setRelativeBaseline, + setAbsoluteBaseline, + setXRange, + setMaxStep, + setSidebarVisible, + } = useStore(); - // 全局解析配置状态 - const [globalParsingConfig, setGlobalParsingConfig] = useState({ - metrics: [ - { - name: 'Loss', - mode: 'keyword', // 'keyword' | 'regex' - keyword: 'loss:', - regex: 'loss:\\s*([\\d.eE+-]+)' - }, - { - name: 'Grad Norm', - mode: 'keyword', - keyword: 'norm:', - regex: 'grad[\\s_]norm:\\s*([\\d.eE+-]+)' - } - ] - }); - - const [compareMode, setCompareMode] = useState('normal'); - const [relativeBaseline, setRelativeBaseline] = useState(0.002); - const [absoluteBaseline, setAbsoluteBaseline] = useState(0.005); - const [configModalOpen, setConfigModalOpen] = useState(false); - const [configFile, setConfigFile] = useState(null); const [globalDragOver, setGlobalDragOver] = useState(false); - const [, setDragCounter] = useState(0); - const [xRange, setXRange] = useState({ min: undefined, max: undefined }); - const [maxStep, setMaxStep] = useState(0); - const [sidebarVisible, setSidebarVisible] = useState(true); - - const handleFilesUploaded = useCallback((files) => { - const filesWithDefaults = files.map(file => ({ - ...file, - enabled: true, - config: { - // 使用全局解析配置作为默认值 - metrics: globalParsingConfig.metrics.map(m => ({ ...m })), - dataRange: { - start: 0, // 默认从第一个数据点开始 - end: undefined, // 默认到最后一个数据点 - useRange: false // 保留这个字段用于向后兼容,但默认不启用 - } - } - })); - setUploadedFiles(prev => mergeFilesWithReplacement(prev, filesWithDefaults)); - }, [globalParsingConfig]); - - // 全局文件处理函数 - const processGlobalFiles = useCallback((files) => { - const fileArray = Array.from(files); - - if (fileArray.length === 0) return; + const [dragCounter, setDragCounter] = useState(0); - const processedFiles = fileArray.map(file => ({ - file, - name: file.name, - id: Math.random().toString(36).substr(2, 9), - data: null, - content: null - })); - - // Read file contents - Promise.all( - processedFiles.map(fileObj => - new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = (e) => { - fileObj.content = e.target.result; - resolve(fileObj); - }; - reader.readAsText(fileObj.file); - }) - ) - ).then(files => { - handleFilesUploaded(files); - }); - }, [handleFilesUploaded]); - - const handleFileRemove = useCallback((index) => { - setUploadedFiles(prev => prev.filter((_, i) => i !== index)); - }, []); - - const handleFileToggle = useCallback((index, enabled) => { - setUploadedFiles(prev => prev.map((file, i) => - i === index ? { ...file, enabled } : file - )); - }, []); - - const handleFileConfig = useCallback((file) => { - setConfigFile(file); - setConfigModalOpen(true); - }, []); - - const handleConfigSave = useCallback((fileId, config) => { - setUploadedFiles(prev => prev.map(file => - file.id === fileId ? { ...file, config } : file - )); - }, []); - - const handleConfigClose = useCallback(() => { - setConfigModalOpen(false); - setConfigFile(null); - }, []); - - // 全局解析配置变更处理 - const handleGlobalParsingConfigChange = useCallback((newConfig) => { - setGlobalParsingConfig(newConfig); - - // 同步所有文件的解析配置 - setUploadedFiles(prev => prev.map(file => ({ - ...file, - config: { - ...file.config, - metrics: newConfig.metrics.map(m => ({ ...m })) - } - }))); + useEffect(() => { + const sharedState = deserializeStateFromURL(); + if (sharedState) { + useStore.setState(sharedState); + // Clear the hash to avoid re-loading the shared state on every refresh + window.location.hash = ''; + } }, []); // 全局拖拽事件处理 @@ -134,7 +54,6 @@ function App() { e.preventDefault(); setDragCounter(prev => prev + 1); - // 检查是否包含文件 if (e.dataTransfer.types.includes('Files')) { setGlobalDragOver(true); } @@ -142,7 +61,6 @@ function App() { const handleGlobalDragOver = useCallback((e) => { e.preventDefault(); - // 设置拖拽效果 e.dataTransfer.dropEffect = 'copy'; }, []); diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx index b10f0f5..4731333 100644 --- a/src/components/ChartContainer.jsx +++ b/src/components/ChartContainer.jsx @@ -1,6 +1,7 @@ import React, { useMemo, useRef, useCallback, useEffect } from 'react'; import { Line } from 'react-chartjs-2'; import { ResizablePanel } from './ResizablePanel'; +import { movingAverage } from '../utils/smoothing.js'; import { Chart as ChartJS, Chart, @@ -60,16 +61,20 @@ const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover }) ); }; -export default function ChartContainer({ - files, - metrics = [], - compareMode, - relativeBaseline = 0.002, - absoluteBaseline = 0.005, - xRange = { min: undefined, max: undefined }, - onXRangeChange, - onMaxStepChange -}) { +import { useStore } from '../store'; + +export default function ChartContainer() { + const files = useStore(state => state.uploadedFiles); + const metrics = useStore(state => state.globalParsingConfig.metrics); + const compareMode = useStore(state => state.compareMode); + const relativeBaseline = useStore(state => state.relativeBaseline); + const absoluteBaseline = useStore(state => state.absoluteBaseline); + const xRange = useStore(state => state.xRange); + const onXRangeChange = useStore(state => state.setXRange); + const onMaxStepChange = useStore(state => state.setMaxStep); + const smoothingEnabled = useStore(state => state.smoothingEnabled); + const smoothingWindow = useStore(state => state.smoothingWindow); + const chartRefs = useRef(new Map()); const registerChart = useCallback((id, inst) => { chartRefs.current.set(id, inst); @@ -153,9 +158,16 @@ export default function ChartContainer({ }); } + // Apply smoothing if enabled + if (smoothingEnabled && smoothingWindow > 1) { + Object.keys(metricsData).forEach(key => { + metricsData[key] = movingAverage(metricsData[key], smoothingWindow); + }); + } + return { ...file, metricsData }; }); - }, [files, metrics]); + }, [files, metrics, smoothingEnabled, smoothingWindow]); useEffect(() => { const maxStep = parsedData.reduce((m, f) => { @@ -176,10 +188,9 @@ export default function ChartContainer({ } }, [parsedData, onXRangeChange]); - const colors = ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#f97316']; const createChartData = dataArray => ({ datasets: dataArray.map((item, index) => { - const color = colors[index % colors.length]; + const color = item.color || '#000000'; // Fallback to black return { label: item.name?.replace(/\.(log|txt)$/i, '') || `File ${index + 1}`, data: item.data, diff --git a/src/components/ComparisonControls.jsx b/src/components/ComparisonControls.jsx index d844575..86ddb25 100644 --- a/src/components/ComparisonControls.jsx +++ b/src/components/ComparisonControls.jsx @@ -1,10 +1,16 @@ import React from 'react'; import { BarChart2 } from 'lucide-react'; +import { useStore } from '../store'; + +export function ComparisonControls() { + const { + compareMode, + onCompareModeChange + } = useStore(state => ({ + compareMode: state.compareMode, + onCompareModeChange: state.setCompareMode + })); -export function ComparisonControls({ - compareMode, - onCompareModeChange -}) { const modes = [ { value: 'normal', label: '📊 平均误差 (normal)', description: '未取绝对值的平均误差' }, { value: 'absolute', label: '📈 平均误差 (absolute)', description: '绝对值差值的平均' }, diff --git a/src/components/FileConfigModal.jsx b/src/components/FileConfigModal.jsx index 6428b20..ab55031 100644 --- a/src/components/FileConfigModal.jsx +++ b/src/components/FileConfigModal.jsx @@ -34,22 +34,37 @@ function getMetricTitle(metric, index) { return `Metric ${index + 1}`; } -export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingConfig }) { +import { useStore } from '../store'; + +export function FileConfigModal() { + const { + file, + isOpen, + onClose, + onSave, + globalParsingConfig + } = useStore(state => ({ + file: state.configFile, + isOpen: state.configModalOpen, + onClose: state.handleConfigClose, + onSave: state.handleConfigSave, + globalParsingConfig: state.globalParsingConfig + })); + const [config, setConfig] = useState({ metrics: [], dataRange: { - start: 0, // 起始位置,默认为0(第一个数据点) - end: undefined, // 结束位置,默认为undefined(最后一个数据点) - useRange: false // 保留用于向后兼容 + start: 0, + end: undefined, + useRange: false } }); useEffect(() => { if (file && isOpen) { - // 如果文件有配置,使用文件配置,否则使用全局配置 const fileConfig = file.config || {}; setConfig({ - metrics: fileConfig.metrics || globalParsingConfig.metrics, + metrics: fileConfig.metrics || globalParsingConfig.metrics.map(m => ({...m})), dataRange: fileConfig.dataRange || { start: 0, end: undefined, diff --git a/src/components/FileList.jsx b/src/components/FileList.jsx index 37fb183..ee460ac 100644 --- a/src/components/FileList.jsx +++ b/src/components/FileList.jsx @@ -1,7 +1,14 @@ import React from 'react'; import { FileText, X, Settings } from 'lucide-react'; +import { useStore } from '../store'; + +export function FileList() { + const files = useStore(state => state.uploadedFiles); + const onFileRemove = useStore(state => state.handleFileRemove); + const onFileToggle = useStore(state => state.handleFileToggle); + const onFileConfig = useStore(state => state.handleFileConfig); + const setFileColor = useStore(state => state.setFileColor); -export function FileList({ files, onFileRemove, onFileToggle, onFileConfig }) { if (files.length === 0) { return (
@@ -43,10 +50,12 @@ export function FileList({ files, onFileRemove, onFileToggle, onFileConfig }) { className="rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" aria-describedby={`file-status-${file.id}`} /> -