Browse Source

Add workflow groups for hierarchical folder organization

Implement folder/group support for organizing workflows:

Backend:
- Add WorkflowGroupController with full CRUD REST API
- Support nested groups via parentId hierarchy
- Path endpoint for breadcrumb navigation
- Move endpoint with cycle detection
- Force delete option for non-empty groups
- WebSocket broadcasts for real-time updates

Frontend:
- Add workflowGroups API service
- GroupBreadcrumb for navigation
- GroupCard with context menu (rename/move/delete)
- Modal components for create/rename/delete/move
- Update WorkflowsPage with directory navigation UI
- URL-based navigation with ?group= parameter
- Add moveToGroup and groupId filter to workflows API
fszontagh 6 tháng trước cách đây
mục cha
commit
a436aa4d6b

+ 1 - 0
CMakeLists.txt

@@ -187,6 +187,7 @@ add_executable(smartbotic-webserver
     src/webserver/api/credential_controller.cpp
     src/webserver/api/user_controller.cpp
     src/webserver/api/workflow_controller.cpp
+    src/webserver/api/workflow_group_controller.cpp
     src/webserver/api/execution_controller.cpp
     src/webserver/api/node_controller.cpp
     src/webserver/api/runner_controller.cpp

+ 25 - 4
src/webserver/api/workflow_controller.cpp

@@ -89,7 +89,13 @@ void WorkflowController::listWorkflows(const httplib::Request& req, httplib::Res
 
     // Parse query params
     if (req.has_param("groupId")) {
-        options.filters.push_back({"groupId", req.get_param_value("groupId")});
+        std::string group_id = req.get_param_value("groupId");
+        if (!group_id.empty()) {
+            // Filter by specific group
+            options.filters.push_back({"groupId", group_id});
+        }
+        // If groupId is empty string, we want ungrouped workflows (null or "")
+        // Don't add a filter - we'll filter in memory below
     }
 
     int page = 1;
@@ -110,12 +116,27 @@ void WorkflowController::listWorkflows(const httplib::Request& req, httplib::Res
         return;
     }
 
+    // If filtering for ungrouped workflows (empty groupId param), filter in memory
+    // to include workflows with null, empty string, or missing groupId
+    nlohmann::json workflows = result.value().documents;
+    if (req.has_param("groupId") && req.get_param_value("groupId").empty()) {
+        nlohmann::json filtered = nlohmann::json::array();
+        for (const auto& wf : workflows) {
+            // Include if groupId is null, empty string, or doesn't exist
+            if (!wf.contains("groupId") || wf["groupId"].is_null() ||
+                (wf["groupId"].is_string() && wf["groupId"].get<std::string>().empty())) {
+                filtered.push_back(wf);
+            }
+        }
+        workflows = filtered;
+    }
+
     nlohmann::json response;
-    response["workflows"] = result.value().documents;
-    response["total"] = result.value().total_count;
+    response["workflows"] = workflows;
+    response["total"] = workflows.size();  // Update total after filtering
     response["page"] = page;
     response["pageSize"] = page_size;
-    response["hasMore"] = result.value().has_more;
+    response["hasMore"] = false;  // Pagination not accurate after in-memory filter
 
     sendJson(res, response);
 }

+ 445 - 0
src/webserver/api/workflow_group_controller.cpp

@@ -0,0 +1,445 @@
+#include "workflow_group_controller.hpp"
+#include "common/uuid.hpp"
+#include "logging/logger.hpp"
+
+namespace smartbotic::webserver::api {
+
+using namespace common;
+
+WorkflowGroupController::WorkflowGroupController(storage::StorageClient& storage,
+                                                   auth::AuthMiddleware& middleware,
+                                                   WebSocketServer& ws_server)
+    : storage_(storage)
+    , middleware_(middleware)
+    , ws_server_(ws_server) {}
+
+void WorkflowGroupController::registerRoutes(httplib::Server& server) {
+    // List groups - GET /api/v1/workflow-groups?parentId=xxx
+    server.Get("/api/v1/workflow-groups", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            listGroups(req, res, ctx);
+        });
+    });
+
+    // Get single group - GET /api/v1/workflow-groups/:id
+    server.Get(R"(/api/v1/workflow-groups/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            getGroup(req, res, ctx);
+        });
+    });
+
+    // Create group - POST /api/v1/workflow-groups
+    server.Post("/api/v1/workflow-groups", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            createGroup(req, res, ctx);
+        });
+    });
+
+    // Update group - PUT /api/v1/workflow-groups/:id
+    server.Put(R"(/api/v1/workflow-groups/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            updateGroup(req, res, ctx);
+        });
+    });
+
+    // Delete group - DELETE /api/v1/workflow-groups/:id
+    server.Delete(R"(/api/v1/workflow-groups/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            deleteGroup(req, res, ctx);
+        });
+    });
+
+    // Get path to group (for breadcrumbs) - GET /api/v1/workflow-groups/:id/path
+    server.Get(R"(/api/v1/workflow-groups/([^/]+)/path)", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            getGroupPath(req, res, ctx);
+        });
+    });
+
+    // Move group to new parent - POST /api/v1/workflow-groups/:id/move
+    server.Post(R"(/api/v1/workflow-groups/([^/]+)/move)", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            moveGroup(req, res, ctx);
+        });
+    });
+}
+
+void WorkflowGroupController::listGroups(const httplib::Request& req, httplib::Response& res,
+                                          const auth::AuthContext& ctx) {
+    storage::QueryOptions options;
+
+    // Filter by parent ID - "root" means top-level groups (no parent)
+    if (req.has_param("parentId")) {
+        std::string parent_id = req.get_param_value("parentId");
+        if (parent_id == "root" || parent_id.empty()) {
+            // For root-level groups, we need to find groups with no parentId or empty parentId
+            // Storage query doesn't support null checks directly, so we use empty string
+            options.filters.push_back({"parentId", ""});
+        } else {
+            options.filters.push_back({"parentId", parent_id});
+        }
+    }
+
+    // Pagination
+    int page = 1;
+    int page_size = 100;  // Groups typically don't have many at one level
+    if (req.has_param("page")) {
+        page = std::stoi(req.get_param_value("page"));
+    }
+    if (req.has_param("pageSize")) {
+        page_size = std::stoi(req.get_param_value("pageSize"));
+    }
+    options.page = page;
+    options.page_size = page_size;
+    options.sorts.push_back({"name", true});  // Sort by name ascending
+
+    auto result = storage_.query("workflow_groups", options);
+    if (result.failed()) {
+        sendError(res, result.error().message(), 500);
+        return;
+    }
+
+    nlohmann::json response;
+    response["groups"] = result.value().documents;
+    response["total"] = result.value().total_count;
+    response["page"] = page;
+    response["pageSize"] = page_size;
+    response["hasMore"] = result.value().has_more;
+
+    sendJson(res, response);
+}
+
+void WorkflowGroupController::getGroup(const httplib::Request& req, httplib::Response& res,
+                                         const auth::AuthContext& ctx) {
+    std::string id = req.matches[1];
+
+    auto result = storage_.get("workflow_groups", id);
+    if (result.failed()) {
+        sendError(res, "Group not found", 404);
+        return;
+    }
+
+    sendJson(res, result.value());
+}
+
+void WorkflowGroupController::createGroup(const httplib::Request& req, httplib::Response& res,
+                                           const auth::AuthContext& ctx) {
+    try {
+        auto body = nlohmann::json::parse(req.body);
+
+        // Generate ID with wfg_ prefix
+        std::string id = UUID::generatePrefixed("wfg");
+
+        // Set owner
+        body["ownerId"] = ctx.user_id;
+
+        // Validate required fields
+        if (!body.contains("name") || body["name"].get<std::string>().empty()) {
+            sendError(res, "Name is required", 400);
+            return;
+        }
+
+        // Handle parentId - normalize "root" or missing to empty string
+        if (!body.contains("parentId") || body["parentId"].is_null() ||
+            body["parentId"].get<std::string>() == "root") {
+            body["parentId"] = "";
+        }
+
+        // If parentId is provided, verify it exists
+        std::string parent_id = body.value("parentId", "");
+        if (!parent_id.empty()) {
+            auto parent_result = storage_.get("workflow_groups", parent_id);
+            if (parent_result.failed()) {
+                sendError(res, "Parent group not found", 400);
+                return;
+            }
+        }
+
+        auto result = storage_.insert("workflow_groups", body, id);
+        if (result.failed()) {
+            sendError(res, result.error().message(), 500);
+            return;
+        }
+
+        // Get the created group with metadata
+        auto group = storage_.get("workflow_groups", id);
+        if (group.failed()) {
+            sendError(res, "Failed to retrieve created group", 500);
+            return;
+        }
+
+        // Broadcast to WebSocket clients
+        ws_server_.broadcast("workflow_groups.created", group.value());
+
+        LOG_INFO("Workflow group created: {} by user {}", id, ctx.user_id);
+        sendJson(res, group.value(), 201);
+    } catch (const std::exception& e) {
+        sendError(res, "Invalid request body", 400);
+    }
+}
+
+void WorkflowGroupController::updateGroup(const httplib::Request& req, httplib::Response& res,
+                                           const auth::AuthContext& ctx) {
+    try {
+        std::string id = req.matches[1];
+        auto body = nlohmann::json::parse(req.body);
+
+        // Prevent updating metadata fields
+        body.erase("id");
+        body.erase("_id");
+        body.erase("ownerId");
+        body.erase("createdAt");
+        body.erase("_createdAt");
+        body.erase("updatedAt");
+        body.erase("_updatedAt");
+        body.erase("version");
+        body.erase("_version");
+        body.erase("parentId");  // Use move endpoint to change parent
+
+        auto result = storage_.update("workflow_groups", id, body, 0, true);
+        if (result.failed()) {
+            sendError(res, result.error().message(), 404);
+            return;
+        }
+
+        // Get updated group
+        auto group = storage_.get("workflow_groups", id);
+        if (group.ok()) {
+            ws_server_.broadcast("workflow_groups.updated", group.value());
+            sendJson(res, group.value());
+        } else {
+            sendJson(res, {{"id", id}, {"version", result.value()}});
+        }
+    } catch (const std::exception& e) {
+        sendError(res, "Invalid request body", 400);
+    }
+}
+
+void WorkflowGroupController::deleteGroup(const httplib::Request& req, httplib::Response& res,
+                                           const auth::AuthContext& ctx) {
+    std::string id = req.matches[1];
+
+    // Check force parameter
+    bool force = req.has_param("force") && req.get_param_value("force") == "true";
+
+    // Check if group has children
+    if (!force && hasChildren(id)) {
+        sendError(res, "Group is not empty. Use force=true to delete anyway.", 400);
+        return;
+    }
+
+    // If force delete, move all workflows in this group to ungrouped (remove groupId)
+    if (force) {
+        // Find all workflows in this group
+        storage::QueryOptions wf_options;
+        wf_options.filters.push_back({"groupId", id});
+        wf_options.page_size = 1000;  // Get all
+
+        auto workflows_result = storage_.query("workflows", wf_options);
+        if (workflows_result.ok()) {
+            for (const auto& workflow : workflows_result.value().documents) {
+                std::string wf_id = workflow.value("_id", "");
+                if (!wf_id.empty()) {
+                    storage_.update("workflows", wf_id, {{"groupId", ""}}, 0, true);
+                }
+            }
+        }
+
+        // Find all subgroups and move them to parent or root
+        storage::QueryOptions sg_options;
+        sg_options.filters.push_back({"parentId", id});
+        sg_options.page_size = 1000;
+
+        // Get current group's parent
+        auto group_result = storage_.get("workflow_groups", id);
+        std::string new_parent_id = "";
+        if (group_result.ok()) {
+            new_parent_id = group_result.value().value("parentId", "");
+        }
+
+        auto subgroups_result = storage_.query("workflow_groups", sg_options);
+        if (subgroups_result.ok()) {
+            for (const auto& subgroup : subgroups_result.value().documents) {
+                std::string sg_id = subgroup.value("_id", "");
+                if (!sg_id.empty()) {
+                    storage_.update("workflow_groups", sg_id, {{"parentId", new_parent_id}}, 0, true);
+                }
+            }
+        }
+    }
+
+    auto result = storage_.remove("workflow_groups", id);
+    if (result.failed()) {
+        sendError(res, "Group not found", 404);
+        return;
+    }
+
+    ws_server_.broadcast("workflow_groups.deleted", {{"id", id}});
+
+    LOG_INFO("Workflow group deleted: {} by user {}", id, ctx.user_id);
+    sendJson(res, {{"success", true}});
+}
+
+void WorkflowGroupController::getGroupPath(const httplib::Request& req, httplib::Response& res,
+                                            const auth::AuthContext& ctx) {
+    std::string id = req.matches[1];
+
+    // Verify group exists
+    auto result = storage_.get("workflow_groups", id);
+    if (result.failed()) {
+        sendError(res, "Group not found", 404);
+        return;
+    }
+
+    // Build path from root to this group
+    auto path = buildGroupPath(id);
+
+    nlohmann::json response;
+    response["path"] = path;
+    sendJson(res, response);
+}
+
+void WorkflowGroupController::moveGroup(const httplib::Request& req, httplib::Response& res,
+                                         const auth::AuthContext& ctx) {
+    try {
+        std::string id = req.matches[1];
+        auto body = nlohmann::json::parse(req.body);
+
+        // Get new parent ID - can be null/empty/"root" for top-level
+        std::string new_parent_id = "";
+        if (body.contains("parentId") && !body["parentId"].is_null()) {
+            new_parent_id = body["parentId"].get<std::string>();
+            if (new_parent_id == "root") {
+                new_parent_id = "";
+            }
+        }
+
+        // Verify group exists
+        auto group_result = storage_.get("workflow_groups", id);
+        if (group_result.failed()) {
+            sendError(res, "Group not found", 404);
+            return;
+        }
+
+        // Cannot move to self
+        if (new_parent_id == id) {
+            sendError(res, "Cannot move group to itself", 400);
+            return;
+        }
+
+        // If new parent specified, verify it exists
+        if (!new_parent_id.empty()) {
+            auto parent_result = storage_.get("workflow_groups", new_parent_id);
+            if (parent_result.failed()) {
+                sendError(res, "Target parent group not found", 400);
+                return;
+            }
+        }
+
+        // Check for cycles - moving a group to one of its descendants
+        if (!new_parent_id.empty() && wouldCreateCycle(id, new_parent_id)) {
+            sendError(res, "Cannot move group to one of its descendants", 400);
+            return;
+        }
+
+        // Perform the move
+        auto result = storage_.update("workflow_groups", id, {{"parentId", new_parent_id}}, 0, true);
+        if (result.failed()) {
+            sendError(res, result.error().message(), 500);
+            return;
+        }
+
+        // Get updated group
+        auto group = storage_.get("workflow_groups", id);
+        if (group.ok()) {
+            ws_server_.broadcast("workflow_groups.moved", group.value());
+            sendJson(res, group.value());
+        } else {
+            sendJson(res, {{"id", id}, {"parentId", new_parent_id}});
+        }
+
+        LOG_INFO("Workflow group moved: {} to parent {} by user {}", id,
+                 new_parent_id.empty() ? "root" : new_parent_id, ctx.user_id);
+    } catch (const std::exception& e) {
+        sendError(res, "Invalid request body", 400);
+    }
+}
+
+std::vector<nlohmann::json> WorkflowGroupController::buildGroupPath(const std::string& group_id) {
+    std::vector<nlohmann::json> path;
+    std::string current_id = group_id;
+
+    // Build path by traversing up to root (max 50 levels to prevent infinite loops)
+    int max_depth = 50;
+    while (!current_id.empty() && max_depth-- > 0) {
+        auto result = storage_.get("workflow_groups", current_id);
+        if (result.failed()) {
+            break;
+        }
+
+        auto& group = result.value();
+        // Insert at beginning to build root-to-target order
+        path.insert(path.begin(), group);
+
+        current_id = group.value("parentId", "");
+    }
+
+    return path;
+}
+
+bool WorkflowGroupController::wouldCreateCycle(const std::string& group_id, const std::string& new_parent_id) {
+    // Check if new_parent_id is a descendant of group_id
+    std::string current_id = new_parent_id;
+    int max_depth = 50;
+
+    while (!current_id.empty() && max_depth-- > 0) {
+        if (current_id == group_id) {
+            return true;  // Found the group in the ancestry of new parent - would create cycle
+        }
+
+        auto result = storage_.get("workflow_groups", current_id);
+        if (result.failed()) {
+            break;
+        }
+
+        current_id = result.value().value("parentId", "");
+    }
+
+    return false;
+}
+
+bool WorkflowGroupController::hasChildren(const std::string& group_id) {
+    // Check for subgroups
+    storage::QueryOptions sg_options;
+    sg_options.filters.push_back({"parentId", group_id});
+    sg_options.page_size = 1;
+
+    auto subgroups = storage_.query("workflow_groups", sg_options);
+    if (subgroups.ok() && subgroups.value().total_count > 0) {
+        return true;
+    }
+
+    // Check for workflows
+    storage::QueryOptions wf_options;
+    wf_options.filters.push_back({"groupId", group_id});
+    wf_options.page_size = 1;
+
+    auto workflows = storage_.query("workflows", wf_options);
+    if (workflows.ok() && workflows.value().total_count > 0) {
+        return true;
+    }
+
+    return false;
+}
+
+void WorkflowGroupController::sendJson(httplib::Response& res, const nlohmann::json& data, int status) {
+    res.status = status;
+    res.set_content(data.dump(), "application/json");
+}
+
+void WorkflowGroupController::sendError(httplib::Response& res, const std::string& message, int status) {
+    res.status = status;
+    res.set_content(nlohmann::json{{"error", message}}.dump(), "application/json");
+}
+
+} // namespace smartbotic::webserver::api

