feat: api playground, other tweaks

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-06 16:45:04 -07:00
parent 7bf559cd5b
commit 50f2d095aa
104 changed files with 5482 additions and 244 deletions

View file

@ -1,4 +1,4 @@
import { ArrowRight, Plug } from "lucide-react";
import { ArrowRight, Plug, Server } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { getAllConnectors } from "@/lib/connectors-marketing";
@ -75,15 +75,32 @@ export default function ConnectorsIndexPage() {
</Link>
);
})}
{/* Bespoke page (not in the scrape-API registry): SurfSense as an MCP client. */}
{/* Bespoke pages (not in the scrape-API registry): the two MCP directions. */}
<Link
href="/mcp-connector"
href="/mcp-server"
className="group flex flex-col rounded-xl border bg-card p-6 transition-colors hover:border-brand/40"
>
<span className="flex size-11 items-center justify-center rounded-lg border bg-muted/40 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<Server className="size-5 text-foreground" />
</span>
<h2 className="mt-4 text-lg font-semibold">SurfSense MCP Server</h2>
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground line-clamp-4">
Give Claude, Cursor, or any MCP client native tools for your workspace: every scraper
API plus knowledge base search, reads, and writes. One API key.
</p>
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-foreground">
Explore
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</span>
</Link>
<Link
href="/external-mcp-connectors"
className="group flex flex-col rounded-xl border bg-card p-6 transition-colors hover:border-brand/40"
>
<span className="flex size-11 items-center justify-center rounded-lg border bg-muted/40 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<Plug className="size-5 text-foreground" />
</span>
<h2 className="mt-4 text-lg font-semibold">MCP Connector</h2>
<h2 className="mt-4 text-lg font-semibold">External MCP Connectors</h2>
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground line-clamp-4">
Bring any MCP server to your agents. Paste a config like you would in Cursor, tools
are auto-discovered, and Notion, Slack, Jira, and more connect with one-click OAuth.

View file

