Skip to content
Open
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
54 changes: 54 additions & 0 deletions packages/app/cypress/e2e/embed-scatter.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
describe('Embed — Scatter Chart', () => {
describe('default URL', () => {
before(() => {
cy.visit('/embed/scatter');
});

it('renders the embed root container', () => {
cy.get('[data-testid="embed-root"]').should('exist');
});

it('does not render the site header or footer', () => {
cy.get('[data-testid="header"]').should('not.exist');
cy.get('[data-testid="footer"]').should('not.exist');
});

it('renders an SVG chart with real data', () => {
// Wait for chart to render — skeleton or figure
cy.get('[data-testid="embed-scatter-figure"]', { timeout: 15000 }).should('exist');
cy.get('[data-testid="embed-scatter-figure"]').find('svg').should('exist');
cy.contains('No data available').should('not.exist');
});

it('shows the SemiAnalysis InferenceX attribution link', () => {
cy.get('[data-testid="embed-attribution"]')
.should('exist')
.should('contain.text', 'SemiAnalysis InferenceX');
});

it('attribution link points to the canonical /inference URL with seeded params', () => {
cy.get('[data-testid="embed-attribution"]')
.should('have.attr', 'href')
.and('include', '/inference?')
.and('include', 'g_model=DeepSeek-R1-0528')
.and('include', 'i_metric=y_tpPerGpu');
});
});

describe('custom params', () => {
before(() => {
cy.visit('/embed/scatter?model=dsr1&isl=8192&osl=1024&precisions=fp4&y=costh');
});

it('renders chart with the custom y metric', () => {
cy.get('[data-testid="embed-scatter-figure"]', { timeout: 15000 }).should('exist');
cy.contains('No data available').should('not.exist');
});

it('canonical link reflects the y metric override', () => {
cy.get('[data-testid="embed-attribution"]')
.should('have.attr', 'href')
.and('include', 'i_metric=y_costh');
});
});
});
21 changes: 21 additions & 0 deletions packages/app/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ const nextConfig: NextConfig = {
{ hostname: 'substack-post-media.s3.amazonaws.com' },
],
},
// /embed/* routes are explicitly framable from any origin so partner sites
// can iframe them. All other routes are non-framable (frame-ancestors
// 'self' + X-Frame-Options: SAMEORIGIN). Order matters: the more specific
// /embed/* match comes first.
headers() {
return Promise.resolve([
{
source: '/embed/:path*',
headers: [{ key: 'Content-Security-Policy', value: 'frame-ancestors *' }],
},
{
// Negative lookahead excludes /embed/* so its CSP isn't overridden
// by the more general non-framable headers.
source: '/((?!embed/).*)',
headers: [
{ key: 'Content-Security-Policy', value: "frame-ancestors 'self'" },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
],
},
]);
},
};

