mirror of
https://github.com/NotAShelf/nix-evaluator-stats.git
synced 2026-04-12 14:27:41 +00:00
treewide: adapt for monorepo layout; initial TUI work
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Id40b5f5ccb55a8a1ea2793192a38f0256a6a6964
This commit is contained in:
parent
e36b0d89da
commit
33ec901788
35 changed files with 1699 additions and 413 deletions
12
package.json
12
package.json
|
|
@ -1,14 +1,16 @@
|
||||||
{
|
{
|
||||||
"name": "ns",
|
"name": "ns",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "pnpm -F @ns/web dev",
|
||||||
"check": "tsc --noEmit",
|
"check": "pnpm -r check",
|
||||||
"lint": "eslint src --ext .ts,.tsx",
|
"lint": "pnpm -r lint",
|
||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
"build": "pnpm check && pnpm lint && vite build",
|
"build": "pnpm -r --filter '!@ns/web' build && pnpm -F @ns/web build",
|
||||||
"preview": "vite preview"
|
"build:web": "pnpm -r --filter '!@ns/web' build && pnpm -F @ns/web build",
|
||||||
|
"preview": "pnpm -F @ns/web preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
|
|
|
||||||
27
packages/core/README.md
Normal file
27
packages/core/README.md
Normal 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.
|
||||||
20
packages/core/package.json
Normal file
20
packages/core/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/core/src/index.ts
Normal file
2
packages/core/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './types';
|
||||||
|
export * from './parser';
|
||||||
|
|
@ -91,30 +91,6 @@ export function parseStats(json: Record<string, unknown>): StatsData {
|
||||||
return stats;
|
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(
|
export function calculateChange(
|
||||||
current: number,
|
current: number,
|
||||||
previous: number,
|
previous: number,
|
||||||
18
packages/core/tsconfig.json
Normal file
18
packages/core/tsconfig.json
Normal 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
4
packages/tui/README.md
Normal 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
20
packages/tui/package.json
Normal 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
60
packages/tui/src/cli.ts
Normal 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();
|
||||||
18
packages/tui/tsconfig.json
Normal file
18
packages/tui/tsconfig.json
Normal 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/**/*"]
|
||||||
|
}
|
||||||
38
packages/ui-utils/README.md
Normal file
38
packages/ui-utils/README.md
Normal 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?
|
||||||
20
packages/ui-utils/package.json
Normal file
20
packages/ui-utils/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
packages/ui-utils/src/formatters.ts
Normal file
23
packages/ui-utils/src/formatters.ts
Normal 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) + '%';
|
||||||
|
}
|
||||||
1
packages/ui-utils/src/index.ts
Normal file
1
packages/ui-utils/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './formatters';
|
||||||
18
packages/ui-utils/tsconfig.json
Normal file
18
packages/ui-utils/tsconfig.json
Normal 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
22
packages/web/README.md
Normal 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
30
packages/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Component, For, Show, createMemo } from 'solid-js';
|
import { Component, For, Show, createMemo } from 'solid-js';
|
||||||
import { StatsData } from '../utils/types';
|
import { StatsData } from '@ns/core';
|
||||||
import { formatBytes, formatNumber, formatTime } from '../utils/formatters';
|
import { formatBytes, formatNumber, formatTime } from '@ns/ui-utils';
|
||||||
import MetricCard from './MetricCard';
|
import MetricCard from './MetricCard';
|
||||||
import Section from './Section';
|
import Section from './Section';
|
||||||
import MemoryChart from './MemoryChart';
|
import MemoryChart from './MemoryChart';
|
||||||
240
packages/web/src/components/ComparisonView.tsx
Normal file
240
packages/web/src/components/ComparisonView.tsx
Normal 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;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createSignal, Show, For } from 'solid-js';
|
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';
|
import { BarChart2, Clock } from 'lucide-solid';
|
||||||
|
|
||||||
interface FileUploadProps {
|
interface FileUploadProps {
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Component, For, createMemo } from 'solid-js';
|
import { Component, For, createMemo } from 'solid-js';
|
||||||
import { formatBytes } from '../utils/formatters';
|
import { formatBytes } from '@ns/ui-utils';
|
||||||
|
|
||||||
interface MemoryChartProps {
|
interface MemoryChartProps {
|
||||||
data: Array<{
|
data: Array<{
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Component, For, createMemo } from 'solid-js';
|
import { Component, For, createMemo } from 'solid-js';
|
||||||
import { StatsData } from '../utils/types';
|
import { StatsData } from '@ns/core';
|
||||||
import { formatNumber } from '../utils/formatters';
|
import { formatNumber } from '@ns/ui-utils';
|
||||||
|
|
||||||
interface OperationsChartProps {
|
interface OperationsChartProps {
|
||||||
stats: StatsData;
|
stats: StatsData;
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Component, createMemo } from 'solid-js';
|
import { Component, createMemo } from 'solid-js';
|
||||||
import { StatsData } from '../utils/types';
|
import { StatsData } from '@ns/core';
|
||||||
import { formatNumber } from '../utils/formatters';
|
import { formatNumber } from '@ns/ui-utils';
|
||||||
|
|
||||||
interface ThunkChartProps {
|
interface ThunkChartProps {
|
||||||
stats: StatsData;
|
stats: StatsData;
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Component, createMemo, For, Show } from 'solid-js';
|
import { Component, createMemo, For, Show } from 'solid-js';
|
||||||
import { StatsData } from '../utils/types';
|
import { StatsData } from '@ns/core';
|
||||||
import { formatBytes, formatTime, formatPercent } from '../utils/formatters';
|
import { formatBytes, formatTime, formatPercent } from '@ns/ui-utils';
|
||||||
|
|
||||||
interface TimeChartProps {
|
interface TimeChartProps {
|
||||||
stats: StatsData;
|
stats: StatsData;
|
||||||
|
|
@ -2,8 +2,7 @@ import { createSignal, Show, For, onMount, createEffect, lazy } from 'solid-js';
|
||||||
import { render } from 'solid-js/web';
|
import { render } from 'solid-js/web';
|
||||||
import { Github, Save, Upload, Trash2, X } from 'lucide-solid';
|
import { Github, Save, Upload, Trash2, X } from 'lucide-solid';
|
||||||
import FileUpload from './components/FileUpload';
|
import FileUpload from './components/FileUpload';
|
||||||
import { StatsData, ComparisonEntry } from './utils/types';
|
import { StatsData, ComparisonEntry, parseStats } from '@ns/core';
|
||||||
import { parseStats } from './utils/formatters';
|
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
|
function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
|
||||||
1490
pnpm-lock.yaml
generated
1490
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
packages:
|
||||||
|
- 'packages/*'
|
||||||
Loading…
Add table
Add a link
Reference in a new issue