Skip to content

Commit

Permalink
feat: workflow import and export
Browse files Browse the repository at this point in the history
  • Loading branch information
danielgrittner committed Nov 22, 2024
1 parent 08f4f8a commit 2241734
Show file tree
Hide file tree
Showing 13 changed files with 303 additions and 19 deletions.
4 changes: 2 additions & 2 deletions admyral/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def get_workflow(self, workflow_name: str) -> Workflow | None:
Returns:
The workflow with the given name if it exists, otherwise None.
"""
result = self._get(f"{API_V1_STR}/workflows/{workflow_name}")
result = self._get(f"{API_V1_STR}/workflows/get/{workflow_name}")
return Workflow.model_validate(result) if result else None

def push_workflow(
Expand Down Expand Up @@ -139,7 +139,7 @@ def trigger_workflow(
payload: The payload to send to the workflow.
"""
response = self._post(
f"{API_V1_STR}/workflows/{workflow_name}/trigger", payload
f"{API_V1_STR}/workflows/trigger/{workflow_name}", payload
)
return WorkflowTriggerResponse.model_validate(response)

Expand Down
5 changes: 2 additions & 3 deletions admyral/compiler/yaml_workflow_compiler.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from typing import Any
import yaml
import re

Expand Down Expand Up @@ -150,5 +149,5 @@ def compile_from_yaml_workflow(yaml_workflow_str: str) -> WorkflowDAG:
return WorkflowDAG.model_validate(yaml_workflow_dict)


def decompile_workflow_to_yaml(workflow_dag: WorkflowDAG) -> dict[str, Any]:
return workflow_dag.model_dump()
def decompile_workflow_to_yaml(workflow_dag: WorkflowDAG) -> str:
return yaml.dump(workflow_dag.model_dump(mode="json"))
65 changes: 62 additions & 3 deletions admyral/server/endpoints/workflow_endpoints.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from fastapi import APIRouter, status, Body, Depends, HTTPException
from fastapi import APIRouter, status, Body, Depends, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from typing import Optional, Annotated
from uuid import uuid4
from pydantic import BaseModel
import io

from admyral.utils.collections import is_not_empty
from admyral.server.deps import get_admyral_store, get_workers_client
Expand All @@ -22,6 +24,7 @@
from admyral.compiler.yaml_workflow_compiler import (
compile_from_yaml_workflow,
validate_workflow,
decompile_workflow_to_yaml,
)


Expand Down Expand Up @@ -205,7 +208,7 @@ async def push_workflow(
)


@router.get("/{workflow_name}", status_code=status.HTTP_200_OK)
@router.get("/get/{workflow_name}", status_code=status.HTTP_200_OK)
async def get_workflow(
workflow_name: str, authenticated_user: AuthenticatedUser = Depends(authenticate)
) -> Optional[Workflow]:
Expand Down Expand Up @@ -236,7 +239,7 @@ async def list_workflows(
return await get_admyral_store().list_workflows(authenticated_user.user_id)


@router.post("/{workflow_name}/trigger", status_code=status.HTTP_201_CREATED)
@router.post("/trigger/{workflow_name}", status_code=status.HTTP_201_CREATED)
async def trigger_workflow(
workflow_name: str,
payload: Annotated[dict[str, JsonValue], Body()] = {},
Expand Down Expand Up @@ -360,3 +363,59 @@ async def delete_workflow(
await get_admyral_store().remove_workflow(
authenticated_user.user_id, workflow.workflow_id
)


@router.post("/import", status_code=status.HTTP_201_CREATED)
async def import_workflow(
file: UploadFile, authenticated_user: AuthenticatedUser = Depends(authenticate)
) -> str:
db = get_admyral_store()

yaml_str = (await file.read()).decode("utf-8")

try:
workflow_dag = compile_from_yaml_workflow(yaml_str)
await validate_workflow(authenticated_user.user_id, db, workflow_dag)
workflow_name = workflow_dag.name

workflow = Workflow(
workflow_id=str(uuid4()),
workflow_name=workflow_name,
workflow_dag=workflow_dag,
is_active=False,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
) from e

try:
await db.create_workflow(authenticated_user.user_id, workflow)
except ValueError as e:
if "already exists" in str(e):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"A workflow with the name '{workflow.workflow_name}' already exists. Workflow names must be unique.",
)
else:
raise e

return workflow.workflow_id


@router.get("/export/{workflow_id}", status_code=status.HTTP_200_OK)
async def export_workflow(
workflow_id: str, authenticated_user: AuthenticatedUser = Depends(authenticate)
) -> None:
workflow = await _fetch_workflow(authenticated_user.user_id, None, workflow_id)

workflow_dag = workflow.workflow_dag
workflow_name = workflow.workflow_name
yaml_str = decompile_workflow_to_yaml(workflow_dag)
yaml_str_bytes = yaml_str.encode("utf-8")

return StreamingResponse(
io.BytesIO(yaml_str_bytes),
media_type="text/yaml",
headers={"Content-Disposition": f'attachment; filename="{workflow_name}.yaml"'},
)
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function CreateNewWorkflowButton() {
style={{ cursor: "pointer" }}
onClick={handleCreateNewWorkflow}
>
Create New Workflow
New Workflow
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";

import { useExportWorkflow } from "@/hooks/use-export-workflow";
import { useToast } from "@/providers/toast";
import { DownloadIcon } from "@radix-ui/react-icons";
import { Button } from "@radix-ui/themes";
import { useState } from "react";

export default function ExportWorkflowButton({
workflowId,
}: {
workflowId: string;
}) {
const [isDownloading, setIsDownloading] = useState(false);
const exportWorkflow = useExportWorkflow();
const { errorToast } = useToast();

const handleExport = async () => {
try {
setIsDownloading(true);
await exportWorkflow.mutateAsync({ workflowId });
} catch (error) {
errorToast("Failed to export workflow. Please try again.");
} finally {
setIsDownloading(false);
}
};

return (
<Button
variant="outline"
style={{ cursor: "pointer" }}
onClick={handleExport}
loading={isDownloading}
>
Export <DownloadIcon />
</Button>
);
}
82 changes: 82 additions & 0 deletions web/src/components/file-upload/file-upload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client";

import { useImportWorkflow } from "@/hooks/use-import-workflow";
import { UploadIcon } from "@radix-ui/react-icons";
import { Flex, Text } from "@radix-ui/themes";
import { useCallback, useState } from "react";

interface FileUploadProps {
fileType: string;
}

export default function FileUpload({ fileType }: FileUploadProps) {
const { importWorkflow, errorMessage } = useImportWorkflow();
const [isDragging, setIsDragging] = useState(false);

const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
}, []);

const onDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
}, []);

const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);

const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
importWorkflow(file);
}
},
[importWorkflow],
);

const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
importWorkflow(file);
}
},
[importWorkflow],
);

return (
<Flex direction="column" gap="4">
<Flex
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`relative border-2 border-dashed rounded-lg p-8 text-center
${isDragging ? "border-blue-500 bg-blue-50" : "border-gray-300 hover:border-gray-400"}
transition-colors duration-200 ease-in-out`}
direction="column"
>
<input
type="file"
onChange={handleFileInput}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>

<Flex direction="column" gap="4">
<Flex justify="center" width="100%">
<UploadIcon color="gray" height={32} width={32} />
</Flex>
<Text>
<Text color="blue">Click to upload</Text> or drag and
drop {fileType}
</Text>
</Flex>
</Flex>
{errorMessage && <Text color="red">{errorMessage}</Text>}
</Flex>
);
}
29 changes: 27 additions & 2 deletions web/src/components/workflow-editor/new-workflow-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@
import { useCreateWorkflow } from "@/hooks/use-create-workflow";
import { isValidWorkflowName } from "@/lib/workflow-validation";
import { useWorkflowStore } from "@/stores/workflow-store";
import { Button, Dialog, Flex, Text, TextField } from "@radix-ui/themes";
import {
Button,
Dialog,
Flex,
Separator,
Text,
TextField,
} from "@radix-ui/themes";
import { useRouter } from "next/navigation";
import { useState } from "react";
import FileUpload from "../file-upload/file-upload";

export default function NewWorkflowModal() {
const router = useRouter();
Expand All @@ -25,7 +33,7 @@ export default function NewWorkflowModal() {
only contain letters, numbers, and spaces.
</Dialog.Description>

<Flex direction="column" width="100%">
<Flex direction="column" width="100%" gap="4">
<label>
<Text as="div" size="2" mb="1" weight="bold">
Workflow Name
Expand Down Expand Up @@ -81,6 +89,23 @@ export default function NewWorkflowModal() {
Create Workflow
</Button>
</Flex>

<Flex width="100%" justify="center" align="center" my="6">
<Flex width="45%" pr="2">
<Separator orientation="horizontal" size="4" />
</Flex>
<Text color="gray">OR</Text>
<Flex width="45%" pl="2">
<Separator orientation="horizontal" size="4" />
</Flex>
</Flex>

<Flex direction="column" width="100%" gap="4">
<Text as="div" size="4" mb="1" weight="bold">
Import Workflow
</Text>
<FileUpload fileType="workflow in YAML format" />
</Flex>
</Dialog.Content>
</Dialog.Root>
);
Expand Down
3 changes: 3 additions & 0 deletions web/src/components/workflow-editor/workflow-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { useRouter } from "next/navigation";
import { SaveWorkflowProvider } from "@/providers/save-workflow";
import NewWorkflowModal from "./new-workflow-modal";
import { WorkflowRunStatus } from "./run-history/latest-workflow-run-status";
import ExportWorkflowButton from "../export-workflow-button/export-workflow-button";

type View = "workflowBuilder" | "runHistory";

Expand Down Expand Up @@ -212,6 +213,8 @@ export default function WorkflowEditor({
</Flex>

<Flex justify="end" align="center" gap="3">
<ExportWorkflowButton workflowId={workflowId} />

<RunWorkflowButton />

<SaveWorkflowButton />
Expand Down
6 changes: 2 additions & 4 deletions web/src/hooks/use-create-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
isValidWorkflowName,
WORKFLOW_NAME_VALIDATION_ERROR_MESSAGE,
} from "@/lib/workflow-validation";
import { useToast } from "@/providers/toast";
import { useWorkflowStore } from "@/stores/workflow-store";
import { useState } from "react";
import { ApiError } from "@/lib/errors";
Expand Down Expand Up @@ -41,7 +40,6 @@ export function useCreateWorkflow() {
const createWorkflowApi = useCreateWorkflowApi();
const [isPending, setIsPending] = useState(false);
const { getWorkflow, setIsNew } = useWorkflowStore();
const { errorToast } = useToast();
const [errorMessage, setErrorMessage] = useState<string | null>(null);

const createWorkflow = async () => {
Expand All @@ -50,13 +48,13 @@ export function useCreateWorkflow() {
try {
const workflow = getWorkflow();
if (workflow.workflowName.length === 0) {
errorToast(
setErrorMessage(
"Workflow name must not be empty. Go to settings to set one.",
);
return;
}
if (!isValidWorkflowName(workflow.workflowName)) {
errorToast(WORKFLOW_NAME_VALIDATION_ERROR_MESSAGE);
setErrorMessage(WORKFLOW_NAME_VALIDATION_ERROR_MESSAGE);
return;
}
await createWorkflowApi.mutateAsync(workflow);
Expand Down
22 changes: 22 additions & 0 deletions web/src/hooks/use-export-workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useMutation } from "@tanstack/react-query";

export function useExportWorkflow() {
return useMutation({
mutationFn: async ({ workflowId }: { workflowId: string }) => {
const response = await fetch(
`/api/v1/workflows/export/${workflowId}`,
);
if (!response.ok) {
throw new Error("Failed to export workflow.");
}

const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${workflowId}.yaml`;
a.click();
window.URL.revokeObjectURL(url);
},
});
}
Loading

0 comments on commit 2241734

Please sign in to comment.