Skip to content

Commit 0f56bf0

Browse files
JavaZerooclaude
andcommitted
feat: export pipeline, branded report, files panel merge
Export UX: - ExportMenu (new) collapses each chart's four export icons (PNG / Small PNG / Copy image / CSV) into one "Export ▾" dropdown. Two variants: bordered for chart panels, ghost (borderless) for sidebar header. - New "Small PNG (≤50 KB)" export per chart: scales the chart canvas down iteratively until the encoded blob fits the byte budget. Toast reports the actual size on success. - Global "Export report" lives in the sidebar header utility row now (was a loose toolbar above the chart grid). Dropdown gives Copy to clipboard / Download as PNG. - Defensive fixes for browser quirks: download anchor is appended to DOM before .click(), blob URL revocation is deferred 4 s, and an explicit toast warns when no charts are loaded or clipboard API is unavailable. Report generation (utils/chartExport.js): - Branded header band with blue→violet→indigo gradient, diagonal stripe overlay, white logo mark, and timestamp. - Files strip showing the actual file names (mono font). - Cards per chart with drop shadow, rounded corners, and a per-section accent bar (blue for main, violet for comparisons, indigo for combined view). - Stats table under each chart: file color swatch + min/max/mean/last/n with zebra striping and tabular-mono numerics. "last" column is emphasized. - Charts grouped into sections (Main / Comparisons / Combined view) with a section header bar — labels translated. - Chart titles come from the components themselves (e.g. "Loss", "Grad Norm") instead of internal IDs (was: "metric-0", "metric-comp-1"). - ChartContainer exposes copyReport/downloadReport via useImperativeHandle so the sidebar can drive them without lifting all chart state. Sidebar header utility row: - Icon-only buttons replaced with icon + text buttons grouped by purpose: row 1 = Export report / Settings / Shortcuts; row 2 = language toggle / Theme (with current mode label) / GitHub. - ThemeToggle gains a showLabel prop that renders "System" / "Light" / "Dark" next to the cycling icon. FilesPanel (new): - Combines FileUpload and FileList into one card. Empty state shows the large drop zone; otherwise a compact drop strip lives above the list. - Clear-all trash icon in the card header wipes every loaded file in one click (parsing config + display preferences untouched). Default chart config: - Downsampling defaults to off so small logs render every point. Users opt in via Settings → Performance when handling large runs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6db9e7b commit 0f56bf0

9 files changed

Lines changed: 1245 additions & 150 deletions

File tree

public/locales/en/translation.json

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@
66
"fileUpload.support": "📄 Supports all text file formats",
77
"fileUpload.aria": "Select log files, supports all text formats",
88
"fileList.title": "📋 Loaded Files",
9+
"filesPanel.title": "Files",
10+
"filesPanel.dropMore": "Drop more files here, or click",
11+
"filesPanel.clearAll": "Clear all files",
12+
"filesPanel.selectAria": "Select {{name}} for bulk operation",
13+
"filesPanel.selectTooltip": "Select for bulk operation",
14+
"filesPanel.enabledAria": "Show {{name}} on chart",
15+
"filesPanel.enabledTooltip": "Show on chart",
16+
"filesPanel.selectedCount": "{{count}} selected",
17+
"filesPanel.selectAll": "All",
18+
"filesPanel.selectNone": "None",
19+
"filesPanel.selectInvert": "Invert",
20+
"filesPanel.deleteSelected": "Delete selected",
921
"fileList.empty": "📂 No files",
1022
"fileList.loaded": "Loaded {{count}} files",
1123
"fileList.enabled": "Enabled",
@@ -30,6 +42,11 @@
3042
"comparison.relative": "Mean Relative Error (absolute)",
3143
"comparison.relativeDesc": "Mean of absolute relative error",
3244
"themeToggle.aria": "Toggle theme",
45+
"themeToggle.labelSystem": "System",
46+
"themeToggle.labelLight": "Light",
47+
"themeToggle.labelDark": "Dark",
48+
"header.settings": "Settings",
49+
"header.shortcuts": "Shortcuts",
3350
"chart.noData": "📊 No data",
3451
"chart.uploadPrompt": "📁 Upload log files to begin",
3552
"chart.selectPrompt": "🎯 Select charts to display",
@@ -40,9 +57,20 @@
4057
"placeholder.step": "step:",
4158
"resize.drag": "Drag to resize chart height",
4259
"resize.adjust": "Adjust {{title}} chart height",
43-
"exportPNG": "Export PNG",
44-
"copyImage": "Copy image",
45-
"exportCSV": "Export CSV",
60+
"exportMenu": "Export",
61+
"exportPNG": "PNG",
62+
"exportPNGSmall": "Small PNG",
63+
"report.title": "ML Log Analyzer Report",
64+
"report.exportLabel": "Export report",
65+
"report.copy": "Copy report to clipboard",
66+
"report.download": "Download as PNG",
67+
"report.sectionMain": "Charts",
68+
"report.sectionComparison": "Comparisons",
69+
"report.sectionCombined": "Combined view",
70+
"report.copyTooltip": "Copy all charts as one image to clipboard",
71+
"report.downloadTooltip": "Save all charts as one PNG file",
72+
"copyImage": "Copy to clipboard",
73+
"exportCSV": "CSV",
4674
"copyImageError": "Failed to copy image",
4775
"language.en": "English",
4876
"language.zh": "Chinese",
@@ -225,5 +253,11 @@
225253
"toast.parseComplete": "Parsed {{name}}",
226254
"toast.storageQuotaExceeded": "Storage limit reached. File persistence has been disabled for this session.",
227255
"toast.copyImageSuccess": "Image copied to clipboard",
228-
"toast.copyImageError": "Failed to copy image"
256+
"toast.copyImageError": "Failed to copy image",
257+
"toast.smallPNGSaved": "Small PNG saved ({{kb}} KB)",
258+
"toast.reportCopied": "Report copied to clipboard",
259+
"toast.reportSaved": "Report saved",
260+
"toast.exportFailed": "Export failed",
261+
"toast.noChartsToExport": "No charts to export — upload a log first.",
262+
"toast.clipboardUnsupported": "Clipboard API unavailable. Use Download instead."
229263
}

