mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat(web): automations contracts, API client, atoms and hooks
Foundation for the v1 automations UI. Mirrors backend Pydantic schemas into Zod and wires the data layer end-to-end so feature surfaces can be built on top. contracts/types/automation.types.ts: - AutomationStatus, TriggerType, RunStatus enums. - AutomationDefinition envelope (PlanStep, TriggerSpec, Execution, Metadata, Inputs). - AutomationCreate/Update/Detail/Summary/List + listParams. - TriggerCreate/Update/Detail. - RunSummary/Detail/List + runListParams. lib/apis/automations-api.service.ts: - list/get/create/update/delete automations. - add/update/remove triggers (sub-resource). - list/get runs (read-only sub-resource). - safeParse on every write, 204-safe deletes. atoms/automations/: - automationsListAtom (active search space, first page). - 6 mutation atoms with toast + cache invalidation. hooks/: - use-automations.ts wraps the list atom. - use-automation.ts: parameterized detail by id. - use-automation-runs.ts: useAutomationRuns + useAutomationRun. lib/query-client/cache-keys.ts: automations namespace (list, detail, runs, run) keyed by (id, limit, offset) where relevant. Smoke: zod round-trip OK on backend-shape payloads (Automation, AutomationCreate, Trigger, Run); typecheck clean for new files; biome clean.
This commit is contained in:
parent
d48bb2033b
commit
b18a5fdca9
8 changed files with 548 additions and 0 deletions
127
surfsense_web/atoms/automations/automations-mutation.atoms.ts
Normal file
127
surfsense_web/atoms/automations/automations-mutation.atoms.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { atomWithMutation } from "jotai-tanstack-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type {
|
||||||
|
AutomationCreateRequest,
|
||||||
|
AutomationUpdateRequest,
|
||||||
|
TriggerCreateRequest,
|
||||||
|
TriggerUpdateRequest,
|
||||||
|
} from "@/contracts/types/automation.types";
|
||||||
|
import { automationsApiService } from "@/lib/apis/automations-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
import { queryClient } from "@/lib/query-client/client";
|
||||||
|
|
||||||
|
// Cache invalidation strategy:
|
||||||
|
// - Automation writes invalidate the search-space list + the touched detail.
|
||||||
|
// - Trigger writes only invalidate the parent automation detail (triggers
|
||||||
|
// come back inline in AutomationDetail).
|
||||||
|
// We deliberately invalidate the whole "automations" prefix on the list side
|
||||||
|
// because list is keyed by (searchSpaceId, limit, offset) and we don't track
|
||||||
|
// the active pagination in this layer.
|
||||||
|
|
||||||
|
function invalidateList(searchSpaceId: number) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["automations", "list", searchSpaceId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateDetail(automationId: number) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: cacheKeys.automations.detail(automationId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAutomationMutationAtom = atomWithMutation(() => ({
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
|
mutationFn: async (request: AutomationCreateRequest) => {
|
||||||
|
return automationsApiService.createAutomation(request);
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
invalidateList(variables.search_space_id);
|
||||||
|
toast.success("Automation created");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Error creating automation:", error);
|
||||||
|
toast.error("Failed to create automation");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const updateAutomationMutationAtom = atomWithMutation(() => ({
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
|
mutationFn: async (vars: { automationId: number; patch: AutomationUpdateRequest }) => {
|
||||||
|
return automationsApiService.updateAutomation(vars.automationId, vars.patch);
|
||||||
|
},
|
||||||
|
onSuccess: (automation, vars) => {
|
||||||
|
invalidateDetail(vars.automationId);
|
||||||
|
invalidateList(automation.search_space_id);
|
||||||
|
toast.success("Automation updated");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Error updating automation:", error);
|
||||||
|
toast.error("Failed to update automation");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const deleteAutomationMutationAtom = atomWithMutation(() => ({
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
|
mutationFn: async (vars: { automationId: number; searchSpaceId: number }) => {
|
||||||
|
await automationsApiService.deleteAutomation(vars.automationId);
|
||||||
|
return vars;
|
||||||
|
},
|
||||||
|
onSuccess: (vars) => {
|
||||||
|
invalidateList(vars.searchSpaceId);
|
||||||
|
invalidateDetail(vars.automationId);
|
||||||
|
toast.success("Automation deleted");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Error deleting automation:", error);
|
||||||
|
toast.error("Failed to delete automation");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const addTriggerMutationAtom = atomWithMutation(() => ({
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
|
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
|
||||||
|
return automationsApiService.addTrigger(vars.automationId, vars.payload);
|
||||||
|
},
|
||||||
|
onSuccess: (_, vars) => {
|
||||||
|
invalidateDetail(vars.automationId);
|
||||||
|
toast.success("Trigger added");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Error adding trigger:", error);
|
||||||
|
toast.error("Failed to add trigger");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const updateTriggerMutationAtom = atomWithMutation(() => ({
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
|
mutationFn: async (vars: {
|
||||||
|
automationId: number;
|
||||||
|
triggerId: number;
|
||||||
|
patch: TriggerUpdateRequest;
|
||||||
|
}) => {
|
||||||
|
return automationsApiService.updateTrigger(vars.automationId, vars.triggerId, vars.patch);
|
||||||
|
},
|
||||||
|
onSuccess: (_, vars) => {
|
||||||
|
invalidateDetail(vars.automationId);
|
||||||
|
toast.success("Trigger updated");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Error updating trigger:", error);
|
||||||
|
toast.error("Failed to update trigger");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const removeTriggerMutationAtom = atomWithMutation(() => ({
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
|
mutationFn: async (vars: { automationId: number; triggerId: number }) => {
|
||||||
|
await automationsApiService.removeTrigger(vars.automationId, vars.triggerId);
|
||||||
|
return vars;
|
||||||
|
},
|
||||||
|
onSuccess: (vars) => {
|
||||||
|
invalidateDetail(vars.automationId);
|
||||||
|
toast.success("Trigger removed");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Error removing trigger:", error);
|
||||||
|
toast.error("Failed to remove trigger");
|
||||||
|
},
|
||||||
|
}));
|
||||||
31
surfsense_web/atoms/automations/automations-query.atoms.ts
Normal file
31
surfsense_web/atoms/automations/automations-query.atoms.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { atomWithQuery } from "jotai-tanstack-query";
|
||||||
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import { automationsApiService } from "@/lib/apis/automations-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
|
// First page of the active search space's automations.
|
||||||
|
// Detail + paginated/parameterized reads live in hooks (see use-automation.ts,
|
||||||
|
// use-automation-runs.ts) so atoms stay tied to "current scope" and don't
|
||||||
|
// proliferate atom families for every (id, limit, offset) tuple.
|
||||||
|
const DEFAULT_LIMIT = 50;
|
||||||
|
const DEFAULT_OFFSET = 0;
|
||||||
|
|
||||||
|
export const automationsListAtom = atomWithQuery((get) => {
|
||||||
|
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey: cacheKeys.automations.list(Number(searchSpaceId ?? 0), DEFAULT_LIMIT, DEFAULT_OFFSET),
|
||||||
|
enabled: !!searchSpaceId,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!searchSpaceId) {
|
||||||
|
return { items: [], total: 0 };
|
||||||
|
}
|
||||||
|
return automationsApiService.listAutomations({
|
||||||
|
search_space_id: Number(searchSpaceId),
|
||||||
|
limit: DEFAULT_LIMIT,
|
||||||
|
offset: DEFAULT_OFFSET,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
193
surfsense_web/contracts/types/automation.types.ts
Normal file
193
surfsense_web/contracts/types/automation.types.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Enums — mirror app/automations/persistence/enums/*
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const automationStatus = z.enum(["active", "paused", "archived"]);
|
||||||
|
export type AutomationStatus = z.infer<typeof automationStatus>;
|
||||||
|
|
||||||
|
export const triggerType = z.enum(["schedule", "manual"]);
|
||||||
|
export type TriggerType = z.infer<typeof triggerType>;
|
||||||
|
|
||||||
|
export const runStatus = z.enum([
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"succeeded",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
"timed_out",
|
||||||
|
]);
|
||||||
|
export type RunStatus = z.infer<typeof runStatus>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Definition envelope — mirror app/automations/schemas/definition/*
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const planStep = z.object({
|
||||||
|
step_id: z.string().min(1),
|
||||||
|
action: z.string().min(1),
|
||||||
|
when: z.string().nullable().optional(),
|
||||||
|
params: z.record(z.string(), z.any()).default({}),
|
||||||
|
output_as: z.string().nullable().optional(),
|
||||||
|
max_retries: z.number().int().min(0).nullable().optional(),
|
||||||
|
timeout_seconds: z.number().int().positive().nullable().optional(),
|
||||||
|
});
|
||||||
|
export type PlanStep = z.infer<typeof planStep>;
|
||||||
|
|
||||||
|
export const definitionTriggerSpec = z.object({
|
||||||
|
type: z.string().min(1),
|
||||||
|
params: z.record(z.string(), z.any()).default({}),
|
||||||
|
});
|
||||||
|
export type DefinitionTriggerSpec = z.infer<typeof definitionTriggerSpec>;
|
||||||
|
|
||||||
|
export const execution = z.object({
|
||||||
|
timeout_seconds: z.number().int().positive().default(600),
|
||||||
|
max_retries: z.number().int().min(0).default(2),
|
||||||
|
retry_backoff: z.enum(["exponential", "linear", "none"]).default("exponential"),
|
||||||
|
concurrency: z.enum(["drop_if_running", "queue", "always"]).default("drop_if_running"),
|
||||||
|
on_failure: z.array(planStep).default([]),
|
||||||
|
});
|
||||||
|
export type Execution = z.infer<typeof execution>;
|
||||||
|
|
||||||
|
// Backend ``Metadata`` is ``extra="allow"`` — keep ``tags`` typed, accept arbitrary keys.
|
||||||
|
export const metadata = z.object({ tags: z.array(z.string()).default([]) }).catchall(z.any());
|
||||||
|
export type Metadata = z.infer<typeof metadata>;
|
||||||
|
|
||||||
|
// Backend ``Inputs`` serializes its ``schema_`` field as ``schema`` (alias).
|
||||||
|
export const inputs = z.object({
|
||||||
|
schema: z.record(z.string(), z.any()),
|
||||||
|
});
|
||||||
|
export type Inputs = z.infer<typeof inputs>;
|
||||||
|
|
||||||
|
export const automationDefinition = z.object({
|
||||||
|
schema_version: z.string().default("1.0"),
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
goal: z.string().nullable().optional(),
|
||||||
|
inputs: inputs.nullable().optional(),
|
||||||
|
triggers: z.array(definitionTriggerSpec).default([]),
|
||||||
|
plan: z.array(planStep).min(1),
|
||||||
|
execution: execution.default(execution.parse({})),
|
||||||
|
metadata: metadata.default(metadata.parse({})),
|
||||||
|
});
|
||||||
|
export type AutomationDefinition = z.infer<typeof automationDefinition>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Triggers (sub-resource) — mirror app/automations/schemas/api/trigger.py
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const triggerCreateRequest = z.object({
|
||||||
|
type: triggerType,
|
||||||
|
params: z.record(z.string(), z.any()).default({}),
|
||||||
|
static_inputs: z.record(z.string(), z.any()).default({}),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
export type TriggerCreateRequest = z.infer<typeof triggerCreateRequest>;
|
||||||
|
|
||||||
|
export const triggerUpdateRequest = z.object({
|
||||||
|
enabled: z.boolean().nullable().optional(),
|
||||||
|
params: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
|
static_inputs: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
|
});
|
||||||
|
export type TriggerUpdateRequest = z.infer<typeof triggerUpdateRequest>;
|
||||||
|
|
||||||
|
export const trigger = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
type: triggerType,
|
||||||
|
params: z.record(z.string(), z.any()),
|
||||||
|
static_inputs: z.record(z.string(), z.any()),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
last_fired_at: z.string().nullable().optional(),
|
||||||
|
next_fire_at: z.string().nullable().optional(),
|
||||||
|
created_at: z.string(),
|
||||||
|
});
|
||||||
|
export type Trigger = z.infer<typeof trigger>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Automations — mirror app/automations/schemas/api/automation.py
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const automationCreateRequest = z.object({
|
||||||
|
search_space_id: z.number(),
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
definition: automationDefinition,
|
||||||
|
triggers: z.array(triggerCreateRequest).default([]),
|
||||||
|
});
|
||||||
|
export type AutomationCreateRequest = z.infer<typeof automationCreateRequest>;
|
||||||
|
|
||||||
|
export const automationUpdateRequest = z.object({
|
||||||
|
name: z.string().min(1).max(200).nullable().optional(),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
status: automationStatus.nullable().optional(),
|
||||||
|
definition: automationDefinition.nullable().optional(),
|
||||||
|
});
|
||||||
|
export type AutomationUpdateRequest = z.infer<typeof automationUpdateRequest>;
|
||||||
|
|
||||||
|
export const automationSummary = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
search_space_id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
status: automationStatus,
|
||||||
|
version: z.number(),
|
||||||
|
created_at: z.string(),
|
||||||
|
updated_at: z.string(),
|
||||||
|
});
|
||||||
|
export type AutomationSummary = z.infer<typeof automationSummary>;
|
||||||
|
|
||||||
|
export const automation = automationSummary.extend({
|
||||||
|
definition: automationDefinition,
|
||||||
|
triggers: z.array(trigger).default([]),
|
||||||
|
});
|
||||||
|
export type Automation = z.infer<typeof automation>;
|
||||||
|
|
||||||
|
export const automationListResponse = z.object({
|
||||||
|
items: z.array(automationSummary),
|
||||||
|
total: z.number(),
|
||||||
|
});
|
||||||
|
export type AutomationListResponse = z.infer<typeof automationListResponse>;
|
||||||
|
|
||||||
|
export const automationListParams = z.object({
|
||||||
|
search_space_id: z.number(),
|
||||||
|
limit: z.number().int().min(1).max(200).default(50),
|
||||||
|
offset: z.number().int().min(0).default(0),
|
||||||
|
});
|
||||||
|
export type AutomationListParams = z.infer<typeof automationListParams>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Runs (sub-resource) — mirror app/automations/schemas/api/run.py
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const runSummary = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
automation_id: z.number(),
|
||||||
|
trigger_id: z.number().nullable().optional(),
|
||||||
|
status: runStatus,
|
||||||
|
started_at: z.string().nullable().optional(),
|
||||||
|
finished_at: z.string().nullable().optional(),
|
||||||
|
created_at: z.string(),
|
||||||
|
});
|
||||||
|
export type RunSummary = z.infer<typeof runSummary>;
|
||||||
|
|
||||||
|
export const run = runSummary.extend({
|
||||||
|
definition_snapshot: z.record(z.string(), z.any()),
|
||||||
|
inputs: z.record(z.string(), z.any()),
|
||||||
|
step_results: z.array(z.record(z.string(), z.any())),
|
||||||
|
output: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
|
artifacts: z.array(z.record(z.string(), z.any())),
|
||||||
|
error: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
|
});
|
||||||
|
export type Run = z.infer<typeof run>;
|
||||||
|
|
||||||
|
export const runListResponse = z.object({
|
||||||
|
items: z.array(runSummary),
|
||||||
|
total: z.number(),
|
||||||
|
});
|
||||||
|
export type RunListResponse = z.infer<typeof runListResponse>;
|
||||||
|
|
||||||
|
export const runListParams = z.object({
|
||||||
|
limit: z.number().int().min(1).max(200).default(50),
|
||||||
|
offset: z.number().int().min(0).default(0),
|
||||||
|
});
|
||||||
|
export type RunListParams = z.infer<typeof runListParams>;
|
||||||
42
surfsense_web/hooks/use-automation-runs.ts
Normal file
42
surfsense_web/hooks/use-automation-runs.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"use client";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Run, RunListResponse } from "@/contracts/types/automation.types";
|
||||||
|
import { automationsApiService } from "@/lib/apis/automations-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 50;
|
||||||
|
const DEFAULT_OFFSET = 0;
|
||||||
|
|
||||||
|
export interface UseAutomationRunsOptions {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paginated run history for one automation. Newest-first per backend. */
|
||||||
|
export function useAutomationRuns(
|
||||||
|
automationId: number | undefined,
|
||||||
|
{ limit = DEFAULT_LIMIT, offset = DEFAULT_OFFSET, enabled = true }: UseAutomationRunsOptions = {}
|
||||||
|
) {
|
||||||
|
return useQuery<RunListResponse, Error>({
|
||||||
|
queryKey: cacheKeys.automations.runs(automationId ?? 0, limit, offset),
|
||||||
|
queryFn: () => automationsApiService.listRuns(automationId as number, { limit, offset }),
|
||||||
|
enabled: enabled && !!automationId,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single run with the full snapshot, step results, output and artifacts. */
|
||||||
|
export function useAutomationRun(
|
||||||
|
automationId: number | undefined,
|
||||||
|
runId: number | undefined,
|
||||||
|
options: { enabled?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const { enabled = true } = options;
|
||||||
|
return useQuery<Run, Error>({
|
||||||
|
queryKey: cacheKeys.automations.run(automationId ?? 0, runId ?? 0),
|
||||||
|
queryFn: () => automationsApiService.getRun(automationId as number, runId as number),
|
||||||
|
enabled: enabled && !!automationId && !!runId,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
19
surfsense_web/hooks/use-automation.ts
Normal file
19
surfsense_web/hooks/use-automation.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"use client";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Automation } from "@/contracts/types/automation.types";
|
||||||
|
import { automationsApiService } from "@/lib/apis/automations-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single automation with its definition and triggers.
|
||||||
|
* Lives outside the jotai atom layer because it's keyed by id, not by the
|
||||||
|
* "current scope" the atom layer assumes.
|
||||||
|
*/
|
||||||
|
export function useAutomation(automationId: number | undefined) {
|
||||||
|
return useQuery<Automation, Error>({
|
||||||
|
queryKey: cacheKeys.automations.detail(automationId ?? 0),
|
||||||
|
queryFn: () => automationsApiService.getAutomation(automationId as number),
|
||||||
|
enabled: !!automationId,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
24
surfsense_web/hooks/use-automations.ts
Normal file
24
surfsense_web/hooks/use-automations.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"use client";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { automationsListAtom } from "@/atoms/automations/automations-query.atoms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List automations in the active search space (first page).
|
||||||
|
* Pagination knobs live in detail/list hooks below; v1 surfaces only the
|
||||||
|
* first page since automation counts are expected to be small.
|
||||||
|
*/
|
||||||
|
export function useAutomations() {
|
||||||
|
const { data, isLoading, error, refetch } = useAutomationsRaw();
|
||||||
|
return {
|
||||||
|
automations: data?.items ?? [],
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
loading: isLoading,
|
||||||
|
error,
|
||||||
|
refresh: refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposed for callers that prefer the raw react-query result shape.
|
||||||
|
export function useAutomationsRaw() {
|
||||||
|
return useAtomValue(automationsListAtom);
|
||||||
|
}
|
||||||
102
surfsense_web/lib/apis/automations-api.service.ts
Normal file
102
surfsense_web/lib/apis/automations-api.service.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import {
|
||||||
|
type AutomationCreateRequest,
|
||||||
|
type AutomationListParams,
|
||||||
|
type AutomationUpdateRequest,
|
||||||
|
automation,
|
||||||
|
automationCreateRequest,
|
||||||
|
automationListResponse,
|
||||||
|
automationUpdateRequest,
|
||||||
|
type RunListParams,
|
||||||
|
run,
|
||||||
|
runListResponse,
|
||||||
|
type TriggerCreateRequest,
|
||||||
|
type TriggerUpdateRequest,
|
||||||
|
trigger,
|
||||||
|
triggerCreateRequest,
|
||||||
|
triggerUpdateRequest,
|
||||||
|
} from "@/contracts/types/automation.types";
|
||||||
|
import { ValidationError } from "../error";
|
||||||
|
import { baseApiService } from "./base-api.service";
|
||||||
|
|
||||||
|
const BASE = "/api/v1/automations";
|
||||||
|
|
||||||
|
function rejectIfInvalid<T>(
|
||||||
|
parsed: { success: true; data: T } | { success: false; error: { issues: { message: string }[] } }
|
||||||
|
): T {
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AutomationsApiService {
|
||||||
|
// ---- Automations ---------------------------------------------------------
|
||||||
|
|
||||||
|
listAutomations = async (params: AutomationListParams) => {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
search_space_id: String(params.search_space_id),
|
||||||
|
limit: String(params.limit),
|
||||||
|
offset: String(params.offset),
|
||||||
|
});
|
||||||
|
return baseApiService.get(`${BASE}?${qs.toString()}`, automationListResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
getAutomation = async (automationId: number) => {
|
||||||
|
return baseApiService.get(`${BASE}/${automationId}`, automation);
|
||||||
|
};
|
||||||
|
|
||||||
|
createAutomation = async (request: AutomationCreateRequest) => {
|
||||||
|
const data = rejectIfInvalid(automationCreateRequest.safeParse(request));
|
||||||
|
return baseApiService.post(BASE, automation, { body: data });
|
||||||
|
};
|
||||||
|
|
||||||
|
updateAutomation = async (automationId: number, request: AutomationUpdateRequest) => {
|
||||||
|
const data = rejectIfInvalid(automationUpdateRequest.safeParse(request));
|
||||||
|
return baseApiService.patch(`${BASE}/${automationId}`, automation, { body: data });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server returns 204; baseApiService now resolves to null and skips schema validation.
|
||||||
|
deleteAutomation = async (automationId: number) => {
|
||||||
|
return baseApiService.delete(`${BASE}/${automationId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Triggers (sub-resource) --------------------------------------------
|
||||||
|
|
||||||
|
addTrigger = async (automationId: number, request: TriggerCreateRequest) => {
|
||||||
|
const data = rejectIfInvalid(triggerCreateRequest.safeParse(request));
|
||||||
|
return baseApiService.post(`${BASE}/${automationId}/triggers`, trigger, { body: data });
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTrigger = async (
|
||||||
|
automationId: number,
|
||||||
|
triggerId: number,
|
||||||
|
request: TriggerUpdateRequest
|
||||||
|
) => {
|
||||||
|
const data = rejectIfInvalid(triggerUpdateRequest.safeParse(request));
|
||||||
|
return baseApiService.patch(`${BASE}/${automationId}/triggers/${triggerId}`, trigger, {
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
removeTrigger = async (automationId: number, triggerId: number) => {
|
||||||
|
return baseApiService.delete(`${BASE}/${automationId}/triggers/${triggerId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Runs (sub-resource, read-only) -------------------------------------
|
||||||
|
|
||||||
|
listRuns = async (automationId: number, params: RunListParams) => {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
limit: String(params.limit),
|
||||||
|
offset: String(params.offset),
|
||||||
|
});
|
||||||
|
return baseApiService.get(`${BASE}/${automationId}/runs?${qs.toString()}`, runListResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
getRun = async (automationId: number, runId: number) => {
|
||||||
|
return baseApiService.get(`${BASE}/${automationId}/runs/${runId}`, run);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const automationsApiService = new AutomationsApiService();
|
||||||
|
|
@ -126,4 +126,14 @@ export const cacheKeys = {
|
||||||
batchUnreadCounts: (searchSpaceId: number | null) =>
|
batchUnreadCounts: (searchSpaceId: number | null) =>
|
||||||
["notifications", "unread-counts-batch", searchSpaceId] as const,
|
["notifications", "unread-counts-batch", searchSpaceId] as const,
|
||||||
},
|
},
|
||||||
|
automations: {
|
||||||
|
// list endpoint is keyed by pagination too so distinct pages don't collide
|
||||||
|
list: (searchSpaceId: number, limit: number, offset: number) =>
|
||||||
|
["automations", "list", searchSpaceId, limit, offset] as const,
|
||||||
|
detail: (automationId: number) => ["automations", "detail", automationId] as const,
|
||||||
|
runs: (automationId: number, limit: number, offset: number) =>
|
||||||
|
["automations", "runs", automationId, limit, offset] as const,
|
||||||
|
run: (automationId: number, runId: number) =>
|
||||||
|
["automations", "runs", automationId, runId] as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue