initial commit

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I16fd771478019a1b8e716ca4bb9e63b86a6a6964
This commit is contained in:
raf 2026-01-21 13:12:40 +03:00
commit 3bedc467fd
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
22 changed files with 5172 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

11
.prettierrc Normal file
View file

@ -0,0 +1,11 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}

10
eslint.config.mjs Normal file
View file

@ -0,0 +1,10 @@
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
export default [
...tseslint.configs.recommended,
eslintConfigPrettier,
{
ignores: ['dist/', 'node_modules/', '.DS_Store', '*.md'],
},
];

25
index.html Normal file
View file

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NIX_SHOW_STATS Visualizer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f12;
color: #e4e4e7;
min-height: 100vh;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "ns",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"check": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"fmt": "prettier --write .",
"build": "pnpm check && pnpm lint && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"lucide-solid": "^0.562.0",
"prettier": "^3.8.0",
"solid-js": "^1.9.10",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
}
}

2060
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

0
public/.gitkeep Normal file
View file

313
src/components/Analysis.tsx Normal file
View file

@ -0,0 +1,313 @@
import { Component, For, Show, createMemo } from 'solid-js';
import { StatsData } from '../utils/types';
import { formatBytes, formatNumber, formatTime } from '../utils/formatters';
import MetricCard from './MetricCard';
import Section from './Section';
import MemoryChart from './MemoryChart';
import TimeChart from './TimeChart';
import OperationsChart from './OperationsChart';
import ThunkChart from './ThunkChart';
const TOOLTIPS = {
cpuTime: 'Total CPU user time in seconds spent on Nix expression evaluation',
memory: 'Combined memory for all evaluation structures (envs, lists, values, symbols, sets)',
nrExprs: 'Total number of Nix expressions parsed and created during evaluation',
nrThunks:
"Number of thunks (delayed computations) created during evaluation. A thunk is created when a value is needed but hasn't been computed yet.",
nrAvoided:
'Number of thunks avoided by value reuse. When a value is already computed, forcing a thunk reuses it instead of creating a new computation.',
nrLookups: 'Number of attribute lookups performed (e.g., accessing .attr from an attribute set)',
nrPrimOpCalls: 'Total number of builtin function (primop) calls like map, foldl, concatMap, etc.',
nrFunctionCalls: 'Total number of user-defined function calls executed',
nrOpUpdates: 'Number of attribute set update operations performed (the // operator)',
nrOpUpdateValuesCopied: 'Number of values copied during attribute set updates',
envsNumber:
'Total number of lexical environments created during evaluation. Environments bind variables to values.',
envsElements: 'Total number of values stored in all environment slots across all environments',
envsBytes: 'Memory for environments = nrEnvs * sizeof(Env) + nrValuesInEnvs * sizeof(Value*)',
listElements: 'Total number of list elements ([ ... ]) allocated across all lists',
listBytes: 'Memory for list elements = nrListElems * sizeof(Value*) (pointer per element)',
listConcats: 'Number of list concatenation operations (++) performed during evaluation',
valuesNumber: 'Total number of Value objects allocated during evaluation',
valuesBytes: 'Memory for values = nrValues * sizeof(Value)',
symbolsNumber: 'Total number of unique symbols interned in the symbol table',
symbolsBytes: 'Total memory used by all symbol strings in the symbol table',
setsNumber: 'Total number of attribute sets ({ ... }) created during evaluation',
setsElements: 'Total number of attributes (key-value pairs) across all attribute sets',
setsBytes:
'Memory for attribute sets = nrAttrsets * sizeof(Bindings) + nrAttrsInAttrsets * sizeof(Attr)',
narRead: 'Number of NAR (Nix Archive) files read from the Nix store',
narWrite: 'Number of NAR (Nix Archive) files written to the Nix store',
narReadBytes: 'Total uncompressed bytes read from NAR archives',
narWriteBytes: 'Total uncompressed bytes written to NAR archives',
gcHeapSize: 'Current size of the garbage collected heap in bytes',
gcTotalBytes: 'Total number of bytes allocated since program start',
gcCycles: 'Total number of garbage collection cycles performed',
attrSelect: 'Number of attribute selections performed (accessing .attr from an attribute set)',
};
const Analysis: Component<{ stats: StatsData }> = props => {
const totalMemory = createMemo(
() =>
props.stats.envs.bytes +
props.stats.list.bytes +
props.stats.values.bytes +
props.stats.symbols.bytes +
props.stats.sets.bytes,
);
const memoryBreakdown = createMemo(() =>
[
{ label: 'Envs', value: props.stats.envs.bytes, total: totalMemory(), colorClass: 'chart-1' },
{
label: 'Lists',
value: props.stats.list.bytes,
total: totalMemory(),
colorClass: 'chart-2',
},
{
label: 'Values',
value: props.stats.values.bytes,
total: totalMemory(),
colorClass: 'chart-3',
},
{
label: 'Symbols',
value: props.stats.symbols.bytes,
total: totalMemory(),
colorClass: 'chart-4',
},
{ label: 'Sets', value: props.stats.sets.bytes, total: totalMemory(), colorClass: 'chart-5' },
].sort((a, b) => b.value - a.value),
);
const topPrimops = createMemo(() => {
if (!props.stats.primops) return [];
return Object.entries(props.stats.primops)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, count]) => ({ name, count }));
});
const topFunctions = createMemo(() => {
if (!props.stats.functions) return [];
return [...props.stats.functions].sort((a, b) => b.count - a.count).slice(0, 10);
});
const topAttributes = createMemo(() => {
if (!props.stats.attributes) return [];
return [...props.stats.attributes].sort((a, b) => b.count - a.count).slice(0, 10);
});
return (
<div class="analysis">
<div class="analysis-header">
<div class="header-stats">
<MetricCard
label="CPU Time"
value={formatTime(props.stats.cpuTime)}
tooltip={TOOLTIPS.cpuTime}
/>
<MetricCard label="Memory" value={formatBytes(totalMemory())} tooltip={TOOLTIPS.memory} />
<MetricCard
label="Expressions"
value={formatNumber(props.stats.nrExprs)}
tooltip={TOOLTIPS.nrExprs}
/>
<MetricCard
label="Thunks"
value={`${formatNumber(props.stats.nrAvoided)} / ${formatNumber(props.stats.nrThunks)}`}
tooltip="Avoided / Created thunks. Avoided means the value was already computed and reused."
highlight={props.stats.nrAvoided >= props.stats.nrThunks}
/>
</div>
</div>
<div class="charts-grid">
<Section title="Memory Distribution" collapsible>
<MemoryChart data={memoryBreakdown()} />
<Show when={props.stats.gc}>
<div class="gc-inline">
<div class="gc-header">GC Statistics</div>
<div class="gc-values">
<span class="gc-label">Heap:</span>
<span class="gc-value">{formatBytes(props.stats.gc?.heapSize || 0)}</span>
<span class="gc-label">Allocated:</span>
<span class="gc-value">{formatBytes(props.stats.gc?.totalBytes || 0)}</span>
<span class="gc-label">Cycles:</span>
<span class="gc-value">{formatNumber(props.stats.gc?.cycles || 0)}</span>
</div>
</div>
</Show>
</Section>
<Section title="Time & Thunks" collapsible>
<div class="time-thunks-combined">
<TimeChart stats={props.stats} />
<div class="thunks-section">
<ThunkChart stats={props.stats} />
</div>
</div>
</Section>
<Section title="Operations" collapsible>
<OperationsChart stats={props.stats} />
</Section>
</div>
<div class="dashboard-grid">
<Section title="Environments" collapsible>
<div class="metrics-grid small">
<MetricCard
label="Count"
value={formatNumber(props.stats.envs.number)}
tooltip={TOOLTIPS.envsNumber}
/>
<MetricCard
label="Elements"
value={formatNumber(props.stats.envs.elements)}
tooltip={TOOLTIPS.envsElements}
/>
<MetricCard
label="Memory"
value={formatBytes(props.stats.envs.bytes)}
tooltip={TOOLTIPS.envsBytes}
/>
</div>
</Section>
<Section title="Values & Symbols" collapsible>
<div class="metrics-grid small">
<MetricCard
label="Values"
value={formatNumber(props.stats.values.number)}
tooltip={TOOLTIPS.valuesNumber}
/>
<MetricCard
label="Value Bytes"
value={formatBytes(props.stats.values.bytes)}
tooltip={TOOLTIPS.valuesBytes}
/>
<MetricCard
label="Symbols"
value={formatNumber(props.stats.symbols.number)}
tooltip={TOOLTIPS.symbolsNumber}
/>
<MetricCard
label="Symbol Bytes"
value={formatBytes(props.stats.symbols.bytes)}
tooltip={TOOLTIPS.symbolsBytes}
/>
</div>
</Section>
<Section title="Lists & Sets" collapsible>
<div class="metrics-grid small">
<MetricCard
label="List Elements"
value={formatNumber(props.stats.list.elements)}
tooltip={TOOLTIPS.listElements}
/>
<MetricCard
label="Concat Ops"
value={formatNumber(props.stats.list.concats)}
tooltip={TOOLTIPS.listConcats}
/>
<MetricCard
label="Set Count"
value={formatNumber(props.stats.sets.number)}
tooltip={TOOLTIPS.setsNumber}
/>
<MetricCard
label="Attributes"
value={formatNumber(props.stats.sets.elements)}
tooltip={TOOLTIPS.setsElements}
/>
</div>
</Section>
</div>
<Show when={topPrimops().length > 0}>
<Section title="Top Primitive Operations" collapsible>
<div class="top-list">
<For each={topPrimops()}>
{(item, i) => (
<div class="top-item">
<span class="rank">{i() + 1}</span>
<span class="name">{item.name}</span>
<span class="count">{formatNumber(item.count)}</span>
</div>
)}
</For>
</div>
</Section>
</Show>
<Show when={topFunctions().length > 0}>
<Section title="Top Function Calls" collapsible>
<div class="top-list">
<For each={topFunctions()}>
{(item, i) => (
<div class="top-item">
<span class="rank">{i() + 1}</span>
<span class="name">{item.name || '<lambda>'}</span>
<span class="count">{formatNumber(item.count)}</span>
<span class="location">
{item.file}:{item.line}
</span>
</div>
)}
</For>
</div>
</Section>
</Show>
<Show when={props.stats.narRead !== undefined}>
<Section title="Store I/O" collapsible>
<div class="metrics-grid small">
<MetricCard
label="NAR Reads"
value={formatNumber(props.stats.narRead || 0)}
tooltip={TOOLTIPS.narRead}
/>
<MetricCard
label="NAR Writes"
value={formatNumber(props.stats.narWrite || 0)}
tooltip={TOOLTIPS.narWrite}
/>
<MetricCard
label="Read Bytes"
value={formatBytes(props.stats.narReadBytes || 0)}
tooltip={TOOLTIPS.narReadBytes}
/>
<MetricCard
label="Write Bytes"
value={formatBytes(props.stats.narWriteBytes || 0)}
tooltip={TOOLTIPS.narWriteBytes}
/>
</div>
</Section>
</Show>
<Show when={topAttributes().length > 0}>
<Section title="Top Attribute Selections" collapsible>
<div class="top-list">
<For each={topAttributes()}>
{(item, i) => (
<div class="top-item">
<span class="rank">{i() + 1}</span>
<span class="location">
{item.file}:{item.line}
</span>
<span class="count">{formatNumber(item.count)}</span>
</div>
)}
</For>
</div>
</Section>
</Show>
</div>
);
};
export default Analysis;