public/locales/zh/translation.json

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@
66
"fileUpload.support": "📄 支持所有文本格式文件",
77
"fileUpload.aria": "选择日志文件,支持所有文本格式",
88
"fileList.title": "📋 已加载文件",
9+
"filesPanel.title": "文件",
10+
"filesPanel.dropMore": "拖入更多文件或点击选择",
11+
"filesPanel.clearAll": "清空全部文件",
12+
"filesPanel.selectAria": "选中 {{name}} 用于批量操作",
13+
"filesPanel.selectTooltip": "选中用于批量操作",
14+
"filesPanel.enabledAria": "在图表上显示 {{name}}",
15+
"filesPanel.enabledTooltip": "在图表上显示",
16+
"filesPanel.selectedCount": "已选 {{count}}",
17+
"filesPanel.selectAll": "全选",
18+
"filesPanel.selectNone": "取消选中",
19+
"filesPanel.selectInvert": "反选",
20+
"filesPanel.deleteSelected": "删除选中",
921
"fileList.empty": "📂 暂无文件",
1022
"fileList.loaded": "已加载 {{count}} 个文件",
1123
"fileList.enabled": "已启用",
@@ -30,6 +42,11 @@
3042
"comparison.relative": "平均相对误差 (absolute)",
3143
"comparison.relativeDesc": "绝对相对误差的平均",
3244
"themeToggle.aria": "切换主题",
45+
"themeToggle.labelSystem": "跟随系统",
46+
"themeToggle.labelLight": "浅色",
47+
"themeToggle.labelDark": "深色",
48+
"header.settings": "设置",
49+
"header.shortcuts": "快捷键",
3350
"chart.noData": "📊 暂无数据",
3451
"chart.uploadPrompt": "📁 请上传日志文件开始分析",
3552
"chart.selectPrompt": "🎯 请选择要显示的图表",
@@ -40,9 +57,20 @@
4057
"placeholder.step": "step:",
4158
"resize.drag": "拖拽调整图表高度",
4259
"resize.adjust": "调整 {{title}} 图表高度",
43-
"exportPNG": "导出 PNG",
44-
"copyImage": "复制图片",
45-
"exportCSV": "导出 CSV",
60+
"exportMenu": "导出",
61+
"exportPNG": "PNG",
62+
"exportPNGSmall": "小 PNG",
63+
"report.title": "ML 日志分析报告",
64+
"report.exportLabel": "导出报告",
65+
"report.copy": "复制报告到剪贴板",
66+
"report.download": "下载为 PNG",
67+
"report.sectionMain": "图表",
68+
"report.sectionComparison": "对比",
69+
"report.sectionCombined": "合并视图",
70+
"report.copyTooltip": "把所有图表合成一张图复制到剪贴板",
71+
"report.downloadTooltip": "把所有图表保存成一张 PNG 文件",
72+
"copyImage": "复制到剪贴板",
73+
"exportCSV": "CSV 表格",
4674
"copyImageError": "复制图片失败",
4775
"language.en": "English",
4876
"language.zh": "中文",
@@ -225,5 +253,11 @@
225253
"toast.parseComplete": "已解析 {{name}}",
226254
"toast.storageQuotaExceeded": "存储空间已满,本次会话将不再持久化文件。",
227255
"toast.copyImageSuccess": "图片已复制到剪贴板",
228-
"toast.copyImageError": "复制图片失败"
256+
"toast.copyImageError": "复制图片失败",
257+
"toast.smallPNGSaved": "小 PNG 已保存({{kb}} KB)",
258+
"toast.reportCopied": "报告已复制到剪贴板",
259+
"toast.reportSaved": "报告已保存",
260+
"toast.exportFailed": "导出失败",
261+
"toast.noChartsToExport": "暂无图表可导出——请先上传日志。",
262+
"toast.clipboardUnsupported": "剪贴板 API 不可用,请改用下载。"
229263
}