@ -12,35 +12,37 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { FaqItem } from "@/lib/connectors-marketing/types";
const canonicalUrl = "https://www.surfsense.com/mcp-connector";
const canonicalUrl = "https://www.surfsense.com/external-mcp-connectors";
const metaDescription =
"The SurfSense MCP connector lets your AI agents use any MCP server. Paste a config, tools are auto-discovered, and every call runs with per-tool approval. Try it free.";
"External MCP connectors let your SurfSense agents use any MCP server. Paste a config, tools are auto-discovered, and every call runs with per-tool approval. Try it free.";
export const metadata: Metadata = {
title: "MCP Connector for AI Agents: Add Any MCP Server | SurfSense",
title: "External MCP Connectors: Add Any MCP Server | SurfSense",
description: metaDescription,
keywords: [
"mcp connector",
"external mcp connectors",
"what is an mcp connector",
"mcp client",
"add mcp server",
"connect mcp server",
"mcp integrations",
"mcp server for ai agents",
],
alternates: { canonical: canonicalUrl },
openGraph: {
title: "MCP Connector for AI Agents: Add Any MCP Server | SurfSense",
title: "External MCP Connectors: Add Any MCP Server | SurfSense",
description: metaDescription,
url: canonicalUrl,
siteName: "SurfSense",
type: "website",
images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense MCP connector" }],
images: [
{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense external MCP connectors" },
],
},
twitter: {
card: "summary_large_image",
title: "MCP Connector for AI Agents: Add Any MCP Server | SurfSense",
title: "External MCP Connectors: Add Any MCP Server | SurfSense",
description: metaDescription,
images: ["/og-image.png"],
},
@ -94,14 +96,14 @@ const ONE_CLICK_APPS = [
const FAQ: FaqItem[] = [
{
question: "What is an MCP connector?",
question: "What is an external MCP connector?",
answer:
"An MCP connector links an AI application to an MCP (Model Context Protocol) server, so the app's agents can call the server's tools. In SurfSense, you add a server config once, its tools are auto-discovered, and every agent in your workspace can use them with per-tool approval.",
"An external MCP connector links SurfSense to an outside MCP (Model Context Protocol) server, so your agents can call that server's tools. You add a server config once, its tools are auto-discovered, and every agent in your workspace can use them with per-tool approval.",
},
{
question: "How is an MCP connector different from an MCP server?",
question: "How is this different from the SurfSense MCP server?",
answer:
"An MCP server exposes tools; an MCP connector consumes them. This page covers SurfSense acting as the client: plugging outside MCP servers into your agents. SurfSense also ships its own MCP server, which exposes connectors like Reddit and Google Maps as tools inside Claude, Cursor, or any MCP client.",
"Direction. External MCP connectors make SurfSense the client: outside tools flow into your SurfSense agents. The SurfSense MCP server is the reverse: it exposes your workspace and the scraper APIs as tools inside Claude, Cursor, or any MCP client you already run.",
},
{
question: "Which MCP transports are supported?",
@ -139,14 +141,14 @@ function ConfigCard() {
);
}
export default function McpConnectorPage() {
export default function ExternalMcpConnectorsPage() {
return (
<>
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "SurfSense MCP Connector",
name: "SurfSense External MCP Connectors",
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: metaDescription,
@ -176,21 +178,21 @@ export default function McpConnectorPage() {
className="mb-6"
items={[
{ name: "Connectors", href: "/connectors" },
{ name: "MCP Connector", href: "/mcp-connector" },
{ name: "External MCP Connectors", href: "/external-mcp-connectors" },
]}
/>
<Badge variant="outline" className="mb-5 gap-1.5 py-1">
<Plug className="size-3.5" />
MCP connector
External MCP connectors
</Badge>
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl">
Bring any MCP server to your AI agents
Bring any external MCP server to your agents
</h1>
<p className="mt-5 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
The SurfSense MCP connector turns your workspace into an MCP client. Add any MCP
server with the same config you'd use in Cursor, and its tools are auto-discovered
and handed to your agents, guarded by per-tool approval. Notion, Slack, Jira, and
more connect with one-click OAuth.
External MCP connectors turn your SurfSense workspace into an MCP client. Add any
MCP server with the same config you'd use in Cursor, and its tools are
auto-discovered and handed to your agents, guarded by per-tool approval. Notion,
Slack, Jira, and more connect with one-click OAuth.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Button asChild size="lg">
@ -273,12 +275,18 @@ export default function McpConnectorPage() {
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
MCP connector vs MCP server
External MCP connectors vs the SurfSense MCP server
</h2>
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
They are two sides of the same protocol. The MCP connector on this page makes
SurfSense a <em>client</em>: it consumes tools from outside MCP servers. The SurfSense
MCP <em>server</em> does the reverse, exposing platform connectors like{" "}
They are two sides of the same protocol. The external MCP connectors on this page make
SurfSense a <em>client</em>: they consume tools from outside MCP servers. The{" "}
<Link
href="/mcp-server"
className="font-medium text-foreground underline underline-offset-4"
>
SurfSense MCP server
</Link>{" "}
does the reverse, exposing your workspace and scraper APIs like{" "}
<Link
href="/reddit"
className="font-medium text-foreground underline underline-offset-4"
@ -302,7 +310,7 @@ export default function McpConnectorPage() {
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
MCP connector: frequently asked questions
External MCP connectors: frequently asked questions
</h2>
</Reveal>
<Reveal>
@ -320,7 +328,7 @@ export default function McpConnectorPage() {
Give your agents every tool they need
</h2>
<p className="mx-auto mt-3 max-w-xl text-muted-foreground leading-relaxed">
The MCP connector is part of the SurfSense{" "}
External MCP connectors are part of the SurfSense{" "}
<Link href="/" className="font-medium text-foreground underline underline-offset-4">
competitive intelligence platform
</Link>
@ -344,6 +352,9 @@ export default function McpConnectorPage() {
<Button asChild variant="ghost" size="sm">
<Link href="/connectors">All connectors</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/mcp-server">SurfSense MCP Server</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/reddit">Reddit API</Link>
</Button>

View file

@ -0,0 +1,400 @@
import { IconBrandGithub } from "@tabler/icons-react";
import { ArrowRight, Check, Database, KeyRound, Server, TerminalSquare } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { ConnectorFaq } from "@/components/connectors-marketing/connector-faq";
import { Reveal } from "@/components/connectors-marketing/reveal";
import { MarketingSection } from "@/components/marketing/section";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { FaqItem } from "@/lib/connectors-marketing/types";
const canonicalUrl = "https://www.surfsense.com/mcp-server";
const metaDescription =
"The SurfSense MCP server gives Claude, Cursor, and any MCP client native tools for your workspace: scrape Reddit, YouTube, Google Maps, Google Search, and the web, plus full knowledge base access. One API key.";
export const metadata: Metadata = {
title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
description: metaDescription,
keywords: [
"surfsense mcp server",
"mcp server",
"mcp server for web scraping",
"reddit mcp server",
"youtube mcp server",
"google maps mcp server",
"serp mcp server",
"mcp server for claude",
"mcp server for cursor",
"knowledge base mcp server",
],
alternates: { canonical: canonicalUrl },
openGraph: {
title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
description: metaDescription,
url: canonicalUrl,
siteName: "SurfSense",
type: "website",
images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense MCP server" }],
},
twitter: {
card: "summary_large_image",
title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
description: metaDescription,
images: ["/og-image.png"],
},
};
/* Mirrors surfsense_mcp/README.md: the real Cursor config. */
const CURSOR_CONFIG = `{
"mcpServers": {
"surfsense": {
"command": "uv",
"args": ["run", "--directory", ".../surfsense_mcp",
"python", "-m", "surfsense_mcp"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_..."
}
}
}
}`;
const STEPS = [
{
icon: KeyRound,
title: "Create an API key",
description:
"In SurfSense, go to Settings, then API, and create a key. Enable API access on the workspaces you want your agents to reach. That key is all the server needs.",
},
{
icon: TerminalSquare,
title: "Add the server to your client",
description:
"Drop the config into Cursor's mcp.json, run claude mcp add for Claude Code, or paste it into Claude Desktop. Point it at the cloud or your own self-hosted instance.",
},
{
icon: Server,
title: "Your agent has the tools",
description:
"Every scraper and knowledge base operation shows up as a native, typed MCP tool. Your agent picks a workspace once and the server carries the context between calls.",
},
] as const;
/** Mirrors the tool registry in surfsense_mcp (see its README). */
const TOOL_GROUPS = [
{
icon: Server,
title: "Live scrapers",
description: "Structured, current platform data. One returned item is one billable unit.",
tools: [
"surfsense_reddit_scrape",
"surfsense_youtube_scrape",
"surfsense_youtube_comments",
"surfsense_google_maps_scrape",
"surfsense_google_maps_reviews",
"surfsense_google_search",
"surfsense_web_crawl",
"surfsense_list_scraper_runs",
"surfsense_get_scraper_run",
],
},
{
icon: Database,
title: "Knowledge base",
description: "Read and write the same knowledge base your SurfSense agents use.",
tools: [
"surfsense_search_knowledge_base",
"surfsense_list_documents",
"surfsense_get_document",
"surfsense_add_document",
"surfsense_upload_file",
"surfsense_update_document",
"surfsense_delete_document",
],
},
{
icon: KeyRound,
title: "Workspace selector",
description: "Pick a workspace once; every later call defaults to it.",
tools: ["surfsense_list_workspaces", "surfsense_select_workspace"],
},
] as const;
const FAQ: FaqItem[] = [
{
question: "What is the SurfSense MCP server?",
answer:
"It is a Model Context Protocol server that exposes your SurfSense workspace to MCP clients like Claude Code, Cursor, and Claude Desktop. Your agents get native tools for every scraper API (Reddit, YouTube, Google Maps, Google Search, web crawl) and for searching, reading, and writing your knowledge base.",
},
{
question: "Which MCP clients does it work with?",
answer:
"Any MCP client that supports stdio servers. Claude Code, Cursor, and Claude Desktop are documented with copy-paste configs, and the same command works in custom agent harnesses built on the MCP SDK.",
},
{
question: "How is usage billed?",
answer:
"Exactly like the REST API, because the server is a thin layer over it. Scraper tools consume the same pay-as-you-go credits, priced per returned item, and knowledge base operations work within your plan. New accounts start with $5 of free credit.",
},
{
question: "Does it work with a self-hosted SurfSense?",
answer:
"Yes. The server talks to SurfSense purely over its REST API and imports no backend code, so pointing SURFSENSE_BASE_URL at your own instance is all it takes. It works with the cloud at api.surfsense.com the same way.",
},
{
question: "How does the agent know which workspace to use?",
answer:
"The server ships a workspace selector: the agent lists the workspaces your API key can access, selects one by name, and every later call defaults to it. Any tool also accepts a workspace override for a single call, and ids never need to be typed by hand.",
},
];
function ConfigCard() {
return (
<div className="rounded-xl border bg-card p-5 shadow-sm">
<p className="font-mono text-xs text-muted-foreground">.cursor/mcp.json</p>
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted/50 p-4 font-mono text-xs leading-relaxed">
{CURSOR_CONFIG}
</pre>
<p className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<Check className="size-3.5 text-brand" aria-hidden />
Works with Claude Code, Cursor, Claude Desktop, and any MCP client
</p>
</div>
);
}
export default function McpServerPage() {
return (
<>
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "SurfSense MCP Server",
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: metaDescription,
url: canonicalUrl,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
description: "$5 free credit included, pay as you go",
},
provider: {
"@type": "Organization",
name: "SurfSense",
url: "https://www.surfsense.com",
},
isPartOf: { "@type": "WebSite", name: "SurfSense", url: "https://www.surfsense.com" },
}}
/>
<FAQJsonLd questions={FAQ} />
<div className="pb-4">
{/* Hero */}
<MarketingSection className="pt-28 pb-12 sm:pt-32 sm:pb-16">
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-14">
<div>
<BreadcrumbNav
className="mb-6"
items={[
{ name: "Connectors", href: "/connectors" },
{ name: "SurfSense MCP Server", href: "/mcp-server" },
]}
/>
<Badge variant="outline" className="mb-5 gap-1.5 py-1">
<Server className="size-3.5" />
SurfSense MCP server
</Badge>
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl">
Give your agents SurfSense as native tools
</h1>
<p className="mt-5 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
The SurfSense MCP server hands Claude, Cursor, or any MCP client the whole platform:
scrape Reddit, YouTube, Google Maps, Google Search, and the open web, and search,
read, and write your knowledge base. One API key, typed tools, pay as you go.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Button asChild size="lg">
<Link href="/register">
Get your API key
<ArrowRight className="size-4" />
</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/docs">Read the docs</Link>
</Button>
<Button asChild variant="ghost" size="lg">
<Link
href="https://github.com/MODSetter/SurfSense"
target="_blank"
rel="noopener noreferrer"
>
<IconBrandGithub className="size-4" />
GitHub
</Link>
</Button>
</div>
</div>
<ConfigCard />
</div>
</MarketingSection>
{/* How it works */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
From API key to agent tools in three steps
</h2>
</Reveal>
<div className="mt-8 grid gap-4 sm:grid-cols-3">
{STEPS.map((step) => (
<Reveal key={step.title}>
<div className="h-full rounded-xl border bg-card p-6 transition-colors hover:border-brand/40">
<span className="flex size-10 items-center justify-center rounded-lg border bg-muted/40">
<step.icon className="size-5 text-foreground" aria-hidden />
</span>
<h3 className="mt-4 font-semibold">{step.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{step.description}
</p>
</div>
</Reveal>
))}
</div>
</MarketingSection>
{/* Tools */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
Every tool the server exposes
</h2>
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
The server is a thin layer over the SurfSense REST API: the same endpoints, the same
billing, no backend code imported. Whatever ships in the API shows up here.
</p>
</Reveal>
<div className="mt-8 grid gap-4 lg:grid-cols-3">
{TOOL_GROUPS.map((group) => (
<Reveal key={group.title}>
<div className="h-full rounded-xl border bg-card p-6">
<span className="flex size-10 items-center justify-center rounded-lg border bg-muted/40">
<group.icon className="size-5 text-foreground" aria-hidden />
</span>
<h3 className="mt-4 font-semibold">{group.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{group.description}
</p>
<ul className="mt-4 space-y-1.5">
{group.tools.map((tool) => (
<li key={tool} className="truncate font-mono text-xs text-muted-foreground">
{tool}
</li>
))}
</ul>
</div>
</Reveal>
))}
</div>
</MarketingSection>
{/* Server vs external connectors */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
The SurfSense MCP server vs external MCP connectors
</h2>
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
They are two sides of the same protocol. The MCP <em>server</em> on this page pushes
SurfSense tools out to agents you already run in Claude, Cursor, or your own harness.{" "}
<Link
href="/external-mcp-connectors"
className="font-medium text-foreground underline underline-offset-4"
>
External MCP connectors
</Link>{" "}
do the reverse: they pull outside tools like Notion, Slack, and Jira into your
SurfSense agents. Use both and data flows in either direction.
</p>
</Reveal>
</MarketingSection>
{/* FAQ */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
SurfSense MCP server: frequently asked questions
</h2>
</Reveal>
<Reveal>
<div className="mt-6 max-w-3xl">
<ConnectorFaq items={FAQ} />
</div>
</Reveal>
</MarketingSection>
{/* Closing CTA + related */}
<MarketingSection>
<Reveal>
<div className="rounded-2xl border bg-card p-8 text-center sm:p-12">
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
Put live market data inside your agents
</h2>
<p className="mx-auto mt-3 max-w-xl text-muted-foreground leading-relaxed">
The MCP server is part of the SurfSense{" "}
<Link href="/" className="font-medium text-foreground underline underline-offset-4">
competitive intelligence platform
</Link>
. Start with $5 of free credit, no credit card required.
</p>
<div className="mt-7 flex flex-wrap justify-center gap-3">
<Button asChild size="lg">
<Link href="/register">
Start for free
<ArrowRight className="size-4" />
</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/pricing">See pricing</Link>
</Button>
</div>
<Separator className="my-8" />
<nav aria-label="Other connectors" className="flex flex-wrap justify-center gap-2">
<Button asChild variant="ghost" size="sm">
<Link href="/connectors">All connectors</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/external-mcp-connectors">External MCP Connectors</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/reddit">Reddit API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/youtube">YouTube API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/google-maps">Google Maps API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/google-search">SERP API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/web-crawl">Web Crawl API</Link>
</Button>
</nav>
</div>
</Reveal>
</MarketingSection>
</div>
</>
);
}

View file

@ -5,6 +5,7 @@ import { ConnectorGrid } from "@/components/homepage/connector-grid";
import { HeroSection } from "@/components/homepage/hero-section";
import { HomeFaq } from "@/components/homepage/home-faq";
import { HowItWorks } from "@/components/homepage/how-it-works";
import { PersonaPaths } from "@/components/homepage/persona-paths";
import { UseCasesRow } from "@/components/homepage/use-cases";
export default function HomePage() {
@ -12,6 +13,7 @@ export default function HomePage() {
<div className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
<AuthRedirect />
<HeroSection />
<PersonaPaths />
<ConnectorGrid />
<HowItWorks />
<UseCasesRow />

View file

@ -0,0 +1,15 @@
import { PlaygroundRunner } from "../../components/playground-runner";
export default async function PlaygroundVerbPage({
params,
}: {
params: Promise<{ workspace_id: string; platform: string; verb: string }>;
}) {
const { workspace_id, platform, verb } = await params;
return (
<div className="mx-auto w-full max-w-6xl">
<PlaygroundRunner workspaceId={Number(workspace_id)} platform={platform} verb={verb} />
</div>
);
}

View file

@ -0,0 +1,15 @@
import { ApiKeysSection } from "../components/api-keys-section";
export default async function PlaygroundApiKeysPage({
params,
}: {
params: Promise<{ workspace_id: string }>;
}) {
const { workspace_id } = await params;
return (
<div className="mx-auto w-full max-w-3xl">
<ApiKeysSection workspaceId={Number(workspace_id)} />
</div>
);
}

View file

@ -0,0 +1,82 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { toast } from "sonner";
import { updateWorkspaceApiAccessMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent";
/**
* One-stop API key management for the playground: the workspace API-access
* toggle (otherwise buried in workspace settings) plus the personal API key
* manager (otherwise buried in user settings).
*/
export function ApiKeysSection({ workspaceId }: { workspaceId: number }) {
const {
data: workspace,
isLoading,
refetch,
} = useQuery({
queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
enabled: !!workspaceId,
});
const { mutateAsync: updateWorkspaceApiAccess } = useAtomValue(
updateWorkspaceApiAccessMutationAtom
);
const [saving, setSaving] = useState(false);
const handleToggle = async (enabled: boolean) => {
try {
setSaving(true);
await updateWorkspaceApiAccess({ id: workspaceId, api_access_enabled: enabled });
await refetch();
} catch (error) {
console.error("Error updating API access:", error);
toast.error(error instanceof Error ? error.message : "Failed to update API access");
} finally {
setSaving(false);
}
};
const apiAccessEnabled = !!workspace?.api_access_enabled;
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">API Keys</h1>
<p className="mt-1 text-sm text-muted-foreground">
Enable API access for this workspace and manage the keys that use it.
</p>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/60 px-4 py-3">
<div className="space-y-1">
<Label htmlFor="playground-api-access">API key access</Label>
<p className="text-xs text-muted-foreground">
Allow API keys to access this workspace.
{!isLoading && !apiAccessEnabled && " Currently disabled — keys won't work here."}
</p>
</div>
{isLoading ? (
<Skeleton className="h-5 w-9 rounded-full" />
) : (
<Switch
id="playground-api-access"
checked={apiAccessEnabled}
disabled={saving}
onCheckedChange={handleToggle}
/>
)}
</div>
<ApiKeyContent />
</div>
);
}

View file

@ -0,0 +1,122 @@
"use client";
import { Check, Copy } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BACKEND_URL } from "@/lib/env-config";
import { buildExamplePayload, buildSnippets } from "@/lib/playground/code-snippets";
import type { FormField } from "@/lib/playground/json-schema";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={copy}
className="absolute right-2 top-2 h-7 gap-1.5 px-2 text-xs"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
);
}
function CodeBlock({ code }: { code: string }) {
return (
<div className="relative">
<CopyButton text={code} />
<pre className="max-h-[420px] overflow-auto rounded-md border border-border/60 bg-muted/20 p-3 pr-20 text-xs">
<code>{code}</code>
</pre>
</div>
);
}
function SchemaBlock({ title, schema }: { title: string; schema: Record<string, unknown> }) {
const json = useMemo(() => JSON.stringify(schema, null, 2), [schema]);
return (
<details className="group rounded-md border border-border/60">
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">
{title}
</summary>
<div className="relative border-t border-border/60">
<CopyButton text={json} />
<pre className="max-h-[360px] overflow-auto p-3 pr-20 text-xs">
<code>{json}</code>
</pre>
</div>
</details>
);
}
export function ApiReference({
workspaceId,
platform,
verb,
fields,
inputSchema,
outputSchema,
}: {
workspaceId: number;
platform: string;
verb: string;
fields: FormField[];
inputSchema: Record<string, unknown>;
/** Absent only when talking to a backend that predates output schemas. */
outputSchema?: Record<string, unknown>;
}) {
const path = `/api/v1/workspaces/${workspaceId}/scrapers/${platform}/${verb}`;
// In proxy mode BACKEND_URL is intentionally empty (same-origin), so external
// callers use this app's origin. Client component, so window is available.
const baseUrl = BACKEND_URL || (typeof window !== "undefined" ? window.location.origin : "");
const snippets = useMemo(() => {
const payload = buildExamplePayload(fields);
return buildSnippets(baseUrl, path, payload);
}, [fields, baseUrl, path]);
return (
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold">API reference</h2>
<p className="mt-1 text-sm text-muted-foreground">
Call this API from your own project. Create a key under{" "}
<span className="font-medium text-foreground">API Keys</span> (and enable API access for
this workspace), then send it as a{" "}
<code className="rounded bg-muted/40 px-1 py-0.5 text-xs">Authorization: Bearer</code>{" "}
header.
</p>
</div>
<Tabs defaultValue="curl">
<TabsList className="h-auto flex-wrap">
{snippets.map((snippet) => (
<TabsTrigger key={snippet.id} value={snippet.id}>
{snippet.label}
</TabsTrigger>
))}
</TabsList>
{snippets.map((snippet) => (
<TabsContent key={snippet.id} value={snippet.id}>
<CodeBlock code={snippet.code} />
</TabsContent>
))}
</Tabs>
<div className="space-y-2">
<SchemaBlock title="Input schema (JSON)" schema={inputSchema} />
{outputSchema && <SchemaBlock title="Output schema (JSON)" schema={outputSchema} />}
</div>
</section>
);
}

View file

@ -0,0 +1,183 @@
"use client";
import { Check, Copy, Download } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { downloadCsv, rowsToCsv } from "@/lib/playground/csv";
import { cn } from "@/lib/utils";
const MAX_TABLE_ROWS = 200;
function extractItems(data: unknown): Record<string, unknown>[] | null {
if (
typeof data === "object" &&
data !== null &&
"items" in data &&
Array.isArray((data as { items: unknown }).items)
) {
const items = (data as { items: unknown[] }).items;
if (items.every((item) => typeof item === "object" && item !== null)) {
return items as Record<string, unknown>[];
}
}
return null;
}
function cellText(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function ResultTable({ items }: { items: Record<string, unknown>[] }) {
const columns = useMemo(() => {
const keys = new Set<string>();
for (const item of items.slice(0, MAX_TABLE_ROWS)) {
for (const key of Object.keys(item)) keys.add(key);
}
return Array.from(keys);
}, [items]);
const rows = items.slice(0, MAX_TABLE_ROWS);
return (
<div className="overflow-x-auto rounded-md border border-border/60">
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead key={col} className="whitespace-nowrap">
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: rows have no stable id
<TableRow key={i}>
{columns.map((col) => (
<TableCell key={col} className="max-w-xs truncate align-top text-xs">
{cellText(item[col])}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBase?: string }) {
const items = useMemo(() => extractItems(data), [data]);
const [view, setView] = useState<"table" | "json">(items ? "table" : "json");
const [copied, setCopied] = useState(false);
const json = useMemo(() => JSON.stringify(data, null, 2), [data]);
const copy = () => {
navigator.clipboard.writeText(json).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
// Export the full item set (not just the display-capped rows) as CSV, with a
// column per union key so nothing is dropped.
const exportCsv = () => {
if (!items || items.length === 0) return;
const columns = Array.from(
items.reduce((set, item) => {
for (const key of Object.keys(item)) set.add(key);
return set;
}, new Set<string>())
);
downloadCsv(filenameBase ?? "output", rowsToCsv(items, columns));
};
const truncated = items && items.length > MAX_TABLE_ROWS;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="inline-flex rounded-md border border-border/60 p-0.5">
{items && (
<button
type="button"
onClick={() => setView("table")}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
view === "table"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
Table
</button>
)}
<button
type="button"
onClick={() => setView("json")}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
view === "json"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
JSON
</button>
</div>
<div className="flex items-center gap-1">
{items && items.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={exportCsv}
className="gap-1.5"
>
<Download className="h-3.5 w-3.5" />
Export CSV
</Button>
)}
<Button type="button" variant="ghost" size="sm" onClick={copy} className="gap-1.5">
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy JSON"}
</Button>
</div>
</div>
{items && items.length === 0 && (
<p className="rounded-md border border-dashed border-border/60 px-4 py-6 text-center text-sm text-muted-foreground">
No items returned.
</p>
)}
{view === "table" && items && items.length > 0 ? (
<>
<ResultTable items={items} />
{truncated && (
<p className="text-xs text-muted-foreground">
Showing first {MAX_TABLE_ROWS} of {items.length} items. Use Copy JSON for the full
output.
</p>
)}
</>
) : (
<pre className="max-h-[480px] overflow-auto rounded-md border border-border/60 bg-muted/20 p-3 text-xs">
<code>{json}</code>
</pre>
)}
</div>
);
}

View file

@ -0,0 +1,78 @@
"use client";
import { ArrowRight, History, KeyRound } from "lucide-react";
import Link from "next/link";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
const base = `/dashboard/${workspaceId}/playground`;
return (
<div className="space-y-8">
<div>
<h1 className="text-xl font-semibold">API Playground</h1>
<p className="mt-1 text-sm text-muted-foreground">
Manually run SurfSense's platform-native APIs and inspect their output. Every run is
captured under Runs.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Link
href={`${base}/runs`}
className="flex items-center justify-between rounded-lg border border-border/60 bg-accent/40 px-4 py-3 transition-colors hover:bg-accent"
>
<div className="flex items-center gap-3">
<History className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Runs</p>
<p className="text-xs text-muted-foreground">See every API run in this workspace</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
<Link
href={`${base}/api-keys`}
className="flex items-center justify-between rounded-lg border border-border/60 bg-accent/40 px-4 py-3 transition-colors hover:bg-accent"
>
<div className="flex items-center gap-3">
<KeyRound className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">API Keys</p>
<p className="text-xs text-muted-foreground">
Enable workspace access and manage keys
</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</div>
<div className="space-y-6">
{PLAYGROUND_PLATFORMS.map((platform) => (
<div key={platform.id} className="space-y-3">
<div className="flex items-center gap-2">
<platform.icon className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{platform.label}</h2>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{platform.verbs.map((verb) => (
<Link
key={verb.name}
href={`${base}/${platform.id}/${verb.verb}`}
className="group flex flex-col rounded-lg border border-border/60 p-4 transition-colors hover:border-border hover:bg-muted/30"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{verb.label}</span>
<ArrowRight className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</div>
<code className="mt-2 text-xs text-muted-foreground">{verb.name}</code>
</Link>
))}
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,277 @@
"use client";
import { AlertTriangle, Coins, Hash, Loader2, Play, Timer, X } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useRunStream } from "@/hooks/use-run-stream";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { AbortedError, AppError } from "@/lib/error";
import { findVerb } from "@/lib/playground/catalog";
import { formatCost, formatDuration } from "@/lib/playground/format";
import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema";
import { ApiReference } from "./api-reference";
import { OutputViewer } from "./output-viewer";
import { RunProgressPanel } from "./run-progress-panel";
import { SchemaForm } from "./schema-form";
interface PlaygroundRunnerProps {
workspaceId: number;
platform: string;
verb: string;
}
const MAX_OUTPUT_LINES = 200;
/** Parse the stored JSONL output into objects, capped for rendering. */
function parseJsonlOutput(text: string | null | undefined): { items: unknown[] } {
if (!text) return { items: [] };
const lines = text.split("\n").filter((line) => line.trim().length > 0);
const items: unknown[] = [];
for (const line of lines.slice(0, MAX_OUTPUT_LINES)) {
try {
items.push(JSON.parse(line));
} catch {
items.push(line);
}
}
return { items };
}
function RunStat({
icon: Icon,
label,
value,
}: {
icon: typeof Hash;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-1.5 rounded-md border border-border/60 px-2.5 py-1.5">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-xs font-medium tabular-nums">{value}</span>
</div>
);
}
function ErrorPanel({ error, workspaceId }: { error: unknown; workspaceId: number }) {
if (error instanceof AbortedError) {
return null;
}
const status = error instanceof AppError ? error.status : undefined;
if (status === 402) {
return (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm">
<p className="font-medium text-destructive">Insufficient credits</p>
<p className="mt-1 text-muted-foreground">You don't have enough credits to run this API.</p>
<Button asChild size="sm" variant="outline" className="mt-3">
<Link href={`/dashboard/${workspaceId}/buy-more`}>Buy credits</Link>
</Button>
</div>
);
}
const message =
status === 422
? "Invalid input. Check the fields above and try again."
: error instanceof Error && error.message
? error.message
: "Something went wrong running this API.";
return (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{message}</span>
</div>
);
}
export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunnerProps) {
const catalogVerb = findVerb(platform, verb);
const {
data: capabilities,
isLoading,
error: capabilitiesError,
} = useScraperCapabilities(workspaceId);
const capability = useMemo(() => {
if (!capabilities) return undefined;
const name = catalogVerb?.name ?? `${platform}.${verb}`;
return capabilities.find((c) => c.name === name);
}, [capabilities, catalogVerb, platform, verb]);
const fields = useMemo(() => parseSchemaFields(capability?.input_schema), [capability]);
const [values, setValues] = useState<Record<string, unknown>>({});
const run = useRunStream(workspaceId);
const isRunning = run.status === "running";
// Seed form defaults once the schema is available.
useEffect(() => {
if (fields.length > 0) {
setValues(initialFormValues(fields));
}
}, [fields]);
// Survive a refresh: if this verb's newest run is still ``running``, re-attach
// to its live stream instead of showing an idle form.
const reattachedRef = useRef(false);
const { reattach } = run;
useEffect(() => {
if (reattachedRef.current || !capability) return;
reattachedRef.current = true;
let cancelled = false;
(async () => {
try {
const recent = await scrapersApiService.listRuns(workspaceId, {
capability: capability.name,
limit: 1,
});
const top = recent[0];
if (!cancelled && top && top.status === "running") {
reattach(top.id);
}
} catch {
// Best-effort reattach; a fresh run still works.
}
})();
return () => {
cancelled = true;
};
}, [capability, workspaceId, reattach]);
const handleChange = (name: string, value: unknown) => {
setValues((prev) => ({ ...prev, [name]: value }));
};
const handleRun = useCallback(() => {
const payload = buildPayload(fields, values);
void run.start(platform, verb, payload);
}, [fields, values, platform, verb, run]);
const output = useMemo(
() => (run.detail ? parseJsonlOutput(run.detail.output_text) : null),
[run.detail]
);
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (capabilitiesError) {
return (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
Couldn't load API definitions
{capabilitiesError.message ? `: ${capabilitiesError.message}` : "."}
</div>
);
}
if (!capability || !catalogVerb) {
return (
<div className="rounded-md border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
Unknown API: {platform}.{verb}
</div>
);
}
return (
<div className="space-y-10">
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold">
{catalogVerb.label} <span className="text-muted-foreground">· {platform}</span>
</h1>
{capability.description && (
<p className="mt-1 text-sm text-muted-foreground">{capability.description}</p>
)}
<code className="mt-2 inline-block rounded bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground">
POST /workspaces/{workspaceId}/scrapers/{platform}/{verb}
</code>
</div>
<SchemaForm
fields={fields}
values={values}
onChange={handleChange}
disabled={isRunning}
/>
<div className="flex items-center gap-2">
<Button type="button" onClick={handleRun} disabled={isRunning} className="gap-1.5">
{isRunning ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
Run
</Button>
{isRunning && (
<Button type="button" variant="outline" onClick={run.cancel} className="gap-1.5">
<X className="h-4 w-4" />
Cancel
</Button>
)}
</div>
{run.status === "error" && <ErrorPanel error={run.error} workspaceId={workspaceId} />}
</div>
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">Output</h2>
{isRunning ? (
<RunProgressPanel
latest={run.latest}
events={run.events}
elapsedMs={run.elapsedMs}
/>
) : run.status === "cancelled" ? (
<div className="flex h-64 items-center justify-center rounded-md border border-border/60 px-4 text-center text-sm text-muted-foreground">
Run cancelled.
</div>
) : run.status === "success" && output ? (
<>
<div className="flex flex-wrap gap-2">
<RunStat
icon={Hash}
label="Items"
value={String(run.detail?.item_count ?? output.items.length)}
/>
<RunStat
icon={Timer}
label="Time"
value={formatDuration(run.detail?.duration_ms ?? run.elapsedMs)}
/>
<RunStat icon={Coins} label="Cost" value={formatCost(run.detail?.cost_micros)} />
</div>
<OutputViewer data={output} filenameBase={`${platform}-${verb}`} />
</>
) : (
<div className="flex h-64 items-center justify-center rounded-md border border-dashed border-border/60 px-4 text-center text-sm text-muted-foreground">
Run the API to see output here.
</div>
)}
</div>
</div>
<ApiReference
workspaceId={workspaceId}
platform={platform}
verb={verb}
fields={fields}
inputSchema={capability.input_schema}
outputSchema={capability.output_schema}
/>
</div>
);
}

View file

@ -0,0 +1,117 @@
"use client";
import { useMemo } from "react";
import { Spinner } from "@/components/ui/spinner";
import { useScraperRun } from "@/hooks/use-scraper-runs";
import { OutputViewer } from "./output-viewer";
const MAX_OUTPUT_LINES = 200;
/** One-line label for a persisted ``run.progress`` event object. */
function progressLine(event: Record<string, unknown>): string {
const message = typeof event.message === "string" ? event.message : undefined;
const phase = typeof event.phase === "string" ? event.phase : undefined;
const current = typeof event.current === "number" ? event.current : undefined;
const total = typeof event.total === "number" ? event.total : undefined;
const base = message || (phase ? phase.replace(/_/g, " ") : "step");
if (current !== undefined) {
return `${base} (${total !== undefined ? `${current}/${total}` : current})`;
}
return base;
}
/** Parse the stored JSONL output into objects, capped for rendering. */
function parseJsonl(text: string | null): { items: unknown[]; total: number } {
if (!text) return { items: [], total: 0 };
const lines = text.split("\n").filter((line) => line.trim().length > 0);
const items: unknown[] = [];
for (const line of lines.slice(0, MAX_OUTPUT_LINES)) {
try {
items.push(JSON.parse(line));
} catch {
items.push(line);
}
}
return { items, total: lines.length };
}
export function RunDetail({
workspaceId,
runId,
}: {
workspaceId: number;
runId: string;
}) {
const { data: run, isLoading, error } = useScraperRun(workspaceId, runId);
const parsed = useMemo(() => parseJsonl(run?.output_text ?? null), [run?.output_text]);
if (isLoading) {
return (
<div className="flex h-32 items-center justify-center">
<Spinner size="md" />
</div>
);
}
if (error) {
return (
<p className="p-4 text-sm text-destructive">
Couldn't load run{error.message ? `: ${error.message}` : "."}
</p>
);
}
if (!run) return null;
return (
<div className="space-y-4 border-t border-border/60 bg-muted/10 p-4">
{run.error && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{run.error}
</div>
)}
{run.progress && run.progress.length > 0 && (
<div>
<h4 className="mb-1.5 text-xs font-medium text-muted-foreground">Progress</h4>
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-border/60 bg-background p-3 font-mono text-xs text-muted-foreground">
{run.progress.map((event, i) => (
<div key={i} className="truncate">
{progressLine(event)}
</div>
))}
</div>
</div>
)}
<div>
<h4 className="mb-1.5 text-xs font-medium text-muted-foreground">Input</h4>
<pre className="max-h-64 overflow-auto rounded-md border border-border/60 bg-background p-3 text-xs">
<code>{JSON.stringify(run.input ?? {}, null, 2)}</code>
</pre>
</div>
<div>
<h4 className="mb-1.5 text-xs font-medium text-muted-foreground">
Output {parsed.total > 0 && `(${parsed.total} items)`}
</h4>
{run.output_text ? (
<>
<OutputViewer
data={{ items: parsed.items }}
filenameBase={`${run.capability}-${run.id}`}
/>
{parsed.total > MAX_OUTPUT_LINES && (
<p className="mt-2 text-xs text-muted-foreground">
Showing first {MAX_OUTPUT_LINES} of {parsed.total} stored items.
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">No output stored for this run.</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,88 @@
"use client";
import { Loader2 } from "lucide-react";
import { useEffect, useRef } from "react";
import type { ScraperRunEvent } from "@/contracts/types/scraper.types";
import { formatDuration } from "@/lib/playground/format";
/** One-line human label for a progress/lifecycle event. */
function eventLabel(event: ScraperRunEvent): string {
const base =
event.message ||
(event.phase ? event.phase.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase()) : "Working");
if (event.current !== undefined && event.current !== null) {
const counter =
event.total !== undefined && event.total !== null
? `${event.current}/${event.total}`
: String(event.current);
return `${base} (${counter})`;
}
return base;
}
/**
* Live progress for an in-flight async run: a headline status, a determinate
* bar when counts are known, a running elapsed timer, and a scrolling event log.
*/
export function RunProgressPanel({
latest,
events,
elapsedMs,
}: {
latest: ScraperRunEvent | null;
events: ScraperRunEvent[];
elapsedMs: number;
}) {
const logRef = useRef<HTMLDivElement>(null);
// Keep the newest line in view as the log grows.
useEffect(() => {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [events.length]);
const total = latest?.total ?? undefined;
const current = latest?.current ?? undefined;
const pct =
typeof current === "number" && typeof total === "number" && total > 0
? Math.min(100, Math.round((current / total) * 100))
: null;
return (
<div className="space-y-3 rounded-md border border-border/60 p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<span className="truncate text-sm font-medium">
{latest ? eventLabel(latest) : "Starting…"}
</span>
</div>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{formatDuration(elapsedMs)}
</span>
</div>
{pct !== null && (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
)}
{events.length > 0 && (
<div
ref={logRef}
className="max-h-56 space-y-1 overflow-y-auto rounded border border-border/40 bg-muted/20 p-2 font-mono text-xs text-muted-foreground"
>
{events.map((event, i) => (
<div key={`${event.ts ?? i}-${i}`} className="truncate">
{eventLabel(event)}
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,33 @@
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
/** Scraper runs: ``running`` (async, in-flight), ``success``, ``error``, ``cancelled``. */
export function RunStatusBadge({ status }: { status: string }) {
const normalized = status.toLowerCase();
if (normalized === "running") {
return (
<Badge variant="secondary" className="gap-1 bg-blue-500/15 text-blue-600 dark:text-blue-400">
<Loader2 className="h-3 w-3 animate-spin" />
Running
</Badge>
);
}
if (normalized === "success") {
return (
<Badge variant="secondary" className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
Success
</Badge>
);
}
if (normalized === "error") {
return <Badge variant="destructive">Error</Badge>;
}
if (normalized === "cancelled") {
return (
<Badge variant="secondary" className="bg-amber-500/15 text-amber-600 dark:text-amber-400">
Cancelled
</Badge>
);
}
return <Badge variant="outline">{status}</Badge>;
}

View file

@ -0,0 +1,199 @@
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { ChevronDown, ChevronRight, History } from "lucide-react";
import { Fragment, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { formatRelativeDate } from "@/lib/format-date";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
import { formatCost, formatDuration } from "@/lib/playground/format";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
import { RunDetail } from "./run-detail";
import { RunStatusBadge } from "./run-status-badge";
const PAGE_SIZE = 25;
const ALL = "__all__";
const CAPABILITY_OPTIONS = PLAYGROUND_PLATFORMS.flatMap((platform) =>
platform.verbs.map((verb) => verb.name)
);
export function RunsTable({ workspaceId }: { workspaceId: number }) {
const [capability, setCapability] = useState<string>(ALL);
const [status, setStatus] = useState<string>(ALL);
const [expanded, setExpanded] = useState<string | null>(null);
const filters = {
capability: capability === ALL ? undefined : capability,
status: status === ALL ? undefined : status,
};
const query = useInfiniteQuery({
queryKey: [...cacheKeys.scrapers.runs(workspaceId), filters],
queryFn: ({ pageParam }) =>
scrapersApiService.listRuns(workspaceId, {
limit: PAGE_SIZE,
offset: pageParam,
...filters,
}),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) =>
lastPage.length === PAGE_SIZE ? allPages.length * PAGE_SIZE : undefined,
// Poll while any visible run is still in flight so it flips to a terminal
// state without a manual refresh; idle otherwise.
refetchInterval: (q) =>
q.state.data?.pages.flat().some((r) => r.status === "running") ? 5000 : false,
});
const runs = query.data?.pages.flat() ?? [];
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">Runs</h1>
<p className="mt-1 text-sm text-muted-foreground">
Every platform-native API run in this workspace from the playground, API keys, and
agents. Newest first.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={capability} onValueChange={setCapability}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All APIs" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All APIs</SelectItem>
{CAPABILITY_OPTIONS.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-40">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="success">Success</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
{query.isLoading ? (
<div className="flex h-48 items-center justify-center">
<Spinner size="lg" />
</div>
) : query.isError ? (
<p className="text-sm text-destructive">
Couldn't load runs{query.error.message ? `: ${query.error.message}` : "."}
</p>
) : runs.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 bg-muted/20 px-4 py-12 text-center">
<History className="mx-auto h-8 w-8 text-muted-foreground" aria-hidden />
<p className="mt-2 text-sm font-medium">No runs yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Run an API from the playground and it will show up here.
</p>
</div>
) : (
<div className="overflow-hidden rounded-md border border-border/60">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>API</TableHead>
<TableHead>Origin</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Items</TableHead>
<TableHead className="text-right">Duration</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">When</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => {
const isOpen = expanded === run.id;
return (
<Fragment key={run.id}>
<TableRow
className="cursor-pointer"
onClick={() => setExpanded(isOpen ? null : run.id)}
>
<TableCell>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</TableCell>
<TableCell className="font-mono text-xs">{run.capability}</TableCell>
<TableCell className="text-xs text-muted-foreground">{run.origin}</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-right tabular-nums">{run.item_count}</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatDuration(run.duration_ms)}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatCost(run.cost_micros)}
</TableCell>
<TableCell className="text-right text-xs text-muted-foreground">
{formatRelativeDate(run.created_at)}
</TableCell>
</TableRow>
{isOpen && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={8} className="p-0">
<RunDetail workspaceId={workspaceId} runId={run.id} />
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
)}
{query.hasNextPage && (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
className={cn(query.isFetchingNextPage && "opacity-70")}
>
{query.isFetchingNextPage ? "Loading…" : "Load more"}
</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,215 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { FormField } from "@/lib/playground/json-schema";
import { cn } from "@/lib/utils";
interface SchemaFormProps {
fields: FormField[];
values: Record<string, unknown>;
onChange: (name: string, value: unknown) => void;
disabled?: boolean;
/** Field names flagged by a 422 response, shown with error styling. */
fieldErrors?: Record<string, string>;
}
function FieldControl({
field,
value,
onChange,
disabled,
invalid,
}: {
field: FormField;
value: unknown;
onChange: (value: unknown) => void;
disabled?: boolean;
invalid?: boolean;
}) {
const id = `field-${field.name}`;
if (field.kind === "boolean") {
return (
<Switch
id={id}
checked={Boolean(value)}
onCheckedChange={onChange}
disabled={disabled}
/>
);
}
if (field.kind === "enum" && field.enumValues) {
return (
<Select
value={value ? String(value) : undefined}
onValueChange={onChange}
disabled={disabled}
>
<SelectTrigger id={id} className={cn("w-full", invalid && "border-destructive")}>
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{field.enumValues.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (field.kind === "string_array") {
return (
<Textarea
id={id}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder="One value per line"
disabled={disabled}
rows={4}
className={cn("font-mono text-xs", invalid && "border-destructive")}
/>
);
}
if (field.kind === "integer" || field.kind === "number") {
return (
<Input
id={id}
type="number"
value={value === undefined || value === null ? "" : String(value)}
min={field.minimum}
max={field.maximum}
step={field.kind === "integer" ? 1 : "any"}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={cn(invalid && "border-destructive")}
/>
);
}
return (
<Input
id={id}
type="text"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={cn(invalid && "border-destructive")}
/>
);
}
function FieldRow({
field,
value,
onChange,
disabled,
error,
}: {
field: FormField;
value: unknown;
onChange: (value: unknown) => void;
disabled?: boolean;
error?: string;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Label htmlFor={`field-${field.name}`} className="text-sm font-medium">
{field.title}
</Label>
{field.required && <span className="text-xs text-destructive">required</span>}
</div>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
<FieldControl
field={field}
value={value}
onChange={onChange}
disabled={disabled}
invalid={!!error}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
export function SchemaForm({
fields,
values,
onChange,
disabled,
fieldErrors,
}: SchemaFormProps) {
const [showAdvanced, setShowAdvanced] = useState(false);
const { primary, advanced } = useMemo(() => {
const primaryFields = fields.filter((f) => f.required);
const advancedFields = fields.filter((f) => !f.required);
return { primary: primaryFields, advanced: advancedFields };
}, [fields]);
return (
<div className="space-y-5">
{primary.map((field) => (
<FieldRow
key={field.name}
field={field}
value={values[field.name]}
onChange={(value) => onChange(field.name, value)}
disabled={disabled}
error={fieldErrors?.[field.name]}
/>
))}
{advanced.length > 0 && (
<div className="rounded-md border border-border/60">
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="flex w-full items-center justify-between px-4 py-2.5 text-sm font-medium hover:bg-muted/30 transition-colors"
aria-expanded={showAdvanced}
>
<span>Advanced ({advanced.length})</span>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
showAdvanced && "rotate-180"
)}
/>
</button>
{showAdvanced && (
<div className="space-y-5 border-t border-border/60 px-4 py-4">
{advanced.map((field) => (
<FieldRow
key={field.name}
field={field}
value={values[field.name]}
onChange={(value) => onChange(field.name, value)}
disabled={disabled}
error={fieldErrors?.[field.name]}
/>
))}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,15 @@
import { PlaygroundIndex } from "./components/playground-index";
export default async function PlaygroundPage({
params,
}: {
params: Promise<{ workspace_id: string }>;
}) {
const { workspace_id } = await params;
return (
<div className="mx-auto w-full max-w-5xl">
<PlaygroundIndex workspaceId={Number(workspace_id)} />
</div>
);
}

View file

@ -0,0 +1,15 @@
import { RunsTable } from "../components/runs-table";
export default async function PlaygroundRunsPage({
params,
}: {
params: Promise<{ workspace_id: string }>;
}) {
const { workspace_id } = await params;
return (
<div className="mx-auto w-full max-w-6xl">
<RunsTable workspaceId={Number(workspace_id)} />
</div>
);
}

View file

@ -41,7 +41,13 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
{ url: `${BASE_URL}/`, lastModified, changeFrequency: "daily", priority: 1 },
{ url: `${BASE_URL}/free`, lastModified, changeFrequency: "daily", priority: 0.95 },
{ url: `${BASE_URL}/connectors`, lastModified, changeFrequency: "weekly", priority: 0.9 },
{ url: `${BASE_URL}/mcp-connector`, lastModified, changeFrequency: "weekly", priority: 0.85 },
{ url: `${BASE_URL}/mcp-server`, lastModified, changeFrequency: "weekly", priority: 0.85 },
{
url: `${BASE_URL}/external-mcp-connectors`,
lastModified,
changeFrequency: "weekly",
priority: 0.85,
},
{ url: `${BASE_URL}/pricing`, lastModified, changeFrequency: "weekly", priority: 0.9 },
{ url: `${BASE_URL}/contact`, lastModified, changeFrequency: "monthly", priority: 0.7 },
{ url: `${BASE_URL}/blog`, lastModified, changeFrequency: "daily", priority: 0.9 },