Skip to content

Commit a043e52

Browse files
JavaZerooclaude
andcommitted
feat: client-side anomaly detection (NaN / explosion / spike / plateau)
Detector (utils/anomalyDetection.js): - nan: any non-finite value - explosion: |y_i| >= 5x |y_{i-1}| with absolute-value floor to ignore noise around zero - spike: trailing-window z-score >= 4σ vs prior 30 points - plateau: 50+ consecutive steps with |Δy| <= 1e-7 Each detector runs independently and outputs { x, y, type, severity, message }. Events sort stably by x then type priority (nan first). Chart integration: - App.jsx memoizes anomaliesByFile from uploadedFiles so the detection cost only runs after a parse, not on every render. - ChartContainer overlays a scatter dataset of anomaly points per file/metric; marker fill colored by severity (red/amber/zinc), border in the series color for attribution. NaN events are intentionally skipped on-chart (no plottable y) but still listed in the sidebar. - Display Options "Chart" tab gains a "Mark anomalies on chart" toggle (chartConfig.showAnomalies, default on). Sidebar: - New AnomaliesPanel with collapsible header, per-file summary badges (icon + count + label), and a per-metric breakdown when multiple metrics carry anomalies. Total count appears as a red pill next to the panel title. Tests: 15 new cases (anomaly types, edge cases, ordering, summarizer). 118 total passing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1d55e65 commit a043e52

7 files changed

Lines changed: 536 additions & 5 deletions

File tree

public/locales/en/translation.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,14 @@
174174
"configModal.example3": "Start: 0, End: empty → shows all data points (default)",
175175
"configModal.cancel": "Cancel",
176176
"configModal.save": "Save Config",
177+
"anomalies.title": "Anomalies",
178+
"anomalies.empty": "No anomalies detected.",
179+
"anomalies.hint": "Detected: non-finite values, sudden 5× jumps, 4σ spikes vs prior 30 points, and ≥50-step plateaus.",
180+
"anomalies.type.nan": "NaN",
181+
"anomalies.type.explosion": "explosion",
182+
"anomalies.type.spike": "spike",
183+
"anomalies.type.plateau": "plateau",
184+
"display.showAnomalies": "Mark anomalies on chart",
177185
"shortcuts.title": "Keyboard shortcuts",
178186
"shortcuts.close": "Close shortcuts",
179187
"shortcuts.hint": "Shortcuts are disabled while typing in inputs. Esc always works.",

public/locales/zh/translation.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,14 @@
174174
"configModal.example3": "起始: 0, 结束: 留空 → 显示全部数据点(默认)",
175175
"configModal.cancel": "取消",
176176
"configModal.save": "保存配置",
177+
"anomalies.title": "异常检测",
178+
"anomalies.empty": "未检测到异常。",
179+
"anomalies.hint": "自动检测:非有限值、相邻 5× 突增、4σ 离群点、连续 50+ 步无变化。",
180+
"anomalies.type.nan": "NaN",
181+
"anomalies.type.explosion": "突增",
182+
"anomalies.type.spike": "尖峰",
183+
"anomalies.type.plateau": "停滞",
184+
"display.showAnomalies": "在图表上标记异常点",
177185
"shortcuts.title": "键盘快捷键",
178186
"shortcuts.close": "关闭快捷键面板",
179187
"shortcuts.hint": "在输入框聚焦时快捷键暂停(Esc 始终生效)。",

