mirror of
https://github.com/NotAShelf/nix-evaluator-stats.git
synced 2026-04-12 14:27:41 +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