From 04a6be2d7ad2d3d20368c496868f75048a39d8a5 Mon Sep 17 00:00:00 2001 From: Paolo Scattolin Date: Thu, 28 May 2026 15:11:25 +0200 Subject: [PATCH 1/3] bug: filter out variables during manual step and add dictionaries to types --- src/api/manual.ts | 11 +- .../ManualActionModal.styles.tsx | 16 ++- .../timeline-tab-view/ManualActionModal.tsx | 101 ++++++++---------- .../timeline-tab-view/VariableInputs.tsx | 59 +++++++++- 4 files changed, 118 insertions(+), 69 deletions(-) diff --git a/src/api/manual.ts b/src/api/manual.ts index e3d7146..debefca 100644 --- a/src/api/manual.ts +++ b/src/api/manual.ts @@ -1,5 +1,9 @@ -import { Execution, ManualOutArgsUpdatePayload } from "@/types"; -import { HttpMutationMethod, mutationToApi } from "./utils"; +import { + Execution, + InteractionCommandData, + ManualOutArgsUpdatePayload, +} from "@/types"; +import { fetchFromApi, HttpMutationMethod, mutationToApi } from "./utils"; export const postStepActionResult = (data: ManualOutArgsUpdatePayload) => mutationToApi( @@ -7,3 +11,6 @@ export const postStepActionResult = (data: ManualOutArgsUpdatePayload) => `/api/manual/continue`, data, ); + +export const getStepManualData = (executionId: string, stepId: string) => + fetchFromApi(`/api/manual/${executionId}/${stepId}`); diff --git a/src/pages/main-page/monitoring-page/timeline-tab-view/ManualActionModal.styles.tsx b/src/pages/main-page/monitoring-page/timeline-tab-view/ManualActionModal.styles.tsx index bce3e88..fd4d0e9 100644 --- a/src/pages/main-page/monitoring-page/timeline-tab-view/ManualActionModal.styles.tsx +++ b/src/pages/main-page/monitoring-page/timeline-tab-view/ManualActionModal.styles.tsx @@ -56,22 +56,21 @@ export const CommandText = styled.div` `; export const VariableList = styled.div` - display: flex; - flex-direction: column; - gap: ${({ theme }) => theme.spacing.md}; + display: grid; + grid-template-columns: max-content 1fr; + column-gap: ${({ theme }) => theme.spacing.lg}; + row-gap: ${({ theme }) => theme.spacing.md}; + align-items: center; `; export const VariableRow = styled.div` - display: flex; - align-items: center; - gap: ${({ theme }) => theme.spacing.lg}; + display: contents; `; export const VariableLabel = styled.div` display: flex; align-items: center; gap: ${({ theme }) => theme.spacing.xs}; - min-width: 180px; flex-shrink: 0; font: ${({ theme }) => theme.typography.body.font}; color: ${({ theme }) => theme.colors.text.primary}; @@ -100,6 +99,5 @@ export const InfoIconWrapper = styled.div` `; export const VariableInputContainer = styled.div` - flex: 1; - display: flex; + min-width: 0; `; diff --git a/src/pages/main-page/monitoring-page/timeline-tab-view/ManualActionModal.tsx b/src/pages/main-page/monitoring-page/timeline-tab-view/ManualActionModal.tsx index da72430..bec1a15 100644 --- a/src/pages/main-page/monitoring-page/timeline-tab-view/ManualActionModal.tsx +++ b/src/pages/main-page/monitoring-page/timeline-tab-view/ManualActionModal.tsx @@ -2,13 +2,14 @@ import { QueryObserverResult, useMutation, UseMutationResult, + useQuery, } from "@tanstack/react-query"; import React, { useState } from "react"; import toast from "react-hot-toast"; -import { postStepActionResult } from "@/api/manual"; +import { getStepManualData, postStepActionResult } from "@/api/manual"; import { formatErrorForToast } from "@/api/utils"; -import { Button, Modal, RadioGroup, ThemeVariant } from "@/components"; +import { Button, Modal, RadioGroup, Spinner, ThemeVariant } from "@/components"; import { Execution, ManualOutArgsUpdatePayload, @@ -53,30 +54,11 @@ const enum ActionType { const decodeCommand = (commandB64: string): string => { try { return atob(commandB64); - } catch (error) { - console.error("Failed to decode command:", error); + } catch { return commandB64; } }; -const initFromStep = (step?: StepExecutionReport | null) => { - const initialValues: Record = {}; - const initialChoices: Record = {}; - - if (step?.variables) { - Object.entries(step.variables).forEach(([key, variable]) => { - if (variable?.value && variable.value.trim() !== "") { - initialValues[key] = variable.value; - initialChoices[key] = ActionType.CONFIRM; - } else { - initialValues[key] = ""; - } - }); - } - - return { initialValues, initialChoices }; -}; - export const ManualActionModal: React.FC = ({ activeStep, playbookId, @@ -84,14 +66,18 @@ export const ManualActionModal: React.FC = ({ onClose, onSuccess, }) => { - const initial = initFromStep(activeStep); - const [variableValues, setVariableValues] = useState>( - initial.initialValues, + {}, ); const [variableChoices, setVariableChoices] = useState< Record - >(initial.initialChoices); + >({}); + + const { data: interactionData, isLoading: isLoadingOutArgs } = useQuery({ + queryKey: ["manual", executionId, activeStep?.step_id], + queryFn: () => getStepManualData(executionId!, activeStep!.step_id), + enabled: !!activeStep && !!executionId, + }); const mutation = useMutation({ mutationFn: (payload: ManualOutArgsUpdatePayload) => @@ -122,32 +108,27 @@ export const ManualActionModal: React.FC = ({ if (!activeStep || !playbookId || !executionId) return; const response_out_args: Variables = {}; - const activeStepVariablesArr = Object.entries(activeStep.variables || {}); + const outArgs = interactionData?.out_args ?? {}; - if (activeStepVariablesArr.length > 0) { - // Process variables - Object.entries(activeStep.variables || {}).forEach(([key, variable]) => { - const hasExistingValue = - variable?.value && variable.value.trim() !== ""; + Object.entries(outArgs).forEach(([key, variable]) => { + const hasExistingValue = variable?.value && variable.value.trim() !== ""; - if (hasExistingValue) { - response_out_args[key] = { - ...variable, - name: variable?.name || key, - type: variable?.type || "string", - value: variable.value, - }; - } else { - // Variable needs a value - use the input value - response_out_args[key] = { - ...variable, - name: variable?.name || key, - type: variable?.type || "string", - value: variableValues[key] || "", - }; - } - }); - } + if (hasExistingValue) { + response_out_args[key] = { + ...variable, + name: variable?.name || key, + type: variable?.type || "string", + value: variable.value, + }; + } else { + response_out_args[key] = { + ...variable, + name: variable?.name || key, + type: variable?.type || "string", + value: variableValues[key] || "", + }; + } + }); const responseStatus: ManualResponseStatus = action === ActionType.CONFIRM ? "success" : "failure"; @@ -176,6 +157,8 @@ export const ManualActionModal: React.FC = ({ = ({ $variant={ThemeVariant.Error} title="Reject the action and notify the playbook" onClick={() => handleSubmit(ActionType.REJECT)} - disabled={mutation.isPending} + disabled={mutation.isPending || isLoadingOutArgs} > Reject @@ -205,7 +188,7 @@ export const ManualActionModal: React.FC = ({ $variant={ThemeVariant.Success} title="Confirm the action and submit the variables, if requested" onClick={() => handleSubmit(ActionType.CONFIRM)} - disabled={mutation.isPending} + disabled={mutation.isPending || isLoadingOutArgs} > Confirm @@ -216,6 +199,8 @@ export const ManualActionModal: React.FC = ({ interface ModalContentProps { activeStep: StepExecutionReport | null; + outArgs: Variables; + isLoadingOutArgs: boolean; mutation: UseMutationResult< Execution, Error, @@ -230,13 +215,15 @@ interface ModalContentProps { const ModalContent: React.FC = ({ activeStep, + outArgs, + isLoadingOutArgs, mutation, variableValues, variableChoices, onChoiceChange, onValueChange, }) => { - const activeStepVariables = Object.entries(activeStep?.variables || {}); + const outArgEntries = Object.entries(outArgs); const commandTexts = activeStep?.commands_b64?.map((cmd) => decodeCommand(cmd)) || []; @@ -258,11 +245,15 @@ const ModalContent: React.FC = ({ )} - {activeStepVariables.length > 0 ? ( + {isLoadingOutArgs ? ( +
+ +
+ ) : outArgEntries.length > 0 ? (
Variables - {activeStepVariables.map(([key, variable]) => { + {outArgEntries.map(([key, variable]) => { const hasExistingValue = variable?.value && variable.value.trim() !== ""; const varType = variable?.type || "string"; diff --git a/src/pages/main-page/monitoring-page/timeline-tab-view/VariableInputs.tsx b/src/pages/main-page/monitoring-page/timeline-tab-view/VariableInputs.tsx index beb7a95..b5ec3e9 100644 --- a/src/pages/main-page/monitoring-page/timeline-tab-view/VariableInputs.tsx +++ b/src/pages/main-page/monitoring-page/timeline-tab-view/VariableInputs.tsx @@ -1,5 +1,5 @@ -import { Input, Select } from "@/components"; -import React from "react"; +import { CodeEditor, Input, Select } from "@/components"; +import React, { useState } from "react"; interface VariableInputProps { variableKey: string; @@ -9,6 +9,40 @@ interface VariableInputProps { onChange: (key: string, value: string) => void; } +const DictionaryInput: React.FC<{ + variableKey: string; + value: string; + disabled: boolean; + onChange: (key: string, value: string) => void; +}> = ({ variableKey, value, disabled, onChange }) => { + const [hasError, setHasError] = useState(false); + + const handleChange = (val: string) => { + if (val.trim() === "") { + setHasError(false); + } else { + try { + JSON.parse(val); + setHasError(false); + } catch { + setHasError(true); + } + } + onChange(variableKey, val); + }; + + return ( + + ); +}; + export const VariableInput: React.FC = ({ variableKey, type, @@ -125,7 +159,26 @@ export const VariableInput: React.FC = ({ pattern="^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" /> ); + case "dictionary": + return ( + + ); case "hash": + return ( + onChange(variableKey, e.target.value)} + disabled={disabled} + placeholder="Hash value (hex string)" + pattern="^[a-fA-F0-9]+$" + /> + ); case "md5-hash": return ( = ({ value={value} onChange={(e) => onChange(variableKey, e.target.value)} disabled={disabled} - placeholder={`${type === "md5-hash" ? "MD5" : "Hash"} hash (32 characters)`} + placeholder="MD5 hash (32 characters)" pattern="^[a-fA-F0-9]{32}$" /> ); From eb299809631c3930304cd48ab4ff0172ea7ccd1e Mon Sep 17 00:00:00 2001 From: Paolo Scattolin Date: Thu, 28 May 2026 15:12:46 +0200 Subject: [PATCH 2/3] feat: add support for external variables when running a playbook --- mock-playbooks/block-ad-user.json | 69 ++++ src/api/trigger.ts | 9 +- .../PlaybookDetailPage.tsx | 384 +++++++++--------- .../RunPlaybookModal.styles.tsx | 16 + .../playbook-detail-page/RunPlaybookModal.tsx | 127 ++++++ 5 files changed, 418 insertions(+), 187 deletions(-) create mode 100644 mock-playbooks/block-ad-user.json create mode 100644 src/pages/main-page/playbooks-page/playbook-detail-page/RunPlaybookModal.styles.tsx create mode 100644 src/pages/main-page/playbooks-page/playbook-detail-page/RunPlaybookModal.tsx diff --git a/mock-playbooks/block-ad-user.json b/mock-playbooks/block-ad-user.json new file mode 100644 index 0000000..98b50a6 --- /dev/null +++ b/mock-playbooks/block-ad-user.json @@ -0,0 +1,69 @@ +{ + "type": "playbook", + "spec_version": "cacao-2.0", + "id": "playbook--4e286394-ff8f-487f-84dd-f7ccf1fa59de", + "name": "Block AD user", + "description": "Blocks AD user by providing the AD user name", + "created_by": "identity--aaacfc1a-fa46-4c1c-85dd-2a0823ce3943", + "created": "2024-09-04T08:19:39.424Z", + "modified": "2024-09-04T08:22:25.712Z", + "revoked": false, + "playbook_variables": { + "__user__": { + "type": "string", + "constant": false, + "external": true + } + }, + "workflow_start": "start--4270ba65-31c3-4041-9fa0-17241122bb9f", + "workflow": { + "start--4270ba65-31c3-4041-9fa0-17241122bb9f": { + "on_completion": "action--0a6fbf98-4e75-45fd-9948-8c62908521db", + "type": "start" + }, + "action--0a6fbf98-4e75-45fd-9948-8c62908521db": { + "name": "Disable account", + "description": "Disable windows account in AD", + "on_completion": "end--bfbcfc10-1926-4409-8ea2-bfd1814365b3", + "type": "action", + "commands": [ + { + "type": "powershell", + "description": "Block user given to the playbook", + "command": "Disable-ADAccount -Identity __user__:value" + } + ], + "agent": "soarca--49cde8bd-cc50-4d1a-b60c-8d49a785f4b9", + "targets": ["net-address--78a788f6-c2b8-4c46-9648-c2cb933c63b2"] + }, + "end--bfbcfc10-1926-4409-8ea2-bfd1814365b3": { + "type": "end" + } + }, + "authentication_info_definitions": { + "authentication-info--9a2d2fd6-aa6d-4d2e-ad2a-f6f84d544dbc": { + "type": "user-auth", + "name": "Domain admin", + "username": "admin.project", + "password": "censored", + "kms": false + } + }, + "agent_definitions": { + "soarca--49cde8bd-cc50-4d1a-b60c-8d49a785f4b9": { + "type": "soarca", + "name": "soarca-powershell" + } + }, + "target_definitions": { + "net-address--78a788f6-c2b8-4c46-9648-c2cb933c63b2": { + "type": "net-address", + "name": "Windows AD DS server", + "address": { + "ipv4": ["192.168.1.121"] + }, + "port": "5985", + "authentication_info": "authentication-info--9a2d2fd6-aa6d-4d2e-ad2a-f6f84d544dbc" + } + } +} diff --git a/src/api/trigger.ts b/src/api/trigger.ts index da900bd..7926811 100644 --- a/src/api/trigger.ts +++ b/src/api/trigger.ts @@ -1,10 +1,13 @@ -import { Execution } from "@/types"; +import { Execution, Variables } from "@/types"; import { HttpMutationMethod, mutationToApi } from "./utils"; -export const triggerPlaybookById = (playbookId: string) => { +export const triggerPlaybookById = ( + playbookId: string, + variables?: Variables, +) => { return mutationToApi( HttpMutationMethod.POST, `/api/trigger/playbook/${playbookId}`, - {}, + variables ?? {}, ); }; diff --git a/src/pages/main-page/playbooks-page/playbook-detail-page/PlaybookDetailPage.tsx b/src/pages/main-page/playbooks-page/playbook-detail-page/PlaybookDetailPage.tsx index a283884..0682aad 100644 --- a/src/pages/main-page/playbooks-page/playbook-detail-page/PlaybookDetailPage.tsx +++ b/src/pages/main-page/playbooks-page/playbook-detail-page/PlaybookDetailPage.tsx @@ -14,16 +14,18 @@ import { CardContainer, CardHeader, CardTitle, + CenteredCardContent, ConfirmDialog, ExportButton, Icon, Link, Spacer, - SuspenseCard, + Spinner, + Text, ThemeSize, ThemeVariant, } from "@/components"; -import { ErrorResponse, Execution, Playbook } from "@/types"; +import { Execution, Playbook } from "@/types"; import { PATHS } from "@/utils"; import { generatePlaybookFilename, getOrderedSteps } from "../utils"; @@ -37,6 +39,7 @@ import { TwoColumnLayout, } from "./PlaybookDetailPage.styles"; import PlaybookTimeline from "./PlaybookTimeline"; +import { RunPlaybookModal } from "./RunPlaybookModal"; export const PlaybookDetailPage: React.FC = () => { const navigate = useNavigate(); @@ -51,28 +54,30 @@ export const PlaybookDetailPage: React.FC = () => { }); const [openRunConfirm, setOpenRunConfirm] = useState(false); + const [openRunModal, setOpenRunModal] = useState(false); const [openDeleteConfirm, setOpenDeleteConfirm] = useState(false); + const navigateToExecution = (data: Execution) => { + if (data?.execution_id) { + navigate( + PATHS.MONITORING.DETAIL.replace(":executionId", data.execution_id), + ); + } else { + navigate(PATHS.MONITORING.BASE); + } + }; + const mutation = useMutation({ mutationFn: () => triggerPlaybookById(playbookId!), onSuccess: (data: Execution) => { toast.success("Playbook started"); - // Use the API's `execution_id` when available and - // navigate directly to the detailed execution report. - if (data && data.execution_id) { - navigate( - PATHS.MONITORING.DETAIL.replace(":executionId", data.execution_id), - ); - } else { - navigate(PATHS.MONITORING.BASE); - } + navigateToExecution(data); }, onError: (err: Error) => { toast.error(formatErrorForToast(err, "Failed to trigger playbook")); }, }); - // Mutation for deleting a playbook const deleteMutation = useMutation({ mutationFn: () => deletePlaybook(playbookId!), onSuccess: () => { @@ -85,7 +90,10 @@ export const PlaybookDetailPage: React.FC = () => { }); const playbook: Playbook | undefined = data; - const parsedError = getErrorFromApiResponse(error as Error) as ErrorResponse; + const hasExternalVars = Object.values( + playbook?.playbook_variables ?? {}, + ).some((v) => v.external === true); + const parsedError = getErrorFromApiResponse(error); const toggleStep = (stepId: string) => { setExpandedSteps((prev) => { @@ -103,182 +111,190 @@ export const PlaybookDetailPage: React.FC = () => { ? getOrderedSteps(playbook.workflow, playbook.workflow_start) : []; + if (isLoading) { + return ( + + + + Back to playbooks + + + + + + + + + + ); + } + + if (isError || !playbook) { + return ( + + + + Back to playbooks + + + + Playbook + + + + {isError + ? parsedError?.message || "Could not load playbook" + : "Playbook not found"} + + + + + ); + } + return ( - - {playbook && ( - - + + + + Back to playbooks + + + - - - - toast.success("Playbook exported")} - $onError={(err) => - toast.error( - formatErrorForToast( - err as Error, - "Failed to export playbook", - ), - ) - } - disabled={!playbook} - > - - Export - + + Edit + + + - setOpenRunConfirm(false)} - $onConfirm={() => { - setOpenRunConfirm(false); - mutation.mutate(); - }} - /> + toast.success("Playbook exported")} + $onError={(err) => + toast.error(formatErrorForToast(err, "Failed to export playbook")) + } + > + + Export + - setOpenDeleteConfirm(false)} - $onConfirm={() => { - setOpenDeleteConfirm(false); - deleteMutation.mutate(); - }} - /> - - + setOpenRunConfirm(false)} + $onConfirm={() => { + setOpenRunConfirm(false); + mutation.mutate(); + }} + /> - - - - - Playbook information - - - - - - - - - - Additional details - - - - - - - - - - - - Workflow steps ({orderedSteps.length}) - - - - - - - - - + setOpenDeleteConfirm(false)} + $onConfirm={() => { + setOpenDeleteConfirm(false); + deleteMutation.mutate(); + }} + /> + {openRunModal && ( + setOpenRunModal(false)} + onSuccess={navigateToExecution} + /> + )} - )} - + + + + + + + Playbook information + + + + + + + + Additional details + + + + + + + + + + Workflow steps ({orderedSteps.length}) + + + + + + + + ); }; diff --git a/src/pages/main-page/playbooks-page/playbook-detail-page/RunPlaybookModal.styles.tsx b/src/pages/main-page/playbooks-page/playbook-detail-page/RunPlaybookModal.styles.tsx new file mode 100644 index 0000000..4a1378e --- /dev/null +++ b/src/pages/main-page/playbooks-page/playbook-detail-page/RunPlaybookModal.styles.tsx @@ -0,0 +1,16 @@ +import styled from "styled-components"; + +export const Description = styled.p` + font: ${({ theme }) => theme.typography.body.font}; + color: ${({ theme }) => theme.colors.text.secondary}; + margin: 0 0 ${({ theme }) => theme.spacing.lg} 0; +`; + +export { + VariableInputContainer, + VariableLabel, + VariableList, + VariableName, + VariableRow, + VariableType, +} from "@/pages/main-page/monitoring-page/timeline-tab-view/ManualActionModal.styles"; diff --git a/src/pages/main-page/playbooks-page/playbook-detail-page/RunPlaybookModal.tsx b/src/pages/main-page/playbooks-page/playbook-detail-page/RunPlaybookModal.tsx new file mode 100644 index 0000000..c16c5ea --- /dev/null +++ b/src/pages/main-page/playbooks-page/playbook-detail-page/RunPlaybookModal.tsx @@ -0,0 +1,127 @@ +import { useMutation } from "@tanstack/react-query"; +import React, { useState } from "react"; +import toast from "react-hot-toast"; + +import { triggerPlaybookById } from "@/api/trigger"; +import { formatErrorForToast } from "@/api/utils"; +import { Button, Modal, ThemeVariant } from "@/components"; +import { VariableInput } from "@/pages/main-page/monitoring-page/timeline-tab-view/VariableInputs"; +import { Execution, Playbook, Variables } from "@/types"; + +import { + Description, + VariableInputContainer, + VariableLabel, + VariableList, + VariableName, + VariableRow, + VariableType, +} from "./RunPlaybookModal.styles"; + +interface RunPlaybookModalProps { + playbook: Playbook; + isOpen: boolean; + onClose: () => void; + onSuccess: (execution: Execution) => void; +} + +export const RunPlaybookModal: React.FC = ({ + playbook, + isOpen, + onClose, + onSuccess, +}) => { + const externalVars = Object.entries(playbook.playbook_variables ?? {}).filter( + ([, v]) => v.external === true, + ); + + const [values, setValues] = useState>(() => + Object.fromEntries(externalVars.map(([key, v]) => [key, v.value ?? ""])), + ); + + const mutation = useMutation({ + mutationFn: (variables: Variables | undefined) => + triggerPlaybookById(playbook.id, variables), + onSuccess: (data: Execution) => { + toast.success("Playbook started"); + onSuccess(data); + onClose(); + }, + onError: (err: Error) => { + toast.error(formatErrorForToast(err, "Failed to trigger playbook")); + }, + }); + + const handleValueChange = (key: string, value: string) => { + setValues((prev) => ({ ...prev, [key]: value })); + }; + + const handleRunWithVariables = () => { + const vars: Variables = {}; + externalVars.forEach(([key, variable]) => { + vars[key] = { + type: variable.type, + value: values[key] ?? "", + }; + }); + mutation.mutate(vars); + }; + + const handleRunWithoutVariables = () => { + mutation.mutate(undefined); + }; + + return ( + !mutation.isPending && onClose()}> + + Run playbook + !mutation.isPending && onClose()} + disabled={mutation.isPending} + /> + + + + This playbook accepts external variables. Provide values below or run + without setting any. + + + {externalVars.map(([key, variable]) => ( + + + {variable.name ?? key} + {variable.type} + + + + + + ))} + + + + + + + + ); +}; From 550a6c6012ddd9cb7d6c6e1a57b6a2b01931aa74 Mon Sep 17 00:00:00 2001 From: Paolo Scattolin Date: Thu, 28 May 2026 15:14:15 +0200 Subject: [PATCH 3/3] refactor: remove the suspense card in favour of spinner it was quite annoying that nothing would be accessible if soarca was not reachabble. Also the skeleton cards were quite ugly, I changed it to a simple spinner. --- src/api/utils.ts | 8 +- src/components/Containers.tsx | 7 + src/components/CopyButton.tsx | 6 +- src/components/Form.tsx | 18 +- src/components/cards/SuspenseCard.tsx | 154 ------------- src/components/cards/index.ts | 1 - src/pages/main-page/Credits.tsx | 20 +- src/pages/main-page/SettingsPage.tsx | 112 +++++----- .../monitoring-page/ExecutionDetailPage.tsx | 209 +++++++++--------- .../monitoring-page/MonitoringPage.tsx | 116 +++++----- .../playbooks-page/PlaybookEditPage.tsx | 134 +++++------ .../playbooks-page/PlaybooksPage.tsx | 81 ++++--- 12 files changed, 370 insertions(+), 496 deletions(-) delete mode 100644 src/components/cards/SuspenseCard.tsx diff --git a/src/api/utils.ts b/src/api/utils.ts index 4beb986..88bccee 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -77,8 +77,8 @@ export async function deleteToApi(url: string): Promise { * @example * const errorResponse = apiErrorToErrorResponse(error); */ -export const getErrorFromApiResponse = (error: Error): ErrorResponse => { - if (error && error instanceof Error) { +export const getErrorFromApiResponse = (error: unknown): ErrorResponse => { + if (error instanceof Error) { try { return JSON.parse(error.message) as ErrorResponse; } catch { @@ -90,14 +90,14 @@ export const getErrorFromApiResponse = (error: Error): ErrorResponse => { /** * Formats an error for display in a toast notification. - * @param error - The Error object to format + * @param error - The error to format (any value; non-Error inputs use the default message) * @param defaultMessage - The default message to use if the error does not have a specific message * @returns Formatted error message string * @example * const message = formatErrorForToast(error, "An unexpected error occurred."); */ export const formatErrorForToast = ( - error: Error, + error: unknown, defaultMessage: string, ): string => { const parsed = getErrorFromApiResponse(error); diff --git a/src/components/Containers.tsx b/src/components/Containers.tsx index b879e32..31d90bc 100644 --- a/src/components/Containers.tsx +++ b/src/components/Containers.tsx @@ -34,3 +34,10 @@ export const PageContainer = styled.div` padding: 0; `; + +export const CenteredCardContent = styled.div` + display: flex; + align-items: center; + justify-content: center; + min-height: 12rem; +`; diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 41ccf78..7a29ae3 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -2,6 +2,7 @@ import { Copy } from "lucide-react"; import React from "react"; import toast from "react-hot-toast"; +import { formatErrorForToast } from "@/api/utils"; import { Button } from "./Button"; import Icon from "./Icon"; import { ThemeSize } from "./utils"; @@ -23,8 +24,6 @@ export const CopyButton: React.FC = ({ disabled = false, ...rest }) => { - - const handleClick = async (e: React.MouseEvent) => { e.stopPropagation(); if (disabled || !$text) return; @@ -33,8 +32,7 @@ export const CopyButton: React.FC = ({ await navigator.clipboard.writeText($text); toast.success("Copied to clipboard"); } catch (err) { - console.error("Copy failed", err); - toast.error("Failed to copy to clipboard"); + toast.error(formatErrorForToast(err, "Failed to copy to clipboard")); } }; diff --git a/src/components/Form.tsx b/src/components/Form.tsx index ef908da..e2fc366 100644 --- a/src/components/Form.tsx +++ b/src/components/Form.tsx @@ -18,7 +18,7 @@ export const Input = styled.input` padding: ${({ theme }) => theme.spacing.md} ${({ theme }) => theme.spacing.md}; - font: ${({ theme }) => theme.typography.caption.font}; + font: ${({ theme }) => theme.typography.body.font}; color: ${({ theme }) => theme.colors.text.primary}; background: ${({ theme }) => theme.colors.background.primary}; @@ -53,16 +53,24 @@ export const Input = styled.input` const StyledSelect = styled.select` width: 100%; box-sizing: border-box; + appearance: none; + -webkit-appearance: none; border: 1px solid ${({ theme }) => theme.colors.border.medium}; border-radius: ${({ theme }) => theme.radius.md}; - padding: ${({ theme }) => `${theme.spacing.sm} ${theme.spacing.md}`}; + padding: ${({ theme }) => + `${theme.spacing.md} ${theme.spacing["2xl"]} ${theme.spacing.md} ${theme.spacing.md}`}; font: ${({ theme }) => theme.typography.body.font}; color: ${({ theme }) => theme.colors.text.primary}; - background: ${({ theme }) => theme.colors.background.primary}; - line-height: 1.5; + background-color: ${({ theme }) => theme.colors.background.primary}; + background-image: ${({ theme }) => { + const color = theme.colors.text.tertiary.replace("#", "%23"); + return `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='${color}' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C%2Fsvg%3E")`; + }}; + background-repeat: no-repeat; + background-position: right ${({ theme }) => theme.spacing.md} center; transition: border-color ${({ theme }) => theme.transitions.base}; @@ -80,7 +88,7 @@ const StyledSelect = styled.select` &:disabled { opacity: 0.5; cursor: not-allowed; - background: ${({ theme }) => theme.colors.background.secondary}; + background-color: ${({ theme }) => theme.colors.background.secondary}; } & > option { diff --git a/src/components/cards/SuspenseCard.tsx b/src/components/cards/SuspenseCard.tsx deleted file mode 100644 index 90e68f3..0000000 --- a/src/components/cards/SuspenseCard.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import React from "react"; -import styled from "styled-components"; - -import { theme } from "@/theme"; -import { shimmer } from "@/theme/animations"; -import { Text } from "../Typography"; -import { CardBody, CardContainer, CardFooter, CardHeader } from "./Card"; - -const SuspenseBody = styled(CardBody)` - display: flex; - flex-direction: column; - - gap: ${({ theme }) => theme.spacing.md}; -`; - -const SkeletonLine = styled.div` - height: 1.5rem; - - border-radius: ${({ theme }) => theme.radius.full}; - - background: linear-gradient( - 90deg, - ${({ theme }) => theme.colors.info.bg} 0%, - ${({ theme }) => theme.colors.accent.bg} 50%, - ${({ theme }) => theme.colors.info.bg} 100% - ); - background-size: 200px 100%; - box-shadow: ${({ theme }) => theme.shadows.sm}; - - animation: ${shimmer} 1.5s linear infinite; -`; - -const SkeletonWrapper = styled.div<{ $visible: boolean }>` - opacity: ${({ $visible }) => ($visible ? 1 : 0)}; - - transition: opacity ${({ theme }) => theme.transitions.base}; -`; - -const ErrorWrapper = styled.div<{ $visible: boolean }>` - opacity: ${({ $visible }) => ($visible ? 1 : 0)}; - - transition: opacity ${({ theme }) => theme.transitions.base}; - - color: ${({ theme }) => theme.colors.error.text}; - text-align: center; -`; - -const NoContentWrapper = styled.div<{ $visible: boolean }>` - opacity: ${({ $visible }) => ($visible ? 1 : 0)}; - - transition: opacity ${({ theme }) => theme.transitions.base}; - - color: ${({ theme }) => theme.colors.text.secondary}; - text-align: center; -`; - -export interface SuspenseCardProps extends React.HTMLAttributes { - $header?: React.ReactNode; - $footer?: React.ReactNode; - $isLoading: boolean; - $isError?: boolean; - $errorMessage?: string; - $returnedNoContent?: boolean; - $noContentMessage?: string; -} - -/** - * SuspenseCard for displaying a subtle, animated loading skeleton inside a card shell. - * @param $header - Optional header content rendered in the non-loading card header area. - * @param $footer - Optional footer content rendered in the card footer area. - * @param $isLoading - When true shows the glowing skeleton instead of the actual children content. - * @param $isError - When true shows an error state instead of normal content once loading has finished. - * @param $errorMessage - Optional specific error message; falls back to a generic one. - * @param $returnedNoContent - When true shows a no-content state instead of normal content (success but empty). - * @param $noContentMessage - Optional specific no-content message; falls back to a generic one. - * @example - * Loading report} - * $isLoading={isFetching} - * $isError={isError} - * $errorMessage={errorText} - * $returnedNoContent={!data} - * $noContentMessage="No report data available" - * > - * - * - * @todo This card should probably be made to support the promise, and handle the states using the state of the promise. - */ -export const SuspenseCard: React.FC = ({ - $header, - $footer, - $isLoading, - $isError = false, - $errorMessage, - $returnedNoContent = false, - $noContentMessage, - children, - ...rest -}) => { - if ($isLoading) { - return ( - - {$header ? {$header} : null} - - - - - - - - - - - - {$footer ? {$footer} : null} - - ); - } - - if ($isError) { - return ( - - {$header ? {$header} : null} - - - - {$errorMessage || "Something went wrong while loading this data."} - - - - {$footer ? {$footer} : null} - - ); - } - - if ($returnedNoContent) { - return ( - - {$header ? {$header} : null} - - - - {$noContentMessage || - "No content was found. The request was successful, but the expected data is not available."} - - - - {$footer ? {$footer} : null} - - ); - } - - return <>{children}; -}; diff --git a/src/components/cards/index.ts b/src/components/cards/index.ts index 7254ba0..4f0dc99 100644 --- a/src/components/cards/index.ts +++ b/src/components/cards/index.ts @@ -1,3 +1,2 @@ export * from "./Card"; export * from "./ExpandableCard"; -export * from "./SuspenseCard"; diff --git a/src/pages/main-page/Credits.tsx b/src/pages/main-page/Credits.tsx index 0829487..ae7f049 100644 --- a/src/pages/main-page/Credits.tsx +++ b/src/pages/main-page/Credits.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { FormLabel, Link } from "@/components"; import { @@ -7,6 +7,8 @@ import { } from "@/pages/main-page/monitoring-page/ExecutionDetailPage.styles"; import { MadeWithLove, PixelHeart } from "./Credits.styles"; +const TRIGGER = "credits"; + /** * Little fun easter egg that shows credits when the user types "credits". * It also desappears when the user types anything else. @@ -14,23 +16,19 @@ import { MadeWithLove, PixelHeart } from "./Credits.styles"; */ export const CreditsEasterEgg: React.FC = () => { const [showEasterEgg, setShowEasterEgg] = useState(false); - const [keyBuffer, setKeyBuffer] = useState(""); + const keyBufferRef = useRef(""); useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { - const newBuffer = (keyBuffer + e.key).slice(-7); - setKeyBuffer(newBuffer); - - if (newBuffer === "credits") { - setShowEasterEgg(true); - } else if (showEasterEgg) { - setShowEasterEgg(false); - } + keyBufferRef.current = (keyBufferRef.current + e.key).slice( + -TRIGGER.length, + ); + setShowEasterEgg(keyBufferRef.current === TRIGGER); }; window.addEventListener("keydown", handleKeyPress); return () => window.removeEventListener("keydown", handleKeyPress); - }, [keyBuffer, showEasterEgg]); + }, []); if (!showEasterEgg) { return null; diff --git a/src/pages/main-page/SettingsPage.tsx b/src/pages/main-page/SettingsPage.tsx index 9a9dd50..33f95cd 100644 --- a/src/pages/main-page/SettingsPage.tsx +++ b/src/pages/main-page/SettingsPage.tsx @@ -2,17 +2,18 @@ import { useQuery } from "@tanstack/react-query"; import React from "react"; import { getSystemStatus } from "@/api/status"; -import { getErrorFromApiResponse } from "@/api/utils"; import { Badge, CardBody, CardContainer, CardHeader, CardTitle, + CenteredCardContent, FormLabel, RadioGroup, Spacer, - SuspenseCard, + Spinner, + ThemeSize, ThemeVariant, } from "@/components"; import { @@ -27,7 +28,7 @@ import { CreditsEasterEgg } from "./Credits"; export const SettingsPage: React.FC = () => { const { mode, setMode } = useThemeMode(); - const { data, isLoading, isError, error } = useQuery({ + const { data, isLoading, isError } = useQuery({ queryKey: ["system-status"], queryFn: getSystemStatus, refetchInterval: 5000, @@ -35,41 +36,40 @@ export const SettingsPage: React.FC = () => { refetchOnWindowFocus: false, }); - const parsedError = getErrorFromApiResponse(error as Error); return ( - - - - - Appearance - - - - - Theme - setMode(v as ThemeMode)} - /> - - - - - - - SOARCA information - - + + + + Appearance + + + + + Theme + setMode(v as ThemeMode)} + /> + + + + + + + SOARCA information + + + {isLoading ? ( + + + + ) : ( Status @@ -79,7 +79,7 @@ export const SettingsPage: React.FC = () => { isError ? ThemeVariant.Error : ThemeVariant.Success } > - {isError ? "Error" : "Online"} + {isError ? "Offline" : "Online"} @@ -119,24 +119,24 @@ export const SettingsPage: React.FC = () => { - - + )} + + - - - SOARCA GUI information - - - - - Version - {__APP_VERSION__} - - - - - - - + + + SOARCA GUI information + + + + + Version + {__APP_VERSION__} + + + + + + ); }; diff --git a/src/pages/main-page/monitoring-page/ExecutionDetailPage.tsx b/src/pages/main-page/monitoring-page/ExecutionDetailPage.tsx index d340ee1..36572bb 100644 --- a/src/pages/main-page/monitoring-page/ExecutionDetailPage.tsx +++ b/src/pages/main-page/monitoring-page/ExecutionDetailPage.tsx @@ -4,18 +4,20 @@ import React from "react"; import { useParams } from "react-router"; import { getReportOfExecutionById } from "@/api/reporter"; +import { getErrorFromApiResponse } from "@/api/utils"; import { Badge, CardBody, CardContainer, CardHeader, CardTitle, + CenteredCardContent, ExpandableText, FormLabel, Icon, Link, Spacer, - SuspenseCard, + Spinner, Tabs, TabsProvider, Text, @@ -38,7 +40,6 @@ import { TabsSection, } from "./ExecutionDetailPage.styles"; -import { getErrorFromApiResponse } from "@/api/utils"; import { SoarcaApiPlaybookExecutionStatus } from "@/enums"; import { formatDateTime, PATHS } from "@/utils"; import { DetailsTabView } from "./details-tab-view/DetailsTabView"; @@ -51,13 +52,11 @@ export const ExecutionDetailPage: React.FC = () => { const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: ["report", executionId], queryFn: () => getReportOfExecutionById(executionId!), - enabled: !!executionId, // only run the query if executionId is defined + enabled: !!executionId, staleTime: 1000, refetchOnWindowFocus: false, retry: false, refetchInterval: (data) => { - // if the status of the playbook is ongoing, refetch every second - // otherwise there is no point in refetching if (!data) return false; return data.status === SoarcaApiPlaybookExecutionStatus.ONGOING ? 1000 @@ -69,7 +68,98 @@ export const ExecutionDetailPage: React.FC = () => { const report: PlaybookExecutionReport | undefined = data; const steps = Object.values(report?.step_results || {}); const status = getPlaybookStatusFromSoarcaStatus(report?.status); - const parsedError = getErrorFromApiResponse(error as Error); + const parsedError = getErrorFromApiResponse(error); + + const renderBody = () => { + if (isLoading) + return ( + + + + ); + if (isError) + return ( + {parsedError?.message || "Could not load execution report"} + ); + if (!report) return No report data found for this execution.; + + return ( + + + + Execution ID + {report.execution_id} + + + Status + + + {status} + + + + Description + {report.description || "—"}} + /> + + + Status details + {report.status_text || "—"}} + /> + + + Started + {formatDateTime(report.started)} + + + Ended + {formatDateTime(report.ended)} + + + {steps.length === 0 ? ( + + No steps found for this execution. + + ) : ( + + + + Timeline + + ), + }, + { + id: TABS.detailed, + label: ( + + Details + + ), + }, + ]} + /> + + + + )} + + ); + }; return ( @@ -77,107 +167,12 @@ export const ExecutionDetailPage: React.FC = () => { Back to monitoring - - - - {report?.name} - - - - - - Execution ID - {report?.execution_id} - - - Status - - - {status} - - - - Description - {report?.description || "—"} - } - /> - - - Status details - {report?.status_text || "—"} - } - /> - - - Started - {formatDateTime(report?.started)} - - - Ended - {formatDateTime(report?.ended)} - - - {steps.length === 0 ? ( - - No steps found for this execution. - - ) : ( - - - - Timeline - - ), - }, - { - id: TABS.detailed, - label: ( - - Details - - ), - }, - ]} - /> - - - - )} - - - - + + + {report?.name ?? "Execution report"} + + {renderBody()} + ); }; diff --git a/src/pages/main-page/monitoring-page/MonitoringPage.tsx b/src/pages/main-page/monitoring-page/MonitoringPage.tsx index 6c71633..5588ca0 100644 --- a/src/pages/main-page/monitoring-page/MonitoringPage.tsx +++ b/src/pages/main-page/monitoring-page/MonitoringPage.tsx @@ -12,14 +12,16 @@ import { CardHeader, CardTitle, Cell, + CenteredCardContent, HeaderCell, Icon, Row, Spacer, - SuspenseCard, + Spinner, Table, TableBody, TableHead, + Text, } from "@/components"; import { ThemeSize, ThemeVariant } from "@/components/utils"; import { PlaybookExecutionStatus } from "@/enums"; @@ -27,7 +29,7 @@ import { getBadgeVariantFromStatus, getPlaybookStatusFromSoarcaStatus, } from "@/pages/main-page/monitoring-page/utils"; -import { ErrorResponse, PlaybookExecutionReport } from "@/types"; +import { PlaybookExecutionReport } from "@/types"; import { computeDurationMs, formatDateTime, @@ -80,66 +82,68 @@ export const MonitoringPage: React.FC = () => { }); const rows = parseReportToRows(data || []) as PlaybookRow[]; - - const noContent = !isLoading && !isError && rows.length === 0; - const parsedError = getErrorFromApiResponse(error as Error) as ErrorResponse; + const parsedError = getErrorFromApiResponse(error); const navigateToExecutionDetail = (executionId: string) => { navigate(PATHS.MONITORING.DETAIL.replace(":executionId", executionId)); }; - return ( - - - - - - - - Playbook name - Start time - Execution duration - Status - - - - {rows.map((r) => ( - navigateToExecutionDetail(r.id)} + const renderBody = () => { + if (isLoading) + return ( + + + + ); + if (isError) + return {parsedError?.message || "Could not load executions"}; + if (rows.length === 0) + return It looks like nothing is happening...; + return ( +
+ + + Playbook name + Start time + Execution duration + Status + + + + {rows.map((r) => ( + navigateToExecutionDetail(r.id)} + > + {r.name} + {r.startTime} + {formatDuration(r.durationMs)} + + - {r.name} - {r.startTime} - {formatDuration(r.durationMs)} - - - {r.status} - {r.hasActionRequired && ( - - )} - - - - ))} - -
-
-
-
+ {r.status} + {r.hasActionRequired && ( + + )} + + + + ))} + + + ); + }; + + return ( + + + {renderBody()} + ); }; diff --git a/src/pages/main-page/playbooks-page/PlaybookEditPage.tsx b/src/pages/main-page/playbooks-page/PlaybookEditPage.tsx index 41d505d..655c2f5 100644 --- a/src/pages/main-page/playbooks-page/PlaybookEditPage.tsx +++ b/src/pages/main-page/playbooks-page/PlaybookEditPage.tsx @@ -12,13 +12,16 @@ import { CardContainer, CardHeader, CardTitle, + CenteredCardContent, Icon, Link, Spacer, - SuspenseCard, + Spinner, + Text, + ThemeSize, ThemeVariant, } from "@/components"; -import { ErrorResponse, Playbook } from "@/types"; +import { Playbook } from "@/types"; import { PATHS } from "@/utils"; import CodeEditor from "@/components/CodeEditor"; @@ -55,9 +58,7 @@ export const PlaybookEditPage: React.FC = () => { enabled: !!playbookId, }); - const fetchPlaybookError = getErrorFromApiResponse( - queryError as Error, - ) as ErrorResponse; + const fetchPlaybookError = getErrorFromApiResponse(queryError); const initialJsonContent = useMemo(() => { return playbook ? JSON.stringify(playbook, null, 2) : ""; @@ -94,64 +95,73 @@ export const PlaybookEditPage: React.FC = () => { Back to playbook - - - - Edit Playbook: {playbook?.name} - - - - Edit the playbook JSON below. Changes will be validated before - saving. - + + + Edit Playbook: {playbook?.name ?? "..."} + + + {isLoading ? ( + + + + ) : isError || !playbook ? ( + + {isError + ? fetchPlaybookError?.message || "Could not load playbook" + : "Playbook not found"} + + ) : ( + <> + + Edit the playbook JSON below. Changes will be validated before + saving. + - { - setJsonContent(v); - setError(""); - if (v.trim()) { - const validation = validatePlaybookJson(v); - if (!validation.valid) - setError(validation.error || "Invalid JSON"); - } - }} - $placeholder="Edit your CACAO playbook JSON here..." - $disabled={updateMutation.isPending} - $hasError={!!error} - $minHeight="500px" - /> - {error && {error}} - - - - - - - + { + setJsonContent(v); + setError(""); + if (v.trim()) { + const validation = validatePlaybookJson(v); + if (!validation.valid) + setError(validation.error || "Invalid JSON"); + } + }} + $placeholder="Edit your CACAO playbook JSON here..." + $disabled={updateMutation.isPending} + $hasError={!!error} + $minHeight="500px" + /> + {error && {error}} + + + + + + )} + + ); }; diff --git a/src/pages/main-page/playbooks-page/PlaybooksPage.tsx b/src/pages/main-page/playbooks-page/PlaybooksPage.tsx index bce3493..ecd5a02 100644 --- a/src/pages/main-page/playbooks-page/PlaybooksPage.tsx +++ b/src/pages/main-page/playbooks-page/PlaybooksPage.tsx @@ -1,4 +1,4 @@ -import { SuspenseCard, ThemeSize, ThemeVariant } from "@/components"; +import { ThemeSize, ThemeVariant } from "@/components"; import { useQuery } from "@tanstack/react-query"; import { FilePlusCorner, MoreVertical } from "lucide-react"; import React, { useEffect, useState } from "react"; @@ -12,9 +12,12 @@ import { CardContainer, CardHeader, CardTitle, + CenteredCardContent, Icon, + Spinner, + Text, } from "@/components"; -import { ErrorResponse, Playbook, Step } from "@/types"; +import { Playbook, Step } from "@/types"; import { PATHS, sortByString } from "@/utils"; import { @@ -143,45 +146,51 @@ export const PlaybooksPage: React.FC = () => { true, false, ); - const noContent = !isLoading && !isError && sortedPlaybooks.length === 0; - const parsedError = getErrorFromApiResponse(error as Error) as ErrorResponse; + + const parsedError = getErrorFromApiResponse(error); const handlePlaybookClick = (playbookId: string) => { navigate(PATHS.PLAYBOOKS.DETAIL.replace(":playbookId", playbookId)); }; + const renderBody = () => { + if (isLoading) + return ( + + + + ); + if (isError) + return {parsedError?.message || "Could not load playbooks"}; + if (sortedPlaybooks.length === 0) + return No playbooks available; + return ( + + {sortedPlaybooks.map((playbook) => ( + handlePlaybookClick(playbook.id)} + /> + ))} + + ); + }; + return ( - - - - Playbooks - - - - - {sortedPlaybooks.map((playbook) => ( - handlePlaybookClick(playbook.id)} - /> - ))} - - - - + + + Playbooks + + + {renderBody()} + ); };