treewide: adapt for monorepo layout; initial TUI work

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id40b5f5ccb55a8a1ea2793192a38f0256a6a6964
This commit is contained in:
raf 2026-02-07 12:43:59 +03:00
commit 33ec901788
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
35 changed files with 1699 additions and 413 deletions

22
packages/web/README.md Normal file
View file

@ -0,0 +1,22 @@
# @ns/web
SolidJS web application for visualizing Nix evaluator statistics.
## Development
```bash
# Start dev server
$ pnpm dev
# Type checking
$ pnpm check
# Linting
$ pnpm lint
# Production build
$ pnpm build
# Preview production build
$ pnpm preview
```

25
packages/web/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>

30
packages/web/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "@ns/web",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"check": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"build": "pnpm check && pnpm lint && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ns/core": "workspace:*",
"@ns/ui-utils": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@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.1",
"solid-js": "^1.9.10",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
}
}

View file

View file

@ -0,0 +1,313 @@
import { Component, For, Show, createMemo } from 'solid-js';
import { StatsData } from '@ns/core';
import { formatBytes, formatNumber, formatTime } from '@ns/ui-utils';
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,240 @@
import { Component, For, createSignal, createMemo, Show } from 'solid-js';
import { ComparisonEntry, calculateChange } from '@ns/core';
import { formatBytes, formatNumber, formatTime, formatPercent } from '@ns/ui-utils';
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'],
raw: Record<string, unknown>,
): { value: number; present: boolean } => {
const keys = field.key.split('.');
let value: unknown = data;
for (const k of keys) value = value && (value as Record<string, unknown>)?.[k];
let present = typeof value === 'number';
if (!present) {
present =
keys.reduce((obj: unknown, k: string) => {
if (obj && typeof obj === 'object' && k in (obj as Record<string, unknown>)) {
const nested = (obj as Record<string, unknown>)[k];
return typeof nested === 'object' && nested !== null ? nested : true;
}
return undefined;
}, raw as unknown) !== undefined;
}
return { value: typeof value === 'number' ? value : 0, present };
};
const leftVal = getValue(left.data, left.raw);
const rightVal = getValue(right.data, right.raw);
const change = calculateChange(rightVal.value, leftVal.value);
const isMissing = !leftVal.present || !rightVal.present;
return {
...field,
leftValue: leftVal.value,
rightValue: rightVal.value,
change: change.percent,
isReduction: change.isReduction,
isDifferent: leftVal.value !== rightVal.value,
isMissing,
};
});
});
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' : ''} ${row.isMissing ? 'missing' : ''}`}
>
<div class="col-label" title={row.label}>
{row.label}
</div>
<div class="col-value">
<Show
when={row.isMissing}
fallback={
row.format === 'bytes'
? formatBytes(row.leftValue)
: row.format === 'time'
? formatTime(row.leftValue)
: row.format === 'percent'
? formatPercent(row.leftValue)
: formatNumber(row.leftValue)
}
>
<span class="missing-value">N/A</span>
</Show>
</div>
<div class="col-value">
<Show
when={row.isMissing}
fallback={
row.format === 'bytes'
? formatBytes(row.rightValue)
: row.format === 'time'
? formatTime(row.rightValue)
: row.format === 'percent'
? formatPercent(row.rightValue)
: formatNumber(row.rightValue)
}
>
<span class="missing-value">N/A</span>
</Show>
</div>
<div
class={`col-change ${row.isReduction ? 'good' : row.isDifferent && !row.isMissing ? 'bad' : ''}`}
>
<Show
when={row.isMissing}
fallback={
<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>
}
>
<span
class="missing-indicator"
title="Field not available in one or both implementations"
>
N/A
</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,120 @@
import { createSignal, Show, For } from 'solid-js';
import { StatsData, ComparisonEntry } from '@ns/core';
import { BarChart2, Clock } from 'lucide-solid';
interface FileUploadProps {
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
onTextLoad: (text: string) => void;
showHelp: boolean;
onToggleHelp: () => void;
snapshots?: ComparisonEntry[];
onLoadSnapshot?: (entry: ComparisonEntry) => 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, 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>
<Show when={props.snapshots && props.snapshots.length > 0}>
<div class="recent-analyses">
<h3>
<Clock size={16} />
Recent Analyses
</h3>
<div class="snapshot-list">
<For each={props.snapshots}>
{entry => (
<div class="snapshot-item" onClick={() => props.onLoadSnapshot?.(entry)}>
<span class="snapshot-name">{entry.name}</span>
<span class="snapshot-date">
{new Date(entry.timestamp).toLocaleDateString()}
</span>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,94 @@
import { Component, For, createMemo } from 'solid-js';
import { formatBytes } from '@ns/ui-utils';
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 '@ns/core';
import { formatNumber } from '@ns/ui-utils';
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 '@ns/core';
import { formatNumber } from '@ns/ui-utils';
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 '@ns/core';
import { formatBytes, formatTime, formatPercent } from '@ns/ui-utils';
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;

320
packages/web/src/index.tsx Normal file
View file

@ -0,0 +1,320 @@
import { createSignal, Show, For, onMount, createEffect, 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, parseStats } from '@ns/core';
import './styles.css';
function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
fn: T,
delay: number,
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
const Analysis = lazy(() => import('./components/Analysis'));
const ComparisonView = lazy(() => import('./components/ComparisonView'));
function App() {
const [currentStats, setCurrentStats] = createSignal<StatsData | null>(null);
const [currentRaw, setCurrentRaw] = createSignal<Record<string, unknown> | 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.map((s: ComparisonEntry) => ({
...s,
raw: s.raw || {},
})),
);
}
if (parsed.currentStats) {
setCurrentStats(parsed.currentStats);
}
if (parsed.currentRaw) {
setCurrentRaw(parsed.currentRaw);
}
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);
};
});
// Debounced save to localStorage
const saveToStorage = debounce(
(
stats: StatsData | null,
raw: Record<string, unknown> | null,
snaps: ComparisonEntry[],
v: 'analysis' | 'compare',
) => {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
snapshots: snaps,
currentStats: stats,
currentRaw: raw,
view: v,
}),
);
} catch (e) {
console.warn('Failed to save data:', e);
}
},
500,
);
// Save to localStorage on any change
createEffect(() => {
const stats = currentStats();
const raw = currentRaw();
const snaps = snapshots();
const v = view();
if (isLoading()) return;
saveToStorage(stats, raw, snaps, v);
});
const saveSnapshot = () => {
const stats = currentStats();
const raw = currentRaw();
if (!stats || !raw) return;
const name = snapshotName().trim() || `Snapshot ${snapshots().length + 1}`;
const entry: ComparisonEntry = {
id: Date.now(),
name,
data: stats,
raw,
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, raw: Record<string, unknown>) => {
setCurrentStats(data);
setCurrentRaw(raw);
setView('analysis');
};
const loadFromText = (text: string) => {
try {
const raw = JSON.parse(text);
const data = parseStats(raw);
setCurrentStats(data);
setCurrentRaw(raw);
} 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">
<Show when={isLoading()}>
<div class="loading">
<div class="spinner"></div>
<span>Loading saved data...</span>
</div>
</Show>
<Show when={!isLoading()}>
<Show when={view() === 'analysis'}>
<Show when={!currentStats()}>
<FileUpload
onFileLoad={handleFileLoad}
onTextLoad={loadFromText}
showHelp={showHelp()}
onToggleHelp={() => setShowHelp(!showHelp())}
snapshots={snapshots()}
onLoadSnapshot={loadSnapshot}
/>
</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>
</Show>
</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')!);

1626
packages/web/src/styles.css Normal file

File diff suppressed because it is too large Load diff

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
}
}

View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
export default defineConfig({
plugins: [solidPlugin()],
base: process.env.GITHUB_PAGES ? '/nix-evaluator-stats/' : './',
server: {
port: 3000,
open: false,
},
build: {
target: 'esnext',
outDir: 'dist',
},
});