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"> <div align="center">
<h1 id="header">nix-evaluator-stats</h1> <h1 id="header">nix-evaluator-stats</h1>
@ -20,21 +20,29 @@
![Demo](./assets/ns-demo.png) ![Demo](./assets/ns-demo.png)
`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

View file

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

View file

@ -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.

View file

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

View file

@ -1,2 +1,3 @@
export * from './types'; export * from './types';
export * from './parser'; 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 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'; import FileUpload from './FileUpload';
interface ComparisonViewProps { interface ComparisonViewProps {
@ -17,6 +18,10 @@ interface ComparisonViewProps {
onPasteStats: (text: string, name: string) => ComparisonEntry | null; onPasteStats: (text: string, name: string) => ComparisonEntry | null;
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void; onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
onTextLoad: (text: string) => 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 => {
@ -47,6 +52,22 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
onMount(() => { onMount(() => {
document.addEventListener('paste', handlePaste); 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(() => { onCleanup(() => {
@ -192,6 +213,16 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
Replace Replace
</button> </button>
</div> </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>
</Show> </Show>

View file

@ -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>>(
@ -35,6 +43,9 @@ function App() {
const [isLoading, setIsLoading] = createSignal(true); const [isLoading, setIsLoading] = createSignal(true);
const [showOverrideModal, setShowOverrideModal] = createSignal(false); const [showOverrideModal, setShowOverrideModal] = createSignal(false);
const [pendingOverrideText, setPendingOverrideText] = createSignal(''); 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';
@ -71,6 +82,51 @@ function App() {
} 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) => {
@ -166,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));
}; };
@ -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 ( return (
<div class="app"> <div class="app">
<header class="header"> <header class="header">
@ -364,6 +460,13 @@ function App() {
onPasteStats={handlePasteStats} onPasteStats={handlePasteStats}
onFileLoad={handleCompareFileLoad} onFileLoad={handleCompareFileLoad}
onTextLoad={handleCompareTextLoad} onTextLoad={handleCompareTextLoad}
onGenerateShareUrl={handleGenerateShareUrl}
initialLeftId={initialLeftId()}
initialRightId={initialRightId()}
onInitialSelectionUsed={() => {
setInitialLeftId(null);
setInitialRightId(null);
}}
/> />
</Show> </Show>
</Show> </Show>
@ -429,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>
); );
} }

View file

@ -444,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);
@ -1496,6 +1506,26 @@ body {
color: white; 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;
@ -1768,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

File diff suppressed because it is too large Load diff