+ 56 - 0
src/webserver/api/workflow_group_controller.hpp

@@ -0,0 +1,56 @@
+#pragma once
+
+#include <httplib.h>
+#include <nlohmann/json.hpp>
+#include "../auth/auth_middleware.hpp"
+#include "storage/storage_client.hpp"
+#include "../websocket_server.hpp"
+
+namespace smartbotic::webserver::api {
+
+class WorkflowGroupController {
+public:
+    WorkflowGroupController(storage::StorageClient& storage,
+                           auth::AuthMiddleware& middleware,
+                           WebSocketServer& ws_server);
+
+    void registerRoutes(httplib::Server& server);
+
+private:
+    // CRUD operations
+    void listGroups(const httplib::Request& req, httplib::Response& res,
+                    const auth::AuthContext& ctx);
+    void getGroup(const httplib::Request& req, httplib::Response& res,
+                  const auth::AuthContext& ctx);
+    void createGroup(const httplib::Request& req, httplib::Response& res,
+                     const auth::AuthContext& ctx);
+    void updateGroup(const httplib::Request& req, httplib::Response& res,
+                     const auth::AuthContext& ctx);
+    void deleteGroup(const httplib::Request& req, httplib::Response& res,
+                     const auth::AuthContext& ctx);
+
+    // Additional operations
+    void getGroupPath(const httplib::Request& req, httplib::Response& res,
+                      const auth::AuthContext& ctx);
+    void moveGroup(const httplib::Request& req, httplib::Response& res,
+                   const auth::AuthContext& ctx);
+
+    // Helpers
+    void sendJson(httplib::Response& res, const nlohmann::json& data, int status = 200);
+    void sendError(httplib::Response& res, const std::string& message, int status);
+
+    // Build path from root to given group ID
+    std::vector<nlohmann::json> buildGroupPath(const std::string& group_id);
+
+    // Check if moving to target would create a cycle
+    bool wouldCreateCycle(const std::string& group_id, const std::string& new_parent_id);
+
+    // Check if group has any children (subgroups or workflows)
+    bool hasChildren(const std::string& group_id);
+
+    storage::StorageClient& storage_;
+    auth::AuthMiddleware& middleware_;
+    WebSocketServer& ws_server_;
+};
+
+} // namespace smartbotic::webserver::api

