From fa0cdb9760f0b08bfb5db4596c79ffbefdbc845b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 28 May 2026 16:07:54 +0200 Subject: [PATCH] feat(web): unified json viewer/editor + edit existing automation --- .../automation-definition-section.tsx | 5 +- .../components/automation-detail-header.tsx | 10 +- .../components/inputs-schema-preview.tsx | 15 +- .../components/plan-step-card.tsx | 7 +- .../components/run-details-panel.tsx | 7 +- .../components/trigger-card.tsx | 7 +- .../edit/automation-edit-content.tsx | 56 ++++++ .../edit/components/automation-edit-form.tsx | 121 +++++++++++++ .../automations/[automation_id]/edit/page.tsx | 18 ++ .../new/components/automation-json-form.tsx | 62 +++---- .../components/json-metadata-viewer.tsx | 11 +- surfsense_web/components/json-view.tsx | 93 ++++++++++ .../tool-ui/automation/create-automation.tsx | 50 ++---- surfsense_web/package.json | 2 +- surfsense_web/pnpm-lock.yaml | 159 ++++++++++++++++-- 15 files changed, 504 insertions(+), 119 deletions(-) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/page.tsx create mode 100644 surfsense_web/components/json-view.tsx 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 index e8721d9b0..4ff9b8b8c 100644 --- 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 @@ -11,9 +11,8 @@ interface AutomationDefinitionSectionProps { } /** - * The Definition card. Read-only in v1 — editing definitions happens via - * chat (re-run create_automation with a refined intent) or, later, via - * the raw-JSON path. Layout is top-down: + * The Definition card. Read view; editing happens on the sibling /edit + * route (Edit button in the header). Layout is top-down: * goal → tags → execution defaults → inputs schema (if any) → plan * * The schema_version is rendered as a small badge next to the section 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 index 4cf3efcc1..0bce3fa2d 100644 --- 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 @@ -1,6 +1,6 @@ "use client"; import { useAtomValue } from "jotai"; -import { ArrowLeft, Pause, Play, Trash2 } from "lucide-react"; +import { ArrowLeft, Pause, Pencil, Play, Trash2 } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useState } from "react"; @@ -82,6 +82,14 @@ export function AutomationDetailHeader({
+ {canUpdate && ( + + )} {canToggle && (
-
-						{JSON.stringify(step.params, null, 2)}
-					
+
+ +
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 index 94a96b199..f9c6fbb5a 100644 --- 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 @@ -1,5 +1,6 @@ "use client"; import { AlertCircle, FileOutput, GitCommitHorizontal, Package, Settings2 } from "lucide-react"; +import { JsonView } from "@/components/json-view"; import { Skeleton } from "@/components/ui/skeleton"; import { useAutomationRun } from "@/hooks/use-automation-runs"; @@ -109,8 +110,8 @@ function Section({ function JsonBlock({ value }: { value: unknown }) { return ( -
-			{JSON.stringify(value, null, 2)}
-		
+
+ +
); } 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 index 200a15f57..a1d84d2d7 100644 --- 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 @@ -3,6 +3,7 @@ import { useAtomValue } from "jotai"; import { CalendarClock, Clock, Trash2 } from "lucide-react"; import { useState } from "react"; import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms"; +import { JsonView } from "@/components/json-view"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import type { Trigger } from "@/contracts/types/automation.types"; @@ -109,9 +110,9 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri {hasStaticInputs && (
Static inputs
-
-								{JSON.stringify(trigger.static_inputs, null, 2)}
-							
+
+ +
)} 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 new file mode 100644 index 000000000..219552a1a --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx @@ -0,0 +1,56 @@ +"use client"; +import { ShieldAlert } from "lucide-react"; +import { useAutomation } from "@/hooks/use-automation"; +import { useAutomationPermissions } from "../../hooks/use-automation-permissions"; +import { AutomationDetailLoading } from "../components/automation-detail-loading"; +import { AutomationNotFound } from "../components/automation-not-found"; +import { AutomationEditForm } from "./components/automation-edit-form"; + +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-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx new file mode 100644 index 000000000..86b355838 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx @@ -0,0 +1,121 @@ +"use client"; +import { useAtomValue } from "jotai"; +import { AlertCircle, ArrowLeft, Save } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms"; +import { JsonView } from "@/components/json-view"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { + type Automation, + automationUpdateRequest, +} from "@/contracts/types/automation.types"; + +interface AutomationEditFormProps { + automation: Automation; + searchSpaceId: number; +} + +/** + * Edit-existing-automation form. Surfaces the four mutable fields + * (name, description, status, definition) as one editable JSON tree; + * triggers stay on the detail page where they have their own management + * UI. Validates with the same Zod schema the API expects, then PATCHes + * the changed shape back. + */ +export function AutomationEditForm({ automation, searchSpaceId }: AutomationEditFormProps) { + const router = useRouter(); + const { mutateAsync: updateAutomation, isPending } = useAtomValue(updateAutomationMutationAtom); + const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`; + + const [value, setValue] = useState(() => ({ + name: automation.name, + description: automation.description ?? null, + status: automation.status, + definition: automation.definition, + })); + const [issues, setIssues] = useState([]); + + async function handleSave() { + setIssues([]); + const result = automationUpdateRequest.safeParse(value); + if (!result.success) { + setIssues( + result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) + ); + return; + } + try { + await updateAutomation({ automationId: automation.id, patch: result.data }); + router.push(detailHref); + } catch (err) { + setIssues([(err as Error).message ?? "Update failed"]); + } + } + + return ( + <> +
+ +
+

+ Edit automation +

+

{automation.name}

+
+
+ + + + Definition + + +
+ setValue(next as typeof value)} + collapsed={false} + /> +
+ + {issues.length > 0 && ( +
+
+ + {issues.length === 1 ? "1 issue" : `${issues.length} issues`} +
+
    + {issues.map((issue) => ( +
  • {issue}
  • + ))} +
+
+ )} + +
+ + +
+
+
+ + ); +} 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 new file mode 100644 index 000000000..8477b9e12 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/page.tsx @@ -0,0 +1,18 @@ +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/new/components/automation-json-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-json-form.tsx index 8fe065295..94b608b8f 100644 --- 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 @@ -1,9 +1,10 @@ "use client"; import { useAtomValue } from "jotai"; -import { AlertCircle, Code, FileJson, Save } from "lucide-react"; +import { AlertCircle, FileJson, Save } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { createAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms"; +import { JsonView } from "@/components/json-view"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; @@ -17,45 +18,24 @@ interface AutomationJsonFormProps { /** * 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 + * edit tree → 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. + * ``search_space_id`` is injected here rather than required in the edited + * tree — 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 [value, setValue] = useState>( + () => DEFAULT_AUTOMATION_TEMPLATE as Record + ); 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 payload = { ...value, search_space_id: searchSpaceId }; const result = automationCreateRequest.safeParse(payload); if (!result.success) { setIssues( @@ -76,25 +56,21 @@ export function AutomationJsonForm({ searchSpaceId }: AutomationJsonFormProps) { return ( - + Definition + triggers - -