View file

@ -0,0 +1,200 @@
import { Component, For, createSignal, createMemo, Show } from 'solid-js';
import { ComparisonEntry } from '../utils/types';
import {
formatBytes,
formatNumber,
formatTime,
formatPercent,
calculateChange,
} from '../utils/formatters';
import { ArrowRight, ArrowDown, ArrowUp, X } from 'lucide-solid';
interface ComparisonViewProps {
entries: ComparisonEntry[];
onSelect: (entry: ComparisonEntry) => void;
onDelete: (id: number) => void;
}
const ComparisonView: Component<ComparisonViewProps> = props => {
const [leftEntry, setLeftEntry] = createSignal<ComparisonEntry | null>(null);
const [rightEntry, setRightEntry] = createSignal<ComparisonEntry | null>(null);
const comparison = createMemo(() => {
const left = leftEntry();
const right = rightEntry();
if (!left || !right) return null;
const fields = [
['cpuTime', 'CPU Time', 'time'],
['envs.number', 'Env Count', 'number'],
['envs.bytes', 'Env Memory', 'bytes'],
['list.elements', 'List Elements', 'number'],
['list.concats', 'List Concat', 'number'],
['values.number', 'Value Count', 'number'],
['symbols.number', 'Symbol Count', 'number'],
['sets.number', 'Set Count', 'number'],
['sets.elements', 'Attributes', 'number'],
['nrExprs', 'Expressions', 'number'],
['nrThunks', 'Thunks', 'number'],
['nrAvoided', 'Thunks Avoided', 'number'],
['nrLookups', 'Lookups', 'number'],
['nrFunctionCalls', 'Function Calls', 'number'],
['nrPrimOpCalls', 'PrimOp Calls', 'number'],
].map(([key, label, format]) => ({
key,
label,
format: format as 'number' | 'bytes' | 'time' | 'percent',
}));
return fields.map(field => {
const getValue = (data: ComparisonEntry['data']) => {
const keys = field.key.split('.');
let value: unknown = data;
for (const k of keys) value = value && (value as Record<string, unknown>)?.[k];
return typeof value === 'number' ? value : 0;
};
const leftVal = getValue(left.data);
const rightVal = getValue(right.data);
const change = calculateChange(rightVal, leftVal);
return {
...field,
leftValue: leftVal,
rightValue: rightVal,
change: change.percent,
isReduction: change.isReduction,
isDifferent: leftVal !== rightVal,
};
});
});
const selectLeft = (e: Event) => {
const id = parseInt((e.target as HTMLSelectElement).value);
const entry = props.entries.find(en => en.id === id);
setLeftEntry(entry || null);
};
const selectRight = (e: Event) => {
const id = parseInt((e.target as HTMLSelectElement).value);
const entry = props.entries.find(en => en.id === id);
setRightEntry(entry || null);
};
return (
<div class="comparison-view">
<div class="comparison-controls">
<div class="compare-selector">
<label>Baseline</label>
<select onChange={selectLeft} value={leftEntry()?.id || ''}>
<option value="">Select snapshot...</option>
<For each={props.entries}>
{entry => <option value={entry.id}>{entry.name}</option>}
</For>
</select>
</div>
<div class="compare-arrow">
<ArrowRight size={20} />
</div>
<div class="compare-selector">
<label>Current</label>
<select onChange={selectRight} value={rightEntry()?.id || ''}>
<option value="">Select snapshot...</option>
<For each={props.entries}>
{entry => <option value={entry.id}>{entry.name}</option>}
</For>
</select>
</div>
</div>
<Show when={props.entries.length > 0}>
<div class="snapshots-list">
<h4>Saved Snapshots</h4>
<For each={props.entries}>
{entry => (
<div class="snapshot-item">
<span class="snapshot-name">{entry.name}</span>
<button class="delete-btn" onClick={() => props.onDelete(entry.id)}>
<X size={16} />
</button>
</div>
)}
</For>
</div>
</Show>
<Show
when={leftEntry() && rightEntry()}
fallback={<div class="compare-placeholder">Select two snapshots to compare metrics</div>}
>
<div class="comparison-table">
<div class="compare-header">
<div class="col-label">Metric</div>
<div class="col-value">{leftEntry()?.name}</div>
<div class="col-value">{rightEntry()?.name}</div>
<div class="col-change">Change</div>
</div>
<For each={comparison()}>
{row => (
<div class={`compare-row ${row.isDifferent ? 'diff' : ''}`}>
<div class="col-label" title={row.label}>
{row.label}
</div>
<div class="col-value">
{row.format === 'bytes'
? formatBytes(row.leftValue)
: row.format === 'time'
? formatTime(row.leftValue)
: row.format === 'percent'
? formatPercent(row.leftValue)
: formatNumber(row.leftValue)}
</div>
<div class="col-value">
{row.format === 'bytes'
? formatBytes(row.rightValue)
: row.format === 'time'
? formatTime(row.rightValue)
: row.format === 'percent'
? formatPercent(row.rightValue)
: formatNumber(row.rightValue)}
</div>
<div
class={`col-change ${row.isReduction ? 'good' : row.isDifferent ? 'bad' : ''}`}
>
<Show when={row.isDifferent} fallback={<span class="neutral"></span>}>
<span class="change-value">
<Show when={row.isReduction}>
<ArrowDown size={14} />
</Show>
<Show when={!row.isReduction}>
<ArrowUp size={14} />
</Show>
{Math.abs(row.change).toFixed(1)}%
</span>
</Show>
</div>
</div>
)}
</For>
</div>
<div class="comparison-summary">
<Show when={comparison()?.some(r => r.isReduction)}>
<div class="summary-good">
<ArrowDown size={16} />{' '}
{comparison()?.filter(r => r.isReduction && r.isDifferent).length} improved
</div>
</Show>
<Show when={comparison()?.some(r => !r.isReduction && r.isDifferent)}>
<div class="summary-bad">
<ArrowUp size={16} />{' '}
{comparison()?.filter(r => !r.isReduction && r.isDifferent).length} regressed
</div>
</Show>
</div>
</Show>
</div>
);
};
export default ComparisonView;

