treewide: adapt for monorepo layout; initial TUI work

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

27
packages/core/README.md Normal file
View file

@ -0,0 +1,27 @@
# @ns/core
Core types and parsing logic for Nix evaluator statistics. This package is
framework-agnostic and can be used in any JavaScript/TypeScript environment.
## Usage
```typescript
import { calculateChange, parseStats, StatsData } from "@ns/core";
// Parse raw stats from Nix
const raw = JSON.parse(statsJson);
const stats: StatsData = parseStats(raw);
console.log(`CPU Time: ${stats.cpuTime}s`);
console.log(`Expressions: ${stats.nrExprs}`);
// Compare two values
const change = calculateChange(stats.nrThunks, previousStats.nrThunks);
console.log(`Thunks changed by ${change.percent.toFixed(2)}%`);
```
## Version Compatibility
The parser handles different Nix implementations (Nix, Lix, Snix, etc.) by
checking for field existence in the raw JSON, since not all implementations
expose the same statistics.

View file

@ -0,0 +1,20 @@
{
"name": "@ns/core",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,2 @@
export * from './types';
export * from './parser';

105
packages/core/src/parser.ts Normal file
View file

@ -0,0 +1,105 @@
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 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 };
}

View file

@ -0,0 +1,85 @@
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;
raw: Record<string, unknown>;
timestamp: Date;
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}

4
packages/tui/README.md Normal file
View file

@ -0,0 +1,4 @@
# @ns/tui
Provides a terminal-based interface for viewing and analyzing Nix evaluator
statistics.

20
packages/tui/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "@ns/tui",
"version": "1.0.0",
"type": "module",
"bin": {
"ns-tui": "./dist/cli.js"
},
"scripts": {
"build": "tsc",
"check": "tsc --noEmit",
"dev": "node --loader ts-node/esm src/cli.ts"
},
"dependencies": {
"@ns/core": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.0.10",
"typescript": "^5.9.3"
}
}

60
packages/tui/src/cli.ts Normal file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env node
import { readFile } from 'fs/promises';
import { parseStats } from '@ns/core';
async function main() {
const args = process.argv.slice(2);
// FIXME: nuke all of this actually
if (args.length === 0) {
console.log('NS');
console.log('\nUsage: ns-tui <stats.json>');
process.exit(1);
}
const filePath = args[0];
try {
const content = await readFile(filePath, 'utf-8');
const raw = JSON.parse(content);
const stats = parseStats(raw);
console.log('\n=== Nix Evaluator Statistics ===\n');
console.log(`CPU Time: ${stats.cpuTime.toFixed(3)}s`);
console.log(`Expressions: ${stats.nrExprs.toLocaleString()}`);
console.log(`Thunks: ${stats.nrThunks.toLocaleString()}`);
console.log(` - Avoided: ${stats.nrAvoided.toLocaleString()}`);
console.log(` - Ratio: ${((stats.nrAvoided / stats.nrThunks) * 100).toFixed(2)}%`);
const totalMemory =
stats.envs.bytes +
stats.list.bytes +
stats.values.bytes +
stats.symbols.bytes +
stats.sets.bytes;
console.log(`Total Memory: ${(totalMemory / 1024 / 1024).toFixed(2)} MB`);
console.log('\n=== Memory Breakdown ===\n');
console.log(`Environments: ${(stats.envs.bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(`Lists: ${(stats.list.bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(`Values: ${(stats.values.bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(`Symbols: ${(stats.symbols.bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(`Sets: ${(stats.sets.bytes / 1024 / 1024).toFixed(2)} MB`);
if (stats.gc) {
console.log('\n=== Garbage Collection ===\n');
console.log(`Heap Size: ${(stats.gc.heapSize / 1024 / 1024).toFixed(2)} MB`);
console.log(`Total Alloc: ${(stats.gc.totalBytes / 1024 / 1024).toFixed(2)} MB`);
console.log(`GC Cycles: ${stats.gc.cycles.toLocaleString()}`);
}
console.log('\n' + '='.repeat(40));
console.log('='.repeat(40) + '\n');
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
main();

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}

View file

@ -0,0 +1,38 @@
# @ns/ui-utils
Display formatting utilities for Nix evaluator statistics. Framework-agnostic
number, byte, and time formatters.
## Usage
```typescript
import {
formatBytes,
formatNumber,
formatPercent,
formatTime,
} from "@ns/ui-utils";
// Format bytes
console.log(formatBytes(1024)); // "1.00 KB"
console.log(formatBytes(1048576)); // "1.00 MB"
// Format large numbers
console.log(formatNumber(1234)); // "1.23K"
console.log(formatNumber(1234567)); // "1.23M"
// Format time
console.log(formatTime(0.0001)); // "100.00μs"
console.log(formatTime(0.5)); // "500.00ms"
console.log(formatTime(5.234)); // "5.234s"
// Format percentage
console.log(formatPercent(0.123)); // "12.30%"
```
## Design
All formatters are pure functions with no dependencies, making them easy to use
in any UI framework (React, SolidJS, Vue, etc.) or in CLI [^1] applications.
[^1]: Could you have guessed that this is the main goal?

View file

@ -0,0 +1,20 @@
{
"name": "@ns/ui-utils",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,23 @@
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) + '%';
}

View file

@ -0,0 +1 @@
export * from './formatters';

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}

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

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

25
packages/web/index.html Normal file
View file

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

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

@ -0,0 +1,30 @@
{
"name": "@ns/web",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"check": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"build": "pnpm check && pnpm lint && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ns/core": "workspace:*",
"@ns/ui-utils": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"lucide-solid": "^0.562.0",
"prettier": "^3.8.1",
"solid-js": "^1.9.10",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
}
}

View file

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

File diff suppressed because it is too large Load diff

View file

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

View file

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