+ 5 - 0
src/webserver/webserver_service.cpp

@@ -2,6 +2,7 @@
 #include "api/auth_controller.hpp"
 #include "api/user_controller.hpp"
 #include "api/workflow_controller.hpp"
+#include "api/workflow_group_controller.hpp"
 #include "api/execution_controller.hpp"
 #include "api/node_controller.hpp"
 #include "api/runner_controller.hpp"
@@ -199,6 +200,10 @@ void WebServerService::setupRoutes() {
         *scheduler_, *node_store_);
     workflow_ctrl_->registerRoutes(server);
 
+    workflow_group_ctrl_ = std::make_unique<api::WorkflowGroupController>(
+        *storage_, *auth_middleware_, *ws_server_);
+    workflow_group_ctrl_->registerRoutes(server);
+
     execution_ctrl_ = std::make_unique<api::ExecutionController>(*storage_, *auth_middleware_, *ws_server_);
     execution_ctrl_->registerRoutes(server);
 

+ 2 - 0
src/webserver/webserver_service.hpp

@@ -29,6 +29,7 @@ namespace smartbotic::webserver::api {
     class AuthController;
     class UserController;
     class WorkflowController;
+    class WorkflowGroupController;
     class ExecutionController;
     class NodeController;
     class RunnerController;
@@ -103,6 +104,7 @@ private:
     std::unique_ptr<api::AuthController> auth_ctrl_;
     std::unique_ptr<api::UserController> user_ctrl_;
     std::unique_ptr<api::WorkflowController> workflow_ctrl_;
+    std::unique_ptr<api::WorkflowGroupController> workflow_group_ctrl_;
     std::unique_ptr<api::ExecutionController> execution_ctrl_;
     std::unique_ptr<api::NodeController> node_ctrl_;
     std::unique_ptr<api::RunnerController> runner_ctrl_;

+ 94 - 0
webui/src/api/workflowGroups.ts

@@ -0,0 +1,94 @@
+import { api } from './client'
+
+export interface WorkflowGroup {
+  id: string
+  name: string
+  description?: string
+  parentId?: string
+  ownerId: string
+  createdAt: number
+  updatedAt: number
+}
+
+// Transform backend group data to frontend format
+function transformGroup(data: any): WorkflowGroup {
+  return {
+    id: data._id,
+    name: data.name,
+    description: data.description,
+    parentId: data.parentId || undefined,
+    ownerId: data.ownerId,
+    createdAt: data._createdAt,
+    updatedAt: data._updatedAt,
+  }
+}
+
+export const workflowGroupsApi = {
+  /**
+   * List workflow groups
+   * @param parentId - Filter by parent group. Use 'root' or undefined for top-level groups
+   */
+  list: async (parentId?: string) => {
+    const params: Record<string, string> = {}
+    if (parentId !== undefined) {
+      params.parentId = parentId
+    }
+    const response = await api.get('/workflow-groups', { params })
+    return {
+      ...response.data,
+      groups: (response.data.groups || []).map(transformGroup),
+    }
+  },
+
+  /**
+   * Get a single workflow group by ID
+   */
+  get: async (id: string) => {
+    const response = await api.get(`/workflow-groups/${id}`)
+    return transformGroup(response.data)
+  },
+
+  /**
+   * Create a new workflow group
+   */
+  create: async (data: { name: string; description?: string; parentId?: string }) => {
+    const response = await api.post('/workflow-groups', data)
+    return transformGroup(response.data)
+  },
+
+  /**
+   * Update a workflow group
+   */
+  update: async (id: string, data: { name?: string; description?: string }) => {
+    const response = await api.put(`/workflow-groups/${id}`, data)
+    return transformGroup(response.data)
+  },
+
+  /**
+   * Delete a workflow group
+   * @param force - If true, delete even if group is not empty (moves children to parent)
+   */
+  delete: async (id: string, force = false) => {
+    await api.delete(`/workflow-groups/${id}`, { params: { force: force.toString() } })
+  },
+
+  /**
+   * Get the path from root to a group (for breadcrumbs)
+   * Returns an array of groups ordered from root to target
+   */
+  getPath: async (id: string): Promise<WorkflowGroup[]> => {
+    const response = await api.get(`/workflow-groups/${id}/path`)
+    return (response.data.path || []).map(transformGroup)
+  },
+
+  /**
+   * Move a group to a new parent
+   * @param parentId - New parent group ID, or 'root'/undefined for top-level
+   */
+  move: async (id: string, parentId?: string) => {
+    const response = await api.post(`/workflow-groups/${id}/move`, {
+      parentId: parentId || null,
+    })
+    return transformGroup(response.data)
+  },
+}

+ 22 - 2
webui/src/api/workflows.ts

@@ -68,8 +68,18 @@ function transformWorkflow(data: any): Workflow {
 }
 
 export const workflowsApi = {
-  list: async (page = 1, pageSize = 20) => {
-    const response = await api.get('/workflows', { params: { page, pageSize } })
+  /**
+   * List workflows with optional filtering
+   * @param page - Page number (1-indexed)
+   * @param pageSize - Number of items per page
+   * @param groupId - Filter by group ID. Use 'root' or empty string for ungrouped workflows
+   */
+  list: async (page = 1, pageSize = 20, groupId?: string) => {
+    const params: Record<string, any> = { page, pageSize }
+    if (groupId !== undefined) {
+      params.groupId = groupId
+    }
+    const response = await api.get('/workflows', { params })
     return {
       ...response.data,
       workflows: (response.data.workflows || []).map(transformWorkflow),
@@ -129,6 +139,16 @@ export const workflowsApi = {
     const response = await api.post(`/workflows/${id}/deactivate`)
     return response.data
   },
+
+  /**
+   * Move a workflow to a different group
+   * @param id - Workflow ID
+   * @param groupId - Target group ID, or null/undefined to remove from group
+   */
+  moveToGroup: async (id: string, groupId?: string | null) => {
+    const response = await api.put(`/workflows/${id}`, { groupId: groupId || '' })
+    return transformWorkflow(response.data)
+  },
 }
 
 export const nodesApi = {

+ 37 - 0
webui/src/components/GroupBreadcrumb.tsx

@@ -0,0 +1,37 @@
+import { ChevronRight, Home } from 'lucide-react'
+import { WorkflowGroup } from '../api/workflowGroups'
+
+interface GroupBreadcrumbProps {
+  path: WorkflowGroup[]
+  onNavigate: (groupId: string | null) => void
+}
+
+export function GroupBreadcrumb({ path, onNavigate }: GroupBreadcrumbProps) {
+  return (
+    <nav className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400 mb-4 overflow-x-auto">
+      <button
+        onClick={() => onNavigate(null)}
+        className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded text-gray-700 dark:text-gray-300 whitespace-nowrap"
+      >
+        <Home className="w-4 h-4" />
+        <span>Home</span>
+      </button>
+
+      {path.map((group, index) => (
+        <div key={group.id} className="flex items-center">
+          <ChevronRight className="w-4 h-4 text-gray-400 dark:text-gray-500 flex-shrink-0" />
+          <button
+            onClick={() => onNavigate(group.id)}
+            className={`px-2 py-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded whitespace-nowrap ${
+              index === path.length - 1
+                ? 'text-gray-900 dark:text-gray-100 font-medium'
+                : 'text-gray-700 dark:text-gray-300'
+            }`}
+          >
+            {group.name}
+          </button>
+        </div>
+      ))}
+    </nav>
+  )
+}

+ 112 - 0
webui/src/components/GroupCard.tsx

@@ -0,0 +1,112 @@
+import { useState } from 'react'
+import { Folder, MoreVertical, Pencil, Trash2, FolderInput } from 'lucide-react'
+import { WorkflowGroup } from '../api/workflowGroups'
+import { formatDistanceToNow } from 'date-fns'
+
+interface GroupCardProps {
+  group: WorkflowGroup
+  onClick: () => void
+  onRename: () => void
+  onMove: () => void
+  onDelete: () => void
+}
+
+// Helper to safely format timestamps
+function safeFormatDistanceToNow(timestamp: number): string {
+  if (!timestamp || timestamp < 0 || timestamp > 8640000000000000) {
+    return 'unknown'
+  }
+  try {
+    return formatDistanceToNow(timestamp, { addSuffix: true })
+  } catch {
+    return 'unknown'
+  }
+}
+
+export function GroupCard({ group, onClick, onRename, onMove, onDelete }: GroupCardProps) {
+  const [showMenu, setShowMenu] = useState(false)
+
+  return (
+    <div className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 p-4 hover:shadow-md transition-shadow">
+      <div className="flex items-start justify-between mb-3">
+        <button
+          onClick={onClick}
+          className="flex-1 flex items-start gap-3 text-left"
+        >
+          <div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
+            <Folder className="w-5 h-5 text-amber-600 dark:text-amber-400" />
+          </div>
+          <div className="flex-1 min-w-0">
+            <h3 className="font-semibold text-gray-900 dark:text-gray-100 hover:text-primary-600 dark:hover:text-primary-400 truncate">
+              {group.name}
+            </h3>
+            {group.description && (
+              <p className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
+                {group.description}
+              </p>
+            )}
+          </div>
+        </button>
+
+        <div className="relative flex-shrink-0">
+          <button
+            onClick={(e) => {
+              e.stopPropagation()
+              setShowMenu(!showMenu)
+            }}
+            className="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 rounded"
+          >
+            <MoreVertical className="w-5 h-5" />
+          </button>
+
+          {showMenu && (
+            <>
+              <div
+                className="fixed inset-0 z-10"
+                onClick={() => setShowMenu(false)}
+              />
+              <div className="absolute right-0 mt-1 w-40 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-gray-200 dark:border-slate-700 z-20">
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation()
+                    onRename()
+                    setShowMenu(false)
+                  }}
+                  className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2"
+                >
+                  <Pencil className="w-4 h-4" /> Rename
+                </button>
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation()
+                    onMove()
+                    setShowMenu(false)
+                  }}
+                  className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2"
+                >
+                  <FolderInput className="w-4 h-4" /> Move
+                </button>
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation()
+                    onDelete()
+                    setShowMenu(false)
+                  }}
+                  className="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 flex items-center gap-2"
+                >
+                  <Trash2 className="w-4 h-4" /> Delete
+                </button>
+              </div>
+            </>
+          )}
+        </div>
+      </div>
+
+      <div className="flex items-center justify-end text-sm">
+        <span className="text-gray-400 dark:text-gray-500">
+          {safeFormatDistanceToNow(group.updatedAt)}
+        </span>
+      </div>
+    </div>
+  )
+}