View file

@ -0,0 +1,97 @@
import { createSignal, Show } from 'solid-js';
import { StatsData } from '../utils/types';
import { BarChart2 } from 'lucide-solid';
interface FileUploadProps {
onFileLoad: (data: StatsData) => void;
onTextLoad: (text: string) => void;
showHelp: boolean;
onToggleHelp: () => void;
}
export default function FileUpload(props: FileUploadProps) {
const [textInput, setTextInput] = createSignal('');
const [isTextMode, setIsTextMode] = createSignal(false);
const [error, setError] = createSignal('');
const handleFile = async (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const json = JSON.parse(text);
props.onFileLoad(json);
} catch {
setError('Invalid JSON file');
}
};
const handleTextLoad = () => {
if (!textInput().trim()) return;
try {
JSON.parse(textInput());
props.onTextLoad(textInput());
} catch {
setError('Invalid JSON');
}
};
return (
<div class="upload-container">
<div class="upload-card">
<div class="upload-icon">
<BarChart2 size={48} />
</div>
<h2>Load Statistics</h2>
<div class="help-link" onClick={props.onToggleHelp}>
How do I use this?
</div>
<Show when={props.showHelp}>
<div class="help-panel">
<h4>Generating Stats</h4>
<code>NIX_SHOW_STATS=1 nix-instantiate expr.nix &gt; stats.json</code>
<code>NIX_SHOW_STATS=1 NIX_COUNT_CALLS=1 nix-build &gt; stats.json</code>
<p>
Or use <code>NIX_SHOW_STATS_PATH=/path/to/output.json</code> for file output.
</p>
</div>
</Show>
<div class="upload-mode-toggle">
<button class={!isTextMode() ? 'active' : ''} onClick={() => setIsTextMode(false)}>
File
</button>
<button class={isTextMode() ? 'active' : ''} onClick={() => setIsTextMode(true)}>
Paste
</button>
</div>
<Show when={!isTextMode()}>
<label class="file-input-label">
<input type="file" accept=".json" onChange={handleFile} />
<span>Choose File</span>
</label>
</Show>
<Show when={isTextMode()}>
<textarea
class="json-input"
value={textInput()}
onInput={e => setTextInput(e.currentTarget.value)}
/>
<button class="load-btn" onClick={handleTextLoad}>
Load
</button>
</Show>
<Show when={error()}>
<div class="error">{error()}</div>
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,94 @@
import { Component, For, createMemo } from 'solid-js';
import { formatBytes } from '../utils/formatters';
interface MemoryChartProps {
data: Array<{
label: string;
value: number;
total: number;
colorClass: string;
}>;
}
const MemoryChart: Component<MemoryChartProps> = props => {
const chartData = createMemo(() => {
const total = props.data.reduce((sum, item) => sum + item.value, 0);
if (total === 0) return [];
let cumulative = 0;
return props.data.map(item => {
const startAngle = (cumulative / total) * 360;
cumulative += item.value;
const endAngle = (cumulative / total) * 360;
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
return {
...item,
startAngle,
endAngle,
largeArc,
percentage: ((item.value / total) * 100).toFixed(1),
};
});
});
const polarToCartesian = (cx: number, cy: number, r: number, angle: number) => {
const rad = ((angle - 90) * Math.PI) / 180;
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad),
};
};
const describeArc = (
cx: number,
cy: number,
r: number,
startAngle: number,
endAngle: number,
largeArc: number,
) => {
const start = polarToCartesian(cx, cy, r, startAngle);
const end = polarToCartesian(cx, cy, r, endAngle);
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`;
};
return (
<div class="memory-chart">
<div class="chart-legend">
<For each={chartData()}>
{item => (
<div class="legend-item">
<span class="legend-color" classList={{ [item.colorClass]: true }} />
<span class="legend-label">{item.label}</span>
<span class="legend-value">{formatBytes(item.value)}</span>
<span class="legend-percent">{item.percentage}%</span>
</div>
)}
</For>
</div>
<div class="donut-chart">
<svg viewBox="0 0 200 200">
<For each={chartData()}>
{item => (
<path
d={describeArc(100, 100, 70, item.startAngle, item.endAngle, item.largeArc)}
fill="none"
classList={{ [item.colorClass]: true }}
stroke-width="35"
/>
)}
</For>
<circle cx="100" cy="100" r="52" fill="var(--card-bg)" />
<text x="100" y="95" text-anchor="middle" class="chart-center-value">
{formatBytes(props.data.reduce((sum, d) => sum + d.value, 0))}
</text>
<text x="100" y="110" text-anchor="middle" class="chart-center-label">
Total
</text>
</svg>
</div>
</div>
);
};
export default MemoryChart;

View file

@ -0,0 +1,85 @@
import { Component, createSignal, Show } from 'solid-js';
interface MetricCardProps {
label: string;
value: string;
tooltip?: string;
highlight?: boolean;
}
const MetricCard: Component<MetricCardProps> = props => {
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPos, setTooltipPos] = createSignal<{
top: number;
left: number;
position: 'top' | 'bottom';
} | null>(null);
let cardRef: HTMLDivElement | undefined;
const updateTooltipPosition = () => {
if (!cardRef || !showTooltip()) {
setTooltipPos(null);
return;
}
const rect = cardRef.getBoundingClientRect();
const tooltipHeight = 60;
const tooltipWidth = 280;
const margin = 8;
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
const position: 'top' | 'bottom' =
spaceAbove >= tooltipHeight + margin || spaceBelow < tooltipHeight + margin
? 'top'
: 'bottom';
const top = position === 'top' ? rect.top - tooltipHeight - margin : rect.bottom + margin;
const left = Math.max(
margin,
Math.min(
rect.left + rect.width / 2 - tooltipWidth / 2,
window.innerWidth - tooltipWidth - margin,
),
);
setTooltipPos({ top, left, position });
};
const handleMouseEnter = () => {
setShowTooltip(true);
updateTooltipPosition();
};
const handleMouseLeave = () => {
setShowTooltip(false);
setTooltipPos(null);
};
return (
<div
ref={cardRef}
class={`metric-card ${props.highlight ? 'highlight' : ''}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseMove={updateTooltipPosition}
>
<div class="metric-value">{props.value}</div>
<div class="metric-label">{props.label}</div>
<Show when={props.tooltip && tooltipPos()}>
<div
class="tooltip"
data-position={tooltipPos()?.position}
style={{
top: `${tooltipPos()?.top}px`,
left: `${tooltipPos()?.left}px`,
}}
>
{props.tooltip}
</div>
</Show>
</div>
);
};
export default MetricCard;

