Merge pull request #20 from NotAShelf/notashelf/push-oszosmuyzyoo

allow sharing analysis and comparison pages independently
This commit is contained in:
raf 2026-04-16 09:49:11 +03:00 committed by GitHub
commit c8b307341a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 751 additions and 1060 deletions

View file

@ -1,4 +1,4 @@
<!-- markdownlint-disable MD033 -->
<!-- markdownlint-disable MD033 MD041 -->
<div align="center">
<h1 id="header">nix-evaluator-stats</h1>
@ -20,21 +20,29 @@
![Demo](./assets/ns-demo.png)
`nix-evaluator-stats`, or "ns" for short, is a pretty visualiser for the Nix
evaluator stats export from `NIX_SHOW_STATS` and `NIX_COUNT_CALLS` invocations.
It takes the resulting JSON data from your Nix invocation with the relevant
variables, and provides a ✨ pretty ✨ dashboard-like visual with the ability to
compare your "snapshots" of benchmarks. Besides looking nice, it is helpful in
`nix-evaluator-stats`, or "`ns`", is a pretty visualiser for the Nix evaluator
stats export from `NIX_SHOW_STATS` and `NIX_COUNT_CALLS` invocations. It takes
the resulting JSON data from your Nix invocation with the relevant variables,
and provides a ✨ pretty ✨ dashboard-like visual with the ability to compare
your "snapshots" of benchmarks. Besides looking nice, it is helpful in
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
NS provides both a web application for pretty visuals, and a terminal client (a
TUI) for rendering the statistics from your terminal.
NS is primarily a web application for pretty visuals, with a terminal client
(both a CLI and a TUI) planned for rendering statistics or sharing your analyses
with others.
### 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
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,
@ -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
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 are an "experimental" (just means they're new and unpolished) feature
that lets you save an analysis in your browser storage with a name to be used
later on in the comparison view. At least two **named** analyses (i.e.,
snapshots) are required for an analysis.
Snapshots are a new feature that lets you save an analysis in your browser
storage with a name to be used later on in the comparison view. At least two
**named** analyses (i.e., 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

View file

@ -20,7 +20,7 @@ stdenv.mkDerivation (finalAttrs: {
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname src;
hash = "sha256-zhdgC+sjIUAUsStG3H8RfVSrbhQG62a3zXeIPTUoGbI=";
hash = "sha256-KLOhMupWtsD4GO7bndVQ152e1c9t3yu7iCebJrf5dzg=";
fetcherVersion = 3; # https://nixos.org/manual/nixpkgs/stable/#javascript-pnpm-fetcherVersion
};

View file

@ -1,7 +1,11 @@
# @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.
Core types, parsing logic for Nix evaluator statistics, and various bits from
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
@ -20,8 +24,14 @@ const change = calculateChange(stats.nrThunks, previousStats.nrThunks);
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
checking for field existence in the raw JSON, since not all implementations
expose the same statistics.
There are currently 4 competing implementations of Nix, and only two of those
are considered first-class. The parser handles different implementations the
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.

View file

@ -16,5 +16,8 @@
},
"devDependencies": {
"typescript": "^6.0.2"
},
"dependencies": {
"lzutf8": "^0.6.3"
}
}

View file

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

View 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;
}
}

View file

@ -5,6 +5,7 @@ import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
import ArrowDownIcon from 'lucide-solid/icons/arrow-down';
import ArrowUpIcon from 'lucide-solid/icons/arrow-up';
import XIcon from 'lucide-solid/icons/x';
import ShareIcon from 'lucide-solid/icons/share';
import FileUpload from './FileUpload';
interface ComparisonViewProps {
@ -17,6 +18,10 @@ interface ComparisonViewProps {
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 => {
@ -47,6 +52,22 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
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(() => {
@ -192,6 +213,16 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
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>
</Show>

View file

@ -4,8 +4,16 @@ import SaveIcon from 'lucide-solid/icons/save';
import UploadIcon from 'lucide-solid/icons/upload';
import Trash2Icon from 'lucide-solid/icons/trash-2';
import XIcon from 'lucide-solid/icons/x';
import ShareIcon from 'lucide-solid/icons/link-2';
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';
function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
@ -35,6 +43,9 @@ function App() {
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';
@ -71,6 +82,51 @@ function App() {
} catch (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);
const handleKeyDown = (e: KeyboardEvent) => {
@ -166,6 +222,25 @@ function App() {
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) => {
setSnapshots(prev => prev.filter(e => e.id !== id));
};
@ -248,6 +323,27 @@ function App() {
}
};
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 (
<div class="app">
<header class="header">
@ -364,6 +460,13 @@ function App() {
onPasteStats={handlePasteStats}
onFileLoad={handleCompareFileLoad}
onTextLoad={handleCompareTextLoad}
onGenerateShareUrl={handleGenerateShareUrl}
initialLeftId={initialLeftId()}
initialRightId={initialRightId()}
onInitialSelectionUsed={() => {
setInitialLeftId(null);
setInitialRightId(null);
}}
/>
</Show>
</Show>
@ -429,11 +532,18 @@ function App() {
>
<SaveIcon size={20} />
</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">
<UploadIcon size={20} />
</button>
</div>
</Show>
<Show when={showShareToast()}>
<div class="toast">Share URL copied to clipboard!</div>
</Show>
</div>
);
}

View file

@ -444,6 +444,16 @@ body {
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 {
background: var(--bg-secondary);
color: var(--text-secondary);
@ -1496,6 +1506,26 @@ body {
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 {
text-align: center;
padding: 4rem;
@ -1768,3 +1798,30 @@ body {
font-size: 0.75rem;
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

File diff suppressed because it is too large Load diff