diff --git a/surfsense_web/app/(home)/[slug]/page.tsx b/surfsense_web/app/(home)/[slug]/page.tsx new file mode 100644 index 000000000..94a7912f7 --- /dev/null +++ b/surfsense_web/app/(home)/[slug]/page.tsx @@ -0,0 +1,94 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { ConnectorPage } from "@/components/connectors-marketing/connector-page"; +import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld"; +import { getAllConnectorSlugs, getConnector } from "@/lib/connectors-marketing"; + +interface PageProps { + params: Promise<{ slug: string }>; +} + +// Only the known connector slugs are served; every other path falls through to 404. +export const dynamicParams = false; + +export function generateStaticParams() { + return getAllConnectorSlugs().map((slug) => ({ slug })); +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + const content = getConnector(slug); + if (!content) return { title: "Connector Not Found | SurfSense" }; + + const canonicalUrl = `https://www.surfsense.com/${content.slug}`; + + return { + title: content.metaTitle, + description: content.metaDescription, + keywords: content.keywords, + alternates: { canonical: canonicalUrl }, + openGraph: { + title: content.metaTitle, + description: content.metaDescription, + url: canonicalUrl, + siteName: "SurfSense", + type: "website", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: `${content.name} Scraper API on SurfSense`, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: content.metaTitle, + description: content.metaDescription, + images: ["/og-image.png"], + }, + }; +} + +export default async function ConnectorMarketingPage({ params }: PageProps) { + const { slug } = await params; + const content = getConnector(slug); + if (!content) notFound(); + + const canonicalUrl = `https://www.surfsense.com/${content.slug}`; + + return ( + <> + + + + + ); +} diff --git a/surfsense_web/app/(home)/connectors/page.tsx b/surfsense_web/app/(home)/connectors/page.tsx new file mode 100644 index 000000000..06fc4e4aa --- /dev/null +++ b/surfsense_web/app/(home)/connectors/page.tsx @@ -0,0 +1,100 @@ +import { ArrowRight, Plug } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { getAllConnectors } from "@/lib/connectors-marketing"; + +const canonicalUrl = "https://www.surfsense.com/connectors"; + +const metaDescription = + "Platform-native scraper APIs for AI agents. Pull live data from the platforms your market uses through one typed API or the SurfSense MCP server. Explore every connector."; + +export const metadata: Metadata = { + title: "Scraper APIs for AI Agents: All Connectors | SurfSense", + description: metaDescription, + keywords: [ + "scraper api", + "web scraping api", + "scraper api for ai agents", + "data connectors", + "mcp server", + "competitive intelligence platform", + ], + alternates: { canonical: canonicalUrl }, + openGraph: { + title: "Scraper APIs for AI Agents: All Connectors | SurfSense", + description: metaDescription, + url: canonicalUrl, + siteName: "SurfSense", + type: "website", + images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense connectors" }], + }, +}; + +export default function ConnectorsIndexPage() { + const connectors = getAllConnectors(); + + return ( +
+
+
+

+ Connectors for every platform your market uses +

+

+ Each connector is a platform-native scraper API your AI agents can call directly, or + through the SurfSense MCP server. They are the live data behind the SurfSense{" "} + + competitive intelligence platform + + . +

+
+ +
+ {connectors.map((connector) => { + const Icon = connector.icon; + return ( + + + + +

+ {connector.cardTitle ?? `${connector.name} API`} +

+

+ {connector.heroLede} +

+ + Explore + + + + ); + })} + {/* Bespoke page (not in the scrape-API registry): SurfSense as an MCP client. */} + + + + +

MCP Connector

+

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

+ + Explore + + + +
+
+
+ ); +} diff --git a/surfsense_web/app/(home)/mcp-connector/page.tsx b/surfsense_web/app/(home)/mcp-connector/page.tsx new file mode 100644 index 000000000..7059025b4 --- /dev/null +++ b/surfsense_web/app/(home)/mcp-connector/page.tsx @@ -0,0 +1,369 @@ +import { IconBrandGithub } from "@tabler/icons-react"; +import { ArrowRight, Check, Plug, ShieldCheck, Wrench } 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-connector"; + +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."; + +export const metadata: Metadata = { + title: "MCP Connector for AI Agents: Add Any MCP Server | SurfSense", + description: metaDescription, + keywords: [ + "mcp connector", + "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", + description: metaDescription, + url: canonicalUrl, + siteName: "SurfSense", + type: "website", + images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense MCP connector" }], + }, + twitter: { + card: "summary_large_image", + title: "MCP Connector for AI Agents: Add Any MCP Server | SurfSense", + description: metaDescription, + images: ["/og-image.png"], + }, +}; + +/* Mirrors the real server_config contract (stdio + HTTP transports). */ +const STDIO_CONFIG = `{ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"], + "env": { "LOG_LEVEL": "info" }, + "transport": "stdio" +}`; + +const HTTP_CONFIG = `{ + "url": "https://mcp.example.com/mcp", + "headers": { "Authorization": "Bearer " }, + "transport": "streamable-http" +}`; + +const STEPS = [ + { + icon: Plug, + title: "Paste a server config", + description: + "Add any MCP server the same way you would in Cursor: a local command for stdio servers, or a URL and headers for remote HTTP and SSE servers.", + }, + { + icon: Wrench, + title: "Tools are auto-discovered", + description: + "SurfSense tests the connection and pulls the full tool list from the server. No manual tool configuration, no schema files to maintain.", + }, + { + icon: ShieldCheck, + title: "Your agent uses them, safely", + description: + "Read-only tools run automatically. Anything that writes asks for your approval first, and you can trust a tool once to always allow it.", + }, +] as const; + +/** Hosted MCP apps with one-click OAuth (mirrors the backend MCP service registry). */ +const ONE_CLICK_APPS = [ + "Notion", + "Slack", + "Jira", + "Confluence", + "Linear", + "ClickUp", + "Airtable", +] as const; + +const FAQ: FaqItem[] = [ + { + question: "What is an 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.", + }, + { + question: "How is an MCP connector different from an 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.", + }, + { + question: "Which MCP transports are supported?", + answer: + "All the common ones. Local stdio servers run as a process with a command, args, and environment variables. Remote servers connect over streamable HTTP, plain HTTP, or SSE with a URL and optional headers, which covers hosted MCP servers that require an auth token.", + }, + { + question: "Is it safe to give an agent MCP tools?", + answer: + "Every MCP tool runs through SurfSense's permission layer. Read-only tools are allowed automatically, while any tool that can write or act asks for your approval before it executes. You can mark tools you rely on as trusted so they skip the prompt on later calls.", + }, + { + question: "Can I connect Notion or Slack without writing a config?", + answer: + "Yes. Notion, Slack, Jira, Confluence, Linear, ClickUp, and Airtable connect through their official hosted MCP servers with one-click OAuth. SurfSense handles the token exchange and curates each app's tool list, so you sign in once and your agents can use them immediately.", + }, +]; + +function ConfigCard() { + return ( +
+

Local server (stdio)

+
+				{STDIO_CONFIG}
+			
+

Remote server (HTTP / SSE)

+
+				{HTTP_CONFIG}
+			
+

+ + Tools auto-discovered on connect +

+
+ ); +} + +export default function McpConnectorPage() { + return ( + <> + + + +
+ {/* Hero */} + +
+
+ + + + MCP connector + +

+ Bring any MCP server to your AI agents +

+

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

+
+ + + +
+
+ +
+
+ + {/* How it works */} + + +

+ From config to agent tool in three steps +

+
+
+ {STEPS.map((step) => ( + +
+ + + +

{step.title}

+

+ {step.description} +

+
+
+ ))} +
+
+ + {/* One-click apps */} + + +

+ Your work apps, no config required +

+

+ These apps run on their official hosted MCP servers. Sign in once with OAuth and + SurfSense manages the tokens and curates each tool list, so your agents can search + Notion, read Slack threads, or file Jira issues alongside your market intelligence. +

+
+ +
+ {ONE_CLICK_APPS.map((app) => ( + + + {app} + + ))} +
+
+
+ + {/* Connector vs server */} + + +

+ MCP connector vs MCP server +

+

+ They are two sides of the same protocol. The MCP connector on this page makes + SurfSense a client: it consumes tools from outside MCP servers. The SurfSense + MCP server does the reverse, exposing platform connectors like{" "} + + Reddit + {" "} + and{" "} + + Google Maps + {" "} + as native tools inside Claude, Cursor, or any agent you already run. Use both and data + flows in either direction. +

+
+
+ + {/* FAQ */} + + +

+ MCP connector: frequently asked questions +

+
+ +
+ +
+
+
+ + {/* Closing CTA + related */} + + +
+

+ Give your agents every tool they need +

+

+ The MCP connector is part of the SurfSense{" "} + + competitive intelligence platform + + . Start free, no credit card required. +

+
+ + +
+ + + + +
+
+
+
+ + ); +} diff --git a/surfsense_web/app/(home)/page.tsx b/surfsense_web/app/(home)/page.tsx index 367ef2dc5..94dc419b2 100644 --- a/surfsense_web/app/(home)/page.tsx +++ b/surfsense_web/app/(home)/page.tsx @@ -1,29 +1,23 @@ -import dynamic from "next/dynamic"; import { AuthRedirect } from "@/components/homepage/auth-redirect"; -import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid"; -import { FeaturesCards } from "@/components/homepage/features-card"; +import { CommunityStrip } from "@/components/homepage/community-strip"; +import { CompareTable } from "@/components/homepage/compare-table"; +import { ConnectorGrid } from "@/components/homepage/connector-grid"; import { HeroSection } from "@/components/homepage/hero-section"; - -const WhySurfSense = dynamic(() => - import("@/components/homepage/why-surfsense").then((m) => ({ default: m.WhySurfSense })) -); - -const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations")); - -const CTAHomepage = dynamic(() => - import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage })) -); +import { HomeFaq } from "@/components/homepage/home-faq"; +import { HowItWorks } from "@/components/homepage/how-it-works"; +import { UseCasesRow } from "@/components/homepage/use-cases"; export default function HomePage() { return ( -
+
- - - - - + + + + + +
); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx deleted file mode 100644 index 8f8109156..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import { useParams } from "next/navigation"; -import { ArtifactsLibrary } from "@/features/artifacts-library"; - -export default function ArtifactsPage() { - const params = useParams(); - const searchSpaceId = Number(params.search_space_id); - - return ; -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx deleted file mode 100644 index 4085d47a8..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; -import { ShieldAlert } from "lucide-react"; -import { useAutomation } from "@/hooks/use-automation"; -import { useAutomationPermissions } from "../hooks/use-automation-permissions"; -import { AutomationDefinitionSection } from "./components/automation-definition-section"; -import { AutomationDetailHeader } from "./components/automation-detail-header"; -import { AutomationDetailLoading } from "./components/automation-detail-loading"; -import { AutomationNotFound } from "./components/automation-not-found"; -import { AutomationRunsSection } from "./components/automation-runs-section"; -import { AutomationTriggersSection } from "./components/automation-triggers-section"; - -interface AutomationDetailContentProps { - searchSpaceId: number; - automationId: number; -} - -/** - * Client orchestrator for one automation's detail view. Branches: - * - permissions loading → skeleton - * - no read permission → access denied panel - * - bad id (NaN) → not-found panel - * - detail fetching → skeleton - * - detail error / null → not-found panel (we don't distinguish 404 - * from 403 in the UI) - * - detail loaded → header + definition + triggers - * - * Each child component is gated independently on the relevant permission - * so the orchestrator stays thin. - */ -export function AutomationDetailContent({ - searchSpaceId, - automationId, -}: AutomationDetailContentProps) { - const perms = useAutomationPermissions(); - const validId = Number.isInteger(automationId) && automationId > 0; - const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined); - - if (perms.loading) { - return ; - } - - if (!perms.canRead) { - return ( -
- -

Access denied

-

- You don't have permission to view automations in this search space. -

-
- ); - } - - if (!validId) { - return ; - } - - if (isLoading) { - return ; - } - - if (error || !automation) { - return ; - } - - return ( - <> - - -
-
- - -
-
- -
-
- - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx deleted file mode 100644 index ab6168305..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; -import { Dot } from "lucide-react"; -import { useState } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import type { AutomationDefinition } from "@/contracts/types/automation.types"; -import { ExecutionSummary } from "./execution-summary"; -import { InputsSchemaPreview } from "./inputs-schema-preview"; -import { PlanStepCard } from "./plan-step-card"; - -interface AutomationDefinitionSectionProps { - definition: AutomationDefinition; -} - -/** - * User-facing read view of the saved automation definition. Editing happens on - * the sibling /edit route; this card should summarize behavior, not expose the - * raw persisted schema. - */ -export function AutomationDefinitionSection({ definition }: AutomationDefinitionSectionProps) { - const hasTags = definition.metadata.tags.length > 0; - const hasInputs = !!definition.inputs; - const [advancedOpen, setAdvancedOpen] = useState(false); - const stepCount = `${definition.plan.length} step${definition.plan.length === 1 ? "" : "s"}`; - - return ( - - - Automation details - - - {definition.goal && ( - -

{definition.goal}

-
- )} - - {hasTags && ( - -
- {definition.metadata.tags.map((tag) => ( - - {tag} - - ))} -
-
- )} - - {hasInputs && ( - - {definition.inputs && } - - )} - - - Plan - - {stepCount} - - } - > -
- {definition.plan.map((step, idx) => ( - - ))} -
- - - {advancedOpen ? "Hide advanced options" : "Advanced options"} - - -
-
- Execution defaults -
- -
-
-
-
-
-
- ); -} - -function Field({ label, children }: { label: React.ReactNode; children: React.ReactNode }) { - return ( -
-
{label}
- {children} -
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx deleted file mode 100644 index 71730baeb..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client"; -import { useAtomValue } from "jotai"; -import { ArrowLeft, Pause, Pencil, Play, Trash2 } from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useCallback, useState } from "react"; -import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms"; -import { Button } from "@/components/ui/button"; -import { Spinner } from "@/components/ui/spinner"; -import type { Automation } from "@/contracts/types/automation.types"; -import { DeleteAutomationDialog } from "../../components/delete-automation-dialog"; - -interface AutomationDetailHeaderProps { - automation: Automation; - searchSpaceId: number; - canUpdate: boolean; - canDelete: boolean; -} - -/** - * Title bar for the detail page: back link, name, status badge, - * description, and the two destructive-ish primary actions (pause / - * resume + delete). Same mutation atoms as the list-row actions to - * keep caches coherent. - * - * Archived automations hide the pause/resume toggle (we don't unarchive - * here — that flow comes later if we need it). - */ -export function AutomationDetailHeader({ - automation, - searchSpaceId, - canUpdate, - canDelete, -}: AutomationDetailHeaderProps) { - const router = useRouter(); - const { mutateAsync: updateAutomation, isPending: updating } = useAtomValue( - updateAutomationMutationAtom - ); - const [deleteOpen, setDeleteOpen] = useState(false); - - const canToggle = canUpdate && automation.status !== "archived"; - const nextStatus = automation.status === "active" ? "paused" : "active"; - const pauseLabel = automation.status === "active" ? "Pause" : "Resume"; - const PauseIcon = automation.status === "active" ? Pause : Play; - - const handleDeleted = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/automations`); - }, [router, searchSpaceId]); - - async function handleTogglePause() { - await updateAutomation({ - automationId: automation.id, - patch: { status: nextStatus }, - }); - } - - return ( - <> -
- - -
-
-

- {automation.name} -

- {automation.description && ( -

{automation.description}

- )} -
- -
- {canUpdate && ( - - )} - {canToggle && ( - - )} - {canDelete && ( - - )} -
-
-
- - {canDelete && ( - - )} - - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-loading.tsx deleted file mode 100644 index 0d6ba3110..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-loading.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; - -/** - * Skeleton for the detail page. Mirrors the loaded view's main/sidebar - * grid (Definition + Runs on the left, Triggers on the right) so layout - * doesn't reflow when data arrives. - */ -export function AutomationDetailLoading() { - return ( - <> -
- -
- - -
- -
- -
-
- - - - - - - - - - - - - - - - - - -
-
- - - - - - - - -
-
- - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-not-found.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-not-found.tsx deleted file mode 100644 index 1681caf25..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-not-found.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; -import { ArrowLeft, FileWarning } from "lucide-react"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; - -interface AutomationNotFoundProps { - searchSpaceId: number; - error?: Error | null; -} - -/** - * Rendered when the detail fetch fails (404 / 403 / network) or the id - * is not a number. We don't distinguish "missing" from "forbidden" in the - * UI on purpose — leaking that an id exists you can't read is worse than - * a vague message. - */ -export function AutomationNotFound({ searchSpaceId, error }: AutomationNotFoundProps) { - return ( -
- -

Automation not found

-

- This automation doesn't exist or you don't have access to it. - {error?.message ? ` (${error.message})` : null} -

- -
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-runs-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-runs-section.tsx deleted file mode 100644 index bd683fe57..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-runs-section.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; -import { History } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useAutomationRuns } from "@/hooks/use-automation-runs"; -import { RunRow } from "./run-row"; -import { RunsLoading } from "./runs-loading"; - -interface AutomationRunsSectionProps { - automationId: number; -} - -const LIMIT = 20; - -/** - * Run history card. Shows the most recent ``LIMIT`` runs; pagination is - * intentionally deferred — for the foreseeable v1 surface (one-trigger - * automations firing daily), 20 covers ~3 weeks of history which is - * enough to tell whether things are working. Real "load more" lands if - * we see usage spike past that. - */ -export function AutomationRunsSection({ automationId }: AutomationRunsSectionProps) { - const { data, isLoading, error } = useAutomationRuns(automationId, { limit: LIMIT }); - const runs = data?.items ?? []; - - return ( - - -
- - Recent runs - -

- Most recent first. Click a row to inspect step results, output and artifacts. -

-
- {!isLoading && !error && data && ( - {data.total} total - )} -
- - {isLoading ? ( - - ) : error ? ( -

- Couldn't load runs{error.message ? `: ${error.message}` : "."} -

- ) : runs.length === 0 ? ( -
- -

No runs yet

-

- This automation hasn't fired. Once a trigger fires (or you invoke it manually), runs - will appear here. -

-
- ) : ( -
- {runs.map((run) => ( - - ))} -
- )} -
-
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx deleted file mode 100644 index abe739dcc..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; -import { CalendarClock } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { Trigger } from "@/contracts/types/automation.types"; -import { TriggerCard } from "./trigger-card"; - -interface AutomationTriggersSectionProps { - triggers: Trigger[]; - automationId: number; - canUpdate: boolean; - canDelete: boolean; -} - -/** - * The Triggers card. Lists each attached trigger with its own enable - * toggle and remove button. v1 attaches triggers at automation-creation - * time only; there is no in-place "add trigger" affordance here. - */ -export function AutomationTriggersSection({ - triggers, - automationId, - canUpdate, - canDelete, -}: AutomationTriggersSectionProps) { - return ( - - - Triggers -

When this automation runs

-
- - {triggers.length === 0 ? ( -
- -

No triggers attached

-

- This automation can still be invoked, but nothing will fire it on its own. -

-
- ) : ( -
- {triggers.map((trigger) => ( - - ))} -
- )} -
-
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx deleted file mode 100644 index 71e905724..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; -import { useAtomValue } from "jotai"; -import { useState } from "react"; -import { removeTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Spinner } from "@/components/ui/spinner"; - -interface DeleteTriggerDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - automationId: number; - triggerId: number; - triggerLabel: string; -} - -/** - * Confirm + detach one trigger from its automation. The automation itself - * is untouched; only this trigger row is removed. The mutation atom - * invalidates the parent automation detail so the page rerenders. - */ -export function DeleteTriggerDialog({ - open, - onOpenChange, - automationId, - triggerId, - triggerLabel, -}: DeleteTriggerDialogProps) { - const { mutateAsync: removeTrigger } = useAtomValue(removeTriggerMutationAtom); - const [submitting, setSubmitting] = useState(false); - - async function handleConfirm() { - setSubmitting(true); - try { - await removeTrigger({ automationId, triggerId }); - onOpenChange(false); - } finally { - setSubmitting(false); - } - } - - return ( - - - - Remove this trigger? - - {triggerLabel} will be detached. - The automation itself stays, but it won't fire on this trigger anymore. - - - - Cancel - - {submitting ? ( - - - Removing… - - ) : ( - "Remove" - )} - - - - - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx deleted file mode 100644 index 82abce173..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; -import type { Execution } from "@/contracts/types/automation.types"; - -interface ExecutionSummaryProps { - execution: Execution; -} - -/** - * Compact view of an automation's execution defaults (wall-clock cap, - * retries, backoff, concurrency, on_failure presence). Per-step overrides - * are shown inside each PlanStepCard, not here. - */ -export function ExecutionSummary({ execution }: ExecutionSummaryProps) { - return ( -
- - - - - {execution.on_failure.length > 0 && ( - - )} -
- ); -} - -function formatEnumValue(value: string): string { - const text = value.replace(/_/g, " "); - return text.charAt(0).toUpperCase() + text.slice(1); -} - -function Item({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx deleted file mode 100644 index dce6ac4a7..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; -import type { Inputs } from "@/contracts/types/automation.types"; - -interface InputsSchemaPreviewProps { - inputs: Inputs; -} - -/** - * Read-only preview of an automation's accepted-inputs schema. Most - * automations don't define inputs (defaults are baked into the trigger's - * static_inputs), so the parent skips rendering this card when ``inputs`` - * is null. - */ -export function InputsSchemaPreview({ inputs }: InputsSchemaPreviewProps) { - const fields = getInputFields(inputs.schema); - - if (fields.length === 0) { - return

No extra inputs are required.

; - } - - return ( -
- {fields.map((field) => ( -
-
-
{field.name}
- {field.description ? ( -
{field.description}
- ) : null} -
-
- {field.type} - {field.required ? " · required" : ""} -
-
- ))} -
- ); -} - -function getInputFields(schema: Record): { - name: string; - type: string; - description?: string; - required: boolean; -}[] { - const properties = schema.properties; - if (!properties || typeof properties !== "object" || Array.isArray(properties)) { - return []; - } - - const required = new Set(Array.isArray(schema.required) ? schema.required : []); - return Object.entries(properties as Record).map(([name, value]) => { - const field = value && typeof value === "object" && !Array.isArray(value) ? value : {}; - return { - name, - type: formatType((field as Record).type), - description: - typeof (field as Record).description === "string" - ? ((field as Record).description as string) - : undefined, - required: required.has(name), - }; - }); -} - -function formatType(value: unknown): string { - if (Array.isArray(value)) return value.join(" or "); - if (typeof value === "string") return value; - return "value"; -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx deleted file mode 100644 index 7505ef49b..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; -import type { PlanStep } from "@/contracts/types/automation.types"; - -interface PlanStepCardProps { - step: PlanStep; - index: number; -} - -/** - * Read-only view of one plan step. Keep this user-facing: summarize what the - * step does and only show advanced step controls when they are explicitly set. - */ -export function PlanStepCard({ step, index }: PlanStepCardProps) { - const title = getStepTitle(step); - const details = getStepDetails(step); - - return ( -
-
- - {index + 1} - -
-

{title}

- {details.length > 0 ? ( -
- {details.map((detail) => ( - - ))} -
- ) : null} -
-
-
- ); -} - -function DefRow({ label, value }: { label: string; value: string }) { - return ( -
-
{label}:
-
{value}
-
- ); -} - -function getStepTitle(step: PlanStep): string { - if (step.action === "agent_task") { - return readStringParam(step.params, "query") ?? "Run an agent task"; - } - return sentenceCase(formatAction(step.action)); -} - -function getStepDetails(step: PlanStep): { label: string; value: string }[] { - const details: { label: string; value: string }[] = []; - - if (step.action === "agent_task") { - if (typeof step.params.auto_approve_all === "boolean") { - details.push({ - label: "Approval", - value: step.params.auto_approve_all ? "Auto-approve agent actions" : "Ask before actions", - }); - } - - const mentionSummary = summarizeMentions(step.params); - if (mentionSummary) { - details.push({ label: "Scope", value: mentionSummary }); - } - } else { - const readableParams = Object.entries(step.params) - .filter(([, value]) => value !== null && value !== undefined && value !== "") - .map(([key, value]) => `${sentenceCase(formatKey(key))}: ${formatValue(value)}`); - if (readableParams.length > 0) { - details.push({ label: "Details", value: readableParams.join(" · ") }); - } - } - - if (step.when) details.push({ label: "Runs when", value: step.when }); - if (step.output_as) details.push({ label: "Saves output as", value: step.output_as }); - if (step.max_retries != null) - details.push({ label: "Max retries", value: String(step.max_retries) }); - if (step.timeout_seconds != null) - details.push({ label: "Timeout", value: `${step.timeout_seconds}s` }); - - return details; -} - -function readStringParam(params: Record, key: string): string | null { - const value = params[key]; - return typeof value === "string" && value.trim() ? value : null; -} - -function summarizeMentions(params: Record): string | null { - const parts: string[] = []; - addMentionTitles(parts, params.mentioned_documents, "Documents and folders"); - addMentionTitles(parts, params.mentioned_connectors, "Connectors"); - if (parts.length === 0) { - addCount(parts, params.mentioned_document_ids, "document"); - addCount(parts, params.mentioned_folder_ids, "folder"); - addCount(parts, params.mentioned_connector_ids, "connector"); - } - return parts.length > 0 ? parts.join(", ") : null; -} - -function addMentionTitles(parts: string[], value: unknown, label: string): void { - if (!Array.isArray(value) || value.length === 0) return; - const titles = value - .map((entry) => { - const record = asRecord(entry); - const title = typeof record.title === "string" ? record.title : null; - const accountName = typeof record.account_name === "string" ? record.account_name : null; - return title ?? accountName; - }) - .filter((title): title is string => !!title); - if (titles.length === 0) return; - parts.push(`${label}: ${titles.join(", ")}`); -} - -function addCount(parts: string[], value: unknown, singular: string): void { - if (!Array.isArray(value) || value.length === 0) return; - parts.push(`${value.length} ${singular}${value.length === 1 ? "" : "s"}`); -} - -function formatAction(action: string): string { - return formatKey(action); -} - -function formatKey(key: string): string { - return key.replace(/_/g, " "); -} - -function sentenceCase(value: string): string { - return value.charAt(0).toUpperCase() + value.slice(1); -} - -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function formatValue(value: unknown): string { - if (typeof value === "boolean") return value ? "Yes" : "No"; - if (typeof value === "string" || typeof value === "number") return String(value); - if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`; - if (value && typeof value === "object") return "Configured"; - return String(value); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx deleted file mode 100644 index ab82589dc..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx +++ /dev/null @@ -1,200 +0,0 @@ -"use client"; -import { - AlertCircle, - ChevronDown, - FileOutput, - GitCommitHorizontal, - Package, - Settings2, -} from "lucide-react"; -import { useState } from "react"; -import { JsonView } from "@/components/json-view"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { Skeleton } from "@/components/ui/skeleton"; -import type { RunStatus, RunStepResult } from "@/contracts/types/automation.types"; -import { useAutomationRun } from "@/hooks/use-automation-runs"; -import { cn } from "@/lib/utils"; -import { RunStepResultCard } from "./run-step-result-card"; - -interface RunDetailsPanelProps { - automationId: number; - runId: number; - /** Live step entries from Zero; rendered while the run is in-flight and - * also kept as the authoritative source once it finishes. */ - liveSteps: RunStepResult[]; - /** Live run status from Zero. Used to hide diagnostic sections that - * only make sense after the run reaches a terminal state. */ - liveStatus: RunStatus; -} - -/** - * Expanded view of a single run. Steps render immediately from the live - * Zero row so the panel updates as the run progresses; the heavy REST - * payload (output, artifacts, resolved inputs, run-level error) is - * fetched lazily and merged in when it arrives. - * - * Surfacing order is outcome-first: a run-level error (when present), - * then per-step cards that render the agent's markdown ``final_message`` - * directly, and finally the structural artifacts/inputs. The full - * ``definition_snapshot`` is omitted because it usually mirrors the live - * definition — surfacing it would dominate the panel without informing - * what the user is trying to learn ("did this work? what did it do?"). - */ -export function RunDetailsPanel({ - automationId, - runId, - liveSteps, - liveStatus, -}: RunDetailsPanelProps) { - const isTerminal = liveStatus !== "pending" && liveStatus !== "running"; - // Defer the REST round-trip until the run can actually carry heavy - // fields — output/artifacts/error are only written at terminal mark. - const { - data: run, - isLoading, - error, - } = useAutomationRun(automationId, runId, { - enabled: isTerminal, - }); - - const runError = run?.error && Object.keys(run.error).length > 0 ? run.error : null; - const hasOutput = !!run?.output && Object.keys(run.output).length > 0; - const hasInputs = !!run && Object.keys(run.inputs ?? {}).length > 0; - const hasDiagnostics = !!run && (run.artifacts.length > 0 || hasInputs); - const heavyLoading = isTerminal && isLoading && !run; - const heavyError = isTerminal && !!error; - - return ( -
- {runError ? : null} - - {hasOutput ? ( -
- -
- ) : null} - -
- {liveSteps.length === 0 ? ( -

- {isTerminal ? "No steps recorded." : "Waiting for first step…"} -

- ) : ( -
- {liveSteps.map((step, index) => ( - - ))} -
- )} -
- - {heavyLoading ? ( - - ) : heavyError ? ( -

- Couldn't load run details{error?.message ? `: ${error.message}` : "."} -

- ) : hasDiagnostics ? ( - <> - - {run && run.artifacts.length > 0 ? ( -
- -
- ) : null} - {hasInputs ? ( -
- -
- ) : null} - - ) : null} -
- ); -} - -/** - * Run-level error: a readable destructive alert when a message is present, - * with the full structured error available behind a raw toggle. - */ -function RunErrorSection({ error }: { error: Record }) { - const [rawOpen, setRawOpen] = useState(false); - const message = typeof error.message === "string" ? error.message : null; - const type = typeof error.type === "string" ? error.type : "Run failed"; - - return ( -
- {message ? ( - - - {type} - {message} - - ) : null} - - - - - - - - - - -
- ); -} - -function Section({ - icon: Icon, - label, - tone = "default", - children, -}: { - icon: typeof AlertCircle; - label: string; - tone?: "default" | "destructive"; - children: React.ReactNode; -}) { - return ( -
-
- - {label} -
- {children} -
- ); -} - -function JsonBlock({ value }: { value: unknown }) { - return ( - - - - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx deleted file mode 100644 index b48230e3f..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; -import { ChevronDown, ChevronRight, Hand } from "lucide-react"; -import { useState } from "react"; -import type { LiveRunSummary } from "@/hooks/use-automation-runs"; -import { formatDuration } from "@/lib/automations/run-duration"; -import { formatRelativeDate } from "@/lib/format-date"; -import { RunDetailsPanel } from "./run-details-panel"; -import { RunStatusBadge } from "./run-status-badge"; - -interface RunRowProps { - run: LiveRunSummary; - automationId: number; -} - -/** - * One run row. Click to expand → renders the details panel inline. - * Status and step_results come live from the parent's Zero query; the - * panel itself only fetches the heavy REST fields on first expand. - */ -export function RunRow({ run, automationId }: RunRowProps) { - const [open, setOpen] = useState(false); - const duration = formatDuration(run.started_at, run.finished_at); - const startedLabel = run.started_at - ? formatRelativeDate(run.started_at) - : formatRelativeDate(run.created_at); - - return ( -
- - - {open && ( - - )} -
- ); -} - -function TriggerSource({ triggerId }: { triggerId: number | null }) { - if (triggerId == null) { - return ( - - - Manual - - ); - } - return via trigger #{triggerId}; -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-status-badge.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-status-badge.tsx deleted file mode 100644 index e5532a500..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-status-badge.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; -import { AlertCircle, CheckCircle2, Clock, Loader2, TimerOff, XCircle } from "lucide-react"; -import type { RunStatus } from "@/contracts/types/automation.types"; -import { cn } from "@/lib/utils"; - -const STATUS_STYLES: Record< - RunStatus, - { label: string; icon: typeof CheckCircle2; classes: string; spin?: boolean } -> = { - pending: { - label: "Pending", - icon: Clock, - classes: "bg-muted text-muted-foreground border-border/60", - }, - running: { - label: "Running", - icon: Loader2, - classes: "bg-blue-500/10 text-blue-600 border-blue-500/20", - spin: true, - }, - succeeded: { - label: "Succeeded", - icon: CheckCircle2, - classes: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20", - }, - failed: { - label: "Failed", - icon: XCircle, - classes: "bg-destructive/10 text-destructive border-destructive/20", - }, - cancelled: { - label: "Cancelled", - icon: AlertCircle, - classes: "bg-muted text-muted-foreground border-border/60", - }, - timed_out: { - label: "Timed out", - icon: TimerOff, - classes: "bg-amber-500/10 text-amber-600 border-amber-500/20", - }, -}; - -export function RunStatusBadge({ status, className }: { status: RunStatus; className?: string }) { - const { label, icon: Icon, classes, spin } = STATUS_STYLES[status]; - return ( - - - {label} - - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-step-result-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-step-result-card.tsx deleted file mode 100644 index eef572300..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-step-result-card.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; -import { CheckCircle2, ChevronDown, MinusCircle, XCircle } from "lucide-react"; -import { memo, useState } from "react"; -import { JsonView } from "@/components/json-view"; -import { MarkdownViewer } from "@/components/markdown-viewer"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import type { RunStepResult } from "@/contracts/types/automation.types"; -import { formatDuration } from "@/lib/automations/run-duration"; -import { cn } from "@/lib/utils"; - -type BadgeVariant = React.ComponentProps["variant"]; - -const STATUS_BADGE: Record< - string, - { label: string; variant: BadgeVariant; icon: typeof CheckCircle2 } -> = { - succeeded: { label: "Succeeded", variant: "outline", icon: CheckCircle2 }, - failed: { label: "Failed", variant: "destructive", icon: XCircle }, - skipped: { label: "Skipped", variant: "secondary", icon: MinusCircle }, -}; - -function StepStatusBadge({ status }: { status: string }) { - const meta = STATUS_BADGE[status] ?? { - label: status, - variant: "outline" as const, - icon: MinusCircle, - }; - const Icon = meta.icon; - return ( - - - {meta.label} - - ); -} - -/** - * One step from a run's ``step_results``. Surfaces the agent's markdown - * ``final_message`` first-class (rendered, not raw), shows step errors as a - * readable alert, and keeps the full structured payload behind a "View raw" - * collapsible escape hatch. - */ -export const RunStepResultCard = memo(function RunStepResultCard({ - step, -}: { - step: RunStepResult; -}) { - const [rawOpen, setRawOpen] = useState(false); - - const duration = formatDuration(step.started_at, step.finished_at); - const attempts = step.attempts ?? 0; - const finalMessage = - typeof step.result?.final_message === "string" ? step.result.final_message : null; - const errorMessage = step.error?.message; - const hasMeta = Boolean(duration) || attempts > 1; - - return ( - - -
-
- {step.action} - {step.step_id} -
- -
- {hasMeta ? ( -
- {duration ? {duration} : null} - {attempts > 1 ? {attempts} attempts : null} -
- ) : null} -
- - - {errorMessage ? ( - - - {step.error?.type ?? "Error"} - {errorMessage} - - ) : null} - - {finalMessage ? ( -
- -
- ) : null} - - - - - - - - - - - -
-
- ); -}); diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/runs-loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/runs-loading.tsx deleted file mode 100644 index 61ce25e32..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/runs-loading.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; -import { Skeleton } from "@/components/ui/skeleton"; - -const ROW_KEYS = ["a", "b", "c"] as const; - -export function RunsLoading() { - return ( -
- {ROW_KEYS.map((key) => ( -
-
- - -
- -
- ))} -
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx deleted file mode 100644 index de156a09c..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx +++ /dev/null @@ -1,386 +0,0 @@ -"use client"; -import { useAtomValue } from "jotai"; -import { AlertCircle, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; -import { useState } from "react"; -import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Spinner } from "@/components/ui/spinner"; -import { Switch } from "@/components/ui/switch"; -import { type Trigger, triggerUpdateRequest } from "@/contracts/types/automation.types"; -import { describeCron } from "@/lib/automations/describe-cron"; -import { - DEFAULT_SCHEDULE, - fromCron, - type ScheduleFrequency, - toCron, -} from "@/lib/automations/schedule-builder"; -import { formatRelativeFutureDate } from "@/lib/format-date"; -import { TimezoneCombobox } from "../../components/builder/timezone-combobox"; -import { DeleteTriggerDialog } from "./delete-trigger-dialog"; - -interface TriggerCardProps { - trigger: Trigger; - automationId: number; - canUpdate: boolean; - canDelete: boolean; -} - -type SimpleFrequency = Extract | "custom"; - -interface TriggerDraft { - frequency: SimpleFrequency; - hour: number; - minute: number; - timezone: string; - cron: string; -} - -const SIMPLE_FREQUENCIES = new Set(["hourly", "daily", "weekdays"]); - -function draftFromTrigger(trigger: Trigger): TriggerDraft { - const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : ""; - const timezone = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC"; - const model = fromCron(cron); - if (model && SIMPLE_FREQUENCIES.has(model.frequency)) { - return { - frequency: model.frequency as SimpleFrequency, - hour: model.hour, - minute: model.minute, - timezone, - cron, - }; - } - return { - frequency: "custom", - hour: DEFAULT_SCHEDULE.hour, - minute: DEFAULT_SCHEDULE.minute, - timezone, - cron, - }; -} - -function pad(value: number): string { - return value.toString().padStart(2, "0"); -} - -function clampInt(raw: string, min: number, max: number): number { - const value = Number.parseInt(raw, 10); - if (Number.isNaN(value)) return min; - return Math.min(max, Math.max(min, value)); -} - -/** - * One trigger row in the Triggers section of the detail page. Renders: - * - human-readable schedule - * - compact enable toggle - * - dropdown actions for edit/remove - * - * Inline edit keeps schedule editing intentionally small: common frequencies, - * time, timezone, and raw cron only for schedules outside the simple model. - * ``enabled`` stays on the Switch so the two surfaces don't fight. - */ -export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: TriggerCardProps) { - const { mutateAsync: updateTrigger, isPending: updating } = - useAtomValue(updateTriggerMutationAtom); - const [deleteOpen, setDeleteOpen] = useState(false); - const [isEditing, setIsEditing] = useState(false); - const [draft, setDraft] = useState(() => draftFromTrigger(trigger)); - const [issues, setIssues] = useState([]); - - const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined; - const human = cron ? describeCron(cron) : trigger.type; - const triggerLabel = human; - const showActions = (canUpdate && !isEditing) || canDelete; - - async function handleToggle(checked: boolean) { - await updateTrigger({ - automationId, - triggerId: trigger.id, - patch: { enabled: checked }, - }); - } - - function startEdit() { - setDraft(draftFromTrigger(trigger)); - setIssues([]); - setIsEditing(true); - } - - function cancelEdit() { - setIsEditing(false); - setIssues([]); - } - - async function saveEdit() { - setIssues([]); - const params = - draft.frequency === "custom" - ? { cron: draft.cron.trim(), timezone: draft.timezone } - : { - cron: toCron({ - ...DEFAULT_SCHEDULE, - frequency: draft.frequency, - hour: draft.hour, - minute: draft.minute, - }), - timezone: draft.timezone, - }; - const result = triggerUpdateRequest.safeParse({ - params, - static_inputs: trigger.static_inputs ?? {}, - }); - if (!result.success) { - setIssues( - result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) - ); - return; - } - try { - await updateTrigger({ - automationId, - triggerId: trigger.id, - patch: result.data, - }); - setIsEditing(false); - } catch (err) { - setIssues([(err as Error).message ?? "Update failed"]); - } - } - - return ( - <> -
-
-
{human}
- -
- {canUpdate && ( - - )} - {showActions && ( - - - - - - {canUpdate && !isEditing && ( - - - Edit - - )} - {canDelete && ( - setDeleteOpen(true)}> - - Delete - - )} - - - )} -
-
- - {!isEditing && trigger.next_fire_at ? ( -
-
- Next fire: -
-
- {formatRelativeFutureDate(trigger.next_fire_at)} -
-
- ) : null} - - {isEditing ? ( -
-
-
- - -
- - {draft.frequency === "hourly" ? ( -
- - - setDraft((prev) => ({ - ...prev, - minute: clampInt(event.target.value, 0, 59), - })) - } - /> -
- ) : draft.frequency !== "custom" ? ( -
- - { - const [hour, minute] = event.target.value.split(":"); - setDraft((prev) => ({ - ...prev, - hour: clampInt(hour, 0, 23), - minute: clampInt(minute, 0, 59), - })); - }} - /> -
- ) : ( -
- - - setDraft((prev) => ({ ...prev, cron: event.target.value })) - } - /> -
- )} - -
-
Timezone
- setDraft((prev) => ({ ...prev, timezone }))} - /> -
-
- - {issues.length > 0 && ( - - - - {issues.length === 1 ? "1 issue" : `${issues.length} issues`} - - -
    - {issues.map((issue) => ( -
  • {issue}
  • - ))} -
-
-
- )} - -
- - -
-
- ) : null} -
- - {canDelete && ( - - )} - - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx deleted file mode 100644 index c05bff7d9..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; -import { ShieldAlert } from "lucide-react"; -import { useAutomation } from "@/hooks/use-automation"; -import { AutomationBuilderForm } from "../../components/builder/automation-builder-form"; -import { useAutomationPermissions } from "../../hooks/use-automation-permissions"; -import { AutomationDetailLoading } from "../components/automation-detail-loading"; -import { AutomationNotFound } from "../components/automation-not-found"; -import { AutomationEditHeader } from "./components/automation-edit-header"; - -interface AutomationEditContentProps { - searchSpaceId: number; - automationId: number; -} - -/** - * Client orchestrator for the edit route. Mirrors detail-content's branch - * structure but gates on ``canUpdate`` instead of ``canRead``: a user who - * can read but not update is bounced to the access-denied panel. - */ -export function AutomationEditContent({ searchSpaceId, automationId }: AutomationEditContentProps) { - const perms = useAutomationPermissions(); - const validId = Number.isInteger(automationId) && automationId > 0; - const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined); - - if (perms.loading) { - return ; - } - - if (!perms.canUpdate) { - return ( -
- -

Access denied

-

- You don't have permission to edit automations in this search space. -

-
- ); - } - - if (!validId) { - return ; - } - - if (isLoading) { - return ; - } - - if (error || !automation) { - return ; - } - - return ( - ( - - )} - /> - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx deleted file mode 100644 index ca477220e..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; -import { ArrowLeft } from "lucide-react"; -import Link from "next/link"; -import type { ReactNode } from "react"; -import { Button } from "@/components/ui/button"; -import type { Automation } from "@/contracts/types/automation.types"; - -interface AutomationEditHeaderProps { - automation: Automation; - searchSpaceId: number; - modeSwitcher?: ReactNode; -} - -export function AutomationEditHeader({ - automation, - searchSpaceId, - modeSwitcher, -}: AutomationEditHeaderProps) { - const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`; - - return ( -
- -
-

- Edit automation -

- {modeSwitcher ?
{modeSwitcher}
: null} -
-
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/page.tsx deleted file mode 100644 index 8477b9e12..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { AutomationEditContent } from "./automation-edit-content"; - -export default async function AutomationEditPage({ - params, -}: { - params: Promise<{ search_space_id: string; automation_id: string }>; -}) { - const { search_space_id, automation_id } = await params; - - return ( -
- -
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/page.tsx deleted file mode 100644 index dbaceecdd..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { AutomationDetailContent } from "./automation-detail-content"; - -export default async function AutomationDetailPage({ - params, -}: { - params: Promise<{ search_space_id: string; automation_id: string }>; -}) { - const { search_space_id, automation_id } = await params; - - return ( -
- -
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx deleted file mode 100644 index d9c949058..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; -import { AlertCircle, ShieldAlert } from "lucide-react"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { useAutomations } from "@/hooks/use-automations"; -import { AutomationsEmptyState } from "./components/automations-empty-state"; -import { AutomationsHeader } from "./components/automations-header"; -import { AutomationsTable } from "./components/automations-table"; -import { useAutomationPermissions } from "./hooks/use-automation-permissions"; - -interface AutomationsContentProps { - searchSpaceId: number; -} - -/** - * Client orchestrator for the automations list page. Pulls the active - * search space's first page (via ``useAutomations`` → ``automationsListAtom``) - * and the user's permissions, then decides between empty / loading / table. - * - * Read access is mandatory; anything else is hidden behind RBAC. The - * permissions hook is co-located in this slice so adding/removing - * surfaces is a one-file change. - */ -export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) { - const { automations, total, loading, error } = useAutomations(); - const perms = useAutomationPermissions(); - - if (perms.loading) { - // Permissions gate the entire page; defer everything until we know. - return ( - <> - - - - ); - } - - if (!perms.canRead) { - return ( -
- -

Access denied

-

- You don't have permission to view automations in this search space. -

-
- ); - } - - if (error) { - return ( - <> - - - - Couldn't load automations {error.message} - - - ); - } - - if (!loading && automations.length === 0) { - return ( - <> - - - - ); - } - - return ( - <> - - - - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx deleted file mode 100644 index 95ee23445..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; -import { useAtomValue } from "jotai"; -import { MoreHorizontal, Pause, Play, Trash2 } from "lucide-react"; -import { useState } from "react"; -import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import type { AutomationSummary } from "@/contracts/types/automation.types"; -import { DeleteAutomationDialog } from "./delete-automation-dialog"; - -interface AutomationRowActionsProps { - automation: AutomationSummary; - searchSpaceId: number; - canUpdate: boolean; - canDelete: boolean; -} - -/** - * Three-dot menu on each row: pause/resume (if updatable) and delete - * (if deletable). The menu itself is hidden when the user has neither - * permission so we don't render an empty trigger. - */ -export function AutomationRowActions({ - automation, - searchSpaceId, - canUpdate, - canDelete, -}: AutomationRowActionsProps) { - const { mutateAsync: updateAutomation, isPending: updating } = useAtomValue( - updateAutomationMutationAtom - ); - const [deleteOpen, setDeleteOpen] = useState(false); - - if (!canUpdate && !canDelete) return null; - - const nextStatus = automation.status === "active" ? "paused" : "active"; - const pauseLabel = automation.status === "active" ? "Pause" : "Resume"; - const PauseIcon = automation.status === "active" ? Pause : Play; - const canToggle = canUpdate && automation.status !== "archived"; - - async function handleTogglePause() { - await updateAutomation({ - automationId: automation.id, - patch: { status: nextStatus }, - }); - } - - return ( - <> - - - - - - {canToggle && ( - - - {pauseLabel} - - )} - {canDelete && ( - setDeleteOpen(true)}> - - Delete - - )} - - - - {canDelete && ( - - )} - - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx deleted file mode 100644 index 74c95cee4..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; -import Link from "next/link"; -import { TableCell, TableRow } from "@/components/ui/table"; -import type { AutomationSummary } from "@/contracts/types/automation.types"; -import { formatRelativeDate } from "@/lib/format-date"; -import { AutomationRowActions } from "./automation-row-actions"; -import { AutomationStatusBadge } from "./automation-status-badge"; - -interface AutomationRowProps { - automation: AutomationSummary; - searchSpaceId: number; - canUpdate: boolean; - canDelete: boolean; -} - -/** - * One row in the automations table. The name links to the detail page; - * actions are gated by ``canUpdate`` / ``canDelete``. Trigger summary - * is intentionally left to the detail page — list responses don't - * include triggers and we want to avoid N+1 detail fetches. - */ -export function AutomationRow({ - automation, - searchSpaceId, - canUpdate, - canDelete, -}: AutomationRowProps) { - return ( - - - - {automation.name} - - - - - - - {formatRelativeDate(automation.updated_at)} - - -
- -
-
-
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx deleted file mode 100644 index c3cab1dc1..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; -import type { AutomationStatus } from "@/contracts/types/automation.types"; -import { cn } from "@/lib/utils"; - -interface AutomationStatusBadgeProps { - status: AutomationStatus; - className?: string; -} - -// Small borderless status pills, matching model-selector badges. -const STATUS_STYLES: Record = { - active: { - label: "Active", - classes: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300", - }, - paused: { - label: "Paused", - classes: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", - }, - archived: { - label: "Archived", - classes: "bg-muted text-muted-foreground", - }, -}; - -export function AutomationStatusBadge({ status, className }: AutomationStatusBadgeProps) { - const { label, classes } = STATUS_STYLES[status]; - return ( - - {label} - - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx deleted file mode 100644 index 270a1f844..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; -import { CalendarClock, Pause } from "lucide-react"; -import type { Trigger } from "@/contracts/types/automation.types"; -import { describeCron } from "@/lib/automations/describe-cron"; - -interface AutomationTriggersSummaryProps { - triggers: Trigger[]; -} - -/** - * One-line summary of an automation's triggers for the list view. - * - * v1 only registers ``schedule`` so this stays compact: - * - 0 triggers → "No triggers" - * - 1 schedule trigger → "Mon–Fri at 09:00 · UTC" + disabled badge if off - * - >1 → "N triggers" - * - * The detail page renders the full per-trigger editor. - */ -export function AutomationTriggersSummary({ triggers }: AutomationTriggersSummaryProps) { - if (triggers.length === 0) { - return No triggers; - } - - if (triggers.length > 1) { - return {triggers.length} triggers; - } - - const [trigger] = triggers; - - if (trigger.type === "schedule") { - const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined; - const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC"; - const human = cron ? describeCron(cron) : "Schedule"; - - return ( - - - {human} - · {tz} - {!trigger.enabled && ( - - - Off - - )} - - ); - } - - return {trigger.type}; -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx deleted file mode 100644 index 1ee71c636..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; -import { AlarmClock } from "lucide-react"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; - -interface AutomationsEmptyStateProps { - searchSpaceId: number; - canCreate: boolean; -} - -/** - * Zero-state for the automations list. The primary CTA points to a new - * chat — creation happens via the ``create_automation`` HITL tool, not a - * "new automation" form. We surface the chat path explicitly so users - * don't go hunting for an "add" button that doesn't exist. - */ -export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) { - return ( -
-
- -
-

No automations yet

-

- Automations let SurfSense run agent tasks on a schedule. Describe what you want in chat and - SurfSense drafts the automation for your approval. -

- {canCreate ? ( -
- - -
- ) : ( -

- You don't have permission to create automations in this search space. -

- )} -
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx deleted file mode 100644 index 5c1fcb507..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; - -interface AutomationsHeaderProps { - searchSpaceId: number; - total: number; - loading: boolean; - canCreate: boolean; - /** - * Render the header's Create CTA. Defaults to true; the empty state owns - * the primary CTA on its own card, so the orchestrator turns this off - * there to avoid a duplicate button. - */ - showCreateCta?: boolean; -} - -/** - * Page header: title + count + "Create via chat" CTA. Creation is intent-driven - * (the create_automation tool runs inside chat with a HITL approval card), so - * the CTA links to a new chat rather than opening a form. Model eligibility is - * handled per-automation in the builder + approval card, not gated here. - */ -export function AutomationsHeader({ - searchSpaceId, - total, - loading, - canCreate, - showCreateCta = true, -}: AutomationsHeaderProps) { - return ( -
-
-

Automations

- {!loading && ( - - {total} {total === 1 ? "automation" : "automations"} - - )} -
- {canCreate && showCreateCta && ( -
- - -
- )} -
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-loading.tsx deleted file mode 100644 index 1156be3f6..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-loading.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; -import { Skeleton } from "@/components/ui/skeleton"; -import { TableCell, TableRow } from "@/components/ui/table"; - -const ROW_KEYS = ["sk-1", "sk-2", "sk-3"]; - -/** - * Skeleton rows for the automations table. Number of rows is fixed since - * we don't know the count ahead of time and three placeholders is enough - * to communicate "loading" without flashing too much chrome. - */ -export function AutomationsLoadingRows() { - return ( - <> - {ROW_KEYS.map((key) => ( - - -
- - -
-
- - - - - - - - - -
- ))} - - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx deleted file mode 100644 index 74c604173..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; -import { AlarmClock, CalendarDays, Info } from "lucide-react"; -import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import type { AutomationSummary } from "@/contracts/types/automation.types"; -import { AutomationRow } from "./automation-row"; -import { AutomationsLoadingRows } from "./automations-loading"; - -interface AutomationsTableProps { - automations: AutomationSummary[]; - searchSpaceId: number; - loading: boolean; - canUpdate: boolean; - canDelete: boolean; -} - -/** - * Table shell + header. Rows render below — loading state renders skeleton - * rows in the same shell so the layout doesn't shift on data arrival. - */ -export function AutomationsTable({ - automations, - searchSpaceId, - loading, - canUpdate, - canDelete, -}: AutomationsTableProps) { - return ( -
- - - - - - - Name - - - - - - Status - - - - - - Updated - - - - Actions - - - - - {loading ? ( - - ) : ( - automations.map((automation) => ( - - )) - )} - -
-
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx deleted file mode 100644 index 110de57f6..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx +++ /dev/null @@ -1,129 +0,0 @@ -"use client"; -import { useState } from "react"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { BuilderExecution } from "@/lib/automations/builder-schema"; -import { Field } from "./form-field"; - -interface AdvancedSectionProps { - execution: BuilderExecution; - tags: string[]; - onExecutionChange: (patch: Partial) => void; - onTagsChange: (tags: string[]) => void; -} - -const BACKOFF_OPTIONS: ReadonlyArray<{ value: BuilderExecution["retryBackoff"]; label: string }> = [ - { value: "exponential", label: "Exponential" }, - { value: "linear", label: "Linear" }, - { value: "none", label: "None" }, -]; - -const CONCURRENCY_OPTIONS: ReadonlyArray<{ - value: BuilderExecution["concurrency"]; - label: string; -}> = [ - { value: "drop_if_running", label: "Skip if already running" }, - { value: "queue", label: "Queue the next run" }, - { value: "always", label: "Always run" }, -]; - -function clampInt(raw: string, min: number, fallback: number): number { - const value = Number.parseInt(raw, 10); - if (Number.isNaN(value)) return fallback; - return Math.max(min, value); -} - -export function AdvancedSection({ - execution, - tags, - onExecutionChange, - onTagsChange, -}: AdvancedSectionProps) { - const [tagsText, setTagsText] = useState(tags.join(", ")); - - function commitTags(text: string) { - const next = text - .split(",") - .map((tag) => tag.trim()) - .filter(Boolean); - onTagsChange(next); - } - - return ( -
-
- - - onExecutionChange({ timeoutSeconds: clampInt(e.target.value, 1, 600) }) - } - /> - - - onExecutionChange({ maxRetries: clampInt(e.target.value, 0, 2) })} - /> - - - - - - - -
- - - setTagsText(e.target.value)} - onBlur={(e) => commitTags(e.target.value)} - /> - -
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx deleted file mode 100644 index a68e53a1c..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx +++ /dev/null @@ -1,534 +0,0 @@ -"use client"; -import { useAtomValue } from "jotai"; -import { AlertCircle, Code2, LayoutList } from "lucide-react"; -import { useRouter } from "next/navigation"; -import type { ReactNode } from "react"; -import { useMemo, useState } from "react"; -import type { z } from "zod"; -import { - addTriggerMutationAtom, - createAutomationMutationAtom, - removeTriggerMutationAtom, - updateAutomationMutationAtom, - updateTriggerMutationAtom, -} from "@/atoms/automations/automations-mutation.atoms"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { Spinner } from "@/components/ui/spinner"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { - type Automation, - automationCreateRequest, - automationUpdateRequest, -} from "@/contracts/types/automation.types"; -import { useAutomationEligibleModels } from "@/hooks/use-automation-eligible-models"; -import { - type BuilderForm, - type BuilderModels, - buildCreatePayload, - builderFormSchema, - buildScheduleTrigger, - buildUpdatePayload, - createEmptyForm, - formFromAutomation, - type HydratableTrigger, - hasResolvedModels, - hydrateForm, -} from "@/lib/automations/builder-schema"; -import { AdvancedSection } from "./advanced-section"; -import { AutomationModelFields } from "./automation-model-fields"; -import { BasicsSection } from "./basics-section"; -import { BuilderSummary } from "./builder-summary"; -import { JsonModePanel } from "./json-mode-panel"; -import { ScheduleSection } from "./schedule-section"; -import { TaskList } from "./task-list"; -import { UnattendedToggle } from "./unattended-toggle"; - -interface AutomationBuilderFormProps { - mode: "create" | "edit"; - searchSpaceId: number; - /** Required in edit mode; seeds the form and trigger reconciliation. */ - automation?: Automation; - /** - * Optional extra create-mode block reason (composed with the form's own - * model-eligibility gate). Shown as the submit button's tooltip. Model - * eligibility itself is now owned by the in-form pickers. - */ - submitDisabledReason?: string; - renderModeSwitcher?: (modeSwitcher: ReactNode) => ReactNode; -} - -type Mode = "form" | "json"; - -function mapFormErrors(error: z.ZodError): Record { - const out: Record = {}; - for (const issue of error.issues) { - const path = issue.path; - let key: string; - if (path[0] === "tasks" && typeof path[1] === "number") key = `tasks.${path[1]}.query`; - else if (path[0] === "schedule") key = "schedule"; - else key = String(path[0] ?? "_root"); - if (!out[key]) out[key] = issue.message; - } - return out; -} - -export function AutomationBuilderForm({ - mode, - searchSpaceId, - automation, - submitDisabledReason, - renderModeSwitcher, -}: AutomationBuilderFormProps) { - const router = useRouter(); - const { mutateAsync: createAutomation } = useAtomValue(createAutomationMutationAtom); - const { mutateAsync: updateAutomation } = useAtomValue(updateAutomationMutationAtom); - const { mutateAsync: addTrigger } = useAtomValue(addTriggerMutationAtom); - const { mutateAsync: updateTrigger } = useAtomValue(updateTriggerMutationAtom); - const { mutateAsync: removeTrigger } = useAtomValue(removeTriggerMutationAtom); - - // Initial state: create starts empty in form mode; edit hydrates, falling - // back to JSON mode when the definition can't be represented in the form. - const initial = useMemo(() => { - if (mode === "edit" && automation) { - const result = formFromAutomation(automation); - if (result.formable) { - return { mode: "form" as Mode, form: result.form, notice: undefined }; - } - return { - mode: "json" as Mode, - form: createEmptyForm(), - notice: `This automation ${result.reason}, which the form can't show. Edit it as JSON below`, - }; - } - return { mode: "form" as Mode, form: createEmptyForm(), notice: undefined }; - }, [mode, automation]); - - const [activeMode, setActiveMode] = useState(initial.mode); - const [form, setForm] = useState(initial.form); - const [errors, setErrors] = useState>({}); - const [rootError, setRootError] = useState(null); - - const [jsonValue, setJsonValue] = useState>(() => - initial.mode === "json" ? jsonFromAutomation(automation) : {} - ); - const [jsonIssues, setJsonIssues] = useState([]); - const [jsonNotice, setJsonNotice] = useState(initial.notice); - - const [submitting, setSubmitting] = useState(false); - - // Eligible models + the search-space-seeded defaults. Models are chosen per - // automation on create; in edit mode the backend preserves the captured - // snapshot, so the picker is create-only. - const eligibleModels = useAutomationEligibleModels(); - - // Resolve each slot during render: an explicit (non-zero) pick wins, - // otherwise fall back to the eligible default. No effect copies async hook - // data into state, so there's no flicker/loop and the user's pick is sticky. - const resolvedModels = useMemo( - () => ({ - chatModelId: form.models.chatModelId || eligibleModels.llm.defaultId || 0, - imageConfigId: form.models.imageConfigId || eligibleModels.image.defaultId || 0, - visionConfigId: form.models.visionConfigId || eligibleModels.vision.defaultId || 0, - }), - [ - form.models, - eligibleModels.llm.defaultId, - eligibleModels.image.defaultId, - eligibleModels.vision.defaultId, - ] - ); - - // The form with resolved models folded in — what every payload builder reads. - const formForPayload = useMemo( - () => ({ ...form, models: resolvedModels }), - [form, resolvedModels] - ); - - function patchForm(patch: Partial) { - setForm((prev) => ({ ...prev, ...patch })); - } - - function jsonFromCurrentForm(): Record { - if (mode === "edit" && automation) { - return { ...buildUpdatePayload(formForPayload), status: automation.status }; - } - const { search_space_id: _ignored, ...rest } = buildCreatePayload( - formForPayload, - searchSpaceId - ); - return rest; - } - - function switchToJson() { - setJsonValue(jsonFromCurrentForm()); - setJsonIssues([]); - setJsonNotice(undefined); - setActiveMode("json"); - } - - function switchToForm() { - const result = tryJsonToForm(); - if (result.ok) { - setForm(result.form); - setErrors({}); - setRootError(null); - setActiveMode("form"); - return; - } - setJsonIssues(result.issues); - setJsonNotice(result.notice); - } - - function tryJsonToForm(): - | { ok: true; form: BuilderForm } - | { ok: false; issues: string[]; notice?: string } { - // Read the raw tree defensively rather than strict-validating: an - // incomplete JSON edit should still round-trip into the form, where the - // form's own validation enforces completeness on submit. - const definition = jsonValue.definition; - if (!definition || typeof definition !== "object") { - return { ok: false, issues: [], notice: "Add a definition before switching to the form" }; - } - - const name = - typeof jsonValue.name === "string" - ? jsonValue.name - : mode === "edit" && automation - ? automation.name - : ""; - const description = typeof jsonValue.description === "string" ? jsonValue.description : null; - const triggers = - mode === "edit" && automation - ? (automation.triggers ?? []) - : extractTriggers(jsonValue.triggers); - - const h = hydrateForm(name, description, definition, triggers); - return h.formable - ? { ok: true, form: h.form } - : { ok: false, issues: [], notice: `Can't show in the form: it ${h.reason}` }; - } - - function validateForm(): Record | null { - const result = builderFormSchema.safeParse(form); - const next = result.success ? {} : mapFormErrors(result.error); - - // The schedule model fields aren't deeply validated by the schema. - if (form.schedule?.mode === "preset") { - const m = form.schedule.model; - if (m.frequency === "weekly" && m.daysOfWeek.length === 0) { - next.schedule = "Pick at least one day for the weekly schedule"; - } - } else if (form.schedule?.mode === "cron" && !form.schedule.cron.trim()) { - next.schedule = "Enter a schedule expression"; - } - - return Object.keys(next).length > 0 ? next : null; - } - - async function reconcileTriggers(automationId: number) { - const desired = buildScheduleTrigger(form); - const existing = (automation?.triggers ?? [])[0]; - if (!existing && desired) { - await addTrigger({ automationId, payload: desired }); - } else if (existing && !desired) { - await removeTrigger({ automationId, triggerId: existing.id }); - } else if (existing && desired) { - await updateTrigger({ - automationId, - triggerId: existing.id, - patch: { params: desired.params, enabled: desired.enabled }, - }); - } - } - - async function submitForm() { - setRootError(null); - const formErrors = validateForm(); - if (formErrors) { - setErrors(formErrors); - return; - } - setErrors({}); - - setSubmitting(true); - try { - if (mode === "edit" && automation) { - const payload = buildUpdatePayload(formForPayload); - const parsed = automationUpdateRequest.safeParse(payload); - if (!parsed.success) { - setRootError(zodIssueList(parsed.error).join("; ")); - return; - } - await updateAutomation({ automationId: automation.id, patch: parsed.data }); - await reconcileTriggers(automation.id); - router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`); - } else { - const payload = buildCreatePayload(formForPayload, searchSpaceId); - const parsed = automationCreateRequest.safeParse(payload); - if (!parsed.success) { - setRootError(zodIssueList(parsed.error).join("; ")); - return; - } - const created = await createAutomation(parsed.data); - router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`); - } - } catch (err) { - setRootError((err as Error).message ?? "Submit failed"); - } finally { - setSubmitting(false); - } - } - - async function submitJson() { - setJsonIssues([]); - setSubmitting(true); - try { - if (mode === "edit" && automation) { - const parsed = automationUpdateRequest.safeParse(jsonValue); - if (!parsed.success) { - setJsonIssues(zodIssueList(parsed.error)); - return; - } - await updateAutomation({ automationId: automation.id, patch: parsed.data }); - router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`); - } else { - const parsed = automationCreateRequest.safeParse({ - ...jsonValue, - search_space_id: searchSpaceId, - }); - if (!parsed.success) { - setJsonIssues(zodIssueList(parsed.error)); - return; - } - const created = await createAutomation(parsed.data); - router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`); - } - } catch (err) { - setJsonIssues([(err as Error).message ?? "Submit failed"]); - } finally { - setSubmitting(false); - } - } - - const submitLabel = mode === "edit" ? "Save changes" : "Create automation"; - // Block creation until every model slot resolves to an eligible id. The - // per-field Alert already explains *why* a slot is empty; this just guards - // submit. `submitDisabledReason` (from the caller) still composes in. - const modelsUnresolved = - mode === "create" && !eligibleModels.isLoading && !hasResolvedModels(resolvedModels); - const effectiveDisabledReason = - submitDisabledReason ?? - (modelsUnresolved - ? "Set up a premium or your own (BYOK) agent, image, and vision model in role settings before creating an automation." - : undefined); - // Only gate creation; editing an existing automation isn't blocked here. - const submitBlocked = mode === "create" && !!effectiveDisabledReason; - const modeSwitcher = ( - { - if (value === activeMode) return; - if (value === "form") switchToForm(); - else if (value === "json") switchToJson(); - }} - > - - - - Form - - - - Edit as JSON - - - - ); - - return ( -
- {renderModeSwitcher ? ( - renderModeSwitcher(modeSwitcher) - ) : ( -
{modeSwitcher}
- )} - - {activeMode === "json" ? ( - - ) : ( -
-
- -
- - Basics - - - - -
- -
- - Tasks - - - patchForm({ tasks })} - /> - patchForm({ unattended })} - /> - -
- -
- - Schedule - - - patchForm({ schedule })} - onTimezoneChange={(timezone) => patchForm({ timezone })} - /> - -
- -
- - Models - - - patchForm({ models: { ...form.models, ...patch } })} - /> - -
- -
- - Settings - - - - patchForm({ execution: { ...form.execution, ...patch } }) - } - onTagsChange={(tags) => patchForm({ tags })} - /> - -
-
-
- -
- - - Summary - - - - - -
-
- )} - - {rootError && ( - - - {rootError} - - )} - -
- {submitBlocked ? ( - - - {/* aria-disabled keeps the button focusable so the tooltip is - reachable by hover and keyboard; onClick is a no-op. */} - - - {effectiveDisabledReason} - - ) : ( - - )} -
-
- ); -} - -function extractTriggers(raw: unknown): HydratableTrigger[] { - if (!Array.isArray(raw)) return []; - return raw.map((entry) => { - const obj = entry && typeof entry === "object" ? (entry as Record) : {}; - return { - type: typeof obj.type === "string" ? obj.type : "", - params: - obj.params && typeof obj.params === "object" ? (obj.params as Record) : {}, - }; - }); -} - -function zodIssueList(error: z.ZodError): string[] { - return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`); -} - -function jsonFromAutomation(automation: Automation | undefined): Record { - if (!automation) return {}; - return { - name: automation.name, - description: automation.description ?? null, - status: automation.status, - definition: automation.definition, - }; -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx deleted file mode 100644 index 6dd42366b..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx +++ /dev/null @@ -1,197 +0,0 @@ -"use client"; - -import { TriangleAlert } from "lucide-react"; -import Link from "next/link"; -import { memo, useId } from "react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - type EligibleModelKind, - type EligibleModelOption, - useAutomationEligibleModels, -} from "@/hooks/use-automation-eligible-models"; -import { getProviderIcon } from "@/lib/provider-icons"; -import { Field } from "./form-field"; - -export interface AutomationModelSelection { - chatModelId: number; - imageConfigId: number; - visionConfigId: number; -} - -interface AutomationModelFieldsProps { - /** Resolved (effective) ids — never `0` once defaults are seeded. */ - value: AutomationModelSelection; - onChange: (patch: Partial) => void; - searchSpaceId: number; - errors?: Partial>; -} - -/** - * Three eligible-only model pickers (Chat / Image / Vision) for the - * automation builder + chat approval card. Options come from - * {@link useAutomationEligibleModels} (premium globals + BYOK only); selection - * is validated + snapshotted onto `definition.models` at create time. - */ -export function AutomationModelFields({ - value, - onChange, - searchSpaceId, - errors, -}: AutomationModelFieldsProps) { - const { llm, image, vision, isLoading } = useAutomationEligibleModels(); - const rolesHref = `/dashboard/${searchSpaceId}/search-space-settings/models`; - - return ( -
- onChange({ chatModelId: id })} - /> - onChange({ imageConfigId: id })} - /> - onChange({ visionConfigId: id })} - /> -
- ); -} - -interface ModelSelectFieldProps { - label: string; - kind: EligibleModelKind; - value: number; - isLoading: boolean; - rolesHref: string; - error?: string; - onChange: (id: number) => void; -} - -const ModelSelectField = memo(function ModelSelectField({ - label, - kind, - value, - isLoading, - rolesHref, - error, - onChange, -}: ModelSelectFieldProps) { - const triggerId = useId(); - - if (isLoading) { - return ( - - - - ); - } - - if (kind.options.length === 0) { - return ( - - - - No eligible models - - Use a premium model or your own (BYOK) model in{" "} - - role settings - - - - - ); - } - - const premium = kind.options.filter((o) => !o.isBYOK); - const byok = kind.options.filter((o) => o.isBYOK); - const selected = value ? kind.byId.get(value) : undefined; - - return ( - - - - ); -}); - -function ModelOption({ - option, - badge, -}: { - option: EligibleModelOption; - badge: "Premium" | "BYOK"; -}) { - return ( - - - {getProviderIcon(option.provider)} - {option.name} - {badge} - - - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx deleted file mode 100644 index fdc9f4526..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Field } from "./form-field"; - -interface BasicsSectionProps { - name: string; - description: string | null; - errors: Record; - onChange: (patch: { name?: string; description?: string | null }) => void; -} - -export function BasicsSection({ name, description, errors, onChange }: BasicsSectionProps) { - return ( -
- - onChange({ name: e.target.value })} - /> - - - -