View file

@ -0,0 +1,45 @@
import { Component, For, createMemo } from 'solid-js';
import { StatsData } from '../utils/types';
import { formatNumber } from '../utils/formatters';
interface OperationsChartProps {
stats: StatsData;
}
const OperationsChart: Component<OperationsChartProps> = props => {
const operations = createMemo(() =>
[
{ label: 'Lookups', value: props.stats.nrLookups, colorClass: 'chart-1' },
{ label: 'Function Calls', value: props.stats.nrFunctionCalls, colorClass: 'chart-2' },
{ label: 'PrimOp Calls', value: props.stats.nrPrimOpCalls, colorClass: 'chart-3' },
{ label: 'Op Updates', value: props.stats.nrOpUpdates, colorClass: 'chart-4' },
{ label: 'Values Copied', value: props.stats.nrOpUpdateValuesCopied, colorClass: 'chart-5' },
].sort((a, b) => b.value - a.value),
);
const maxValue = createMemo(() => Math.max(...operations().map(o => o.value)));
return (
<div class="operations-chart">
<For each={operations()}>
{item => (
<div class="op-row">
<div class="op-label">{item.label}</div>
<div class="op-bar-container">
<div
class="op-bar"
classList={{ [item.colorClass]: true }}
style={{
width: `${maxValue() > 0 ? (item.value / maxValue()) * 100 : 0}%`,
}}
/>
</div>
<div class="op-value">{formatNumber(item.value)}</div>
</div>
)}
</For>
</div>
);
};
export default OperationsChart;