+ 485 - 0
webui/src/components/GroupModals.tsx

@@ -0,0 +1,485 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { X, AlertTriangle, Folder, ChevronRight, Home } from 'lucide-react'
+import { WorkflowGroup, workflowGroupsApi } from '../api/workflowGroups'
+
+// Create Group Modal
+interface CreateGroupModalProps {
+  onClose: () => void
+  onCreate: (name: string, description: string) => void
+  isPending?: boolean
+}
+
+export function CreateGroupModal({ onClose, onCreate, isPending }: CreateGroupModalProps) {
+  const [name, setName] = useState('')
+  const [description, setDescription] = useState('')
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault()
+    if (name.trim()) {
+      onCreate(name.trim(), description.trim())
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+      <div className="bg-white dark:bg-slate-800 rounded-xl p-6 w-full max-w-md">
+        <div className="flex items-center justify-between mb-4">
+          <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
+            Create New Folder
+          </h2>
+          <button
+            onClick={onClose}
+            className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+        <form onSubmit={handleSubmit}>
+          <div className="space-y-4">
+            <div>
+              <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                Name
+              </label>
+              <input
+                type="text"
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+                placeholder="Folder name"
+                className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                autoFocus
+              />
+            </div>
+            <div>
+              <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                Description (optional)
+              </label>
+              <textarea
+                value={description}
+                onChange={(e) => setDescription(e.target.value)}
+                placeholder="Brief description..."
+                rows={2}
+                className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none"
+              />
+            </div>
+          </div>
+          <div className="flex justify-end gap-3 mt-6">
+            <button
+              type="button"
+              onClick={onClose}
+              className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
+            >
+              Cancel
+            </button>
+            <button
+              type="submit"
+              disabled={!name.trim() || isPending}
+              className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
+            >
+              {isPending ? 'Creating...' : 'Create'}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
+// Rename Group Modal
+interface RenameGroupModalProps {
+  group: WorkflowGroup
+  onClose: () => void
+  onRename: (name: string, description: string) => void
+  isPending?: boolean
+}
+
+export function RenameGroupModal({ group, onClose, onRename, isPending }: RenameGroupModalProps) {
+  const [name, setName] = useState(group.name)
+  const [description, setDescription] = useState(group.description || '')
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault()
+    if (name.trim()) {
+      onRename(name.trim(), description.trim())
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+      <div className="bg-white dark:bg-slate-800 rounded-xl p-6 w-full max-w-md">
+        <div className="flex items-center justify-between mb-4">
+          <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
+            Rename Folder
+          </h2>
+          <button
+            onClick={onClose}
+            className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+        <form onSubmit={handleSubmit}>
+          <div className="space-y-4">
+            <div>
+              <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                Name
+              </label>
+              <input
+                type="text"
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+                placeholder="Folder name"
+                className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                autoFocus
+              />
+            </div>
+            <div>
+              <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                Description (optional)
+              </label>
+              <textarea
+                value={description}
+                onChange={(e) => setDescription(e.target.value)}
+                placeholder="Brief description..."
+                rows={2}
+                className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none"
+              />
+            </div>
+          </div>
+          <div className="flex justify-end gap-3 mt-6">
+            <button
+              type="button"
+              onClick={onClose}
+              className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
+            >
+              Cancel
+            </button>
+            <button
+              type="submit"
+              disabled={!name.trim() || isPending}
+              className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
+            >
+              {isPending ? 'Saving...' : 'Save'}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
+// Delete Group Modal
+interface DeleteGroupModalProps {
+  group: WorkflowGroup
+  hasChildren?: boolean
+  onClose: () => void
+  onDelete: (force: boolean) => void
+  isPending?: boolean
+}
+
+export function DeleteGroupModal({ group, hasChildren, onClose, onDelete, isPending }: DeleteGroupModalProps) {
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+      <div className="bg-white dark:bg-slate-800 rounded-xl w-full max-w-md p-6">
+        <div className="flex items-center justify-between mb-4">
+          <h2 className="text-lg font-semibold flex items-center gap-2 text-red-600 dark:text-red-400">
+            <Folder className="w-5 h-5" />
+            Delete Folder
+          </h2>
+          <button
+            onClick={onClose}
+            className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+        <p className="text-gray-600 dark:text-gray-400 mb-2">
+          Are you sure you want to delete "<span className="font-medium text-gray-900 dark:text-gray-100">{group.name}</span>"?
+        </p>
+        {hasChildren && (
+          <p className="text-sm text-amber-600 dark:text-amber-400 mb-4 flex items-start gap-2">
+            <AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
+            <span>This folder is not empty. Deleting it will move all items to the parent folder.</span>
+          </p>
+        )}
+        <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
+          This action cannot be undone.
+        </p>
+        <div className="flex justify-end gap-2">
+          <button
+            onClick={onClose}
+            className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
+          >
+            Cancel
+          </button>
+          <button
+            onClick={() => onDelete(hasChildren || false)}
+            disabled={isPending}
+            className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
+          >
+            {isPending ? 'Deleting...' : 'Delete'}
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+// Move Item Modal (for moving groups or workflows)
+interface MoveItemModalProps {
+  itemType: 'group' | 'workflow'
+  itemName: string
+  currentParentId: string | null
+  excludeIds?: string[]  // IDs to exclude from selection (e.g., the item being moved and its children)
+  onClose: () => void
+  onMove: (targetGroupId: string | null) => void
+  isPending?: boolean
+}
+
+export function MoveItemModal({
+  itemType,
+  itemName,
+  currentParentId,
+  excludeIds = [],
+  onClose,
+  onMove,
+  isPending,
+}: MoveItemModalProps) {
+  const [selectedGroupId, setSelectedGroupId] = useState<string | null>(currentParentId)
+  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
+
+  // Fetch root-level groups
+  const { data: rootGroupsData } = useQuery({
+    queryKey: ['workflow-groups', 'root'],
+    queryFn: () => workflowGroupsApi.list('root'),
+  })
+
+  const rootGroups = (rootGroupsData?.groups || []).filter((g: WorkflowGroup) => !excludeIds.includes(g.id))
+
+  // Fetch children for expanded groups
+  const fetchChildren = useCallback(async (parentId: string): Promise<WorkflowGroup[]> => {
+    const result = await workflowGroupsApi.list(parentId)
+    return (result.groups || []).filter((g: WorkflowGroup) => !excludeIds.includes(g.id))
+  }, [excludeIds])
+
+  const toggleExpand = (groupId: string) => {
+    setExpandedGroups(prev => {
+      const next = new Set(prev)
+      if (next.has(groupId)) {
+        next.delete(groupId)
+      } else {
+        next.add(groupId)
+      }
+      return next
+    })
+  }
+
+  const handleSelect = (groupId: string | null) => {
+    setSelectedGroupId(groupId)
+  }
+
+  const handleMove = () => {
+    onMove(selectedGroupId)
+  }
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+      <div className="bg-white dark:bg-slate-800 rounded-xl p-6 w-full max-w-md max-h-[80vh] flex flex-col">
+        <div className="flex items-center justify-between mb-4">
+          <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
+            Move {itemType === 'group' ? 'Folder' : 'Workflow'}
+          </h2>
+          <button
+            onClick={onClose}
+            className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
+          Select a destination folder for "<span className="font-medium">{itemName}</span>"
+        </p>
+
+        <div className="flex-1 overflow-auto border border-gray-200 dark:border-slate-700 rounded-lg min-h-[200px]">
+          {/* Root option */}
+          <button
+            onClick={() => handleSelect(null)}
+            className={`w-full px-4 py-2 text-left flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-700 ${
+              selectedGroupId === null
+                ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
+                : 'text-gray-700 dark:text-gray-300'
+            }`}
+          >
+            <Home className="w-4 h-4" />
+            <span className="font-medium">Home (Root)</span>
+          </button>
+
+          {/* Groups tree */}
+          <GroupTreeNode
+            groups={rootGroups}
+            level={0}
+            selectedGroupId={selectedGroupId}
+            expandedGroups={expandedGroups}
+            excludeIds={excludeIds}
+            onSelect={handleSelect}
+            onToggleExpand={toggleExpand}
+            fetchChildren={fetchChildren}
+          />
+        </div>
+
+        <div className="flex justify-end gap-3 mt-6">
+          <button
+            onClick={onClose}
+            className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
+          >
+            Cancel
+          </button>
+          <button
+            onClick={handleMove}
+            disabled={isPending || selectedGroupId === currentParentId}
+            className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
+          >
+            {isPending ? 'Moving...' : 'Move Here'}
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+// Helper component for rendering tree nodes
+interface GroupTreeNodeProps {
+  groups: WorkflowGroup[]
+  level: number
+  selectedGroupId: string | null
+  expandedGroups: Set<string>
+  excludeIds: string[]
+  onSelect: (groupId: string) => void
+  onToggleExpand: (groupId: string) => void
+  fetchChildren: (parentId: string) => Promise<WorkflowGroup[]>
+}
+
+function GroupTreeNode({
+  groups,
+  level,
+  selectedGroupId,
+  expandedGroups,
+  excludeIds,
+  onSelect,
+  onToggleExpand,
+  fetchChildren,
+}: GroupTreeNodeProps) {
+  return (
+    <>
+      {groups.map(group => (
+        <GroupTreeItem
+          key={group.id}
+          group={group}
+          level={level}
+          isSelected={selectedGroupId === group.id}
+          isExpanded={expandedGroups.has(group.id)}
+          excludeIds={excludeIds}
+          onSelect={onSelect}
+          onToggleExpand={onToggleExpand}
+          selectedGroupId={selectedGroupId}
+          expandedGroups={expandedGroups}
+          fetchChildren={fetchChildren}
+        />
+      ))}
+    </>
+  )
+}
+
+interface GroupTreeItemProps {
+  group: WorkflowGroup
+  level: number
+  isSelected: boolean
+  isExpanded: boolean
+  excludeIds: string[]
+  onSelect: (groupId: string) => void
+  onToggleExpand: (groupId: string) => void
+  selectedGroupId: string | null
+  expandedGroups: Set<string>
+  fetchChildren: (parentId: string) => Promise<WorkflowGroup[]>
+}
+
+function GroupTreeItem({
+  group,
+  level,
+  isSelected,
+  isExpanded,
+  excludeIds,
+  onSelect,
+  onToggleExpand,
+  selectedGroupId,
+  expandedGroups,
+  fetchChildren,
+}: GroupTreeItemProps) {
+  const [children, setChildren] = useState<WorkflowGroup[]>([])
+  const [isLoading, setIsLoading] = useState(false)
+
+  useEffect(() => {
+    if (isExpanded && children.length === 0) {
+      setIsLoading(true)
+      fetchChildren(group.id).then(data => {
+        setChildren(data)
+        setIsLoading(false)
+      })
+    }
+  }, [isExpanded, group.id, fetchChildren, children.length])
+
+  return (
+    <>
+      <div
+        className={`w-full flex items-center hover:bg-gray-50 dark:hover:bg-slate-700 ${
+          isSelected
+            ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
+            : 'text-gray-700 dark:text-gray-300'
+        }`}
+        style={{ paddingLeft: `${level * 16 + 8}px` }}
+      >
+        <button
+          onClick={(e) => {
+            e.stopPropagation()
+            onToggleExpand(group.id)
+          }}
+          className="p-1 hover:bg-gray-200 dark:hover:bg-slate-600 rounded"
+        >
+          <ChevronRight
+            className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
+          />
+        </button>
+        <button
+          onClick={() => onSelect(group.id)}
+          className="flex-1 px-2 py-2 text-left flex items-center gap-2"
+        >
+          <Folder className="w-4 h-4 text-amber-500" />
+          <span>{group.name}</span>
+        </button>
+      </div>
+
+      {isExpanded && (
+        isLoading ? (
+          <div
+            className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400"
+            style={{ paddingLeft: `${(level + 1) * 16 + 24}px` }}
+          >
+            Loading...
+          </div>
+        ) : children.length > 0 ? (
+          <GroupTreeNode
+            groups={children}
+            level={level + 1}
+            selectedGroupId={selectedGroupId}
+            expandedGroups={expandedGroups}
+            excludeIds={excludeIds}
+            onSelect={onSelect}
+            onToggleExpand={onToggleExpand}
+            fetchChildren={fetchChildren}
+          />
+        ) : null
+      )}
+    </>
+  )
+}

+ 6 - 2
webui/src/pages/WorkflowEditorPage.tsx

@@ -1,4 +1,4 @@
-import { useParams, useNavigate } from 'react-router-dom'
+import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 import dagre from 'dagre'
 import { workflowsApi, nodesApi, Workflow, NodeDefinition, NodeOutput } from '../api/workflows'
@@ -169,7 +169,11 @@ const edgeTypes = {
 function WorkflowEditorInner() {
   const { id } = useParams<{ id: string }>()
   const navigate = useNavigate()
+  const [searchParams] = useSearchParams()
   const queryClient = useQueryClient()
+
+  // Get the group context for back navigation
+  const fromGroupId = searchParams.get('from')
   const reactFlowInstance = useReactFlow()
 
   const [nodes, setNodes, onNodesChange] = useNodesState([])
@@ -2005,7 +2009,7 @@ function WorkflowEditorInner() {
         showExecutionPanel={showExecutionPanel || isViewingExecution || !!pinnedExecution}
         hasExecutionData={!!(effectiveExecutionState.executionId || Object.keys(effectiveExecutionState.nodeStates).length > 0)}
         triggers={triggerNodes}
-        onNavigateBack={() => navigate('/workflows')}
+        onNavigateBack={() => navigate(fromGroupId ? `/workflows?group=${encodeURIComponent(fromGroupId)}` : '/workflows')}
         onSave={handleSave}
         onExecute={() => executeWorkflow()}
         onExecuteTrigger={executeTrigger}

+ 291 - 51
webui/src/pages/WorkflowsPage.tsx

@@ -1,9 +1,18 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import { Link, useNavigate } from 'react-router-dom'
+import { Link, useNavigate, useSearchParams } from 'react-router-dom'
 import { workflowsApi, Workflow } from '../api/workflows'
-import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X } from 'lucide-react'
+import { workflowGroupsApi, WorkflowGroup } from '../api/workflowGroups'
+import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X, FolderPlus, FolderInput } from 'lucide-react'
 import { useState } from 'react'
 import { formatDistanceToNow } from 'date-fns'
+import { GroupBreadcrumb } from '../components/GroupBreadcrumb'
+import { GroupCard } from '../components/GroupCard'
+import {
+  CreateGroupModal,
+  RenameGroupModal,
+  DeleteGroupModal,
+  MoveItemModal,
+} from '../components/GroupModals'
 
 // Helper to safely format timestamps that might be invalid or too large
 function safeFormatDistanceToNow(timestamp: number): string {
@@ -21,15 +30,97 @@ function safeFormatDistanceToNow(timestamp: number): string {
 export default function WorkflowsPage() {
   const queryClient = useQueryClient()
   const navigate = useNavigate()
-  const [showNewModal, setShowNewModal] = useState(false)
+  const [searchParams, setSearchParams] = useSearchParams()
+
+  // Current group from URL
+  const currentGroupId = searchParams.get('group') || null
+
+  // Modal states
+  const [showNewWorkflowModal, setShowNewWorkflowModal] = useState(false)
+  const [showNewGroupModal, setShowNewGroupModal] = useState(false)
   const [workflowToDelete, setWorkflowToDelete] = useState<Workflow | null>(null)
+  const [groupToRename, setGroupToRename] = useState<WorkflowGroup | null>(null)
+  const [groupToDelete, setGroupToDelete] = useState<WorkflowGroup | null>(null)
+  const [groupToMove, setGroupToMove] = useState<WorkflowGroup | null>(null)
+  const [workflowToMove, setWorkflowToMove] = useState<Workflow | null>(null)
+
+  // Fetch current path (breadcrumbs)
+  const { data: pathData } = useQuery({
+    queryKey: ['workflow-groups', currentGroupId, 'path'],
+    queryFn: () => workflowGroupsApi.getPath(currentGroupId!),
+    enabled: !!currentGroupId,
+  })
+  const currentPath = pathData || []
+
+  // Fetch groups in current location
+  const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
+    queryKey: ['workflow-groups', currentGroupId || 'root'],
+    queryFn: () => workflowGroupsApi.list(currentGroupId || 'root'),
+  })
+  const groups: WorkflowGroup[] = groupsData?.groups || []
+
+  // Fetch workflows in current location
+  const { data: workflowsData, isLoading: isLoadingWorkflows } = useQuery({
+    queryKey: ['workflows', currentGroupId || ''],
+    queryFn: () => workflowsApi.list(1, 100, currentGroupId || ''),
+  })
+  const workflows: Workflow[] = workflowsData?.workflows || []
+
+  const isLoading = isLoadingGroups || isLoadingWorkflows
+
+  // Navigate to a group
+  const navigateToGroup = (groupId: string | null) => {
+    if (groupId) {
+      setSearchParams({ group: groupId })
+    } else {
+      setSearchParams({})
+    }
+  }
+
+  // Group mutations
+  const createGroupMutation = useMutation({
+    mutationFn: ({ name, description }: { name: string; description: string }) =>
+      workflowGroupsApi.create({
+        name,
+        description: description || undefined,
+        parentId: currentGroupId || undefined,
+      }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['workflow-groups'] })
+      setShowNewGroupModal(false)
+    },
+  })
+
+  const renameGroupMutation = useMutation({
+    mutationFn: ({ id, name, description }: { id: string; name: string; description: string }) =>
+      workflowGroupsApi.update(id, { name, description: description || undefined }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['workflow-groups'] })
+      setGroupToRename(null)
+    },
+  })
+
+  const deleteGroupMutation = useMutation({
+    mutationFn: ({ id, force }: { id: string; force: boolean }) =>
+      workflowGroupsApi.delete(id, force),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['workflow-groups'] })
+      queryClient.invalidateQueries({ queryKey: ['workflows'] })
+      setGroupToDelete(null)
+    },
+  })
 
-  const { data, isLoading } = useQuery({
-    queryKey: ['workflows'],
-    queryFn: () => workflowsApi.list(),
+  const moveGroupMutation = useMutation({
+    mutationFn: ({ id, parentId }: { id: string; parentId: string | null }) =>
+      workflowGroupsApi.move(id, parentId || undefined),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['workflow-groups'] })
+      setGroupToMove(null)
+    },
   })
 
