diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py b/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py index 96413e553..db46a5927 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py @@ -29,6 +29,7 @@ SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = { "web_crawler": frozenset(), "youtube": frozenset(), "google_maps": frozenset(), + "google_search": frozenset(), "airtable": frozenset({"AIRTABLE_CONNECTOR"}), "calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}), "clickup": frozenset({"CLICKUP_CONNECTOR"}), diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/__init__.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/__init__.py new file mode 100644 index 000000000..134c436ca --- /dev/null +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/__init__.py @@ -0,0 +1 @@ +"""``google_search`` builtin subagent: structured Google Search results.""" diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/agent.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/agent.py new file mode 100644 index 000000000..f98d68ac4 --- /dev/null +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/agent.py @@ -0,0 +1,43 @@ +"""``google_search`` route: ``SurfSenseSubagentSpec`` builder for deepagents.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.chat.multi_agent_chat.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.chat.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec +from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import NAME, RULESET, load_tools + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + middleware_stack: dict[str, Any] | None = None, + mcp_tools: list[BaseTool] | None = None, +) -> SurfSenseSubagentSpec: + tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])] + description = ( + read_md_file(__package__, "description").strip() + or "Searches Google and returns structured results for a query." + ) + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + ruleset=RULESET, + dependencies=dependencies, + model=model, + middleware_stack=middleware_stack, + ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/description.md b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/description.md new file mode 100644 index 000000000..26fda0374 --- /dev/null +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/description.md @@ -0,0 +1,2 @@ +Google Search specialist: runs Google searches and returns structured SERP data — organic results (title, URL, description), related queries, people-also-ask, and any AI overview. Searches by plain query (optionally scoped to a country/language or restricted to a single site) or scrapes a Google Search URL as-is, and can page deeper with max_pages_per_query. Also compares fresh search results against earlier findings in this chat. +Use whenever the task is to find pages or sources on the open web via Google, discover which sites rank for a topic, or gather a list of result URLs to hand off for crawling. Triggers include "search Google for X", "find websites/pages about X", "who ranks for X", "top results for X", "find X's website", and comparisons against earlier search results in this chat. Not for reading a specific page's content (use the web crawling specialist), Google Maps places (use the Google Maps specialist), or YouTube (use the YouTube specialist). diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/system_prompt.md b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/system_prompt.md new file mode 100644 index 000000000..b5935878a --- /dev/null +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/system_prompt.md @@ -0,0 +1,62 @@ +You are the SurfSense Google Search sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Answer the delegated question from live Google Search data gathered with your verb, comparing against earlier results already in this conversation when the task calls for it. + + + +- `google_search_scrape` + + + +- Finding pages on a topic: call `google_search_scrape` with `queries`, scoping with `country_code`/`language_code` when locale matters. +- Restricting to one website: set `site` (e.g. "example.com") to only return results from that domain. +- Scraping a specific results page: pass the full Google Search URL in `queries`. +- Need more results: raise `max_pages_per_query` to page beyond the first page. +- Batch multiple search terms into one call rather than many single-term calls. +- Handing URLs off for crawling: return the organic result URLs so the supervisor can route them to the web crawling specialist. +- Comparison requests: pull the current results, compare against prior values already in this conversation's earlier tool results, and report concrete deltas (added, removed, moved up/down). + + + +- Use only tools in ``. +- Report only results present in the tool output. Never invent titles, URLs, snippets, or rankings. + + + +- Do not read or extract a specific page's content — return the URLs for the web crawling specialist. +- Do not generate deliverables or perform connector mutations; return findings for the supervisor to act on. +- Google Maps places belong to the Google Maps specialist; YouTube belongs to the YouTube specialist. + + + +- Report uncertainty explicitly when evidence is incomplete or conflicting. +- Never present unverified claims as facts. + + + +- Underspecified request — no usable query or URL — return `status=blocked` with the missing fields. +- Tool failure: return `status=error` with a concise recovery `next_step`. +- No useful evidence: return `status=blocked` with a narrower query or the scope you still need. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "findings": string[], + "sources": string[], + "confidence": "high" | "medium" | "low" + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} + +Route-specific rules: +- `evidence.findings`: max 10 entries, each a single sentence stating one distinct result or delta. Do not paste raw payloads. +- `evidence.sources`: max 10 URLs, one per finding when applicable. List each URL once. + diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/tools/__init__.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/tools/index.py new file mode 100644 index 000000000..4851b7e3e --- /dev/null +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/google_search/tools/index.py @@ -0,0 +1,27 @@ +"""``google_search`` sub-agent tools: the Google Search scrape capability verb.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset +from app.capabilities.core.access.agent import build_capability_tools +from app.capabilities.google_search.scrape.definition import GOOGLE_SEARCH_SCRAPE + +NAME = "google_search" + +RULESET = Ruleset(origin=NAME, rules=[]) + +_CI_VERBS = [GOOGLE_SEARCH_SCRAPE] + + +def load_tools( + *, dependencies: dict[str, Any] | None = None, **kwargs: Any +) -> list[BaseTool]: + d = {**(dependencies or {}), **kwargs} + return build_capability_tools( + workspace_id=d.get("workspace_id"), + capabilities=_CI_VERBS, + ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py index 7ae689eb7..4ec4afb57 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py @@ -18,6 +18,9 @@ from app.agents.chat.multi_agent_chat.subagents.builtins.deliverables.agent impo from app.agents.chat.multi_agent_chat.subagents.builtins.google_maps.agent import ( build_subagent as build_google_maps_subagent, ) +from app.agents.chat.multi_agent_chat.subagents.builtins.google_search.agent import ( + build_subagent as build_google_search_subagent, +) from app.agents.chat.multi_agent_chat.subagents.builtins.knowledge_base.agent import ( build_subagent as build_knowledge_base_subagent, ) @@ -106,6 +109,7 @@ SUBAGENT_BUILDERS_BY_NAME: dict[str, SubagentBuilder] = { "gmail": build_gmail_subagent, "google_drive": build_google_drive_subagent, "google_maps": build_google_maps_subagent, + "google_search": build_google_search_subagent, "jira": build_jira_subagent, "knowledge_base": build_knowledge_base_subagent, "linear": build_linear_subagent, diff --git a/surfsense_backend/app/capabilities/google_search/__init__.py b/surfsense_backend/app/capabilities/google_search/__init__.py new file mode 100644 index 000000000..7d34b6b3b --- /dev/null +++ b/surfsense_backend/app/capabilities/google_search/__init__.py @@ -0,0 +1,5 @@ +"""``google_search.*`` namespace: platform-native Google Search data verbs.""" + +from __future__ import annotations + +from app.capabilities.google_search.scrape import definition as _scrape # noqa: F401 diff --git a/surfsense_backend/app/capabilities/google_search/scrape/__init__.py b/surfsense_backend/app/capabilities/google_search/scrape/__init__.py new file mode 100644 index 000000000..c4b1ba5d1 --- /dev/null +++ b/surfsense_backend/app/capabilities/google_search/scrape/__init__.py @@ -0,0 +1,3 @@ +"""``google_search.scrape`` verb: search terms / Google Search URLs → SERP items.""" + +from __future__ import annotations diff --git a/surfsense_backend/app/capabilities/google_search/scrape/definition.py b/surfsense_backend/app/capabilities/google_search/scrape/definition.py new file mode 100644 index 000000000..9ef0f0dd6 --- /dev/null +++ b/surfsense_backend/app/capabilities/google_search/scrape/definition.py @@ -0,0 +1,24 @@ +"""``google_search.scrape`` capability registration (free — see 04-capabilities open item).""" + +from __future__ import annotations + +from app.capabilities.core import Capability, register_capability +from app.capabilities.google_search.scrape.executor import build_scrape_executor +from app.capabilities.google_search.scrape.schemas import ScrapeInput, ScrapeOutput + +GOOGLE_SEARCH_SCRAPE = Capability( + name="google_search.scrape", + description=( + "Search Google and return structured results. Give it search terms " + "(optionally scoped by country/language or to a single site) or full " + "Google Search URLs, and it returns SERP items — organic results " + "(title, url, description), related queries, people-also-ask, and any " + "AI overview. Use max_pages_per_query to page deeper." + ), + input_schema=ScrapeInput, + output_schema=ScrapeOutput, + executor=build_scrape_executor(), + billing_unit=None, +) + +register_capability(GOOGLE_SEARCH_SCRAPE) diff --git a/surfsense_backend/app/capabilities/google_search/scrape/executor.py b/surfsense_backend/app/capabilities/google_search/scrape/executor.py new file mode 100644 index 000000000..72e6e27e2 --- /dev/null +++ b/surfsense_backend/app/capabilities/google_search/scrape/executor.py @@ -0,0 +1,41 @@ +"""``google_search.scrape`` executor: verb input → scraper → SERP items.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from app.capabilities.core import Executor +from app.capabilities.google_search.scrape.schemas import ( + MAX_PAGES_PER_QUERY, + MAX_SEARCH_QUERIES, + ScrapeInput, + ScrapeOutput, +) +from app.proprietary.platforms.google_search import ( + GoogleSearchScrapeInput, + scrape_serps, +) + +ScrapeFn = Callable[..., Awaitable[list[dict]]] + +# Hard ceiling on SERP pages returned per call (protects the run regardless of +# how queries * max_pages_per_query multiply out). +_MAX_SERP_ITEMS = MAX_SEARCH_QUERIES * MAX_PAGES_PER_QUERY + + +def build_scrape_executor(scrape_fn: ScrapeFn | None = None) -> Executor: + """Bind the executor to a scraper fn (defaults to the proprietary actor).""" + scrape_fn = scrape_fn or scrape_serps + + async def execute(payload: ScrapeInput) -> ScrapeOutput: + actor_input = GoogleSearchScrapeInput( + queries="\n".join(payload.queries), + maxPagesPerQuery=payload.max_pages_per_query, + countryCode=payload.country_code, + languageCode=payload.language_code, + site=payload.site, + ) + items = await scrape_fn(actor_input, limit=_MAX_SERP_ITEMS) + return ScrapeOutput(items=items) + + return execute diff --git a/surfsense_backend/app/capabilities/google_search/scrape/schemas.py b/surfsense_backend/app/capabilities/google_search/scrape/schemas.py new file mode 100644 index 000000000..5fdb52e24 --- /dev/null +++ b/surfsense_backend/app/capabilities/google_search/scrape/schemas.py @@ -0,0 +1,55 @@ +"""``google_search.scrape`` I/O contracts. + +A lean, agent-friendly surface over ``GoogleSearchScrapeInput`` +(``app/proprietary/platforms/google_search``). The executor maps this to the +full scraper input; the scraper's ``SerpItem`` is reused verbatim as the output +element. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from app.proprietary.platforms.google_search import SerpItem + +MAX_SEARCH_QUERIES = 20 +"""Per-call cap on queries: bounds a synchronous request's fan-out.""" + +MAX_PAGES_PER_QUERY = 10 +"""Deepest result-page pagination a single query will follow.""" + + +class ScrapeInput(BaseModel): + queries: list[str] = Field( + min_length=1, + max_length=MAX_SEARCH_QUERIES, + description=( + "Search terms (e.g. 'wedding photographers denver') or full Google " + "Search URLs. Each term is searched; each URL is scraped as-is." + ), + ) + max_pages_per_query: int = Field( + default=1, + ge=1, + le=MAX_PAGES_PER_QUERY, + description="Result pages to fetch per query (1 = first page only).", + ) + country_code: str | None = Field( + default=None, + description="Two-letter country to search from, e.g. 'us', 'fr'.", + ) + language_code: str = Field( + default="", + description="Result language code, e.g. 'en', 'fr' (blank = Google default).", + ) + site: str | None = Field( + default=None, + description="Restrict results to a single domain, e.g. 'example.com'.", + ) + + +class ScrapeOutput(BaseModel): + items: list[SerpItem] = Field( + default_factory=list, + description="One item per fetched SERP page, in the scraper's emission order.", + ) diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index f4f22e6f7..f32c921fa 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends # Import verb namespaces for their registration side effects before the door builds. import app.capabilities.google_maps +import app.capabilities.google_search import app.capabilities.web import app.capabilities.youtube # noqa: F401 from app.automations.api import router as automations_router diff --git a/surfsense_web/app/dashboard/[workspace_id]/artifacts/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/artifacts/page.tsx new file mode 100644 index 000000000..a9fdb5843 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/artifacts/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { ArtifactsLibrary } from "@/features/artifacts-library"; + +export default function ArtifactsPage() { + const params = useParams(); + const workspaceId = Number(params.workspace_id); + + return ; +} diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/automation-detail-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/automation-detail-content.tsx new file mode 100644 index 000000000..d4f0962f4 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/automation-detail-content.tsx @@ -0,0 +1,91 @@ +"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 { + workspaceId: 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({ + workspaceId, + 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/[workspace_id]/automations/[automation_id]/components/automation-definition-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-definition-section.tsx new file mode 100644 index 000000000..ab6168305 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-definition-section.tsx @@ -0,0 +1,99 @@ +"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/[workspace_id]/automations/[automation_id]/components/automation-detail-header.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-header.tsx new file mode 100644 index 000000000..945630ba2 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-header.tsx @@ -0,0 +1,150 @@ +"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; + workspaceId: 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, + workspaceId, + 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/${workspaceId}/automations`); + }, [router, workspaceId]); + + 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/[workspace_id]/automations/[automation_id]/components/automation-detail-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-loading.tsx new file mode 100644 index 000000000..0d6ba3110 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-loading.tsx @@ -0,0 +1,56 @@ +"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/[workspace_id]/automations/[automation_id]/components/automation-not-found.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-not-found.tsx new file mode 100644 index 000000000..23fe823fe --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-not-found.tsx @@ -0,0 +1,34 @@ +"use client"; +import { ArrowLeft, FileWarning } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +interface AutomationNotFoundProps { + workspaceId: 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({ workspaceId, 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/[workspace_id]/automations/[automation_id]/components/automation-runs-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-runs-section.tsx new file mode 100644 index 000000000..bd683fe57 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-runs-section.tsx @@ -0,0 +1,66 @@ +"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/[workspace_id]/automations/[automation_id]/components/automation-triggers-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-triggers-section.tsx new file mode 100644 index 000000000..abe739dcc --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-triggers-section.tsx @@ -0,0 +1,56 @@ +"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/[workspace_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx new file mode 100644 index 000000000..71e905724 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx @@ -0,0 +1,80 @@ +"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/[workspace_id]/automations/[automation_id]/components/execution-summary.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/execution-summary.tsx new file mode 100644 index 000000000..82abce173 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/execution-summary.tsx @@ -0,0 +1,42 @@ +"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/[workspace_id]/automations/[automation_id]/components/inputs-schema-preview.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/inputs-schema-preview.tsx new file mode 100644 index 000000000..dce6ac4a7 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/inputs-schema-preview.tsx @@ -0,0 +1,74 @@ +"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/[workspace_id]/automations/[automation_id]/components/plan-step-card.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/plan-step-card.tsx new file mode 100644 index 000000000..7505ef49b --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/plan-step-card.tsx @@ -0,0 +1,148 @@ +"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/[workspace_id]/automations/[automation_id]/components/run-details-panel.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-details-panel.tsx new file mode 100644 index 000000000..ab82589dc --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-details-panel.tsx @@ -0,0 +1,200 @@ +"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/[workspace_id]/automations/[automation_id]/components/run-row.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-row.tsx new file mode 100644 index 000000000..b48230e3f --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-row.tsx @@ -0,0 +1,72 @@ +"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/[workspace_id]/automations/[automation_id]/components/run-status-badge.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-status-badge.tsx new file mode 100644 index 000000000..e5532a500 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-status-badge.tsx @@ -0,0 +1,57 @@ +"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/[workspace_id]/automations/[automation_id]/components/run-step-result-card.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-step-result-card.tsx new file mode 100644 index 000000000..eef572300 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-step-result-card.tsx @@ -0,0 +1,123 @@ +"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/[workspace_id]/automations/[automation_id]/components/runs-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/runs-loading.tsx new file mode 100644 index 000000000..61ce25e32 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/runs-loading.tsx @@ -0,0 +1,23 @@ +"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/[workspace_id]/automations/[automation_id]/components/trigger-card.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/trigger-card.tsx new file mode 100644 index 000000000..de156a09c --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/trigger-card.tsx @@ -0,0 +1,386 @@ +"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/[workspace_id]/automations/[automation_id]/edit/automation-edit-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/automation-edit-content.tsx new file mode 100644 index 000000000..77e26e51a --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/automation-edit-content.tsx @@ -0,0 +1,67 @@ +"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 { + workspaceId: 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({ workspaceId, 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/[workspace_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx new file mode 100644 index 000000000..6951803df --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx @@ -0,0 +1,37 @@ +"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; + workspaceId: number; + modeSwitcher?: ReactNode; +} + +export function AutomationEditHeader({ + automation, + workspaceId, + modeSwitcher, +}: AutomationEditHeaderProps) { + const detailHref = `/dashboard/${workspaceId}/automations/${automation.id}`; + + return ( +
+ +
+