View file

@ -0,0 +1,29 @@
import { Component, createSignal, type JSX, Show } from 'solid-js';
import { ChevronDown } from 'lucide-solid';
interface SectionProps {
title: string;
children: JSX.Element;
collapsible?: boolean;
defaultCollapsed?: boolean;
}
const Section: Component<SectionProps> = props => {
const [collapsed, setCollapsed] = createSignal(props.defaultCollapsed || false);
return (
<div class={`section ${collapsed() ? 'collapsed' : ''}`}>
<Show when={props.collapsible}>
<button class="section-header" onClick={() => setCollapsed(!collapsed())}>
<span class="section-title">{props.title}</span>
<ChevronDown size={16} class="section-toggle" />
</button>
</Show>
<Show when={!props.collapsible || !collapsed()}>
<div class="section-content">{props.children}</div>
</Show>
</div>
);
};
export default Section;

View file

@ -0,0 +1,56 @@
import { Component, createMemo } from 'solid-js';
import { StatsData } from '../utils/types';
import { formatNumber } from '../utils/formatters';
interface ThunkChartProps {
stats: StatsData;
}
const ThunkChart: Component<ThunkChartProps> = props => {
const maxValue = createMemo(() => Math.max(props.stats.nrThunks, props.stats.nrAvoided));
const avoidedRatio = createMemo(() => {
if (props.stats.nrThunks === 0) return 0;
return Math.min(1, props.stats.nrAvoided / props.stats.nrThunks);
});
return (
<div class="thunk-chart">
<div class="thunk-bars">
<div class="thunk-row">
<div class="thunk-label">Created</div>
<div class="thunk-bar-container">
<div
class="thunk-bar created"
style={{
width: `${maxValue() > 0 ? (props.stats.nrThunks / maxValue()) * 100 : 0}%`,
}}
/>
</div>
<div class="thunk-value">{formatNumber(props.stats.nrThunks)}</div>
</div>
<div class="thunk-row">
<div class="thunk-label">Avoided</div>
<div class="thunk-bar-container">
<div
class="thunk-bar avoided"
style={{
width: `${maxValue() > 0 ? (props.stats.nrAvoided / maxValue()) * 100 : 0}%`,
}}
/>
</div>
<div class="thunk-value">{formatNumber(props.stats.nrAvoided)}</div>
</div>
</div>
<div class="thunk-ratio">
<div class="ratio-bar">
<div class="ratio-fill" style={{ width: `${avoidedRatio() * 100}%` }} />
</div>
<span class="ratio-label">Avoidance rate: {(avoidedRatio() * 100).toFixed(1)}%</span>
</div>
</div>
);
};
export default ThunkChart;

View file

@ -0,0 +1,74 @@
import { Component, createMemo, For, Show } from 'solid-js';
import { StatsData } from '../utils/types';
import { formatBytes, formatTime, formatPercent } from '../utils/formatters';
interface TimeChartProps {
stats: StatsData;
}
const TimeChart: Component<TimeChartProps> = props => {
const timeData = createMemo(() => {
const cpu = props.stats.time.cpu;
const gc = props.stats.time.gc || 0;
const gcNonInc = props.stats.time.gcNonIncremental || 0;
const other = Math.max(0, cpu - gc - gcNonInc);
return [
{ label: 'Evaluation', value: other, colorClass: 'chart-1' },
{ label: 'Incremental GC', value: gc, colorClass: 'chart-gc' },
{ label: 'Full GC', value: gcNonInc, colorClass: 'chart-gc-full' },
].filter(d => d.value > 0);
});
const total = createMemo(() => timeData().reduce((sum, d) => sum + d.value, 0));
const barData = createMemo(() => {
return timeData().map(item => ({
...item,
percent: total() > 0 ? (item.value / total()) * 100 : 0,
}));
});
return (
<div class="time-chart">
<div class="time-bars">
<For each={barData()}>
{item => (
<div class="time-bar-row">
<div class="time-bar-label">{item.label}</div>
<div class="time-bar-track">
<div
class="time-bar-fill"
classList={{ [item.colorClass]: true }}
style={{
width: `${item.percent}%`,
}}
/>
</div>
<div class="time-bar-value">{formatTime(item.value)}</div>
</div>
)}
</For>
</div>
<Show when={props.stats.gc}>
<div class="gc-stats">
<div class="gc-stat">
<span class="gc-stat-label">GC Cycles</span>
<span class="gc-stat-value">{props.stats.gc?.cycles || 0}</span>
</div>
<div class="gc-stat">
<span class="gc-stat-label">Heap Size</span>
<span class="gc-stat-value">{formatBytes(props.stats.gc?.heapSize || 0)}</span>
</div>
<div class="gc-stat">
<span class="gc-stat-label">GC Fraction</span>
<span class="gc-stat-value">{formatPercent(props.stats.time.gcFraction || 0)}</span>
</div>
</div>
</Show>
</div>
);
};
export default TimeChart;

274
src/index.tsx Normal file
View file

