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

View file

@ -1,14 +1,16 @@
{
"name": "ns",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"check": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"dev": "pnpm -F @ns/web dev",
"check": "pnpm -r check",
"lint": "pnpm -r lint",
"fmt": "prettier --write .",
"build": "pnpm check && pnpm lint && vite build",
"preview": "vite preview"
"build": "pnpm -r --filter '!@ns/web' build && pnpm -F @ns/web build",
"build:web": "pnpm -r --filter '!@ns/web' build && pnpm -F @ns/web build",
"preview": "pnpm -F @ns/web preview"
},
"devDependencies": {
"@types/node": "^25.0.10",

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';

View file

@ -91,30 +91,6 @@ export function parseStats(json: Record<string, unknown>): StatsData {
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,

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

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

@ -1,6 +1,6 @@
import { Component, For, Show, createMemo } from 'solid-js';
import { StatsData } from '../utils/types';
import { formatBytes, formatNumber, formatTime } from '../utils/formatters';
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';

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

@ -1,5 +1,5 @@
import { createSignal, Show, For } from 'solid-js';
import { StatsData, ComparisonEntry } from '../utils/types';
import { StatsData, ComparisonEntry } from '@ns/core';
import { BarChart2, Clock } from 'lucide-solid';
interface FileUploadProps {

View file

@ -1,5 +1,5 @@
import { Component, For, createMemo } from 'solid-js';
import { formatBytes } from '../utils/formatters';
import { formatBytes } from '@ns/ui-utils';
interface MemoryChartProps {
data: Array<{

View file

@ -1,6 +1,6 @@
import { Component, For, createMemo } from 'solid-js';
import { StatsData } from '../utils/types';
import { formatNumber } from '../utils/formatters';
import { StatsData } from '@ns/core';
import { formatNumber } from '@ns/ui-utils';
interface OperationsChartProps {
stats: StatsData;

View file

@ -1,6 +1,6 @@
import { Component, createMemo } from 'solid-js';
import { StatsData } from '../utils/types';
import { formatNumber } from '../utils/formatters';
import { StatsData } from '@ns/core';
import { formatNumber } from '@ns/ui-utils';
interface ThunkChartProps {
stats: StatsData;

View file

@ -1,6 +1,6 @@
import { Component, createMemo, For, Show } from 'solid-js';
import { StatsData } from '../utils/types';
import { formatBytes, formatTime, formatPercent } from '../utils/formatters';
import { StatsData } from '@ns/core';
import { formatBytes, formatTime, formatPercent } from '@ns/ui-utils';
interface TimeChartProps {
stats: StatsData;

View file

@ -2,8 +2,7 @@ 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 } from './utils/types';
import { parseStats } from './utils/formatters';
import { StatsData, ComparisonEntry, parseStats } from '@ns/core';
import './styles.css';
function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(

1490
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
packages:
- 'packages/*'