+ Edit automation +

+ {modeSwitcher ?
{modeSwitcher}
: null} +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/page.tsx new file mode 100644 index 000000000..728e018d0 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_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<{ workspace_id: string; automation_id: string }>; +}) { + const { workspace_id, automation_id } = await params; + + return ( +
+ +
+ ); +} diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/page.tsx new file mode 100644 index 000000000..8f6024a7b --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/page.tsx @@ -0,0 +1,18 @@ +import { AutomationDetailContent } from "./automation-detail-content"; + +export default async function AutomationDetailPage({ + params, +}: { + params: Promise<{ workspace_id: string; automation_id: string }>; +}) { + const { workspace_id, automation_id } = await params; + + return ( +
+ +
+ ); +} diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/automations-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/automations-content.tsx new file mode 100644 index 000000000..9e0fa81c6 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/automations-content.tsx @@ -0,0 +1,104 @@ +"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 { + workspaceId: 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({ workspaceId }: 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/[workspace_id]/automations/components/automation-row-actions.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row-actions.tsx new file mode 100644 index 000000000..617441dd5 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row-actions.tsx @@ -0,0 +1,93 @@ +"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; + workspaceId: 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, + workspaceId, + 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/[workspace_id]/automations/components/automation-row.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row.tsx new file mode 100644 index 000000000..a7c831ed1 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row.tsx @@ -0,0 +1,56 @@ +"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; + workspaceId: 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, + workspaceId, + canUpdate, + canDelete, +}: AutomationRowProps) { + return ( + + + + {automation.name} + + + + + + + {formatRelativeDate(automation.updated_at)} + + +
+ +
+
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-status-badge.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-status-badge.tsx new file mode 100644 index 000000000..c3cab1dc1 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-status-badge.tsx @@ -0,0 +1,39 @@ +"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/[workspace_id]/automations/components/automation-triggers-summary.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-triggers-summary.tsx new file mode 100644 index 000000000..270a1f844 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-triggers-summary.tsx @@ -0,0 +1,52 @@ +"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/[workspace_id]/automations/components/automations-empty-state.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-empty-state.tsx new file mode 100644 index 000000000..c689d85bb --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-empty-state.tsx @@ -0,0 +1,48 @@ +"use client"; +import { AlarmClock } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +interface AutomationsEmptyStateProps { + workspaceId: 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({ workspaceId, 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/[workspace_id]/automations/components/automations-header.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-header.tsx new file mode 100644 index 000000000..6e284709a --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-header.tsx @@ -0,0 +1,58 @@ +"use client"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +interface AutomationsHeaderProps { + workspaceId: 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({ + workspaceId, + total, + loading, + canCreate, + showCreateCta = true, +}: AutomationsHeaderProps) { + return ( +
+
+

Automations

+ {!loading && ( + + {total} {total === 1 ? "automation" : "automations"} + + )} +
+ {canCreate && showCreateCta && ( +
+ + +
+ )} +
+ ); +} diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx new file mode 100644 index 000000000..1156be3f6 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx @@ -0,0 +1,36 @@ +"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/[workspace_id]/automations/components/automations-table.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-table.tsx new file mode 100644 index 000000000..09a1156a1 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-table.tsx @@ -0,0 +1,73 @@ +"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[]; + workspaceId: 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, + workspaceId, + loading, + canUpdate, + canDelete, +}: AutomationsTableProps) { + return ( +
+ + + + + + + Name + + + + + + Status + + + + + + Updated + + + + Actions + + + + + {loading ? ( + + ) : ( + automations.map((automation) => ( + + )) + )} + +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/advanced-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/advanced-section.tsx new file mode 100644 index 000000000..110de57f6 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/advanced-section.tsx @@ -0,0 +1,129 @@ +"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/[workspace_id]/automations/components/builder/automation-builder-form.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx new file mode 100644 index 000000000..e6fb96605 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx @@ -0,0 +1,534 @@ +"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"; + workspaceId: 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, + workspaceId, + 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 { workspace_id: _ignored, ...rest } = buildCreatePayload( + formForPayload, + workspaceId + ); + 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/${workspaceId}/automations/${automation.id}`); + } else { + const payload = buildCreatePayload(formForPayload, workspaceId); + const parsed = automationCreateRequest.safeParse(payload); + if (!parsed.success) { + setRootError(zodIssueList(parsed.error).join("; ")); + return; + } + const created = await createAutomation(parsed.data); + router.push(`/dashboard/${workspaceId}/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/${workspaceId}/automations/${automation.id}`); + } else { + const parsed = automationCreateRequest.safeParse({ + ...jsonValue, + workspace_id: workspaceId, + }); + if (!parsed.success) { + setJsonIssues(zodIssueList(parsed.error)); + return; + } + const created = await createAutomation(parsed.data); + router.push(`/dashboard/${workspaceId}/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/[workspace_id]/automations/components/builder/automation-model-fields.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-model-fields.tsx new file mode 100644 index 000000000..7f7ca3138 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-model-fields.tsx @@ -0,0 +1,197 @@ +"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; + workspaceId: 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, + workspaceId, + errors, +}: AutomationModelFieldsProps) { + const { llm, image, vision, isLoading } = useAutomationEligibleModels(); + const rolesHref = `/dashboard/${workspaceId}/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/[workspace_id]/automations/components/builder/basics-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/basics-section.tsx new file mode 100644 index 000000000..fdc9f4526 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/basics-section.tsx @@ -0,0 +1,42 @@ +"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 })} + /> + + + +