Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ A free online EXPLAIN plan visualizer. Paste the EXPLAIN output of a **PostgreSQ

- **Four views:** an interactive graph, a compact diagram, a sortable table and summary statistics.
- **Automatic findings:** hotspots, bad row estimates, disk sorts, large sequential scans and cache misses are flagged on each node.
- **Encrypted short links:** the plan is encrypted in the browser before it is stored, and the key exists only in the link, so the server cannot read it. Links expire after 30 days.
- **Encrypted sharing links:** the plan is encrypted in the browser before it is stored, and the key exists only in the link, so the server cannot read it. Links expire 30 days after they were last opened.
- **Auto-detection:** the engine and format are detected from the pasted text, or you can pick them by hand.

> [!NOTE]
Expand Down Expand Up @@ -80,7 +80,7 @@ WHERE plan_id = (SELECT MAX(plan_id) FROM plan_table);
- [`@tabularis/explain`](https://www.npmjs.com/package/@tabularis/explain) for parsing, analysis and the plan views
- [`@tabularis/explain-sqlserver`](https://www.npmjs.com/package/@tabularis/explain-sqlserver) for SQL Server SHOWPLAN XML
- [`@tabularis/explain-oracle`](https://www.npmjs.com/package/@tabularis/explain-oracle) for Oracle execution plans
- [Cloudflare Workers](https://developers.cloudflare.com/workers/) and [R2](https://developers.cloudflare.com/r2/) for the short links
- [Cloudflare Workers](https://developers.cloudflare.com/workers/) and [R2](https://developers.cloudflare.com/r2/) for the sharing links
- [Vitest](https://vitest.dev) + Testing Library for tests

## Development
Expand Down Expand Up @@ -111,9 +111,9 @@ vercel deploy

(Framework preset: **Vite**, build command `pnpm build`, output directory `dist`.)

## Short links
## Sharing links

Short links are served by a small Cloudflare Worker in [`worker/`](./worker), deployed at `share.tabularis.dev`. The browser compresses and encrypts the plan with AES-GCM, sends only the ciphertext, and keeps the key in the part of the link after `#`, which browsers never send to a server. The Worker stores the ciphertext in an R2 bucket, where a lifecycle rule deletes it after 30 days.
Sharing links are served by a small Cloudflare Worker in [`worker/`](./worker), deployed at `share.tabularis.dev`. The browser compresses and encrypts the plan with AES-GCM, sends only the ciphertext, and keeps the key in the part of the link after `#`, which browsers never send to a server. The Worker stores the ciphertext in an R2 bucket, where a lifecycle rule deletes it 30 days after its last write. Opening a link rewrites a blob that is more than a day old, so links expire 30 days after they were last opened.

```bash
cd worker
Expand Down
1 change: 0 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- Same favicon treatment as tabularis.dev: the Tabularis logo -->
<link rel="icon" type="image/svg+xml" href="/tabularis-logo.svg" />
<link rel="apple-touch-icon" href="/tabularis-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
Expand Down
21 changes: 21 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,27 @@ describe('App', () => {
expect(screen.queryByRole('dialog', {name: /copy-paste/i})).not.toBeInTheDocument();
});

it('keeps the promo open when the demo video is closed with Escape', async () => {
const user = userEvent.setup();
renderApp();

await loadSample(user, 'SQLite sample');
await visualize(user);
const dialog = await screen.findByRole('dialog', {name: /copy-paste/i});

await user.tab();
expect(within(dialog).getByRole('button', {name: 'Close'})).toHaveFocus();

const preview = within(dialog).getByRole('button', {name: /watch the tabularis/i});
preview.focus();
await user.keyboard('{Enter}');
expect(screen.getByRole('dialog', {name: /watch the tabularis/i})).toBeInTheDocument();
await user.keyboard('{Escape}');

expect(screen.queryByRole('dialog', {name: /watch the tabularis/i})).not.toBeInTheDocument();
expect(screen.getByRole('dialog', {name: /copy-paste/i})).toBeInTheDocument();
});

it('visualizes the Oracle sample and explains how to capture a plan', async () => {
const user = userEvent.setup();
const {container} = renderApp();
Expand Down
3 changes: 2 additions & 1 deletion src/components/layout/Footer/Footer.module.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.footerWrapper {
width: 100%;
padding: 4rem 1.25rem 3rem;
margin-top: 4rem;
padding: 3rem 1.25rem;
}

.footerContent {
Expand Down
10 changes: 0 additions & 10 deletions src/components/layout/Header/Header.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,3 @@
white-space: nowrap;
}
}

.buttonSpinner {
animation: buttonSpin 0.8s linear infinite;
}

@keyframes buttonSpin {
to {
transform: rotate(360deg);
}
}
81 changes: 15 additions & 66 deletions src/components/layout/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,10 @@
import {Check, DownloadIcon, Link2, LoaderCircle, RotateCcwIcon, TriangleAlert} from 'lucide-react';
import {useEffect, useRef, useState} from 'react';
import {DownloadIcon, Link2, RotateCcwIcon} from 'lucide-react';
import {useEffect, useState} from 'react';
import {Link, useLocation} from 'react-router-dom';
import {TABULARIS} from '../../../lib/links/links';
import {createShortLink} from '../../../lib/share/share';
import {Button} from '../../ui/Button/Button';
import styles from './Header.module.scss';

type ShareStatus = 'idle' | 'sharing' | 'copied' | 'failed';

const STATUS_LABEL: Record<ShareStatus, string> = {
idle: 'Share',
sharing: 'Sharing',
copied: 'Copied',
failed: 'Share failed',
};

const STATUS_ICON = {
idle: Link2,
sharing: LoaderCircle,
copied: Check,
failed: TriangleAlert,
};

async function copyToClipboard(link: Promise<string>) {
if (typeof ClipboardItem === 'undefined') {
await navigator.clipboard.writeText(await link);
return;
}
const blob = link.then((text) => new Blob([text], {type: 'text/plain'}));
await navigator.clipboard.write([new ClipboardItem({'text/plain': blob})]);
}
import {ShareModal} from './ShareModal/ShareModal';

interface HeaderProps {
isHomePage?: boolean;
Expand All @@ -38,38 +13,13 @@ interface HeaderProps {

export function Header({isHomePage = true, shareable = false}: HeaderProps) {
const {hash} = useLocation();
const [status, setStatus] = useState<ShareStatus>('idle');
const shortLink = useRef<Promise<string> | null>(null);
const [shareOpen, setShareOpen] = useState(false);
const [shareLink, setShareLink] = useState<string | null>(null);

useEffect(() => {
shortLink.current = null;
setShareLink(hash.startsWith('#s=') ? `${window.location.origin}/plan${hash}` : null);
}, [hash]);

useEffect(() => {
if (status !== 'copied' && status !== 'failed') return;
const timer = setTimeout(() => setStatus('idle'), 2000);
return () => clearTimeout(timer);
}, [status]);

const share = async () => {
if (status === 'sharing') return;
setStatus('sharing');
shortLink.current ??= (
hash.startsWith('#s=') ? Promise.resolve(window.location.href) : createShortLink()
).catch((error: unknown) => {
shortLink.current = null;
throw error;
});
try {
await copyToClipboard(shortLink.current);
setStatus('copied');
} catch {
setStatus('failed');
}
};

const StatusIcon = STATUS_ICON[status];

return (
<header className={styles.header}>
<Link to="/" className={styles.logo}>
Expand All @@ -83,17 +33,12 @@ export function Header({isHomePage = true, shareable = false}: HeaderProps) {
variant="secondary"
size="sm"
className={styles.button}
title="Copy a link to this plan"
onClick={share}
title="Share this plan"
aria-haspopup="dialog"
onClick={() => setShareOpen(true)}
>
<StatusIcon
size={16}
className={status === 'sharing' ? styles.buttonSpinner : undefined}
aria-hidden="true"
/>
<span className={styles.buttonLabel} aria-live="polite">
{STATUS_LABEL[status]}
</span>
<Link2 size={16} aria-hidden="true" />
<span className={styles.buttonLabel}>Share</span>
</Button>
)}
{!isHomePage && (
Expand All @@ -107,6 +52,10 @@ export function Header({isHomePage = true, shareable = false}: HeaderProps) {
<span className={styles.buttonLabel}>Get Tabularis</span>
</Button>
</nav>

{shareOpen && (
<ShareModal link={shareLink} onLinkCreated={setShareLink} onClose={() => setShareOpen(false)} />
)}
</header>
);
}
48 changes: 48 additions & 0 deletions src/components/layout/Header/ShareModal/ShareModal.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
.shareFacts {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.5rem 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
line-height: 1.5;
list-style: none;

li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}

svg {
flex-shrink: 0;
margin-top: 0.2rem;
color: var(--color-accent-teal);
}
}

.shareButton {
align-self: flex-end;
}

.shareSpinner {
animation: shareSpin 0.8s linear infinite;
}

.shareError {
margin: 0;
color: var(--color-accent-error);
font-size: 0.8rem;
}

@keyframes shareSpin {
to {
transform: rotate(360deg);
}
}

@media (prefers-reduced-motion: reduce) {
.shareSpinner {
animation: none;
}
}
107 changes: 107 additions & 0 deletions src/components/layout/Header/ShareModal/ShareModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {Check, Clock, Copy, Link2, LoaderCircle, Lock, RotateCw, Users, type LucideIcon} from 'lucide-react';
import {useEffect, useState} from 'react';
import {createShareLink} from '../../../../lib/share/share';
import {Button} from '../../../ui/Button/Button';
import {Modal} from '../../../ui/Modal/Modal';
import styles from './ShareModal.module.scss';

type ShareStatus = 'idle' | 'creating' | 'copied' | 'failed';

const FACTS = [
{
icon: Lock,
text: 'The plan is encrypted in your browser before it is sent. The key exists only in the link, so we cannot read it.',
},
{
icon: Clock,
text: 'The link stays available for 30 days after it was last opened. After that, the encrypted plan is deleted.',
},
{icon: Users, text: 'Anyone with the link can open the plan, without an account.'},
];

const BUTTON_LABEL: Record<ShareStatus, string> = {
idle: 'Copy link',
creating: 'Creating link',
copied: 'Copied',
failed: 'Try again',
};

const BUTTON_ICON: Record<ShareStatus, LucideIcon> = {
idle: Copy,
creating: LoaderCircle,
copied: Check,
failed: RotateCw,
};

async function copyToClipboard(link: Promise<string>) {
if (typeof ClipboardItem === 'undefined') {
await navigator.clipboard.writeText(await link);
return;
}
const blob = link.then((text) => new Blob([text], {type: 'text/plain'}));
await navigator.clipboard.write([new ClipboardItem({'text/plain': blob})]);
}

interface ShareModalProps {
link: string | null;
onLinkCreated: (link: string) => void;
onClose: () => void;
}

export function ShareModal({link, onLinkCreated, onClose}: ShareModalProps) {
const [status, setStatus] = useState<ShareStatus>('idle');

useEffect(() => {
if (status !== 'copied') return;
const timer = setTimeout(() => setStatus('idle'), 2000);
return () => clearTimeout(timer);
}, [status]);

const copyLink = async () => {
if (status === 'creating') return;
if (!link) setStatus('creating');

const pending = link ? Promise.resolve(link) : createShareLink();
const copied = copyToClipboard(pending).then(
() => true,
() => false,
);
try {
const value = await pending;
if (!link) onLinkCreated(value);
setStatus((await copied) ? 'copied' : 'failed');
} catch {
setStatus('failed');
}
};

const StatusIcon = BUTTON_ICON[status];

return (
<Modal title="Share this plan" icon={<Link2 size={20} aria-hidden="true" />} onClose={onClose}>
<ul className={styles.shareFacts}>
{FACTS.map(({icon: Icon, text}) => (
<li key={text}>
<Icon size={14} aria-hidden="true" />
{text}
</li>
))}
</ul>

<Button size="sm" onClick={copyLink} aria-disabled={status === 'creating'} className={styles.shareButton}>
<StatusIcon
size={14}
className={status === 'creating' ? styles.shareSpinner : undefined}
aria-hidden="true"
/>
<span aria-live="polite">{BUTTON_LABEL[status]}</span>
</Button>

{status === 'failed' && (
<p className={styles.shareError} role="alert">
The link could not be copied. Check your connection and try again.
</p>
)}
</Modal>
);
}
7 changes: 4 additions & 3 deletions src/components/pages/home/Faq/Faq.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const QUESTIONS: Question[] = [
<>
No. The plan is parsed in your browser and stays there while you work on it. Only if you click Share is
it sent, and it is encrypted in your browser first. The key exists only in the link, so we cannot read
shared plans. They are deleted after 30 days.
shared plans. They are deleted 30 days after they were last opened.
</>
),
},
Expand Down Expand Up @@ -45,8 +45,9 @@ const QUESTIONS: Question[] = [
question: 'How do I share a plan with my team?',
answer: (
<>
Once the plan is displayed, click Share at the top of the page. A short link is copied to your
clipboard, and anyone who opens it sees the same views, without an account. Links work for 30 days.
Once the plan is displayed, click Share at the top of the page, then Copy link. Anyone who opens the
link sees the same views, without an account. The link stays available for 30 days after it was last
opened.
</>
),
},
Expand Down
4 changes: 2 additions & 2 deletions src/components/pages/home/Highlights/Highlights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ const HIGHLIGHTS: Highlight[] = [
{
icon: Link2,
kicker: 'Sharing',
title: 'Share a plan with a short link',
text: 'The plan is encrypted in your browser before it is stored, and the key exists only in the link, so we cannot read it. Links work for 30 days.',
title: 'Share a plan with a link',
text: 'The plan is encrypted in your browser before it is stored, and the key exists only in the link, so we cannot read it. Links stay available for 30 days after they were last opened.',
},
];

Expand Down
Loading
Loading