src/App.jsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useCallback, useEffect, useRef } from 'react';
1+
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
22
import { useTranslation, Trans } from 'react-i18next';
33
import { FileUpload } from './components/FileUpload';
44
import { RegexControls } from './components/RegexControls';
@@ -9,6 +9,7 @@ import { FileConfigModal } from './components/FileConfigModal';
99
import { ThemeToggle } from './components/ThemeToggle';
1010
import { Header } from './components/Header';
1111
import { AnnotationsPanel } from './components/AnnotationsPanel.jsx';
12+
import { AnomaliesPanel } from './components/AnomaliesPanel.jsx';
1213
import { CollapsibleCardHeader } from './components/CollapsibleCardHeader.jsx';
1314
import { ShortcutHelp } from './components/ShortcutHelp.jsx';
1415
import { useCollapsedSection } from './utils/useCollapsedSection.js';
@@ -17,6 +18,7 @@ import { PanelLeftClose, PanelLeftOpen, HelpCircle } from 'lucide-react';
1718
import { mergeFilesWithReplacement } from './utils/mergeFiles.js';
1819
import { useToast } from './components/ToastContext.jsx';
1920
import { loadFiles as loadFilesFromStorage, saveFiles as saveFilesToStorage, clearFiles as clearFilesInStorage } from './utils/fileStorage.js';
21+
import { detectAnomalies } from './utils/anomalyDetection.js';
2022

2123
// Threshold for "large file" - files above this won't have content persisted
2224
const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; // 5MB of content
@@ -50,7 +52,8 @@ export const DEFAULT_CHART_CONFIG = {
5052
showStats: false,
5153
chartType: 'line', // 'line' | 'scatter' | 'bar'
5254
combinedView: false,
53-
annotations: []
55+
annotations: [],
56+
showAnomalies: true
5457
};
5558

