mirror of
https://github.com/NotAShelf/nix-evaluator-stats.git
synced 2026-04-11 22:07:40 +00:00
initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I16fd771478019a1b8e716ca4bb9e63b86a6a6964
This commit is contained in:
commit
3bedc467fd
22 changed files with 5172 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
11
.prettierrc
Normal file
11
.prettierrc
Normal 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
10
eslint.config.mjs
Normal 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
25
index.html
Normal 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
26
package.json
Normal 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
2060
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
313
src/components/Analysis.tsx
Normal file
313
src/components/Analysis.tsx
Normal 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;
|
||||
200
src/components/ComparisonView.tsx
Normal file
200
src/components/ComparisonView.tsx
Normal 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;
|
||||
97
src/components/FileUpload.tsx
Normal file
97
src/components/FileUpload.tsx
Normal 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 > stats.json</code>
|
||||
<code>NIX_SHOW_STATS=1 NIX_COUNT_CALLS=1 nix-build > 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>
|
||||
);
|
||||
}
|
||||
94
src/components/MemoryChart.tsx
Normal file
94
src/components/MemoryChart.tsx
Normal 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;
|
||||
85
src/components/MetricCard.tsx
Normal file
85
src/components/MetricCard.tsx
Normal 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;
|
||||
45
src/components/OperationsChart.tsx
Normal file
45
src/components/OperationsChart.tsx
Normal 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;
|
||||
29
src/components/Section.tsx
Normal file
29
src/components/Section.tsx
Normal 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;
|
||||
56
src/components/ThunkChart.tsx
Normal file
56
src/components/ThunkChart.tsx
Normal 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;
|
||||
74
src/components/TimeChart.tsx
Normal file
74
src/components/TimeChart.tsx
Normal 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
274
src/index.tsx
Normal 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
1528
src/styles.css
Normal file
File diff suppressed because it is too large
Load diff
129
src/utils/formatters.ts
Normal file
129
src/utils/formatters.ts
Normal 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
84
src/utils/types.ts
Normal 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
16
tsconfig.json
Normal 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
14
vite.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue