diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 46a9326f24..bc0012d9e0 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -14974,6 +14974,40 @@ export const $WorkflowDefinitionReadMinimal = { title: "WorkflowDefinitionReadMinimal", } as const +export const $WorkflowRelationRead = { + properties: { + id: { + type: "string", + title: "Id", + }, + title: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Title", + }, + alias: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Alias", + }, + }, + type: "object", + required: ["id"], + title: "WorkflowRelationRead", +} as const + export const $WorkflowDirectoryItem = { properties: { id: { @@ -15071,6 +15105,20 @@ export const $WorkflowDirectoryItem = { }, ], }, + parents: { + anyOf: [ + { + items: { + $ref: "#/components/schemas/WorkflowRelationRead", + }, + type: "array", + }, + { + type: "null", + }, + ], + title: "Parents", + }, folder_id: { anyOf: [ { @@ -15083,6 +15131,20 @@ export const $WorkflowDirectoryItem = { ], title: "Folder Id", }, + subflows: { + anyOf: [ + { + items: { + $ref: "#/components/schemas/WorkflowRelationRead", + }, + type: "array", + }, + { + type: "null", + }, + ], + title: "Subflows", + }, type: { type: "string", const: "workflow", diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index d54642c507..5f3dc56d77 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -4790,6 +4790,12 @@ export type WorkflowDefinitionReadMinimal = { created_at: string } +export type WorkflowRelationRead = { + id: string + title?: string | null + alias?: string | null +} + export type WorkflowDirectoryItem = { id: string title: string @@ -4804,6 +4810,8 @@ export type WorkflowDirectoryItem = { error_handler?: string | null latest_definition?: WorkflowDefinitionReadMinimal | null folder_id?: string | null + parents?: Array | null + subflows?: Array | null type: "workflow" } diff --git a/frontend/src/components/dashboard/workflow-folders-table.tsx b/frontend/src/components/dashboard/workflow-folders-table.tsx index 46525ea837..56c536ca7b 100644 --- a/frontend/src/components/dashboard/workflow-folders-table.tsx +++ b/frontend/src/components/dashboard/workflow-folders-table.tsx @@ -10,8 +10,11 @@ import type { ApiError, FolderDirectoryItem, TagRead, + WorkflowRelationRead, + WorkflowDirectoryItem, WorkflowReadMinimal, } from "@/client" +import Link from "next/link" import { DeleteWorkflowAlertDialog } from "@/components/dashboard/delete-workflow-dialog" import { FolderDeleteAlertDialog } from "@/components/dashboard/folder-delete-dialog" import { FolderMoveDialog } from "@/components/dashboard/folder-move-dialog" @@ -423,6 +426,71 @@ export function WorkflowsDashboardTable({ }, enableHiding: true, }, + { + id: "Relations", + accessorFn: (row: DirectoryItem) => + row.type === "workflow" ? (row as WorkflowDirectoryItem) : undefined, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + if (row.original.type === "folder") { + return ( + + {NO_DATA} + + ) + } + + const { parents, subflows } = row.original as WorkflowDirectoryItem + + if (!(parents?.length || subflows?.length)) { + return ( + + No relations + + ) + } + + const renderRelationBadge = ( + relation: WorkflowRelationRead, + label: string + ) => ( + + + + {label} + + + {relation.alias || relation.title || relation.id} + + + + ) + + return ( +
+ {parents?.map((parent) => + renderRelationBadge(parent, "Parent") + )} + {subflows?.map((subflow) => + renderRelationBadge(subflow, "Subflow") + )} +
+ ) + }, + }, { id: "actions", cell: ({ row }) => { diff --git a/tracecat/workflow/management/folders/schemas.py b/tracecat/workflow/management/folders/schemas.py index dcfa83aca9..27863b0564 100644 --- a/tracecat/workflow/management/folders/schemas.py +++ b/tracecat/workflow/management/folders/schemas.py @@ -7,6 +7,12 @@ from tracecat.workflow.management.schemas import WorkflowReadMinimal +class WorkflowRelationRead(BaseModel): + id: str + title: str | None = None + alias: str | None = None + + class WorkflowFolderRead(BaseModel): id: uuid.UUID name: str @@ -40,6 +46,8 @@ class FolderDirectoryItem(WorkflowFolderRead): class WorkflowDirectoryItem(WorkflowReadMinimal): type: Literal["workflow"] + parents: list[WorkflowRelationRead] | None = None + subflows: list[WorkflowRelationRead] | None = None DirectoryItem = Annotated[ diff --git a/tracecat/workflow/management/folders/service.py b/tracecat/workflow/management/folders/service.py index 56d6bed359..cee7c992ff 100644 --- a/tracecat/workflow/management/folders/service.py +++ b/tracecat/workflow/management/folders/service.py @@ -4,6 +4,8 @@ from collections.abc import Sequence from typing import Literal +import yaml + import sqlalchemy as sa from sqlalchemy import and_, cast, func, or_, select from sqlalchemy.orm import selectinload @@ -23,6 +25,7 @@ DirectoryItem, FolderDirectoryItem, WorkflowDirectoryItem, + WorkflowRelationRead, ) from tracecat.workflow.management.schemas import WorkflowDefinitionReadMinimal @@ -523,6 +526,7 @@ async def get_directory_items( workflow_result = await self.session.execute(workflow_statement) workflows_with_defns = workflow_result.tuples().all() + relations = await self._get_workflow_relations() # For root path, get workflows with no folder_id if path == "/": # Get root-level folders @@ -577,6 +581,9 @@ async def get_directory_items( ) else: latest_definition = None + workflow_relations = relations.get( + workflow.id, {"parents": [], "subflows": []} + ) directory_items.append( WorkflowDirectoryItem( type="workflow", @@ -594,7 +601,72 @@ async def get_directory_items( TagRead.model_validate(tag, from_attributes=True) for tag in workflow.tags ], + parents=workflow_relations["parents"], + subflows=workflow_relations["subflows"], ) ) return directory_items + + async def _get_workflow_relations( + self, + ) -> dict[uuid.UUID, dict[str, list[WorkflowRelationRead]]]: + """Return parent/subflow relations for workflows in the workspace.""" + + workflows_stmt = select(Workflow).where( + Workflow.owner_id == self.workspace_id + ).options(selectinload(Workflow.actions)) + workflows_result = await self.session.execute(workflows_stmt) + workflows = workflows_result.scalars().unique().all() + + alias_index: dict[str, Workflow] = { + workflow.alias: workflow for workflow in workflows if workflow.alias + } + id_index: dict[uuid.UUID, Workflow] = {workflow.id: workflow for workflow in workflows} + + relation_map: dict[uuid.UUID, dict[str, list[WorkflowRelationRead]]] = { + workflow.id: {"parents": [], "subflows": []} for workflow in workflows + } + + for workflow in workflows: + parent_short_id = WorkflowUUID.new(workflow.id).short() + for action in workflow.actions: + if action.type != "core.workflow.execute": + continue + + try: + action_inputs = yaml.safe_load(action.inputs) or {} + except yaml.YAMLError: + continue + + target_alias = action_inputs.get("workflow_alias") + target_id_raw = action_inputs.get("workflow_id") + + target_workflow: Workflow | None = None + if target_alias and target_alias in alias_index: + target_workflow = alias_index[target_alias] + elif target_id_raw: + try: + target_uuid = WorkflowUUID.new(target_id_raw) + target_workflow = id_index.get(target_uuid.uuid()) + except Exception: + continue + + if not target_workflow: + continue + + target_relation = WorkflowRelationRead( + id=WorkflowUUID.new(target_workflow.id).short(), + alias=target_workflow.alias, + title=target_workflow.title, + ) + relation_map[workflow.id]["subflows"].append(target_relation) + relation_map[target_workflow.id]["parents"].append( + WorkflowRelationRead( + id=parent_short_id, + alias=workflow.alias, + title=workflow.title, + ) + ) + + return relation_map