src/App.jsx

Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
22
import { useTranslation, Trans } from 'react-i18next';
33
import { FileUpload } from './components/FileUpload';
4+
import { FilesPanel } from './components/FilesPanel.jsx';
45
import { RegexControls } from './components/RegexControls';
56
import { FileList } from './components/FileList';
67
import ChartContainer from './components/ChartContainer';
@@ -14,9 +15,10 @@ import { CollapsibleCardHeader } from './components/CollapsibleCardHeader.jsx';
1415
import { SmoothCollapse } from './components/SmoothCollapse.jsx';
1516
import { ShortcutHelp } from './components/ShortcutHelp.jsx';
1617
import { SettingsModal } from './components/SettingsModal.jsx';
18+
import { ExportMenu } from './components/ExportMenu.jsx';
1719
import { useCollapsedSection } from './utils/useCollapsedSection.js';
1820
import { useKeyboardShortcuts } from './utils/useKeyboardShortcuts.js';
19-
import { PanelLeftClose, PanelLeftOpen, HelpCircle, Settings as SettingsIcon, Github } from 'lucide-react';
21+
import { PanelLeftClose, PanelLeftOpen, HelpCircle, Settings as SettingsIcon, Github, FileBarChart, Copy, Download } from 'lucide-react';
2022
import { mergeFilesWithReplacement } from './utils/mergeFiles.js';
2123
import { useToast } from './components/ToastContext.jsx';
2224
import { loadFiles as loadFilesFromStorage, saveFiles as saveFilesToStorage, clearFiles as clearFilesInStorage } from './utils/fileStorage.js';
@@ -137,6 +139,9 @@ function App() {
137139
const [displayOpen, setDisplayOpen] = useCollapsedSection('display', true);
138140
const [helpOpen, setHelpOpen] = useState(false);
139141
const [settingsOpen, setSettingsOpen] = useState(false);
142+
// Imperative handle to ChartContainer — lets the sidebar Header trigger
143+
// report export without lifting all chart state into App.
144+
const chartContainerRef = useRef(null);
140145
const savingDisabledRef = useRef(false);
141146
const enabledFiles = uploadedFiles.filter(file => file.enabled);
142147
const workerRef = useRef(null);
@@ -456,6 +461,12 @@ function App() {
456461
'3': () => setDisplayTab('stats')
457462
});
458463

