From 14f7ea2aa2b73ee4f801b34451a7433873957eb3 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 4 Feb 2026 16:15:01 +0530 Subject: [PATCH] fix(boards): Cascade board deletion Delete board-owned tasks, agents, and activity events before\nremoving the board. Adds board edit page with gateway settings\nand edit/delete actions in the boards table.\n\nCo-Authored-By: Claude --- backend/app/api/boards.py | 84 ++++++- .../src/app/boards/[boardId]/edit/page.tsx | 233 ++++++++++++++++++ frontend/src/app/boards/[boardId]/page.tsx | 141 +---------- frontend/src/app/boards/page.tsx | 82 +++++- 4 files changed, 401 insertions(+), 139 deletions(-) create mode 100644 frontend/src/app/boards/[boardId]/edit/page.tsx diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index a10efe70..e98d6e7e 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -1,7 +1,12 @@ from __future__ import annotations -from fastapi import APIRouter, Depends -from sqlmodel import Session, select +import asyncio +import re +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import delete +from sqlmodel import Session, col, select from app.api.deps import ( ActorContext, @@ -11,11 +16,59 @@ from app.api.deps import ( ) from app.core.auth import AuthContext from app.db.session import get_session +from app.integrations.openclaw_gateway import ( + GatewayConfig, + OpenClawGatewayError, + delete_session, + ensure_session, + send_message, +) +from app.models.activity_events import ActivityEvent +from app.models.agents import Agent from app.models.boards import Board +from app.models.tasks import Task from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate router = APIRouter(prefix="/boards", tags=["boards"]) +AGENT_SESSION_PREFIX = "agent" + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or uuid4().hex + + +def _build_session_key(agent_name: str) -> str: + return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main" + + +def _board_gateway_config(board: Board) -> GatewayConfig | None: + if not board.gateway_url: + return None + return GatewayConfig(url=board.gateway_url, token=board.gateway_token) + + +async def _cleanup_agent_on_gateway(agent: Agent, board: Board, config: GatewayConfig) -> None: + if agent.openclaw_session_id: + await delete_session(agent.openclaw_session_id, config=config) + main_session = board.gateway_main_session_key or "agent:main:main" + workspace_root = board.gateway_workspace_root or "~/.openclaw/workspaces" + workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}" + cleanup_message = ( + "Cleanup request for deleted agent.\n\n" + f"Agent name: {agent.name}\n" + f"Agent id: {agent.id}\n" + f"Session key: {agent.openclaw_session_id or _build_session_key(agent.name)}\n" + f"Workspace path: {workspace_path}\n\n" + "Actions:\n" + "1) Remove the workspace directory.\n" + "2) Delete any lingering session artifacts.\n" + "Reply NO_REPLY." + ) + await ensure_session(main_session, config=config, label="Main Agent") + await send_message(cleanup_message, session_key=main_session, config=config, deliver=False) + @router.get("", response_model=list[BoardRead]) def list_boards( @@ -73,6 +126,33 @@ def delete_board( board: Board = Depends(get_board_or_404), auth: AuthContext = Depends(require_admin_auth), ) -> dict[str, bool]: + agents = list(session.exec(select(Agent).where(Agent.board_id == board.id))) + task_ids = list( + session.exec(select(Task.id).where(Task.board_id == board.id)) + ) + + config = _board_gateway_config(board) + if config: + try: + for agent in agents: + asyncio.run(_cleanup_agent_on_gateway(agent, board, config)) + except OpenClawGatewayError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Gateway cleanup failed: {exc}", + ) from exc + + if task_ids: + session.execute( + delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids)) + ) + if agents: + agent_ids = [agent.id for agent in agents] + session.execute( + delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids)) + ) + session.execute(delete(Agent).where(col(Agent.id).in_(agent_ids))) + session.execute(delete(Task).where(col(Task.board_id) == board.id)) session.delete(board) session.commit() return {"ok": True} diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx new file mode 100644 index 00000000..20b07e5d --- /dev/null +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; + +import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; + +import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; +import { DashboardShell } from "@/components/templates/DashboardShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +const apiBase = + process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || + "http://localhost:8000"; + +type Board = { + id: string; + name: string; + slug: string; + gateway_url?: string | null; + gateway_main_session_key?: string | null; + gateway_workspace_root?: string | null; +}; + +const slugify = (value: string) => + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "board"; + +export default function EditBoardPage() { + const { getToken, isSignedIn } = useAuth(); + const router = useRouter(); + const params = useParams(); + const boardIdParam = params?.boardId; + const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam; + + const [board, setBoard] = useState(null); + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [gatewayUrl, setGatewayUrl] = useState(""); + const [gatewayToken, setGatewayToken] = useState(""); + const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState(""); + const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const loadBoard = async () => { + if (!isSignedIn || !boardId) return; + setIsLoading(true); + setError(null); + try { + const token = await getToken(); + const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, { + headers: { Authorization: token ? `Bearer ${token}` : "" }, + }); + if (!response.ok) { + throw new Error("Unable to load board."); + } + const data = (await response.json()) as Board; + setBoard(data); + setName(data.name); + setSlug(data.slug); + setGatewayUrl(data.gateway_url ?? ""); + setGatewayMainSessionKey(data.gateway_main_session_key ?? ""); + setGatewayWorkspaceRoot(data.gateway_workspace_root ?? ""); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadBoard(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSignedIn, boardId]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!isSignedIn || !boardId) return; + const trimmed = name.trim(); + if (!trimmed) { + setError("Board name is required."); + return; + } + setIsLoading(true); + setError(null); + try { + const token = await getToken(); + const payload: Partial & { gateway_token?: string | null } = { + name: trimmed, + slug: slug.trim() || slugify(trimmed), + gateway_url: gatewayUrl.trim() || null, + gateway_main_session_key: gatewayMainSessionKey.trim() || null, + gateway_workspace_root: gatewayWorkspaceRoot.trim() || null, + }; + if (gatewayToken.trim()) { + payload.gateway_token = gatewayToken.trim(); + } + const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error("Unable to update board."); + } + router.push(`/boards/${boardId}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+

Sign in to edit boards.

+ + + +
+
+ + +
+
+

+ Edit board +

+

+ {board?.name ?? "Board"} +

+

+ Update the board identity and gateway connection. +

+
+
+
+ + setName(event.target.value)} + placeholder="e.g. Product ops" + disabled={isLoading} + /> +
+
+ + setSlug(event.target.value)} + placeholder="product-ops" + disabled={isLoading} + /> +
+
+ + setGatewayUrl(event.target.value)} + placeholder="ws://gateway:18789" + disabled={isLoading} + /> +
+
+ + setGatewayToken(event.target.value)} + placeholder="Leave blank to keep current token" + disabled={isLoading} + /> +
+
+ + setGatewayMainSessionKey(event.target.value)} + placeholder="agent:main:main" + disabled={isLoading} + /> +
+
+ + setGatewayWorkspaceRoot(event.target.value)} + placeholder="~/.openclaw/workspaces" + disabled={isLoading} + /> +
+ {error ? ( +
+ {error} +
+ ) : null} + +
+ +
+
+
+ ); +} diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index aad81187..39da3954 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -31,10 +31,6 @@ type Board = { id: string; name: string; slug: string; - gateway_url?: string | null; - gateway_token?: string | null; - gateway_main_session_key?: string | null; - gateway_workspace_root?: string | null; }; type Task = { @@ -74,13 +70,6 @@ export default function BoardDetailPage() { const [priority, setPriority] = useState("medium"); const [createError, setCreateError] = useState(null); const [isCreating, setIsCreating] = useState(false); - const [gatewayUrl, setGatewayUrl] = useState(""); - const [gatewayToken, setGatewayToken] = useState(""); - const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState(""); - const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState(""); - const [isSaving, setIsSaving] = useState(false); - const [saveError, setSaveError] = useState(null); - const [saveSuccess, setSaveSuccess] = useState(false); const titleLabel = useMemo( () => (board ? `${board.name} board` : "Board"), @@ -117,9 +106,6 @@ export default function BoardDetailPage() { const taskData = (await tasksResponse.json()) as Task[]; setBoard(boardData); setTasks(taskData); - setGatewayUrl(boardData.gateway_url ?? ""); - setGatewayMainSessionKey(boardData.gateway_main_session_key ?? ""); - setGatewayWorkspaceRoot(boardData.gateway_workspace_root ?? ""); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong."); } finally { @@ -179,46 +165,6 @@ export default function BoardDetailPage() { } }; - const handleSaveSettings = async () => { - if (!isSignedIn || !boardId) return; - setIsSaving(true); - setSaveError(null); - setSaveSuccess(false); - try { - const token = await getToken(); - const payload: Partial = { - gateway_url: gatewayUrl.trim() || null, - gateway_main_session_key: gatewayMainSessionKey.trim() || null, - gateway_workspace_root: gatewayWorkspaceRoot.trim() || null, - }; - if (gatewayToken.trim()) { - payload.gateway_token = gatewayToken.trim(); - } - const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: token ? `Bearer ${token}` : "", - }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - throw new Error("Unable to update board settings."); - } - const updated = (await response.json()) as Board; - setBoard(updated); - setGatewayUrl(updated.gateway_url ?? ""); - setGatewayMainSessionKey(updated.gateway_main_session_key ?? ""); - setGatewayWorkspaceRoot(updated.gateway_workspace_root ?? ""); - setGatewayToken(""); - setSaveSuccess(true); - setTimeout(() => setSaveSuccess(false), 2500); - } catch (err) { - setSaveError(err instanceof Error ? err.message : "Something went wrong."); - } finally { - setIsSaving(false); - } - }; return ( @@ -268,88 +214,11 @@ export default function BoardDetailPage() { Loading {titleLabel}… ) : ( - <> - setIsDialogOpen(true)} - isCreateDisabled={isCreating} - /> -
-
-

- Gateway settings -

-

- Connect this board to an OpenClaw gateway. -

-

- Used when provisioning agents and checking gateway status for - this board. -

-
-
-
- - setGatewayUrl(event.target.value)} - placeholder="ws://gateway:18789" - /> -
-
- - setGatewayToken(event.target.value)} - placeholder="Leave blank to keep current token" - /> -
-
- - - setGatewayMainSessionKey(event.target.value) - } - placeholder="agent:main:main" - /> -
-
- - - setGatewayWorkspaceRoot(event.target.value) - } - placeholder="~/.openclaw/workspaces" - /> -
-
- {saveError ? ( -
- {saveError} -
- ) : null} - {saveSuccess ? ( -
- Gateway settings saved. -
- ) : null} -
- -
-
- + setIsDialogOpen(true)} + isCreateDisabled={isCreating} + /> )} diff --git a/frontend/src/app/boards/page.tsx b/frontend/src/app/boards/page.tsx index f28bd5cd..0750f577 100644 --- a/frontend/src/app/boards/page.tsx +++ b/frontend/src/app/boards/page.tsx @@ -15,6 +15,14 @@ import { import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; type Board = { id: string; @@ -32,6 +40,9 @@ export default function BoardsPage() { const [boards, setBoards] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const sortedBoards = useMemo( () => [...boards].sort((a, b) => a.name.localeCompare(b.name)), @@ -66,6 +77,30 @@ export default function BoardsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSignedIn]); + const handleDelete = async () => { + if (!deleteTarget || !isSignedIn) return; + setIsDeleting(true); + setDeleteError(null); + try { + const token = await getToken(); + const response = await fetch(`${apiBase}/api/v1/boards/${deleteTarget.id}`, { + method: "DELETE", + headers: { + Authorization: token ? `Bearer ${token}` : "", + }, + }); + if (!response.ok) { + throw new Error("Unable to delete board."); + } + setBoards((prev) => prev.filter((board) => board.id !== deleteTarget.id)); + setDeleteTarget(null); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsDeleting(false); + } + }; + const columns = useMemo[]>( () => [ { @@ -83,7 +118,7 @@ export default function BoardsPage() { header: "", cell: ({ row }) => (
event.stopPropagation()} > Open + + Edit + +
), }, @@ -190,6 +238,38 @@ export default function BoardsPage() { )} + + { + if (!nextOpen) { + setDeleteTarget(null); + setDeleteError(null); + } + }} + > + + + Delete board + + This will remove {deleteTarget?.name}. This action cannot be undone. + + + {deleteError ? ( +
+ {deleteError} +
+ ) : null} + + + + +
+
); }