const hasPostHogKeys = Boolean(
Expand Down
16 changes: 16 additions & 0 deletions packages/app/src/app/embed/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';

export const metadata: Metadata = {
robots: { index: false, follow: false },
};

export default function EmbedLayout({ children }: { children: React.ReactNode }) {
return (
<div
data-testid="embed-root"
className="relative w-full min-h-screen flex flex-col bg-background"
>
{children}
</div>
);
}
81 changes: 81 additions & 0 deletions packages/app/src/app/embed/scatter/embed-scatter-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';

import { useEffect, useRef } from 'react';

import EmbedScatterDisplay from '@/components/embed/embed-scatter-display';
import { EmbedAttribution } from '@/components/embed/embed-attribution';
import { GlobalFilterProvider } from '@/components/GlobalFilterContext';
import { InferenceProvider } from '@/components/inference/InferenceContext';
import { UnofficialRunProvider } from '@/components/unofficial-run-provider';
import { track } from '@/lib/analytics';
import { type EmbedParams, embedParamsToUrlState } from '@/lib/embed-params';
import { seedUrlState } from '@/lib/url-state';

interface Props {
params: EmbedParams;
canonicalHref: string;
}

/**
* Client component for `/embed/scatter`. Seeds the internal URL-state cache
* synchronously before any provider mounts so the first render of the chart
* already reflects the requested embed params, then wraps the providers and
* the chart display.
*
* Lives outside the `(dashboard)` route group, so we re-establish the
* provider stack here (`UnofficialRunProvider` → `GlobalFilterProvider` →
* `InferenceProvider`). `QueryProvider` is in the root layout and inherits.
*/
export default function EmbedScatterClient({ params, canonicalHref }: Props) {
const seededRef = useRef(false);
if (!seededRef.current) {
seedUrlState(embedParamsToUrlState(params));
seededRef.current = true;
}

// Fire `embed_view` once on mount with referrer + host so external embed
// traffic is attributable. Strict mode in dev double-fires effects, but
// that's only in dev — production fires once.
const trackedRef = useRef(false);
useEffect(() => {
if (trackedRef.current) return;
trackedRef.current = true;
const referrer = typeof document !== 'undefined' ? document.referrer : '';
let embedHost: string;
try {
embedHost = referrer ? new URL(referrer).host : '';
} catch {
embedHost = '';
}
const gpus = params.gpus ? params.gpus.split(',').filter(Boolean) : [];
track('embed_view', {
embed_chart: 'scatter',
chart_type: params.chart,
model: params.model,
sequence: `${params.isl}/${params.osl}`,
precisions: params.precisions,
gpus,
gpu_count: gpus.length,
y_metric: params.y,
referrer,
embed_host: embedHost,
});
}, []);

return (
<UnofficialRunProvider>
<GlobalFilterProvider>
<InferenceProvider activeTab="inference">
<div className="flex flex-col gap-2 p-2 sm:p-4 grow">
<div className="grow">
<EmbedScatterDisplay chartType={params.chart} />
</div>
<div className="flex justify-end pt-1">
<EmbedAttribution canonicalHref={canonicalHref} />
</div>
</div>
</InferenceProvider>
</GlobalFilterProvider>
</UnofficialRunProvider>
);
}
26 changes: 26 additions & 0 deletions packages/app/src/app/embed/scatter/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Metadata } from 'next';

import { SITE_URL } from '@semianalysisai/inferencex-constants';
import { buildCanonicalHref, readEmbedParams } from '@/lib/embed-params';

import EmbedScatterClient from './embed-scatter-client';

export const metadata: Metadata = {
title: 'InferenceX — Embedded Chart',
robots: { index: false, follow: false },
};

export default async function EmbedScatterPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await searchParams;
const flat: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(sp)) {
flat[k] = Array.isArray(v) ? v[0] : v;
}
const params = readEmbedParams(flat);
const canonicalHref = buildCanonicalHref(params, SITE_URL);
return <EmbedScatterClient params={params} canonicalHref={canonicalHref} />;
}
9 changes: 7 additions & 2 deletions packages/app/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Metadata } from 'next';
import { DM_Sans } from 'next/font/google';
import localFont from 'next/font/local';

import { ChromeGate } from '@/components/chrome-gate';
import { Footer } from '@/components/footer/footer';
import { Header } from '@/components/header/header';
import { CircuitBackground } from '@/components/circuit-background';
Expand Down Expand Up @@ -191,9 +192,13 @@ export default async function RootLayout({
disableTransitionOnChange
>
<PostHogPageView />
<Header starCount={starCount} />
<ChromeGate>
<Header starCount={starCount} />
</ChromeGate>
<div className="grow flex flex-col">{children}</div>
<Footer starCount={starCount} />
<ChromeGate>
<Footer starCount={starCount} />
</ChromeGate>
</ThemeProvider>
</QueryProvider>
{process.env.VERCEL && <Analytics />}
Expand Down
14 changes: 14 additions & 0 deletions packages/app/src/components/chrome-gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client';

import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';

/**
* Hides chrome (Header, Footer) on `/embed/*` routes so the chart is rendered
* standalone for iframe embedding. All other routes render children unchanged.
*/
export function ChromeGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
if (pathname?.startsWith('/embed')) return null;
return <>{children}</>;
}
22 changes: 22 additions & 0 deletions packages/app/src/components/embed/embed-attribution.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client';

import { track } from '@/lib/analytics';

// Attribution link rendered at the bottom of every embed view. Deep-links to
// the canonical /inference URL with the equivalent internal params so the
// partner site's audience can click through to the full dashboard.
export function EmbedAttribution({ canonicalHref }: { canonicalHref: string }) {
return (
<a
data-testid="embed-attribution"
href={canonicalHref}
target="_blank"
rel="noopener"
onClick={() => track('embed_attribution_clicked', { href: canonicalHref })}
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
SemiAnalysis InferenceX
<span aria-hidden>→</span>
</a>
);
}
Loading
Loading