mirror of
https://github.com/NotAShelf/nix-evaluator-stats.git
synced 2026-05-21 14:26:41 +00:00
Compare commits
12 commits
5af2457177
...
c8b307341a
| Author | SHA1 | Date | |
|---|---|---|---|
|
c8b307341a |
|||
|
|
735c4c7442 | ||
|
6484ded3d7 |
|||
|
55f6a43dc2 |
|||
|
5482cd9df1 |
|||
|
8d7bd7bb05 |
|||
|
06b78c6b0e |
|||
|
d26a70dacd |
|||
|
6102e75a9e |
|||
|
1541046669 |
|||
|
697f1f1c73 |
|||
|
f7457fb9a4 |
14 changed files with 1234 additions and 1188 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
<!-- markdownlint-disable MD033 -->
|
<!-- markdownlint-disable MD033 MD041 -->
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<h1 id="header">nix-evaluator-stats</h1>
|
<h1 id="header">nix-evaluator-stats</h1>
|
||||||
|
|
@ -20,21 +20,29 @@
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
`nix-evaluator-stats`, or "ns" for short, is a pretty visualiser for the Nix
|
`nix-evaluator-stats`, or "`ns`", is a pretty visualiser for the Nix evaluator
|
||||||
evaluator stats export from `NIX_SHOW_STATS` and `NIX_COUNT_CALLS` invocations.
|
stats export from `NIX_SHOW_STATS` and `NIX_COUNT_CALLS` invocations. It takes
|
||||||
It takes the resulting JSON data from your Nix invocation with the relevant
|
the resulting JSON data from your Nix invocation with the relevant variables,
|
||||||
variables, and provides a ✨ pretty ✨ dashboard-like visual with the ability to
|
and provides a ✨ pretty ✨ dashboard-like visual with the ability to compare
|
||||||
compare your "snapshots" of benchmarks. Besides looking nice, it is helpful in
|
your "snapshots" of benchmarks. Besides looking nice, it is helpful in
|
||||||
collecting statistics about your Nix commands and tracking performance
|
collecting statistics about your Nix commands and tracking performance
|
||||||
regressions in subsequent exports.
|
regressions in subsequent exports with the comparison feature with snapshot of
|
||||||
|
your analyses.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
NS provides both a web application for pretty visuals, and a terminal client (a
|
NS is primarily a web application for pretty visuals, with a terminal client
|
||||||
TUI) for rendering the statistics from your terminal.
|
(both a CLI and a TUI) planned for rendering statistics or sharing your analyses
|
||||||
|
with others.
|
||||||
|
|
||||||
### Web
|
### Web
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD059-->
|
||||||
|
|
||||||
|
You can find the site [here](https://notashelf.github.io/nix-evaluator-stats/).
|
||||||
|
|
||||||
|
<!--markdownlint-enable MD059-->
|
||||||
|
|
||||||
Usage instructions are provided in the initial page. Simply navigate to the site
|
Usage instructions are provided in the initial page. Simply navigate to the site
|
||||||
and provide the JSON export (or a file) to render the statistics. The number of
|
and provide the JSON export (or a file) to render the statistics. The number of
|
||||||
rendered fields might differ based on your Nix version or implementation (Lix,
|
rendered fields might differ based on your Nix version or implementation (Lix,
|
||||||
|
|
@ -61,19 +69,15 @@ Once you hit "Load", the JSON will be parsed and you'll be looking at a dash
|
||||||
board of your export. By using the snapshot feature, i.e., saving a particular
|
board of your export. By using the snapshot feature, i.e., saving a particular
|
||||||
analysis you may compare two _named_ analyses at a time.
|
analysis you may compare two _named_ analyses at a time.
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> `nix-evaluator-stats` was created in a very short duration, and there might be
|
|
||||||
> UI bugs or areas where UI polish is very clearly missing. Please crate an
|
|
||||||
> issue if the generated graph or the site UI looks off. Thanks :)
|
|
||||||
|
|
||||||
#### Snapshots
|
#### Snapshots
|
||||||
|
|
||||||
Snapshots are an "experimental" (just means they're new and unpolished) feature
|
Snapshots are a new feature that lets you save an analysis in your browser
|
||||||
that lets you save an analysis in your browser storage with a name to be used
|
storage with a name to be used later on in the comparison view. At least two
|
||||||
later on in the comparison view. At least two **named** analyses (i.e.,
|
**named** analyses (i.e., snapshots) are required for an analysis.
|
||||||
snapshots) are required for an analysis.
|
|
||||||
|
|
||||||
You can save an analysis as a snapshot from the save button on the bottom right.
|
You can save can analysis as a snapshot from the save button on the bottom
|
||||||
|
right. Alternatively, you can visit the "compare" page and paste your JSON
|
||||||
|
directly.
|
||||||
|
|
||||||
## Hacking
|
## Hacking
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ export default [
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
eslintConfigPrettier,
|
eslintConfigPrettier,
|
||||||
{
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
ignores: ['dist/', 'node_modules/', '.DS_Store', '*.md'],
|
ignores: ['dist/', 'node_modules/', '.DS_Store', '*.md'],
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
|
|
||||||
pnpmDeps = fetchPnpmDeps {
|
pnpmDeps = fetchPnpmDeps {
|
||||||
inherit (finalAttrs) pname src;
|
inherit (finalAttrs) pname src;
|
||||||
hash = "sha256-zhdgC+sjIUAUsStG3H8RfVSrbhQG62a3zXeIPTUoGbI=";
|
hash = "sha256-KLOhMupWtsD4GO7bndVQ152e1c9t3yu7iCebJrf5dzg=";
|
||||||
fetcherVersion = 3; # https://nixos.org/manual/nixpkgs/stable/#javascript-pnpm-fetcherVersion
|
fetcherVersion = 3; # https://nixos.org/manual/nixpkgs/stable/#javascript-pnpm-fetcherVersion
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
# @ns/core
|
# @ns/core
|
||||||
|
|
||||||
Core types and parsing logic for Nix evaluator statistics. This package is
|
Core types, parsing logic for Nix evaluator statistics, and various bits from
|
||||||
framework-agnostic and can be used in any JavaScript/TypeScript environment.
|
the web component that the CLI may interact with in the future. The `@ns/core`
|
||||||
|
package contains the truly "generic" logic that can be used around the
|
||||||
|
workspace, or even outside it. This package is framework-agnostic and can be
|
||||||
|
used in any JavaScript/TypeScript environment. Which is to say that you can use
|
||||||
|
it to make your own statistics viewer without doing any heavy lifting.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|
@ -20,8 +24,14 @@ const change = calculateChange(stats.nrThunks, previousStats.nrThunks);
|
||||||
console.log(`Thunks changed by ${change.percent.toFixed(2)}%`);
|
console.log(`Thunks changed by ${change.percent.toFixed(2)}%`);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Version Compatibility
|
## Nix Version/Implementation Compatibility
|
||||||
|
|
||||||
The parser handles different Nix implementations (Nix, Lix, Snix, etc.) by
|
There are currently 4 competing implementations of Nix, and only two of those
|
||||||
checking for field existence in the raw JSON, since not all implementations
|
are considered first-class. The parser handles different implementations the
|
||||||
expose the same statistics.
|
best it can by checking the field existence in the raw JSON, but it is not able
|
||||||
|
to handle new fields. It is unfortunate that not all implementations expose the
|
||||||
|
same statistics.
|
||||||
|
|
||||||
|
Worth noting that besides Lix and Nix, there are and will likely be more
|
||||||
|
implementations. If you want _yours_ to be supported as a first class citizen,
|
||||||
|
please submit a pull request.
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lzutf8": "^0.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './parser';
|
export * from './parser';
|
||||||
|
export * from './share';
|
||||||
|
|
|
||||||
47
packages/core/src/share.ts
Normal file
47
packages/core/src/share.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import LZUTF8 from 'lzutf8';
|
||||||
|
|
||||||
|
export type ShareState =
|
||||||
|
| { type: 'analysis'; data: Record<string, unknown>; name: string }
|
||||||
|
| {
|
||||||
|
type: 'compare';
|
||||||
|
left: Record<string, unknown>;
|
||||||
|
right: Record<string, unknown>;
|
||||||
|
leftName: string;
|
||||||
|
rightName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function encodeShareUrl(state: ShareState): string {
|
||||||
|
const json = JSON.stringify(state);
|
||||||
|
const encoded = LZUTF8.compress(json, { outputEncoding: 'Base64' }) as string;
|
||||||
|
return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeShareUrl(encoded: string): ShareState | null {
|
||||||
|
try {
|
||||||
|
let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padLength = (4 - (base64.length % 4)) % 4;
|
||||||
|
base64 += '='.repeat(padLength);
|
||||||
|
const json = LZUTF8.decompress(base64, {
|
||||||
|
inputEncoding: 'Base64',
|
||||||
|
outputEncoding: 'String',
|
||||||
|
}) as string | null;
|
||||||
|
if (!json) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const state = JSON.parse(json) as ShareState;
|
||||||
|
if (state.type === 'analysis') {
|
||||||
|
if (!state.data || !state.name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (state.type === 'compare') {
|
||||||
|
if (!state.left || !state.right || !state.leftName || !state.rightName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,11 @@ export function formatBytes(bytes: number, precision = 2): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatNumber(num: number, precision = 2): string {
|
export function formatNumber(num: number, precision = 2): string {
|
||||||
if (num >= 1e9) return (num / 1e9).toFixed(precision) + 'B';
|
if (num >= 1e9) return parseFloat((num / 1e9).toFixed(precision)).toString() + 'B';
|
||||||
if (num >= 1e6) return (num / 1e6).toFixed(precision) + 'M';
|
if (num >= 1e6) return parseFloat((num / 1e6).toFixed(precision)).toString() + 'M';
|
||||||
if (num >= 1e3) return (num / 1e3).toFixed(precision) + 'K';
|
if (num >= 1e3) return parseFloat((num / 1e3).toFixed(precision)).toString() + 'K';
|
||||||
return num.toString();
|
const factor = Math.pow(10, precision);
|
||||||
|
return (Math.round(num * factor) / factor).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTime(seconds: number, precision = 2): string {
|
export function formatTime(seconds: number, precision = 2): string {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,96 @@
|
||||||
import { Component, For, createSignal, createMemo, Show } from 'solid-js';
|
import { Component, For, createSignal, createMemo, Show, onMount, onCleanup } from 'solid-js';
|
||||||
import { ComparisonEntry, calculateChange } from '@ns/core';
|
import { ComparisonEntry, calculateChange, StatsData } from '@ns/core';
|
||||||
import { formatBytes, formatNumber, formatTime, formatPercent } from '@ns/ui-utils';
|
import { formatBytes, formatNumber, formatTime, formatPercent } from '@ns/ui-utils';
|
||||||
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
|
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
|
||||||
import ArrowDownIcon from 'lucide-solid/icons/arrow-down';
|
import ArrowDownIcon from 'lucide-solid/icons/arrow-down';
|
||||||
import ArrowUpIcon from 'lucide-solid/icons/arrow-up';
|
import ArrowUpIcon from 'lucide-solid/icons/arrow-up';
|
||||||
import XIcon from 'lucide-solid/icons/x';
|
import XIcon from 'lucide-solid/icons/x';
|
||||||
|
import ShareIcon from 'lucide-solid/icons/share';
|
||||||
|
import FileUpload from './FileUpload';
|
||||||
|
|
||||||
interface ComparisonViewProps {
|
interface ComparisonViewProps {
|
||||||
entries: ComparisonEntry[];
|
entries: ComparisonEntry[];
|
||||||
onSelect: (entry: ComparisonEntry) => void;
|
onSelect: (entry: ComparisonEntry) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
precision?: number;
|
precision?: number;
|
||||||
|
pasteMode: 'advance' | 'replace';
|
||||||
|
onPasteModeChange: (mode: 'advance' | 'replace') => void;
|
||||||
|
onPasteStats: (text: string, name: string) => ComparisonEntry | null;
|
||||||
|
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
|
||||||
|
onTextLoad: (text: string) => void;
|
||||||
|
onGenerateShareUrl: (left: ComparisonEntry, right: ComparisonEntry) => void;
|
||||||
|
initialLeftId?: number | null;
|
||||||
|
initialRightId?: number | null;
|
||||||
|
onInitialSelectionUsed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComparisonView: Component<ComparisonViewProps> = props => {
|
const ComparisonView: Component<ComparisonViewProps> = props => {
|
||||||
const prec = () => props.precision ?? 2;
|
const prec = () => props.precision ?? 2;
|
||||||
const [leftEntry, setLeftEntry] = createSignal<ComparisonEntry | null>(null);
|
const [leftEntry, setLeftEntry] = createSignal<ComparisonEntry | null>(null);
|
||||||
const [rightEntry, setRightEntry] = createSignal<ComparisonEntry | null>(null);
|
const [rightEntry, setRightEntry] = createSignal<ComparisonEntry | null>(null);
|
||||||
|
const [showPasteModal, setShowPasteModal] = createSignal(false);
|
||||||
|
const [pasteError, setPasteError] = createSignal('');
|
||||||
|
const [pasteName, setPasteName] = createSignal('');
|
||||||
|
const [pendingPasteText, setPendingPasteText] = createSignal('');
|
||||||
|
|
||||||
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = e.clipboardData?.getData('text');
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
JSON.parse(text);
|
||||||
|
setPendingPasteText(text);
|
||||||
|
setPasteName(`Snapshot ${props.entries.length + 1}`);
|
||||||
|
setShowPasteModal(true);
|
||||||
|
} catch {
|
||||||
|
// Silently ignore invalid JSON on paste
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener('paste', handlePaste);
|
||||||
|
|
||||||
|
if (props.initialLeftId !== null && props.initialLeftId !== undefined) {
|
||||||
|
const left = props.entries.find(e => e.id === props.initialLeftId);
|
||||||
|
if (left) {
|
||||||
|
setLeftEntry(left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (props.initialRightId !== null && props.initialRightId !== undefined) {
|
||||||
|
const right = props.entries.find(e => e.id === props.initialRightId);
|
||||||
|
if (right) {
|
||||||
|
setRightEntry(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (props.initialLeftId != null || props.initialRightId != null) {
|
||||||
|
props.onInitialSelectionUsed?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
document.removeEventListener('paste', handlePaste);
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmPaste = () => {
|
||||||
|
const entry = props.onPasteStats(pendingPasteText(), pasteName());
|
||||||
|
if (!entry) {
|
||||||
|
setPasteError('Failed to process pasted statistics');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.pasteMode === 'advance') {
|
||||||
|
if (rightEntry()) {
|
||||||
|
setLeftEntry(rightEntry());
|
||||||
|
}
|
||||||
|
setRightEntry(entry);
|
||||||
|
} else {
|
||||||
|
setRightEntry(entry);
|
||||||
|
}
|
||||||
|
setShowPasteModal(false);
|
||||||
|
setPasteError('');
|
||||||
|
};
|
||||||
|
|
||||||
const comparison = createMemo(() => {
|
const comparison = createMemo(() => {
|
||||||
const left = leftEntry();
|
const left = leftEntry();
|
||||||
|
|
@ -100,29 +174,57 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="comparison-view">
|
<div class="comparison-view">
|
||||||
<div class="comparison-controls">
|
<Show when={props.entries.length >= 2}>
|
||||||
<div class="compare-selector">
|
<div class="comparison-controls">
|
||||||
<label>Baseline</label>
|
<div class="compare-selector">
|
||||||
<select onChange={selectLeft} value={leftEntry()?.id || ''}>
|
<label>Baseline</label>
|
||||||
<option value="">Select snapshot...</option>
|
<select onChange={selectLeft} value={leftEntry()?.id || ''}>
|
||||||
<For each={props.entries}>
|
<option value="">Select snapshot...</option>
|
||||||
{entry => <option value={entry.id}>{entry.name}</option>}
|
<For each={props.entries}>
|
||||||
</For>
|
{entry => <option value={entry.id}>{entry.name}</option>}
|
||||||
</select>
|
</For>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="compare-arrow">
|
||||||
|
<ArrowRightIcon 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 class="compare-paste-toggle">
|
||||||
|
<button
|
||||||
|
class={props.pasteMode === 'advance' ? 'active' : ''}
|
||||||
|
onClick={() => props.onPasteModeChange('advance')}
|
||||||
|
title="Paste shifts current to baseline"
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={props.pasteMode === 'replace' ? 'active' : ''}
|
||||||
|
onClick={() => props.onPasteModeChange('replace')}
|
||||||
|
title="Paste replaces current only"
|
||||||
|
>
|
||||||
|
Replace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Show when={leftEntry() && rightEntry()}>
|
||||||
|
<button
|
||||||
|
class="share-btn"
|
||||||
|
onClick={() => props.onGenerateShareUrl(leftEntry()!, rightEntry()!)}
|
||||||
|
title="Copy share URL to clipboard"
|
||||||
|
>
|
||||||
|
<ShareIcon size={16} />
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="compare-arrow">
|
</Show>
|
||||||
<ArrowRightIcon 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}>
|
<Show when={props.entries.length > 0}>
|
||||||
<div class="snapshots-list">
|
<div class="snapshots-list">
|
||||||
|
|
@ -141,101 +243,186 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={leftEntry() && rightEntry()}
|
when={props.entries.length >= 2}
|
||||||
fallback={<div class="compare-placeholder">Select two snapshots to compare metrics</div>}
|
fallback={
|
||||||
>
|
<div class="compare-upload-section">
|
||||||
<div class="comparison-table">
|
<FileUpload
|
||||||
<div class="compare-header">
|
onFileLoad={props.onFileLoad}
|
||||||
<div class="col-label">Metric</div>
|
onTextLoad={props.onTextLoad}
|
||||||
<div class="col-value">{leftEntry()?.name}</div>
|
snapshots={props.entries}
|
||||||
<div class="col-value">{rightEntry()?.name}</div>
|
onLoadSnapshot={props.onSelect}
|
||||||
<div class="col-change">Change</div>
|
/>
|
||||||
</div>
|
<Show when={props.entries.length === 1}>
|
||||||
<For each={comparison()}>
|
<div class="compare-more-needed">
|
||||||
{row => (
|
<div class="compare-placeholder-hint">
|
||||||
<div
|
Upload or paste one more snapshot to start comparing
|
||||||
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}>
|
|
||||||
<ArrowDownIcon size={14} />
|
|
||||||
</Show>
|
|
||||||
<Show when={!row.isReduction}>
|
|
||||||
<ArrowUpIcon size={14} />
|
|
||||||
</Show>
|
|
||||||
{Math.abs(row.change).toFixed(prec())}%
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="missing-indicator"
|
|
||||||
title="Field not available in one or both implementations"
|
|
||||||
>
|
|
||||||
N/A
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Show>
|
||||||
</For>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={leftEntry() && rightEntry()}
|
||||||
|
fallback={
|
||||||
|
<div class="compare-placeholder">
|
||||||
|
<div>Select two snapshots to compare metrics</div>
|
||||||
|
<div class="compare-placeholder-hint">
|
||||||
|
Paste JSON stats here (Ctrl+V) while in compare mode
|
||||||
|
</div>
|
||||||
|
</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, prec())
|
||||||
|
: row.format === 'time'
|
||||||
|
? formatTime(row.leftValue, prec())
|
||||||
|
: row.format === 'percent'
|
||||||
|
? formatPercent(row.leftValue, prec())
|
||||||
|
: formatNumber(row.leftValue, prec())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="missing-value">N/A</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="col-value">
|
||||||
|
<Show
|
||||||
|
when={row.isMissing}
|
||||||
|
fallback={
|
||||||
|
row.format === 'bytes'
|
||||||
|
? formatBytes(row.rightValue, prec())
|
||||||
|
: row.format === 'time'
|
||||||
|
? formatTime(row.rightValue, prec())
|
||||||
|
: row.format === 'percent'
|
||||||
|
? formatPercent(row.rightValue, prec())
|
||||||
|
: formatNumber(row.rightValue, prec())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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}>
|
||||||
|
<ArrowDownIcon size={14} />
|
||||||
|
</Show>
|
||||||
|
<Show when={!row.isReduction}>
|
||||||
|
<ArrowUpIcon size={14} />
|
||||||
|
</Show>
|
||||||
|
{parseFloat(
|
||||||
|
(
|
||||||
|
Math.round(Math.abs(row.change) * Math.pow(10, prec())) /
|
||||||
|
Math.pow(10, prec())
|
||||||
|
).toFixed(prec()),
|
||||||
|
)}
|
||||||
|
%
|
||||||
|
</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">
|
<div class="comparison-summary">
|
||||||
<Show when={comparison()?.some(r => r.isReduction)}>
|
<Show when={comparison()?.some(r => r.isReduction)}>
|
||||||
<div class="summary-good">
|
<div class="summary-good">
|
||||||
<ArrowDownIcon size={16} />{' '}
|
<ArrowDownIcon size={16} />{' '}
|
||||||
{comparison()?.filter(r => r.isReduction && r.isDifferent).length} improved
|
{comparison()?.filter(r => r.isReduction && r.isDifferent).length} improved
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={comparison()?.some(r => !r.isReduction && r.isDifferent)}>
|
||||||
|
<div class="summary-bad">
|
||||||
|
<ArrowUpIcon size={16} />{' '}
|
||||||
|
{comparison()?.filter(r => !r.isReduction && r.isDifferent).length} regressed
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={showPasteModal()}>
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPasteModal(false);
|
||||||
|
setPasteError('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Paste Statistics</h3>
|
||||||
|
<button
|
||||||
|
class="close-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPasteModal(false);
|
||||||
|
setPasteError('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon size={20} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
<input
|
||||||
<Show when={comparison()?.some(r => !r.isReduction && r.isDifferent)}>
|
type="text"
|
||||||
<div class="summary-bad">
|
class="snapshot-name-input"
|
||||||
<ArrowUpIcon size={16} />{' '}
|
placeholder="Enter snapshot name..."
|
||||||
{comparison()?.filter(r => !r.isReduction && r.isDifferent).length} regressed
|
value={pasteName()}
|
||||||
|
onInput={e => setPasteName(e.currentTarget.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && confirmPaste()}
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<Show when={pasteError()}>
|
||||||
|
<div class="error">{pasteError()}</div>
|
||||||
|
</Show>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
class="cancel-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPasteModal(false);
|
||||||
|
setPasteError('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="confirm-btn" onClick={confirmPaste}>
|
||||||
|
{props.entries.length >= 2 ? 'Save & Compare' : 'Save Snapshot'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,15 @@ import ClockIcon from 'lucide-solid/icons/clock';
|
||||||
interface FileUploadProps {
|
interface FileUploadProps {
|
||||||
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
|
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
|
||||||
onTextLoad: (text: string) => void;
|
onTextLoad: (text: string) => void;
|
||||||
showHelp: boolean;
|
|
||||||
onToggleHelp: () => void;
|
|
||||||
snapshots?: ComparisonEntry[];
|
snapshots?: ComparisonEntry[];
|
||||||
onLoadSnapshot?: (entry: ComparisonEntry) => void;
|
onLoadSnapshot?: (entry: ComparisonEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileUpload(props: FileUploadProps) {
|
export default function FileUpload(props: FileUploadProps) {
|
||||||
const [textInput, setTextInput] = createSignal('');
|
const [textInput, setTextInput] = createSignal('');
|
||||||
const [isTextMode, setIsTextMode] = createSignal(false);
|
const [isTextMode, setIsTextMode] = createSignal(true);
|
||||||
const [error, setError] = createSignal('');
|
const [error, setError] = createSignal('');
|
||||||
|
const [showHelp, setShowHelp] = createSignal(false);
|
||||||
|
|
||||||
const handleFile = async (e: Event) => {
|
const handleFile = async (e: Event) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
|
|
@ -49,11 +48,11 @@ export default function FileUpload(props: FileUploadProps) {
|
||||||
</div>
|
</div>
|
||||||
<h2>Load Statistics</h2>
|
<h2>Load Statistics</h2>
|
||||||
|
|
||||||
<div class="help-link" onClick={props.onToggleHelp}>
|
<div class="help-link" onClick={() => setShowHelp(!showHelp())}>
|
||||||
How do I use this?
|
How do I use this?
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={props.showHelp}>
|
<Show when={showHelp()}>
|
||||||
<div class="help-panel">
|
<div class="help-panel">
|
||||||
<h4>Generating Stats</h4>
|
<h4>Generating Stats</h4>
|
||||||
<code>NIX_SHOW_STATS=1 nix-instantiate expr.nix > stats.json</code>
|
<code>NIX_SHOW_STATS=1 nix-instantiate expr.nix > stats.json</code>
|
||||||
|
|
@ -85,6 +84,18 @@ export default function FileUpload(props: FileUploadProps) {
|
||||||
class="json-input"
|
class="json-input"
|
||||||
value={textInput()}
|
value={textInput()}
|
||||||
onInput={e => setTextInput(e.currentTarget.value)}
|
onInput={e => setTextInput(e.currentTarget.value)}
|
||||||
|
onPaste={e => {
|
||||||
|
const text = e.clipboardData?.getData('text');
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
JSON.parse(text);
|
||||||
|
e.preventDefault();
|
||||||
|
props.onTextLoad(text);
|
||||||
|
} catch {
|
||||||
|
setTextInput(text);
|
||||||
|
setError('Invalid JSON in clipboard');
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button class="load-btn" onClick={handleTextLoad}>
|
<button class="load-btn" onClick={handleTextLoad}>
|
||||||
Load
|
Load
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ const ThunkChart: Component<ThunkChartProps> = props => {
|
||||||
<div class="ratio-bar">
|
<div class="ratio-bar">
|
||||||
<div class="ratio-fill" style={{ width: `${avoidedRatio() * 100}%` }} />
|
<div class="ratio-fill" style={{ width: `${avoidedRatio() * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span class="ratio-label">Avoidance rate: {(avoidedRatio() * 100).toFixed(prec())}%</span>
|
<span class="ratio-label">
|
||||||
|
Avoidance rate:{' '}
|
||||||
|
{Math.round(avoidedRatio() * 100 * Math.pow(10, prec())) / Math.pow(10, prec())}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,16 @@ import SaveIcon from 'lucide-solid/icons/save';
|
||||||
import UploadIcon from 'lucide-solid/icons/upload';
|
import UploadIcon from 'lucide-solid/icons/upload';
|
||||||
import Trash2Icon from 'lucide-solid/icons/trash-2';
|
import Trash2Icon from 'lucide-solid/icons/trash-2';
|
||||||
import XIcon from 'lucide-solid/icons/x';
|
import XIcon from 'lucide-solid/icons/x';
|
||||||
|
import ShareIcon from 'lucide-solid/icons/link-2';
|
||||||
import FileUpload from './components/FileUpload';
|
import FileUpload from './components/FileUpload';
|
||||||
import { StatsData, ComparisonEntry, parseStats } from '@ns/core';
|
import {
|
||||||
|
StatsData,
|
||||||
|
ComparisonEntry,
|
||||||
|
parseStats,
|
||||||
|
encodeShareUrl,
|
||||||
|
decodeShareUrl,
|
||||||
|
ShareState,
|
||||||
|
} from '@ns/core';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
|
function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
|
||||||
|
|
@ -29,10 +37,15 @@ function App() {
|
||||||
const [view, setView] = createSignal<'analysis' | 'compare'>('analysis');
|
const [view, setView] = createSignal<'analysis' | 'compare'>('analysis');
|
||||||
const [snapshotName, setSnapshotName] = createSignal('');
|
const [snapshotName, setSnapshotName] = createSignal('');
|
||||||
const [showSaveDialog, setShowSaveDialog] = createSignal(false);
|
const [showSaveDialog, setShowSaveDialog] = createSignal(false);
|
||||||
const [showHelp, setShowHelp] = createSignal(false);
|
|
||||||
const [showManageSnapshots, setShowManageSnapshots] = createSignal(false);
|
const [showManageSnapshots, setShowManageSnapshots] = createSignal(false);
|
||||||
const [precision, setPrecision] = createSignal(2);
|
const [precision, setPrecision] = createSignal(2);
|
||||||
|
const [pasteMode, setPasteMode] = createSignal<'advance' | 'replace'>('advance');
|
||||||
const [isLoading, setIsLoading] = createSignal(true);
|
const [isLoading, setIsLoading] = createSignal(true);
|
||||||
|
const [showOverrideModal, setShowOverrideModal] = createSignal(false);
|
||||||
|
const [pendingOverrideText, setPendingOverrideText] = createSignal('');
|
||||||
|
const [showShareToast, setShowShareToast] = createSignal(false);
|
||||||
|
const [initialLeftId, setInitialLeftId] = createSignal<number | null>(null);
|
||||||
|
const [initialRightId, setInitialRightId] = createSignal<number | null>(null);
|
||||||
|
|
||||||
const STORAGE_KEY = 'ns-data';
|
const STORAGE_KEY = 'ns-data';
|
||||||
|
|
||||||
|
|
@ -62,10 +75,58 @@ function App() {
|
||||||
if (typeof parsed.precision === 'number' && parsed.precision >= 0) {
|
if (typeof parsed.precision === 'number' && parsed.precision >= 0) {
|
||||||
setPrecision(parsed.precision);
|
setPrecision(parsed.precision);
|
||||||
}
|
}
|
||||||
|
if (parsed.pasteMode === 'advance' || parsed.pasteMode === 'replace') {
|
||||||
|
setPasteMode(parsed.pasteMode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to load saved data:', e);
|
console.warn('Failed to load saved data:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const shareParam = params.get('share');
|
||||||
|
if (shareParam) {
|
||||||
|
const shareState = decodeShareUrl(shareParam);
|
||||||
|
if (shareState) {
|
||||||
|
try {
|
||||||
|
if (shareState.type === 'analysis') {
|
||||||
|
const data = parseStats(shareState.data);
|
||||||
|
setCurrentStats(data);
|
||||||
|
setCurrentRaw(shareState.data);
|
||||||
|
setSnapshotName(shareState.name);
|
||||||
|
setView('analysis');
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
} else if (shareState.type === 'compare') {
|
||||||
|
const leftData = parseStats(shareState.left);
|
||||||
|
const rightData = parseStats(shareState.right);
|
||||||
|
const leftId = Date.now();
|
||||||
|
const rightId = Date.now() + 1;
|
||||||
|
const leftEntry: ComparisonEntry = {
|
||||||
|
id: leftId,
|
||||||
|
name: shareState.leftName,
|
||||||
|
data: leftData,
|
||||||
|
raw: shareState.left,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
const rightEntry: ComparisonEntry = {
|
||||||
|
id: rightId,
|
||||||
|
name: shareState.rightName,
|
||||||
|
data: rightData,
|
||||||
|
raw: shareState.right,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setSnapshots([leftEntry, rightEntry]);
|
||||||
|
setInitialLeftId(leftId);
|
||||||
|
setInitialRightId(rightId);
|
||||||
|
setView('compare');
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load shared data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|
@ -76,8 +137,28 @@ function App() {
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
const handleAnalysisPaste = (e: ClipboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = e.clipboardData?.getData('text');
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (view() === 'analysis' && currentStats()) {
|
||||||
|
setPendingOverrideText(text);
|
||||||
|
setShowOverrideModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('paste', handleAnalysisPaste);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.removeEventListener('paste', handleAnalysisPaste);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -89,6 +170,7 @@ function App() {
|
||||||
snaps: ComparisonEntry[],
|
snaps: ComparisonEntry[],
|
||||||
v: 'analysis' | 'compare',
|
v: 'analysis' | 'compare',
|
||||||
prec: number,
|
prec: number,
|
||||||
|
pm: 'advance' | 'replace',
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
|
@ -99,6 +181,7 @@ function App() {
|
||||||
currentRaw: raw,
|
currentRaw: raw,
|
||||||
view: v,
|
view: v,
|
||||||
precision: prec,
|
precision: prec,
|
||||||
|
pasteMode: pm,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -115,10 +198,11 @@ function App() {
|
||||||
const snaps = snapshots();
|
const snaps = snapshots();
|
||||||
const v = view();
|
const v = view();
|
||||||
const prec = precision();
|
const prec = precision();
|
||||||
|
const pm = pasteMode();
|
||||||
|
|
||||||
if (isLoading()) return;
|
if (isLoading()) return;
|
||||||
|
|
||||||
saveToStorage(stats, raw, snaps, v, prec);
|
saveToStorage(stats, raw, snaps, v, prec, pm);
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveSnapshot = () => {
|
const saveSnapshot = () => {
|
||||||
|
|
@ -138,6 +222,25 @@ function App() {
|
||||||
setShowSaveDialog(false);
|
setShowSaveDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shareAnalysis = () => {
|
||||||
|
const stats = currentStats();
|
||||||
|
const raw = currentRaw();
|
||||||
|
if (!stats || !raw) return;
|
||||||
|
const name = snapshotName().trim() || `Analysis ${Date.now()}`;
|
||||||
|
const state: ShareState = { type: 'analysis', data: raw, name };
|
||||||
|
const encoded = encodeShareUrl(state);
|
||||||
|
const url = `${window.location.origin}${window.location.pathname}?share=${encoded}`;
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(url)
|
||||||
|
.then(() => {
|
||||||
|
setShowShareToast(true);
|
||||||
|
setTimeout(() => setShowShareToast(false), 2000);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to copy share URL:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const deleteSnapshot = (id: number) => {
|
const deleteSnapshot = (id: number) => {
|
||||||
setSnapshots(prev => prev.filter(e => e.id !== id));
|
setSnapshots(prev => prev.filter(e => e.id !== id));
|
||||||
};
|
};
|
||||||
|
|
@ -171,6 +274,76 @@ function App() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePasteStats = (text: string, name: string): ComparisonEntry | null => {
|
||||||
|
let raw: Record<string, unknown>;
|
||||||
|
let data: StatsData;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(text);
|
||||||
|
data = parseStats(raw);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse pasted stats:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entry: ComparisonEntry = {
|
||||||
|
id: Date.now(),
|
||||||
|
name: name.trim() || `Snapshot ${snapshots().length + 1}`,
|
||||||
|
data,
|
||||||
|
raw,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setSnapshots(prev => [...prev, entry]);
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompareFileLoad = (data: StatsData, raw: Record<string, unknown>) => {
|
||||||
|
const entry: ComparisonEntry = {
|
||||||
|
id: Date.now(),
|
||||||
|
name: `Snapshot ${snapshots().length + 1}`,
|
||||||
|
data,
|
||||||
|
raw,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setSnapshots(prev => [...prev, entry]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompareTextLoad = (text: string) => {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(text);
|
||||||
|
const data = parseStats(raw);
|
||||||
|
const entry: ComparisonEntry = {
|
||||||
|
id: Date.now(),
|
||||||
|
name: `Snapshot ${snapshots().length + 1}`,
|
||||||
|
data,
|
||||||
|
raw,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setSnapshots(prev => [...prev, entry]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse stats:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateShareUrl = (left: ComparisonEntry, right: ComparisonEntry) => {
|
||||||
|
const state: ShareState = {
|
||||||
|
type: 'compare',
|
||||||
|
left: left.raw,
|
||||||
|
right: right.raw,
|
||||||
|
leftName: left.name,
|
||||||
|
rightName: right.name,
|
||||||
|
};
|
||||||
|
const encoded = encodeShareUrl(state);
|
||||||
|
const url = `${window.location.origin}${window.location.pathname}?share=${encoded}`;
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(url)
|
||||||
|
.then(() => {
|
||||||
|
setShowShareToast(true);
|
||||||
|
setTimeout(() => setShowShareToast(false), 2000);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to copy share URL:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
|
|
@ -181,11 +354,7 @@ function App() {
|
||||||
<button class={view() === 'analysis' ? 'active' : ''} onClick={() => setView('analysis')}>
|
<button class={view() === 'analysis' ? 'active' : ''} onClick={() => setView('analysis')}>
|
||||||
Analysis
|
Analysis
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class={view() === 'compare' ? 'active' : ''} onClick={() => setView('compare')}>
|
||||||
class={view() === 'compare' ? 'active' : ''}
|
|
||||||
onClick={() => setView('compare')}
|
|
||||||
disabled={snapshots().length < 2}
|
|
||||||
>
|
|
||||||
Compare ({snapshots().length})
|
Compare ({snapshots().length})
|
||||||
</button>
|
</button>
|
||||||
<Show when={snapshots().length > 0}>
|
<Show when={snapshots().length > 0}>
|
||||||
|
|
@ -223,8 +392,6 @@ function App() {
|
||||||
<FileUpload
|
<FileUpload
|
||||||
onFileLoad={handleFileLoad}
|
onFileLoad={handleFileLoad}
|
||||||
onTextLoad={loadFromText}
|
onTextLoad={loadFromText}
|
||||||
showHelp={showHelp()}
|
|
||||||
onToggleHelp={() => setShowHelp(!showHelp())}
|
|
||||||
snapshots={snapshots()}
|
snapshots={snapshots()}
|
||||||
onLoadSnapshot={loadSnapshot}
|
onLoadSnapshot={loadSnapshot}
|
||||||
/>
|
/>
|
||||||
|
|
@ -255,6 +422,30 @@ function App() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={showOverrideModal()}>
|
||||||
|
<div class="modal-overlay" onClick={() => setShowOverrideModal(false)}>
|
||||||
|
<div class="modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>Override Analysis?</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', 'margin-bottom': '1rem' }}>
|
||||||
|
This will replace the current analysis with pasted data.
|
||||||
|
</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-btn" onClick={() => setShowOverrideModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="confirm-btn"
|
||||||
|
onClick={() => {
|
||||||
|
loadFromText(pendingOverrideText());
|
||||||
|
setShowOverrideModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Override
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
@ -264,6 +455,18 @@ function App() {
|
||||||
onSelect={entry => setCurrentStats(entry.data)}
|
onSelect={entry => setCurrentStats(entry.data)}
|
||||||
onDelete={deleteSnapshot}
|
onDelete={deleteSnapshot}
|
||||||
precision={precision()}
|
precision={precision()}
|
||||||
|
pasteMode={pasteMode()}
|
||||||
|
onPasteModeChange={setPasteMode}
|
||||||
|
onPasteStats={handlePasteStats}
|
||||||
|
onFileLoad={handleCompareFileLoad}
|
||||||
|
onTextLoad={handleCompareTextLoad}
|
||||||
|
onGenerateShareUrl={handleGenerateShareUrl}
|
||||||
|
initialLeftId={initialLeftId()}
|
||||||
|
initialRightId={initialRightId()}
|
||||||
|
onInitialSelectionUsed={() => {
|
||||||
|
setInitialLeftId(null);
|
||||||
|
setInitialRightId(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -329,11 +532,18 @@ function App() {
|
||||||
>
|
>
|
||||||
<SaveIcon size={20} />
|
<SaveIcon size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
<button class="action-btn share" onClick={shareAnalysis} title="Share Analysis">
|
||||||
|
<ShareIcon size={20} />
|
||||||
|
</button>
|
||||||
<button class="action-btn clear" onClick={() => setCurrentStats(null)} title="Load New">
|
<button class="action-btn clear" onClick={() => setCurrentStats(null)} title="Load New">
|
||||||
<UploadIcon size={20} />
|
<UploadIcon size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={showShareToast()}>
|
||||||
|
<div class="toast">Share URL copied to clipboard!</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,7 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
padding-top: 4rem;
|
padding-top: 4rem;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-card {
|
.upload-card {
|
||||||
|
|
@ -443,6 +444,16 @@ body {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn.share {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.share:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn.clear {
|
.action-btn.clear {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
@ -824,6 +835,9 @@ body {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-label {
|
.metric-label {
|
||||||
|
|
@ -983,6 +997,8 @@ body {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-legend {
|
.chart-legend {
|
||||||
|
|
@ -1029,6 +1045,8 @@ body {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-percent {
|
.legend-percent {
|
||||||
|
|
@ -1036,6 +1054,8 @@ body {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
min-width: 40px;
|
min-width: 40px;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donut-chart svg {
|
.donut-chart svg {
|
||||||
|
|
@ -1130,6 +1150,8 @@ body {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gc-stats {
|
.gc-stats {
|
||||||
|
|
@ -1157,6 +1179,8 @@ body {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Operations Chart */
|
/* Operations Chart */
|
||||||
|
|
@ -1213,6 +1237,8 @@ body {
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thunk Chart */
|
/* Thunk Chart */
|
||||||
|
|
@ -1267,6 +1293,8 @@ body {
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thunk-ratio {
|
.thunk-ratio {
|
||||||
|
|
@ -1294,6 +1322,8 @@ body {
|
||||||
.ratio-label {
|
.ratio-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
|
|
@ -1341,6 +1371,8 @@ body {
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top Lists */
|
/* Top Lists */
|
||||||
|
|
@ -1384,6 +1416,9 @@ body {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-item .location {
|
.top-item .location {
|
||||||
|
|
@ -1441,6 +1476,56 @@ body {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compare-paste-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-paste-toggle button {
|
||||||
|
height: calc(0.75rem * 2 + 1rem + 2px);
|
||||||
|
padding: 0 0.625rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-paste-toggle button:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-paste-toggle button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
height: calc(0.75rem * 2 + 1rem + 2px);
|
||||||
|
padding: 0 0.875rem;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.compare-placeholder {
|
.compare-placeholder {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem;
|
padding: 4rem;
|
||||||
|
|
@ -1450,6 +1535,25 @@ body {
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compare-placeholder-hint {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-upload-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-more-needed {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.comparison-table {
|
.comparison-table {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|
@ -1510,10 +1614,14 @@ body {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compare-row .col-change {
|
.compare-row .col-change {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-value {
|
.change-value {
|
||||||
|
|
@ -1690,3 +1798,30 @@ body {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 6rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 0.875rem 1.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: toast-in 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(0.5rem);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
1494
pnpm-lock.yaml
generated
1494
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue