From ed8d56aa16dc1beba7dfee1e5a93c18a49b29725 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 28 May 2026 01:44:13 +0200
Subject: [PATCH] feat(web): create automation via raw JSON
---
.../components/automations-empty-state.tsx | 22 +++-
.../components/automations-header.tsx | 22 +++-
.../new/automation-new-content.tsx | 42 ++++++
.../new/components/automation-json-form.tsx | 122 ++++++++++++++++++
.../new/components/automation-new-header.tsx | 42 ++++++
.../automations/new/page.tsx | 15 +++
.../lib/automations/default-template.ts | 44 +++++++
7 files changed, 295 insertions(+), 14 deletions(-)
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/new/automation-new-content.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-json-form.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/new/page.tsx
create mode 100644 surfsense_web/lib/automations/default-template.ts
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
index 4004cce9b..83fa52fa8 100644
--- 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
@@ -1,5 +1,5 @@
"use client";
-import { MessageSquarePlus, Workflow } from "lucide-react";
+import { FileJson, MessageSquarePlus, Workflow } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -26,12 +26,20 @@ export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsE
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
index 22ea60664..544c6b7ac 100644
--- 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
@@ -1,5 +1,5 @@
"use client";
-import { MessageSquarePlus } from "lucide-react";
+import { FileJson, MessageSquarePlus } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -39,12 +39,20 @@ export function AutomationsHeader({
)}
{canCreate && showCreateCta && (
-
+
+
+
+
)}
);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/new/automation-new-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/new/automation-new-content.tsx
new file mode 100644
index 000000000..f03b3f4c8
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/new/automation-new-content.tsx
@@ -0,0 +1,42 @@
+"use client";
+import { ShieldAlert } from "lucide-react";
+import { useAutomationPermissions } from "../hooks/use-automation-permissions";
+import { AutomationJsonForm } from "./components/automation-json-form";
+import { AutomationNewHeader } from "./components/automation-new-header";
+
+interface AutomationNewContentProps {
+ searchSpaceId: number;
+}
+
+/**
+ * Orchestrator for the raw-JSON create route. Gates on
+ * ``automations:create`` so users who can't create don't even see the
+ * form; same panel as the detail page's access-denied state for
+ * consistency.
+ */
+export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
+ const perms = useAutomationPermissions();
+
+ if (perms.loading) {
+ return ;
+ }
+
+ if (!perms.canCreate) {
+ return (
+
+
+
Access denied
+
+ You don't have permission to create automations in this search space.
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-json-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-json-form.tsx
new file mode 100644
index 000000000..845d95166
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-json-form.tsx
@@ -0,0 +1,122 @@
+"use client";
+import { useAtomValue } from "jotai";
+import { AlertCircle, Code, FileJson, Save } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { createAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Spinner } from "@/components/ui/spinner";
+import { automationCreateRequest } from "@/contracts/types/automation.types";
+import { DEFAULT_AUTOMATION_TEMPLATE } from "@/lib/automations/default-template";
+
+interface AutomationJsonFormProps {
+ searchSpaceId: number;
+}
+
+/**
+ * Raw-JSON create form. Lets power users skip the chat drafter when they
+ * already know the shape they want. Flow:
+ * parse JSON → inject search_space_id → Zod validate → POST → navigate
+ *
+ * ``search_space_id`` is injected here rather than required in the pasted
+ * payload — the user shouldn't have to know their numeric id, and it
+ * keeps the template copy-paste-friendly across search spaces.
+ */
+export function AutomationJsonForm({ searchSpaceId }: AutomationJsonFormProps) {
+ const router = useRouter();
+ const { mutateAsync: createAutomation, isPending } = useAtomValue(createAutomationMutationAtom);
+ const [text, setText] = useState(() => JSON.stringify(DEFAULT_AUTOMATION_TEMPLATE, null, 2));
+ const [issues, setIssues] = useState([]);
+
+ function handleFormat() {
+ try {
+ const parsed = JSON.parse(text);
+ setText(JSON.stringify(parsed, null, 2));
+ setIssues([]);
+ } catch (err) {
+ setIssues([`Cannot format — not valid JSON: ${(err as Error).message}`]);
+ }
+ }
+
+ async function handleSubmit() {
+ setIssues([]);
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch (err) {
+ setIssues([`Invalid JSON: ${(err as Error).message}`]);
+ return;
+ }
+
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ setIssues(["Root must be a JSON object."]);
+ return;
+ }
+
+ const payload = { ...(parsed as Record), search_space_id: searchSpaceId };
+ const result = automationCreateRequest.safeParse(payload);
+ if (!result.success) {
+ setIssues(
+ result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
+ );
+ return;
+ }
+
+ try {
+ const created = await createAutomation(result.data);
+ router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
+ } catch (err) {
+ setIssues([(err as Error).message ?? "Submit failed"]);
+ }
+ }
+
+ const hasIssues = issues.length > 0;
+
+ return (
+
+
+
+
+ Definition + triggers
+
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx
new file mode 100644
index 000000000..aef2744d5
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx
@@ -0,0 +1,42 @@
+"use client";
+import { ArrowLeft, MessageSquarePlus } from "lucide-react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+
+interface AutomationNewHeaderProps {
+ searchSpaceId: number;
+}
+
+export function AutomationNewHeader({ searchSpaceId }: AutomationNewHeaderProps) {
+ return (
+
+
+
+
+
+
+ New automation · raw JSON
+
+
+ Paste an ``AutomationCreate`` payload and submit. Validated against the schema before
+ save. Prefer natural language? Use chat instead.
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/new/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/new/page.tsx
new file mode 100644
index 000000000..f6e8e0008
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/new/page.tsx
@@ -0,0 +1,15 @@
+import { AutomationNewContent } from "./automation-new-content";
+
+export default async function NewAutomationPage({
+ params,
+}: {
+ params: Promise<{ search_space_id: string }>;
+}) {
+ const { search_space_id } = await params;
+
+ return (
+
+ );
+}
diff --git a/surfsense_web/lib/automations/default-template.ts b/surfsense_web/lib/automations/default-template.ts
new file mode 100644
index 000000000..8963992cb
--- /dev/null
+++ b/surfsense_web/lib/automations/default-template.ts
@@ -0,0 +1,44 @@
+/**
+ * Minimal valid ``AutomationCreate`` skeleton used to seed the raw-JSON
+ * create form. ``search_space_id`` is omitted on purpose — the form
+ * injects it from the route so users never have to know their id.
+ *
+ * The shape matches the Pydantic ``AutomationCreate`` model less the
+ * search_space_id field; Zod validates the merged payload before submit.
+ */
+export const DEFAULT_AUTOMATION_TEMPLATE = {
+ name: "My automation",
+ description: null,
+ definition: {
+ name: "My automation",
+ goal: null,
+ plan: [
+ {
+ step_id: "step_1",
+ action: "agent_task",
+ params: {
+ query: "Summarize new docs added to folder 12 since the last run.",
+ },
+ },
+ ],
+ execution: {
+ timeout_seconds: 600,
+ max_retries: 2,
+ retry_backoff: "exponential",
+ concurrency: "drop_if_running",
+ on_failure: [],
+ },
+ metadata: { tags: [] },
+ },
+ triggers: [
+ {
+ type: "schedule",
+ params: {
+ cron: "0 9 * * 1-5",
+ timezone: "UTC",
+ },
+ static_inputs: {},
+ enabled: true,
+ },
+ ],
+} as const;