5659
function restoreFile(file) {
@@ -129,6 +132,26 @@ function App() {
129132
const enabledFiles = uploadedFiles.filter(file => file.enabled);
130133
const workerRef = useRef(null);
131134

135+
// Pre-compute anomalies for every enabled file × metric. Memoized so this
136+
// only re-runs when metricsData changes (after worker completes a parse).
137+
const anomaliesByFile = useMemo(() => {
138+
const out = {};
139+
uploadedFiles.forEach(file => {
140+
if (!file.enabled || !file.metricsData) return;
141+
const entry = {};
142+
let hasAny = false;
143+
Object.keys(file.metricsData).forEach(metricName => {
144+
const events = detectAnomalies(file.metricsData[metricName]);
145+
if (events.length > 0) {
146+
entry[metricName] = events;
147+
hasAny = true;
148+
}
149+
});
150+
if (hasAny) out[file.id] = entry;
151+
});
152+
return out;
153+
}, [uploadedFiles]);
154+
132155
// Initialize Web Worker
133156
useEffect(() => {
134157
workerRef.current = new Worker(new URL('./workers/logParser.worker.js', import.meta.url), { type: 'module' });
@@ -662,6 +685,12 @@ function App() {
662685
collapseId="annotations"
663686
/>
664687

688+
<AnomaliesPanel
689+
anomaliesByFile={anomaliesByFile}
690+
files={uploadedFiles}
691+
collapseId="anomalies"
692+
/>
693+
665694
{enabledFiles.length >= 2 && (
666695
<ComparisonControls
667696
compareMode={compareMode}
@@ -755,6 +784,15 @@ function App() {
755784
/>
756785
{t('display.combinedView')}
757786
</label>
787+
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300">
788+
<input
789+
type="checkbox"
790+
className="mr-2 checkbox"
791+
checked={chartConfig.showAnomalies !== false}
792+
onChange={(e) => setChartConfig(prev => ({ ...prev, showAnomalies: e.target.checked }))}
793+
/>
794+
{t('display.showAnomalies')}
795+
</label>
758796
</div>
759797
)}
760798

@@ -920,6 +958,8 @@ function App() {
920958
chartType={chartConfig.chartType}
921959
combinedView={chartConfig.combinedView}
922960
annotations={chartConfig.annotations}
961+
anomaliesByFile={anomaliesByFile}
962+
showAnomalies={chartConfig.showAnomalies !== false}
923963
/>
924964
</section>
925965
</main>

src/components/AnomaliesPanel.jsx

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import React, { useMemo } from 'react';
2+
import { AlertTriangle, AlertOctagon, TrendingUp, Minus } from 'lucide-react';
3+
import { useTranslation } from 'react-i18next';
4+
import { CollapsibleCardHeader } from './CollapsibleCardHeader.jsx';
5+
import { useCollapsedSection } from '../utils/useCollapsedSection.js';
6+
7+
const TYPE_META = {
8+
nan: { icon: AlertOctagon, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-950' },
9+
explosion: { icon: AlertTriangle, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-950' },
10+
spike: { icon: TrendingUp, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-950' },
11+
plateau: { icon: Minus, color: 'text-gray-500 dark:text-gray-400', bg: 'bg-gray-50 dark:bg-gray-800' }
12+
};
13+
14+
function TypeBadge({ type, count, t }) {
15+
if (count === 0) return null;
16+
const meta = TYPE_META[type];
17+
const Icon = meta.icon;
18+
return (
19+
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium ${meta.color} ${meta.bg}`}>
20+
<Icon size={11} aria-hidden="true" />
21+
<span className="font-mono tabular">{count}</span>
22+
<span className="hidden sm:inline">{t(`anomalies.type.${type}`)}</span>
23+
</span>
24+
);
25+
}
26+
27+
function fileSummary(eventsByMetric) {
28+
const summary = { nan: 0, explosion: 0, spike: 0, plateau: 0, total: 0 };
29+
Object.values(eventsByMetric).forEach(events => {
30+
events.forEach(e => {
31+
if (summary[e.type] !== undefined) summary[e.type]++;
32+
summary.total++;
33+
});
34+
});
35+
return summary;
36+
}
37+
38+
export function AnomaliesPanel({ anomaliesByFile = {}, files = [], collapseId }) {
39+
const { t } = useTranslation();
40+
const [open, setOpen] = useCollapsedSection(collapseId, false);
41+
const collapsible = Boolean(collapseId);
42+
43+
// Stable list of file entries that actually have anomalies, in upload order.
44+
const entries = useMemo(() => {
45+
const result = [];
46+
files.forEach(file => {
47+
const eventsByMetric = anomaliesByFile[file.id];
48+
if (!eventsByMetric) return;
49+
const summary = fileSummary(eventsByMetric);
50+
if (summary.total === 0) return;
51+
result.push({ file, eventsByMetric, summary });
52+
});
53+
return result;
54+
}, [anomaliesByFile, files]);
55+
56+
const totalCount = useMemo(
57+
() => entries.reduce((s, e) => s + e.summary.total, 0),
58+
[entries]
59+
);
60+
61+
const titleNode = (
62+
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-100 flex items-center gap-2 truncate">
63+
<span>{t('anomalies.title')}</span>
64+
{totalCount > 0 && (
65+
<span className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[11px] font-semibold font-mono tabular bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300">
66+
{totalCount}
67+
</span>
68+
)}
69+
</h3>
70+
);
71+
72+
return (
73+
<section className="card" aria-labelledby="anomalies-heading">
74+
<CollapsibleCardHeader
75+
titleNode={titleNode}
76+
titleId="anomalies-heading"
77+
icon={<AlertTriangle size={16} className="text-amber-600 dark:text-amber-400" />}
78+
collapsible={collapsible}
79+
open={open}
80+
onToggle={() => setOpen(o => !o)}
81+
/>
82+
{(!collapsible || open) && (
83+
<div className="mt-2">
84+
{entries.length === 0 ? (
85+
<p className="text-xs text-gray-500 dark:text-gray-400">
86+
{t('anomalies.empty')}
87+
</p>
88+
) : (
89+
<ul className="space-y-2" role="list">
90+
{entries.map(({ file, eventsByMetric, summary }) => (
91+
<li
92+
key={file.id}
93+
className="text-xs bg-gray-50 dark:bg-gray-700 rounded p-2 space-y-1"
94+
>
95+
<div className="flex items-center justify-between gap-2">
96+
<span className="truncate font-medium text-gray-700 dark:text-gray-200" title={file.name}>
97+
{file.name}
98+
</span>
99+
</div>
100+
<div className="flex flex-wrap gap-1">
101+
<TypeBadge type="nan" count={summary.nan} t={t} />
102+
<TypeBadge type="explosion" count={summary.explosion} t={t} />
103+
<TypeBadge type="spike" count={summary.spike} t={t} />
104+
<TypeBadge type="plateau" count={summary.plateau} t={t} />
105+
</div>
106+
{/* Per-metric breakdown: show metric name + count when >1 metric has anomalies */}
107+
{Object.keys(eventsByMetric).length > 1 && (
108+
<ul className="text-[11px] text-gray-500 dark:text-gray-400 space-y-0.5 mt-1">
109+
{Object.entries(eventsByMetric).map(([metric, events]) => (
110+
<li key={metric} className="flex items-center justify-between">
111+
<span className="font-mono truncate">{metric}</span>
112+
<span className="font-mono tabular">{events.length}</span>
113+
</li>
114+
))}
115+
</ul>
116+
)}
117+
</li>
118+
))}
119+
</ul>
120+
)}
121+
<p className="text-[11px] text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
122+
{t('anomalies.hint')}
123+
</p>
124+
</div>
125+
)}
126+
</section>
127+
);
128+
}

src/components/ChartContainer.jsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ export default function ChartContainer({
126126
showStats = false,
127127
chartType = 'line',
128128
combinedView = false,
129-
annotations = []
129+
annotations = [],
130+
anomaliesByFile = {},
131+
showAnomalies = true
130132
}) {
131133
const downsamplePoints = useCallback(
132134
(points) => (downsampleEnabled ? maybeDownsample(points, downsampleThreshold) : points),
@@ -715,9 +717,17 @@ export default function ChartContainer({
715717
metricNames.forEach(name => {
716718
metricDataArrays[name] = parsedData
717719
.filter(file => file.metricsData[name] && file.metricsData[name].length > 0)
718-
.map(file => ({ name: file.name, data: file.metricsData[name] }));
720+
.map(file => ({ id: file.id, name: file.name, data: file.metricsData[name] }));
719721
});
720722

723+
// Color anomaly markers by severity so high-severity events scream and
724+
// low-severity (plateau) recede into the background.
725+
const anomalyColor = (severity) => {
726+
if (severity === 'high') return '#dc2626'; // red-600
727+
if (severity === 'medium') return '#f59e0b'; // amber-500
728+
return '#71717a'; // zinc-500
729+
};
730+
721731
if (metrics.length === 0) {
722732
return (
723733
<div className="card p-8">
@@ -817,6 +827,34 @@ export default function ChartContainer({
817827
})).filter(entry => entry.stats)
818828
: [];
819829

830+
// Build base chart data, then overlay anomaly markers as scatter datasets.
831+
const mainChartData = createChartData(processedArray);
832+
if (showAnomalies && anomaliesByFile) {
833+
const metricKey = key;
834+
processedArray.forEach((item, fIdx) => {
835+
const events = anomaliesByFile[item.id]?.[metricKey] || [];
836+
// NaN events can't be drawn at a y-coordinate; the sidebar panel still lists them.
837+
const visible = events.filter(e => Number.isFinite(e.y));
838+
if (visible.length === 0) return;
839+
const baseColor = colors[fIdx % colors.length];
840+
mainChartData.datasets.push({
841+
type: 'scatter',
842+
label: `${(item.name || '').replace(/\.(log|txt)$/i, '')} · ⚠`,
843+
data: visible.map(e => ({ x: e.x, y: e.y, _anomaly: e })),
844+
pointRadius: 5,
845+
pointHoverRadius: 7,
846+
pointBackgroundColor: visible.map(e => anomalyColor(e.severity)),
847+
pointBorderColor: baseColor,
848+
pointBorderWidth: 1.5,
849+
showLine: false,
850+
parsing: false,
851+
order: -1, // draw on top
852+
animation: false,
853+
animations: { colors: false, x: false, y: false }
854+
});
855+
});
856+
}
857+
820858
let stats = null;
821859
let comparisonChart = null;
822860
if (showComparison) {
@@ -950,7 +988,7 @@ export default function ChartContainer({
950988
onRegisterChart={registerChart}
951989
onSyncHover={syncHoverToAllCharts}
952990
syncRef={syncLockRef}
953-
data={createChartData(processedArray)}
991+
data={mainChartData}
954992
options={options}
955993
chartType={chartType}
956994
/>

0 commit comments

Comments
 (0)