464+
// Clear all uploaded files (but keep parsing config + display preferences).
465+
// Used by the FilesPanel "Clear all" action.
466+
const handleClearAllFiles = useCallback(() => {
467+
setUploadedFiles([]);
468+
}, []);
469+
459470
// Reset configuration
460471
const handleResetConfig = useCallback(() => {
461472
savingDisabledRef.current = true;
@@ -642,43 +653,67 @@ function App() {
642653
<PanelLeftClose size={16} aria-hidden="true" />
643654
</button>
644655
</div>
645-
{/* Utility row: language toggle on the left, all single-icon utilities
646-
grouped on the right. Reset moved to Settings → Experimental. */}
647-
<div className="flex items-center justify-between gap-2">
656+
{/* Primary actions — icon + text so users don't have to guess
657+
what the icons mean. */}
658+
<div className="flex items-center gap-1 mt-1">
659+
<ExportMenu
660+
icon={FileBarChart}
661+
variant="ghost"
662+
label={t('report.exportLabel')}
663+
tooltip={t('report.exportLabel')}
664+
items={[
665+
{ label: t('report.copy'), icon: Copy, onClick: () => chartContainerRef.current?.copyReport() },
666+
{ label: t('report.download'), icon: Download, onClick: () => chartContainerRef.current?.downloadReport() }
667+
]}
668+
/>
669+
<button
670+
onClick={() => setSettingsOpen(true)}
671+
className="inline-flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-md"
672+
aria-label={t('settings.aria')}
673+
title={t('settings.aria') + ' (Ctrl+,)'}
674+
>
675+
<SettingsIcon size={13} aria-hidden="true" />
676+
<span>{t('header.settings')}</span>
677+
</button>
678+
<button
679+
onClick={() => setHelpOpen(true)}
680+
className="inline-flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-md"
681+
aria-label={t('shortcuts.aria')}
682+
title={t('shortcuts.aria') + ' (?)'}
683+
>
684+
<HelpCircle size={13} aria-hidden="true" />
685+
<span>{t('header.shortcuts')}</span>
686+
</button>
687+
</div>
688+
689+
{/* Preferences — language toggle (already labeled), theme cycle
690+
with label, GitHub link with text. */}
691+
<div className="flex items-center gap-1 mt-1">
648692
<Header />
649-
<div className="flex items-center gap-0.5">
650-
<a
651-
href="https://github.com/JavaZeroo/log-parser"
652-
target="_blank"
653-
rel="noopener noreferrer"
654-
className="p-1 text-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
655-
aria-label={t('github.aria')}
656-
title="GitHub"
657-
>
658-
<Github size={15} aria-hidden="true" />
659-
</a>
660-
<ThemeToggle />
661-
<button
662-
onClick={() => setSettingsOpen(true)}
663-
className="p-1 text-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
664-
aria-label={t('settings.aria')}
665-
title={t('settings.aria') + ' (Ctrl+,)'}
666-
>
667-
<SettingsIcon size={15} aria-hidden="true" />
668-
</button>
669-
<button
670-
onClick={() => setHelpOpen(true)}
671-
className="p-1 text-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
672-
aria-label={t('shortcuts.aria')}
673-
title={t('shortcuts.aria') + ' (?)'}
674-
>
675-
<HelpCircle size={15} aria-hidden="true" />
676-
</button>
677-
</div>
693+
<ThemeToggle showLabel />
694+
<a
695+
href="https://github.com/JavaZeroo/log-parser"
696+
target="_blank"
697+
rel="noopener noreferrer"
698+
className="inline-flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-md"
699+
aria-label={t('github.aria')}
700+
title="GitHub"
701+
>
702+
<Github size={13} aria-hidden="true" />
703+
<span>GitHub</span>
704+
</a>
678705
</div>
679706
</div>
680707

681-
<FileUpload onFilesUploaded={handleFilesUploaded} />
708+
<FilesPanel
709+
files={uploadedFiles}
710+
onFilesUploaded={handleFilesUploaded}
711+
onFileRemove={handleFileRemove}
712+
onFileToggle={handleFileToggle}
713+
onFileConfig={handleFileConfig}
714+
onClearAll={handleClearAllFiles}
715+
collapseId="files"
716+
/>
682717

683718
<RegexControls
684719
globalParsingConfig={globalParsingConfig}
@@ -692,14 +727,6 @@ function App() {
692727
collapseId="regex"
693728
/>
694729

695-
<FileList
696-
files={uploadedFiles}
697-
onFileRemove={handleFileRemove}
698-
onFileToggle={handleFileToggle}
699-
onFileConfig={handleFileConfig}
700-
collapseId="files"
701-
/>
702-
703730
{chartConfig.experimentalAnnotations && (
704731
<AnnotationsPanel
705732
annotations={chartConfig.annotations || []}
@@ -888,6 +915,7 @@ function App() {
888915
aria-label={t('chart.area')}
889916
>
890917
<ChartContainer
918+
ref={chartContainerRef}
891919
files={uploadedFiles}
892920
metrics={globalParsingConfig.metrics}
893921
compareMode={compareMode}

0 commit comments

Comments
 (0)