-  const createMutation = useMutation({
+  // Workflow mutations
+  const createWorkflowMutation = useMutation({
     mutationFn: workflowsApi.create,
     onSuccess: (workflow) => {
       queryClient.invalidateQueries({ queryKey: ['workflows'] })
@@ -37,7 +128,7 @@ export default function WorkflowsPage() {
     },
   })
 
-  const deleteMutation = useMutation({
+  const deleteWorkflowMutation = useMutation({
     mutationFn: workflowsApi.delete,
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['workflows'] })
@@ -53,77 +144,208 @@ export default function WorkflowsPage() {
     },
   })
 
-  const handleCreate = (name: string) => {
-    createMutation.mutate({
+  const moveWorkflowMutation = useMutation({
+    mutationFn: ({ id, groupId }: { id: string; groupId: string | null }) =>
+      workflowsApi.moveToGroup(id, groupId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['workflows'] })
+      setWorkflowToMove(null)
+    },
+  })
+
+  // Handlers
+  const handleCreateWorkflow = (name: string) => {
+    createWorkflowMutation.mutate({
       name,
+      groupId: currentGroupId || undefined,
       nodes: [],
       connections: [],
       settings: {},
     })
-    setShowNewModal(false)
+    setShowNewWorkflowModal(false)
   }
 
-  const handleDeleteConfirm = () => {
+  const handleDeleteWorkflowConfirm = () => {
     if (workflowToDelete?.id) {
-      deleteMutation.mutate(workflowToDelete.id)
+      deleteWorkflowMutation.mutate(workflowToDelete.id)
     }
   }
 
-  const workflows: Workflow[] = data?.workflows || []
+  // Get current group name for title
+  const currentGroup = currentPath.length > 0 ? currentPath[currentPath.length - 1] : null
+  const pageTitle = currentGroup?.name || 'Workflows'
 
   return (
     <div className="h-full overflow-auto p-6">
+      {/* Breadcrumb navigation */}
+      {currentGroupId && (
+        <GroupBreadcrumb path={currentPath} onNavigate={navigateToGroup} />
+      )}
+
+      {/* Header */}
       <div className="flex items-center justify-between mb-6">
-        <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Workflows</h1>
-        <button
-          onClick={() => setShowNewModal(true)}
-          className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
-        >
-          <Plus className="w-5 h-5" />
-          New Workflow
-        </button>
+        <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{pageTitle}</h1>
+        <div className="flex items-center gap-2">
+          <button
+            onClick={() => setShowNewGroupModal(true)}
+            className="flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700"
+          >
+            <FolderPlus className="w-5 h-5" />
+            New Folder
+          </button>
+          <button
+            onClick={() => setShowNewWorkflowModal(true)}
+            className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
+          >
+            <Plus className="w-5 h-5" />
+            New Workflow
+          </button>
+        </div>
       </div>
 
       {isLoading ? (
         <div className="flex items-center justify-center py-12">
           <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
         </div>
-      ) : workflows.length === 0 ? (
+      ) : groups.length === 0 && workflows.length === 0 ? (
         <div className="text-center py-12 bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700">
-          <p className="text-gray-500 dark:text-gray-400 mb-4">No workflows yet</p>
-          <button
-            onClick={() => setShowNewModal(true)}
-            className="text-primary-600 dark:text-primary-400 hover:underline"
-          >
-            Create your first workflow
-          </button>
+          <p className="text-gray-500 dark:text-gray-400 mb-4">
+            {currentGroupId ? 'This folder is empty' : 'No workflows yet'}
+          </p>
+          <div className="flex items-center justify-center gap-4">
+            <button
+              onClick={() => setShowNewGroupModal(true)}
+              className="text-primary-600 dark:text-primary-400 hover:underline"
+            >
+              Create a folder
+            </button>
+            <span className="text-gray-400">or</span>
+            <button
+              onClick={() => setShowNewWorkflowModal(true)}
+              className="text-primary-600 dark:text-primary-400 hover:underline"
+            >
+              Create a workflow
+            </button>
+          </div>
         </div>
       ) : (
-        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
-          {workflows.map((workflow) => (
-            <WorkflowCard
-              key={workflow.id || Math.random()}
-              workflow={workflow}
-              onToggleActive={() => {
-                if (workflow.id) {
-                  toggleActiveMutation.mutate({ id: workflow.id, active: workflow.active })
-                }
-              }}
-              onDelete={() => setWorkflowToDelete(workflow)}
-            />
-          ))}
+        <div className="space-y-6">
+          {/* Folders section */}
+          {groups.length > 0 && (
+            <div>
+              <h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider">
+                Folders
+              </h2>
+              <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+                {groups.map((group) => (
+                  <GroupCard
+                    key={group.id}
+                    group={group}
+                    onClick={() => navigateToGroup(group.id)}
+                    onRename={() => setGroupToRename(group)}
+                    onMove={() => setGroupToMove(group)}
+                    onDelete={() => setGroupToDelete(group)}
+                  />
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Workflows section */}
+          {workflows.length > 0 && (
+            <div>
+              {groups.length > 0 && (
+                <h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider">
+                  Workflows
+                </h2>
+              )}
+              <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+                {workflows.map((workflow) => (
+                  <WorkflowCard
+                    key={workflow.id || Math.random()}
+                    workflow={workflow}
+                    groupId={currentGroupId}
+                    onToggleActive={() => {
+                      if (workflow.id) {
+                        toggleActiveMutation.mutate({ id: workflow.id, active: workflow.active })
+                      }
+                    }}
+                    onMove={() => setWorkflowToMove(workflow)}
+                    onDelete={() => setWorkflowToDelete(workflow)}
+                  />
+                ))}
+              </div>
+            </div>
+          )}
         </div>
       )}
 
       {/* New workflow modal */}
-      {showNewModal && (
+      {showNewWorkflowModal && (
         <NewWorkflowModal
-          onClose={() => setShowNewModal(false)}
-          onCreate={handleCreate}
+          onClose={() => setShowNewWorkflowModal(false)}
+          onCreate={handleCreateWorkflow}
+        />
+      )}
+
+      {/* New group modal */}
+      {showNewGroupModal && (
+        <CreateGroupModal
+          onClose={() => setShowNewGroupModal(false)}
+          onCreate={(name, description) => createGroupMutation.mutate({ name, description })}
+          isPending={createGroupMutation.isPending}
         />
       )}
 
-      {/* Delete confirmation modal */}
+      {/* Rename group modal */}
+      {groupToRename && (
+        <RenameGroupModal
+          group={groupToRename}
+          onClose={() => setGroupToRename(null)}
+          onRename={(name, description) =>
+            renameGroupMutation.mutate({ id: groupToRename.id, name, description })
+          }
+          isPending={renameGroupMutation.isPending}
+        />
+      )}
+
+      {/* Delete group modal */}
+      {groupToDelete && (
+        <DeleteGroupModal
+          group={groupToDelete}
+          hasChildren={true} // Could check this via API but simpler to assume true
+          onClose={() => setGroupToDelete(null)}
+          onDelete={(force) => deleteGroupMutation.mutate({ id: groupToDelete.id, force })}
+          isPending={deleteGroupMutation.isPending}
+        />
+      )}
+
+      {/* Move group modal */}
+      {groupToMove && (
+        <MoveItemModal
+          itemType="group"
+          itemName={groupToMove.name}
+          currentParentId={groupToMove.parentId || null}
+          excludeIds={[groupToMove.id]}
+          onClose={() => setGroupToMove(null)}
+          onMove={(parentId) => moveGroupMutation.mutate({ id: groupToMove.id, parentId })}
+          isPending={moveGroupMutation.isPending}
+        />
+      )}
+
+      {/* Move workflow modal */}
+      {workflowToMove && (
+        <MoveItemModal
+          itemType="workflow"
+          itemName={workflowToMove.name}
+          currentParentId={workflowToMove.groupId || null}
+          onClose={() => setWorkflowToMove(null)}
+          onMove={(groupId) => moveWorkflowMutation.mutate({ id: workflowToMove.id, groupId })}
+          isPending={moveWorkflowMutation.isPending}
+        />
+      )}
+
+      {/* Delete workflow confirmation modal */}
       {workflowToDelete && (
         <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
           <div className="bg-white dark:bg-slate-800 rounded-xl w-full max-w-md p-6">
@@ -154,11 +376,11 @@ export default function WorkflowsPage() {
                 Cancel
               </button>
               <button
-                onClick={handleDeleteConfirm}
-                disabled={deleteMutation.isPending}
+                onClick={handleDeleteWorkflowConfirm}
+                disabled={deleteWorkflowMutation.isPending}
                 className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
               >
-                {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
+                {deleteWorkflowMutation.isPending ? 'Deleting...' : 'Delete'}
               </button>
             </div>
           </div>
@@ -170,19 +392,28 @@ export default function WorkflowsPage() {
 
 function WorkflowCard({
   workflow,
+  groupId,
   onToggleActive,
+  onMove,
   onDelete,
 }: {
   workflow: Workflow
+  groupId: string | null
   onToggleActive: () => void
+  onMove: () => void
   onDelete: () => void
 }) {
   const [showMenu, setShowMenu] = useState(false)
 
+  // Build the link with group context for back navigation
+  const workflowLink = groupId
+    ? `/workflows/${workflow.id}?from=${encodeURIComponent(groupId)}`
+    : `/workflows/${workflow.id}`
+
   return (
     <div className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 p-4 hover:shadow-md transition-shadow">
       <div className="flex items-start justify-between mb-3">
-        <Link to={`/workflows/${workflow.id}`} className="flex-1">
+        <Link to={workflowLink} className="flex-1">
           <h3 className="font-semibold text-gray-900 dark:text-gray-100 hover:text-primary-600 dark:hover:text-primary-400">
             {workflow.name}
           </h3>
@@ -223,6 +454,15 @@ function WorkflowCard({
                     </>
                   )}
                 </button>
+                <button
+                  onClick={() => {
+                    onMove()
+                    setShowMenu(false)
+                  }}
+                  className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2"
+                >
+                  <FolderInput className="w-4 h-4" /> Move to Folder
+                </button>
                 <button
                   onClick={() => {
                     onDelete()