diff --git a/CLAUDE.md b/CLAUDE.md index fac37d5..c3e2ade 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,11 +24,29 @@ This document provides comprehensive guidance for AI assistants (like Claude) wo - Prevents UI blocking during heavy parsing operations - Communication via postMessage/onmessage pattern -3. **LocalStorage Persistence** +3. **Persistence** - Uploaded files and parsing configs persist across sessions - - 5MB storage limit with quota handling + - IndexedDB is the primary store (`src/utils/idb.js` opens the shared DB); + localStorage is the fallback where IndexedDB is unavailable, with a 5MB + limit and quota handling - Config saved on every change (with safeguards) +4. **Perfetto-style Cache Links** (`src/utils/sessionCache.js` + `cacheUrl.js`) + - Every analysis (files + parsed metrics + parsing config) is snapshotted to + IndexedDB under a UUID and addressed by `#!/viewer?local_cache_key=` + - Reload, bookmark or a second tab reopens the same logs without re-uploading + - Local to the browser by design — nothing is uploaded anywhere + - Four stores, split by write frequency: `sessionIndex` (summaries, so + listing never reads log text), `sessions` (small metadata), `content` + (one record per file, UTF-8 bytes), `metrics` (one record per file, + Float64Array pairs). Content and metrics are written only when they + actually change, so a checkbox toggle writes ~4ms regardless of how many + hundreds of MB are cached + - LRU eviction by age (30d), count (20) and bytes (min of 2GB and half the + origin's quota); `navigator.storage.persist()` is requested best-effort + - Out of space: evict other analyses, then keep the parsed series and drop + the raw logs (charts still render; files are flagged for re-upload) + 4. **Global Drag-and-Drop** - Full-page drag overlay for intuitive file uploads - Supports dropping files anywhere in the application @@ -52,10 +70,15 @@ This document provides comprehensive guidance for AI assistants (like Claude) wo │ │ ├── Header.jsx # Header with language/theme toggle │ │ ├── RegexControls.jsx # Global parsing config │ │ ├── ResizablePanel.jsx # Draggable chart height +│ │ ├── SessionsModal.jsx # Cached analyses / cache links │ │ └── ThemeToggle.jsx # Dark mode toggle │ ├── utils/ # Utility functions │ │ ├── __tests__/ # Utility tests (colocated) │ │ ├── ValueExtractor.js # Core parsing logic +│ │ ├── idb.js # Shared IndexedDB open/upgrade +│ │ ├── fileStorage.js # Active working set persistence +│ │ ├── sessionCache.js # Cached analyses (local_cache_key) +│ │ ├── cacheUrl.js # local_cache_key URL plumbing │ │ ├── getMinSteps.js # Chart step calculation │ │ └── mergeFiles.js # File merging logic │ ├── workers/ diff --git a/README.md b/README.md index 1c78e29..e201036 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - 🎛️ **灵活展示**:可独立启用或禁用文件和图表,支持调整数据范围与图表尺寸,图表高度可拖拽调整。 - ⚡ **易用特性**:支持匹配预览、智能推荐解析规则,图表 Shift+拖动可快速缩放。 - 💾 **数据导出**:每个图表均支持导出 PNG、复制图片到剪贴板以及导出 CSV 数据。 +- 🔗 **本地缓存链接**:上传的日志与解析结果会缓存在浏览器中,地址栏自动生成 `#!/viewer?local_cache_key=` 链接(同 [ui.perfetto.dev](https://ui.perfetto.dev/) 的做法)。收藏该链接即可随时回到同一次分析,无需重新上传;链接仅在本机本浏览器有效,日志不会上传到任何服务器。 ## 快速上手 ⚡ @@ -30,6 +31,7 @@ 3. 查看自动生成的图表,必要时可上传第二个文件进行对比分析。 4. 调整图表显示或数据范围以获得更精确的结果。 5. 使用图表右上角的按钮导出 PNG、复制图片或下载 CSV 数据。 +6. 收藏地址栏中的 `local_cache_key` 链接,或点击侧边栏「缓存」新建、打开、重命名、删除已缓存的分析。 ## 部署 diff --git a/package-lock.json b/package-lock.json index 176dd73..e9ec429 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", + "fake-indexeddb": "^6.2.5", "globals": "^16.0.0", "jsdom": "^24.0.0", "vite": "^6.3.5", @@ -5021,6 +5022,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index f35d3b4..2229a4c 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", + "fake-indexeddb": "^6.2.5", "globals": "^16.0.0", "jsdom": "^24.0.0", "vite": "^6.3.5", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index b736650..edfa537 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -259,5 +259,24 @@ "toast.reportSaved": "Report saved", "toast.exportFailed": "Export failed", "toast.noChartsToExport": "No charts to export — upload a log first.", - "toast.clipboardUnsupported": "Clipboard API unavailable. Use Download instead." + "toast.clipboardUnsupported": "Clipboard API unavailable. Use Download instead.", + "header.sessions": "Cache", + "sessions.aria": "Cached analyses", + "sessions.title": "Cached analyses", + "sessions.close": "Close", + "sessions.hint": "Every analysis is cached in this browser and gets a local_cache_key link in the address bar. Bookmark it to come back to the exact same logs without re-uploading. Links only work in this browser on this machine — nothing is ever uploaded to a server.", + "sessions.currentLink": "Link to the current analysis", + "sessions.copyLink": "Copy link", + "sessions.linkCopied": "Link copied", + "sessions.empty": "Nothing cached yet — upload a log and it appears here.", + "sessions.open": "Open", + "sessions.delete": "Delete", + "sessions.clearAll": "Clear cache", + "sessions.current": "Current", + "sessions.meta": "{{files}} files · {{size}} · {{time}}", + "sessions.truncated": "too large to cache in full — metrics only", + "sessions.notFound": "No cached analysis for this link in this browser (cache links are local to the machine that made them).", + "sessions.newAnalysis": "New analysis (keeps the current one cached)", + "sessions.rename": "Rename", + "sessions.firstHint": "This analysis is cached — bookmark the link in the address bar to come straight back to it." } diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 163a460..b8938d2 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -259,5 +259,24 @@ "toast.reportSaved": "报告已保存", "toast.exportFailed": "导出失败", "toast.noChartsToExport": "暂无图表可导出——请先上传日志。", - "toast.clipboardUnsupported": "剪贴板 API 不可用,请改用下载。" + "toast.clipboardUnsupported": "剪贴板 API 不可用,请改用下载。", + "header.sessions": "缓存", + "sessions.aria": "已缓存的分析", + "sessions.title": "已缓存的分析", + "sessions.close": "关闭", + "sessions.hint": "每次上传的日志和解析结果都会缓存在本浏览器中,并在地址栏生成一个 local_cache_key 链接。收藏该链接即可随时回到这次分析,无需重新上传。链接只在本机本浏览器有效,日志不会上传到任何服务器。", + "sessions.currentLink": "当前分析的链接", + "sessions.copyLink": "复制链接", + "sessions.linkCopied": "链接已复制", + "sessions.empty": "暂无缓存,上传日志后会自动生成。", + "sessions.open": "打开", + "sessions.delete": "删除", + "sessions.clearAll": "清空缓存", + "sessions.current": "当前", + "sessions.meta": "{{files}} 个文件 · {{size}} · {{time}}", + "sessions.truncated": "文件过大,仅缓存了解析结果", + "sessions.notFound": "该链接对应的缓存不在此浏览器中(缓存链接仅在本机有效)。", + "sessions.newAnalysis": "新建分析(保留当前缓存)", + "sessions.rename": "重命名", + "sessions.firstHint": "本次分析已缓存,地址栏的链接可以收藏,下次直接回到这里。" } diff --git a/src/App.jsx b/src/App.jsx index 3034549..443bcf3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,14 +15,27 @@ import { CollapsibleCardHeader } from './components/CollapsibleCardHeader.jsx'; import { SmoothCollapse } from './components/SmoothCollapse.jsx'; import { ShortcutHelp } from './components/ShortcutHelp.jsx'; import { SettingsModal } from './components/SettingsModal.jsx'; +import { SessionsModal } from './components/SessionsModal.jsx'; import { ExportMenu } from './components/ExportMenu.jsx'; import { useCollapsedSection } from './utils/useCollapsedSection.js'; import { useKeyboardShortcuts } from './utils/useKeyboardShortcuts.js'; -import { PanelLeftClose, PanelLeftOpen, HelpCircle, Settings as SettingsIcon, Github, FileBarChart, Copy, Download } from 'lucide-react'; +import { PanelLeftClose, PanelLeftOpen, HelpCircle, Settings as SettingsIcon, Github, FileBarChart, Copy, Download, History } from 'lucide-react'; import { mergeFilesWithReplacement } from './utils/mergeFiles.js'; import { useToast } from './components/ToastContext.jsx'; import { loadFiles as loadFilesFromStorage, saveFiles as saveFilesToStorage, clearFiles as clearFilesInStorage } from './utils/fileStorage.js'; import { detectAnomalies } from './utils/anomalyDetection.js'; +import { + isCacheAvailable, + saveSession, + loadSession, + listSessions, + deleteSession as deleteCachedSession, + clearSessions +} from './utils/sessionCache.js'; +import { readCacheKey, writeCacheKey, clearCacheKey, generateCacheKey } from './utils/cacheUrl.js'; + +// One-time explainer for the cache link that appears in the address bar +const CACHE_HINT_KEY = 'sessions.hintShown'; // Threshold for "large file" - files above this won't have content persisted const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; // 5MB of content @@ -86,6 +99,9 @@ function App() { // Sync init from legacy localStorage so the first paint matches prior behavior; // an async IndexedDB load below will replace this state if richer data is present. const [uploadedFiles, setUploadedFiles] = useState(() => { + // A cache key in the URL wins: its snapshot loads asynchronously below, and + // painting the previous working set first would just flash the wrong logs. + if (readCacheKey()) return []; const stored = localStorage.getItem('uploadedFiles'); if (!stored) return []; try { @@ -143,6 +159,18 @@ function App() { // report export without lifting all chart state into App. const chartContainerRef = useRef(null); const savingDisabledRef = useRef(false); + // Perfetto-style cache key for the analysis currently on screen. Mirrored in + // the URL hash so a reload, bookmark or second tab reopens the same logs. + const [cacheKey, setCacheKey] = useState(() => readCacheKey()); + const sessionIdRef = useRef(readCacheKey()); + const cacheDisabledRef = useRef(false); + // Set when state was just loaded *from* the cache, so the save effect doesn't + // immediately write the snapshot it has only just read back. + const skipNextSaveRef = useRef(false); + // The debounced write still in flight, if any. Switching analyses cancels the + // timer, so it has to be flushed first or the last edit is silently lost. + const pendingSaveRef = useRef(null); + const [sessionsOpen, setSessionsOpen] = useState(false); const enabledFiles = uploadedFiles.filter(file => file.enabled); const workerRef = useRef(null); @@ -232,25 +260,90 @@ function App() { } }, [enabledFiles, baselineFile]); - // Async load from IndexedDB on mount. Overrides the sync localStorage init - // if richer data is present (e.g. after migration or in IDB-capable browsers). + // Write out a debounced save that has not fired yet. + const flushPendingSave = useCallback(async () => { + const pending = pendingSaveRef.current; + if (!pending) return; + pendingSaveRef.current = null; + try { + await saveSession(pending); + } catch { + // Best effort — the same failure surfaces through the save effect + } + }, []); + + // Apply a cached snapshot to the live app state. + const applySession = useCallback((key, record) => { + skipNextSaveRef.current = true; + sessionIdRef.current = key; + setCacheKey(key); + setUploadedFiles((record.files || []).map(restoreFile)); + if (record.globalParsingConfig) { + setGlobalParsingConfig(record.globalParsingConfig); + } + }, []); + + // Restore on mount, in order of preference: + // 1. the `local_cache_key` in the URL — a bookmarked/reloaded analysis + // 2. the most recently used cached analysis (bare URL keeps working) + // 3. the legacy single working set, which the first save adopts into a + // cache entry of its own useEffect(() => { let cancelled = false; - loadFilesFromStorage().then(files => { - if (cancelled) return; - initialLoadDoneRef.current = true; - if (Array.isArray(files) && files.length > 0) { - setUploadedFiles(prev => { - // Avoid overwriting if user already added files during async load - if (prev.length > 0) return prev; - return files.map(restoreFile); - }); + + const loadWorkingSet = () => { + loadFilesFromStorage().then(files => { + if (cancelled) return; + initialLoadDoneRef.current = true; + if (Array.isArray(files) && files.length > 0) { + setUploadedFiles(prev => { + // Avoid overwriting if user already added files during async load + if (prev.length > 0) return prev; + return files.map(restoreFile); + }); + } + }).catch(() => { + initialLoadDoneRef.current = true; + }); + }; + + (async () => { + const urlKey = readCacheKey(); + if (isCacheAvailable()) { + try { + let key = urlKey; + let record = key ? await loadSession(key) : null; + if (urlKey && !record) { + // The key is valid but names nothing here: cache links are local to + // the browser that made them, so this is the expected miss. + toastRef.current.warning(tRef.current('sessions.notFound')); + clearCacheKey(); + key = null; + } + if (!record && !urlKey) { + const [latest] = await listSessions(); + if (latest) { + record = await loadSession(latest.id); + key = latest.id; + } + } + if (cancelled) return; + if (record) { + applySession(key, record); + writeCacheKey(key); + initialLoadDoneRef.current = true; + return; + } + } catch { + // Cache unreadable — fall through to the legacy working set + } + if (cancelled) return; } - }).catch(() => { - initialLoadDoneRef.current = true; - }); + loadWorkingSet(); + })(); + return () => { cancelled = true; }; - }, []); + }, [applySession]); // Persist configuration to localStorage useEffect(() => { @@ -263,8 +356,69 @@ function App() { localStorage.setItem('chartConfig', JSON.stringify(chartConfig)); }, [chartConfig]); + // Persist the analysis. Where IndexedDB exists the snapshot goes to the + // session cache under the URL's cache key (minted on first save); otherwise + // we keep the legacy single localStorage working set. useEffect(() => { if (savingDisabledRef.current) return; + // Don't write before the restore above has had its say, or an empty initial + // state would overwrite the snapshot we are about to load. + if (!initialLoadDoneRef.current) return; + if (skipNextSaveRef.current) { + skipNextSaveRef.current = false; + return; + } + + if (isCacheAvailable() && !cacheDisabledRef.current) { + if (uploadedFiles.length === 0) { + const staleId = sessionIdRef.current; + pendingSaveRef.current = null; + if (staleId) { + sessionIdRef.current = null; + setCacheKey(null); + clearCacheKey(); + deleteCachedSession(staleId).catch(() => { /* best effort */ }); + } + return; + } + // Caching mid-parse would snapshot empty metrics; the parse completion + // re-runs this effect a moment later. + if (uploadedFiles.some(file => file.isParsing)) return; + + const minted = !sessionIdRef.current; + const id = sessionIdRef.current || generateCacheKey(); + sessionIdRef.current = id; + const payload = { id, files: uploadedFiles, globalParsingConfig }; + pendingSaveRef.current = payload; + const timer = setTimeout(() => { + if (pendingSaveRef.current === payload) pendingSaveRef.current = null; + saveSession(payload) + .then(() => { + setCacheKey(prev => (prev === id ? prev : id)); + writeCacheKey(id); + // Explain the URL that just appeared — once per browser. + if (minted) { + try { + if (!localStorage.getItem(CACHE_HINT_KEY)) { + localStorage.setItem(CACHE_HINT_KEY, '1'); + toastRef.current.info(tRef.current('sessions.firstHint')); + } + } catch { /* private mode — the hint is optional */ } + } + }) + .catch(err => { + if (err && (err.name === 'QuotaExceededError' || err.code === 22)) { + cacheDisabledRef.current = true; + console.warn('Storage quota exceeded; analysis will not be cached.'); + toastRef.current.warning(tRef.current('toast.storageQuotaExceeded')); + } else { + console.warn('Failed to cache analysis', err); + } + }); + }, 600); + return () => clearTimeout(timer); + } + // Smart serialization: for large files, only store metricsData (not raw content). // Charts still render after refresh, but re-parsing requires re-upload. const serialized = uploadedFiles.map(({ id, name, enabled, content, config, metricsData }) => { @@ -288,7 +442,7 @@ function App() { console.warn('Failed to persist uploaded files', err); } }); - }, [uploadedFiles, t]); + }, [uploadedFiles, globalParsingConfig, t]); const handleFilesUploaded = useCallback((files) => { const filesWithDefaults = files.map(file => ({ @@ -467,12 +621,53 @@ function App() { setUploadedFiles([]); }, []); + // Open a cached analysis by its cache key (from the sessions list or a link). + const handleOpenSession = useCallback(async (id) => { + await flushPendingSave(); + const record = await loadSession(id); + if (!record) { + toastRef.current.warning(tRef.current('sessions.notFound')); + return; + } + applySession(id, record); + // pushState, not replaceState: Back should return to the previous analysis. + writeCacheKey(id, { replace: false }); + }, [applySession, flushPendingSave]); + + // Start a fresh analysis while keeping the current one in the cache. Without + // this the only way to an empty canvas is "clear all", which throws the + // current snapshot away — and then the cache never holds more than one entry. + const handleNewAnalysis = useCallback(async () => { + await flushPendingSave(); + sessionIdRef.current = null; + setCacheKey(null); + clearCacheKey(); + setUploadedFiles([]); + }, [flushPendingSave]); + + // Back/forward between cache links, or a link pasted into the same tab. + const openSessionRef = useRef(handleOpenSession); + useEffect(() => { openSessionRef.current = handleOpenSession; }, [handleOpenSession]); + useEffect(() => { + const onHashChange = () => { + const key = readCacheKey(); + if (!key || key === sessionIdRef.current) return; + openSessionRef.current(key); + }; + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + // Reset configuration const handleResetConfig = useCallback(() => { savingDisabledRef.current = true; localStorage.removeItem('globalParsingConfig'); localStorage.removeItem('chartConfig'); clearFilesInStorage(); + clearSessions(); + sessionIdRef.current = null; + setCacheKey(null); + clearCacheKey(); setGlobalParsingConfig(JSON.parse(JSON.stringify(DEFAULT_GLOBAL_PARSING_CONFIG))); setChartConfig({ ...DEFAULT_CHART_CONFIG }); setUploadedFiles([]); @@ -675,6 +870,15 @@ function App() {