@ -0,0 +1,274 @@
import { createSignal, Show, For, onMount, createEffect, Suspense, lazy } from 'solid-js';
import { render } from 'solid-js/web';
import { Github, Save, Upload, Trash2, X } from 'lucide-solid';
import FileUpload from './components/FileUpload';
import { StatsData, ComparisonEntry } from './utils/types';
import { parseStats } from './utils/formatters';
import './styles.css';
const Analysis = lazy(() => import('./components/Analysis'));
const ComparisonView = lazy(() => import('./components/ComparisonView'));
function App() {
const [currentStats, setCurrentStats] = createSignal<StatsData | null>(null);
const [snapshots, setSnapshots] = createSignal<ComparisonEntry[]>([]);
const [view, setView] = createSignal<'analysis' | 'compare'>('analysis');
const [snapshotName, setSnapshotName] = createSignal('');
const [showSaveDialog, setShowSaveDialog] = createSignal(false);
const [showHelp, setShowHelp] = createSignal(false);
const [showManageSnapshots, setShowManageSnapshots] = createSignal(false);
const [isLoading, setIsLoading] = createSignal(true);
const STORAGE_KEY = 'ns-data';
// Load from localStorage on mount
onMount(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed.snapshots)) {
setSnapshots(parsed.snapshots);
}
if (parsed.currentStats) {
setCurrentStats(parsed.currentStats);
}
if (parsed.view) {
setView(parsed.view);
}
}
} catch (e) {
console.warn('Failed to load saved data:', e);
}
setIsLoading(false);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (showSaveDialog()) setShowSaveDialog(false);
if (showManageSnapshots()) setShowManageSnapshots(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
});
// Save to localStorage on any change
createEffect(() => {
const stats = currentStats();
const snaps = snapshots();
const v = view();
// Don't save while still loading initial data
if (isLoading()) return;
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
snapshots: snaps,
currentStats: stats,
view: v,
}),
);
} catch (e) {
console.warn('Failed to save data:', e);
}
});
const saveSnapshot = () => {
const stats = currentStats();
if (!stats) return;
const name = snapshotName().trim() || `Snapshot ${snapshots().length + 1}`;
const entry: ComparisonEntry = {
id: Date.now(),
name,
data: stats,
timestamp: new Date(),
};
setSnapshots(prev => [...prev, entry]);
setSnapshotName('');
setShowSaveDialog(false);
};
const deleteSnapshot = (id: number) => {
setSnapshots(prev => prev.filter(e => e.id !== id));
};
const clearAllSnapshots = () => {
if (confirm('Delete all saved snapshots?')) {
setSnapshots([]);
}
};
const loadSnapshot = (entry: ComparisonEntry) => {
setCurrentStats(entry.data);
setView('analysis');
setShowManageSnapshots(false);
};
const handleFileLoad = (data: StatsData) => {
setCurrentStats(data);
setView('analysis');
};
const loadFromText = (text: string) => {
try {
const data = parseStats(JSON.parse(text));
setCurrentStats(data);
} catch (e) {
console.error('Failed to parse stats:', e);
}
};
return (
<div class="app">
<header class="header">
<div class="header-left">
<h1>NS</h1>
</div>
<nav class="nav">
<button class={view() === 'analysis' ? 'active' : ''} onClick={() => setView('analysis')}>
Analysis
</button>
<button
class={view() === 'compare' ? 'active' : ''}
onClick={() => setView('compare')}
disabled={snapshots().length < 2}
>
Compare ({snapshots().length})
</button>
<Show when={snapshots().length > 0}>
<button
class={showManageSnapshots() ? 'active' : ''}
onClick={() => setShowManageSnapshots(true)}
title="Manage Snapshots"
>
<Trash2 size={16} />
</button>
</Show>
</nav>
</header>
<main class="main">
<Suspense fallback={<div class="loading">Loading analysis...</div>}>
<Show when={view() === 'analysis'}>
<Show when={!currentStats()}>
<FileUpload
onFileLoad={handleFileLoad}
onTextLoad={loadFromText}
showHelp={showHelp()}
onToggleHelp={() => setShowHelp(!showHelp())}
/>
</Show>
<Show when={currentStats()}>
<Analysis stats={currentStats()!} />
<Show when={showSaveDialog()}>
<div class="modal-overlay" onClick={() => setShowSaveDialog(false)}>
<div class="modal" onClick={e => e.stopPropagation()}>
<h3>Save Snapshot</h3>
<input
type="text"
class="snapshot-name-input"
placeholder="Enter snapshot name..."
value={snapshotName()}
onInput={e => setSnapshotName(e.currentTarget.value)}
onKeyDown={e => e.key === 'Enter' && saveSnapshot()}
autofocus
/>
<div class="modal-actions">
<button class="cancel-btn" onClick={() => setShowSaveDialog(false)}>
Cancel
</button>
<button class="confirm-btn" onClick={saveSnapshot}>
Save
</button>
</div>
</div>
</div>
</Show>
</Show>
</Show>
<Show when={view() === 'compare'}>
<ComparisonView
entries={snapshots()}
onSelect={entry => setCurrentStats(entry.data)}
onDelete={deleteSnapshot}
/>
</Show>
</Suspense>
</main>
<footer class="footer">
<a
href="https://github.com/notashelf/ns"
target="_blank"
rel="noopener noreferrer"
class="footer-link"
>
<Github size={16} />
Source
</a>
</footer>
<Show when={showManageSnapshots()}>
<div class="modal-overlay" onClick={() => setShowManageSnapshots(false)}>
<div class="modal modal-large" onClick={e => e.stopPropagation()}>
<div class="modal-header">
<h3>Manage Snapshots</h3>
<button class="close-btn" onClick={() => setShowManageSnapshots(false)}>
<X size={20} />
</button>
</div>
<div class="snapshot-list-manage">
<For each={snapshots()} fallback={<div class="empty-state">No snapshots saved</div>}>
{entry => (
<div class="snapshot-manage-item">
<div class="snapshot-info" onClick={() => loadSnapshot(entry)}>
<span class="snapshot-name">{entry.name}</span>
<span class="snapshot-date">
{new Date(entry.timestamp).toLocaleDateString()}{' '}
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
</div>
<button class="delete-btn" onClick={() => deleteSnapshot(entry.id)}>
<X size={16} />
</button>
</div>
)}
</For>
</div>
<Show when={snapshots().length > 0}>
<div class="modal-actions">
<button class="danger-btn" onClick={clearAllSnapshots}>
<Trash2 size={16} />
Clear All
</button>
</div>
</Show>
</div>
</div>
</Show>
<Show when={currentStats() && view() === 'analysis'}>
<div class="floating-actions">
<button
class="action-btn save"
onClick={() => setShowSaveDialog(true)}
title="Save Snapshot"
>
<Save size={20} />
</button>
<button class="action-btn clear" onClick={() => setCurrentStats(null)} title="Load New">
<Upload size={20} />
</button>
</div>
</Show>
</div>
);
}
render(() => <App />, document.getElementById('root')!);

1528
src/styles.css Normal file

File diff suppressed because it is too large Load diff

129
src/utils/formatters.ts Normal file
View file

