mirror of
https://github.com/NotAShelf/nix-evaluator-stats.git
synced 2026-04-12 22:37:42 +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
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';
|
||||
105
packages/core/src/parser.ts
Normal file
105
packages/core/src/parser.ts
Normal 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 };
|
||||
}
|
||||
85
packages/core/src/types.ts
Normal file
85
packages/core/src/types.ts
Normal 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;
|
||||
}
|
||||
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
|
||||
```
|
||||
25
packages/web/index.html
Normal file
25
packages/web/index.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NIX_SHOW_STATS Visualizer</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f0f12;
|
||||
color: #e4e4e7;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
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"
|
||||
}
|
||||
}
|
||||
0
packages/web/public/.gitkeep
Normal file
0
packages/web/public/.gitkeep
Normal file
313
packages/web/src/components/Analysis.tsx
Normal file
313
packages/web/src/components/Analysis.tsx
Normal 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;
|
||||
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;
|
||||
120
packages/web/src/components/FileUpload.tsx
Normal file
120
packages/web/src/components/FileUpload.tsx
Normal 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 > stats.json</code>
|
||||
<code>NIX_SHOW_STATS=1 NIX_COUNT_CALLS=1 nix-build > stats.json</code>
|
||||
<p>
|
||||
Or use <code>NIX_SHOW_STATS_PATH=/path/to/output.json</code> for file output.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="upload-mode-toggle">
|
||||
<button class={!isTextMode() ? 'active' : ''} onClick={() => setIsTextMode(false)}>
|
||||
File
|
||||
</button>
|
||||
<button class={isTextMode() ? 'active' : ''} onClick={() => setIsTextMode(true)}>
|
||||
Paste
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={!isTextMode()}>
|
||||
<label class="file-input-label">
|
||||
<input type="file" accept=".json" onChange={handleFile} />
|
||||
<span>Choose File</span>
|
||||
</label>
|
||||
</Show>
|
||||
|
||||
<Show when={isTextMode()}>
|
||||
<textarea
|
||||
class="json-input"
|
||||
value={textInput()}
|
||||
onInput={e => setTextInput(e.currentTarget.value)}
|
||||
/>
|
||||
<button class="load-btn" onClick={handleTextLoad}>
|
||||
Load
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="error">{error()}</div>
|
||||
</Show>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
94
packages/web/src/components/MemoryChart.tsx
Normal file
94
packages/web/src/components/MemoryChart.tsx
Normal 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;
|
||||
85
packages/web/src/components/MetricCard.tsx
Normal file
85
packages/web/src/components/MetricCard.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { Component, createSignal, Show } from 'solid-js';
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
const MetricCard: Component<MetricCardProps> = props => {
|
||||
const [showTooltip, setShowTooltip] = createSignal(false);
|
||||
const [tooltipPos, setTooltipPos] = createSignal<{
|
||||
top: number;
|
||||
left: number;
|
||||
position: 'top' | 'bottom';
|
||||
} | null>(null);
|
||||
let cardRef: HTMLDivElement | undefined;
|
||||
|
||||
const updateTooltipPosition = () => {
|
||||
if (!cardRef || !showTooltip()) {
|
||||
setTooltipPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = cardRef.getBoundingClientRect();
|
||||
const tooltipHeight = 60;
|
||||
const tooltipWidth = 280;
|
||||
const margin = 8;
|
||||
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
|
||||
const position: 'top' | 'bottom' =
|
||||
spaceAbove >= tooltipHeight + margin || spaceBelow < tooltipHeight + margin
|
||||
? 'top'
|
||||
: 'bottom';
|
||||
const top = position === 'top' ? rect.top - tooltipHeight - margin : rect.bottom + margin;
|
||||
const left = Math.max(
|
||||
margin,
|
||||
Math.min(
|
||||
rect.left + rect.width / 2 - tooltipWidth / 2,
|
||||
window.innerWidth - tooltipWidth - margin,
|
||||
),
|
||||
);
|
||||
|
||||
setTooltipPos({ top, left, position });
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setShowTooltip(true);
|
||||
updateTooltipPosition();
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setShowTooltip(false);
|
||||
setTooltipPos(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
class={`metric-card ${props.highlight ? 'highlight' : ''}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseMove={updateTooltipPosition}
|
||||
>
|
||||
<div class="metric-value">{props.value}</div>
|
||||
<div class="metric-label">{props.label}</div>
|
||||
<Show when={props.tooltip && tooltipPos()}>
|
||||
<div
|
||||
class="tooltip"
|
||||
data-position={tooltipPos()?.position}
|
||||
style={{
|
||||
top: `${tooltipPos()?.top}px`,
|
||||
left: `${tooltipPos()?.left}px`,
|
||||
}}
|
||||
>
|
||||
{props.tooltip}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricCard;
|
||||
45
packages/web/src/components/OperationsChart.tsx
Normal file
45
packages/web/src/components/OperationsChart.tsx
Normal 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;
|
||||
29
packages/web/src/components/Section.tsx
Normal file
29
packages/web/src/components/Section.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Component, createSignal, type JSX, Show } from 'solid-js';
|
||||
import { ChevronDown } from 'lucide-solid';
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
children: JSX.Element;
|
||||
collapsible?: boolean;
|
||||
defaultCollapsed?: boolean;
|
||||
}
|
||||
|
||||
const Section: Component<SectionProps> = props => {
|
||||
const [collapsed, setCollapsed] = createSignal(props.defaultCollapsed || false);
|
||||
|
||||
return (
|
||||
<div class={`section ${collapsed() ? 'collapsed' : ''}`}>
|
||||
<Show when={props.collapsible}>
|
||||
<button class="section-header" onClick={() => setCollapsed(!collapsed())}>
|
||||
<span class="section-title">{props.title}</span>
|
||||
<ChevronDown size={16} class="section-toggle" />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={!props.collapsible || !collapsed()}>
|
||||
<div class="section-content">{props.children}</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
56
packages/web/src/components/ThunkChart.tsx
Normal file
56
packages/web/src/components/ThunkChart.tsx
Normal 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;
|
||||
74
packages/web/src/components/TimeChart.tsx
Normal file
74
packages/web/src/components/TimeChart.tsx
Normal 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
320
packages/web/src/index.tsx
Normal 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
1626
packages/web/src/styles.css
Normal file
File diff suppressed because it is too large
Load diff
16
packages/web/tsconfig.json
Normal file
16
packages/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"include": ["src/**/*"],
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"types": ["vite/client"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true
|
||||
}
|
||||
}
|
||||
15
packages/web/vite.config.ts
Normal file
15
packages/web/vite.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue