mirror of
https://github.com/NotAShelf/nix-evaluator-stats.git
synced 2026-05-09 00:45:35 +00:00
Merge pull request #20 from NotAShelf/notashelf/push-oszosmuyzyoo
allow sharing analysis and comparison pages independently
This commit is contained in:
commit
c8b307341a
10 changed files with 751 additions and 1060 deletions
|
|
@ -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 @@
|
|||
|
||||

|
||||
|
||||
`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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -16,5 +16,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"lzutf8": "^0.6.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './types';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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