@ -0,0 +1,129 @@
import { StatsData } from './types';
const num = (val: unknown): number => (typeof val === 'number' ? val : 0);
export function parseStats(json: Record<string, unknown>): StatsData {
const timeObj = json.time as Record<string, unknown> | undefined;
const envsObj = json.envs as Record<string, unknown> | undefined;
const listObj = json.list as Record<string, unknown> | undefined;
const valuesObj = json.values as Record<string, unknown> | undefined;
const symbolsObj = json.symbols as Record<string, unknown> | undefined;
const setsObj = json.sets as Record<string, unknown> | undefined;
const sizesObj = json.sizes as Record<string, unknown> | undefined;
const stats: StatsData = {
cpuTime: num(json.cpuTime),
time: {
cpu: num(timeObj?.cpu) || num(json.cpuTime),
gc: num(timeObj?.gc),
gcNonIncremental: num(timeObj?.gcNonIncremental),
gcFraction: num(timeObj?.gcFraction),
gcNonIncrementalFraction: num(timeObj?.gcNonIncrementalFraction),
},
envs: {
number: num(envsObj?.number),
elements: num(envsObj?.elements),
bytes: num(envsObj?.bytes),
},
list: {
elements: num(listObj?.elements),
bytes: num(listObj?.bytes),
concats: num(listObj?.concats),
},
values: { number: num(valuesObj?.number), bytes: num(valuesObj?.bytes) },
symbols: { number: num(symbolsObj?.number), bytes: num(symbolsObj?.bytes) },
sets: {
number: num(setsObj?.number),
elements: num(setsObj?.elements),
bytes: num(setsObj?.bytes),
},
sizes: {
Env: num(sizesObj?.Env),
Value: num(sizesObj?.Value),
Bindings: num(sizesObj?.Bindings),
Attr: num(sizesObj?.Attr),
},
nrExprs: num(json.nrExprs),
nrThunks: num(json.nrThunks),
nrAvoided: num(json.nrAvoided),
nrLookups: num(json.nrLookups),
nrOpUpdates: num(json.nrOpUpdates),
nrOpUpdateValuesCopied: num(json.nrOpUpdateValuesCopied),
nrPrimOpCalls: num(json.nrPrimOpCalls),
nrFunctionCalls: num(json.nrFunctionCalls),
};
if (json.gc && typeof json.gc === 'object') {
const gc = json.gc as Record<string, unknown>;
stats.gc = {
heapSize: num(gc.heapSize),
totalBytes: num(gc.totalBytes),
cycles: num(gc.cycles),
};
}
if (json.primops && typeof json.primops === 'object')
stats.primops = json.primops as Record<string, number>;
if (json.functions && Array.isArray(json.functions))
stats.functions = json.functions as StatsData['functions'];
if (json.attributes && Array.isArray(json.attributes))
stats.attributes = json.attributes as StatsData['attributes'];
const storeFields = [
'narInfoRead',
'narInfoReadAverted',
'narInfoMissing',
'narInfoWrite',
'narRead',
'narReadBytes',
'narReadCompressedBytes',
'narWrite',
'narWriteAverted',
'narWriteBytes',
'narWriteCompressedBytes',
] as const;
for (const field of storeFields) {
if (typeof json[field] === 'number')
(stats as unknown as Record<string, number>)[field] = json[field] as number;
}
return stats;
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log2(bytes) / 10);
return `${(bytes / (1 << (i * 10))).toFixed(2)} ${units[i]}`;
}
export function formatNumber(num: number): string {
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
return num.toString();
}
export function formatTime(seconds: number): string {
if (seconds < 0.001) return (seconds * 1e6).toFixed(2) + 'μs';
if (seconds < 1) return (seconds * 1000).toFixed(2) + 'ms';
return seconds.toFixed(3) + 's';
}
export function formatPercent(value: number): string {
return (value * 100).toFixed(2) + '%';
}
export function calculateChange(
current: number,
previous: number,
): { value: number; percent: number; isReduction: boolean } {
if (previous === 0) {
const percent = current === 0 ? 0 : 100;
return { value: current, percent, isReduction: false };
}
const value = current - previous;
const percent = (value / previous) * 100;
return { value, percent, isReduction: value < 0 };
}

84
src/utils/types.ts Normal file
View file

@ -0,0 +1,84 @@
export interface StatsData {
cpuTime: number;
time: {
cpu: number;
gc?: number;
gcNonIncremental?: number;
gcFraction?: number;
gcNonIncrementalFraction?: number;
};
envs: {
number: number;
elements: number;
bytes: number;
};
list: {
elements: number;
bytes: number;
concats: number;
};
values: {
number: number;
bytes: number;
};
symbols: {
number: number;
bytes: number;
};
sets: {
number: number;
elements: number;
bytes: number;
};
sizes: {
Env: number;
Value: number;
Bindings: number;
Attr: number;
};
nrExprs: number;
nrThunks: number;
nrAvoided: number;
nrLookups: number;
nrOpUpdates: number;
nrOpUpdateValuesCopied: number;
nrPrimOpCalls: number;
nrFunctionCalls: number;
gc?: {
heapSize: number;
totalBytes: number;
cycles: number;
};
primops?: Record<string, number>;
functions?: Array<{
name: string | null;
file: string;
line: number;
column: number;
count: number;
}>;
attributes?: Array<{
file: string;
line: number;
column: number;
count: number;
}>;
narInfoRead?: number;
narInfoReadAverted?: number;
narInfoMissing?: number;
narInfoWrite?: number;
narRead?: number;
narReadBytes?: number;
narReadCompressedBytes?: number;
narWrite?: number;
narWriteAverted?: number;
narWriteBytes?: number;
narWriteCompressedBytes?: number;
}
export interface ComparisonEntry {
id: number;
name: string;
data: StatsData;
timestamp: Date;
}

16
tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"include": ["src/**/*"],
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"strict": true,
"skipLibCheck": true,
"isolatedModules": true
}
}

14
vite.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
export default defineConfig({
plugins: [solidPlugin()],
server: {
port: 3000,
open: false,
},
build: {
target: 'esnext',
outDir: 'dist',
},
});