From 8452dc110ebf48651e522a3ccbe50ad228f29344 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 4 Feb 2026 23:43:40 +0530 Subject: [PATCH] feat(dashboard): Implement system health check and enhance UI for agent management --- backend/app/api/gateways.py | 29 ++ frontend/next-env.d.ts | 2 +- frontend/src/app/agents/page.tsx | 241 +++++++-------- .../src/app/boards/[boardId]/edit/page.tsx | 41 ++- frontend/src/app/boards/new/page.tsx | 37 ++- frontend/src/app/boards/page.tsx | 131 ++++---- frontend/src/app/dashboard/page.tsx | 57 +--- .../app/gateways/[gatewayId]/edit/page.tsx | 94 ++++-- frontend/src/app/gateways/new/page.tsx | 94 ++++-- frontend/src/app/gateways/page.tsx | 283 ++++++++++-------- frontend/src/app/layout.tsx | 4 +- .../components/organisms/DashboardSidebar.tsx | 49 ++- .../src/components/organisms/TaskBoard.tsx | 3 + .../components/providers/QueryProvider.tsx | 27 ++ frontend/src/lib/api-query.ts | 83 +++++ 15 files changed, 727 insertions(+), 448 deletions(-) create mode 100644 frontend/src/components/providers/QueryProvider.tsx create mode 100644 frontend/src/lib/api-query.ts diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py index 218bd83a..904dfb15 100644 --- a/backend/app/api/gateways.py +++ b/backend/app/api/gateways.py @@ -223,6 +223,13 @@ Start a new session (or restart gateway), then run: openclaw skills list --eligible | grep -i skyll """.strip() +SKYLL_DISABLE_MESSAGE = """ +To uninstall Skyll, remove the broker skill folder from the shared skills directory. + +Exact steps (copy/paste) +rm -rf ~/.openclaw/skills/skyll +""".strip() + async def _send_skyll_enable_message(gateway: Gateway) -> None: if not gateway.url: @@ -241,6 +248,23 @@ async def _send_skyll_enable_message(gateway: Gateway) -> None: ) +async def _send_skyll_disable_message(gateway: Gateway) -> None: + if not gateway.url: + raise OpenClawGatewayError("Gateway url is required") + if not gateway.main_session_key: + raise OpenClawGatewayError("gateway main_session_key is required") + client_config = GatewayClientConfig(url=gateway.url, token=gateway.token) + await ensure_session( + gateway.main_session_key, config=client_config, label="Main Agent" + ) + await send_message( + SKYLL_DISABLE_MESSAGE, + session_key=gateway.main_session_key, + config=client_config, + deliver=False, + ) + + @router.get("", response_model=list[GatewayRead]) def list_gateways( session: Session = Depends(get_session), @@ -306,6 +330,11 @@ async def update_gateway( await _send_skyll_enable_message(gateway) except OpenClawGatewayError: pass + if previous_skyll_enabled and not gateway.skyll_enabled: + try: + await _send_skyll_disable_message(gateway) + except OpenClawGatewayError: + pass return gateway diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/src/app/agents/page.tsx b/frontend/src/app/agents/page.tsx index ad3c96ab..794341ff 100644 --- a/frontend/src/app/agents/page.tsx +++ b/frontend/src/app/agents/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; +import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs"; import { type ColumnDef, type SortingState, @@ -13,6 +13,7 @@ import { getSortedRowModel, useReactTable, } from "@tanstack/react-table"; +import { useQueryClient } from "@tanstack/react-query"; import { StatusPill } from "@/components/atoms/StatusPill"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; @@ -33,9 +34,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { getApiBaseUrl } from "@/lib/api-base"; - -const apiBase = getApiBaseUrl(); +import { apiRequest, useAuthedMutation, useAuthedQuery } from "@/lib/api-query"; type Agent = { id: string; @@ -102,129 +101,91 @@ const truncate = (value?: string | null, max = 18) => { }; export default function AgentsPage() { - const { getToken, isSignedIn } = useAuth(); + const queryClient = useQueryClient(); const router = useRouter(); - const [agents, setAgents] = useState([]); - const [boards, setBoards] = useState([]); const [boardId, setBoardId] = useState(""); const [sorting, setSorting] = useState([ { id: "name", desc: false }, ]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [gatewayStatus, setGatewayStatus] = useState(null); - const [gatewayError, setGatewayError] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); - const [isDeleting, setIsDeleting] = useState(false); - const [deleteError, setDeleteError] = useState(null); + + const boardsQuery = useAuthedQuery(["boards"], "/api/v1/boards", { + refetchInterval: 30_000, + refetchOnMount: "always", + }); + const agentsQuery = useAuthedQuery(["agents"], "/api/v1/agents", { + refetchInterval: 15_000, + refetchOnMount: "always", + }); + + const boards = boardsQuery.data ?? []; + const agents = agentsQuery.data ?? []; + + useEffect(() => { + if (!boardId && boards.length > 0) { + setBoardId(boards[0].id); + } + }, [boardId, boards]); + + const statusPath = boardId + ? `/api/v1/gateways/status?board_id=${boardId}` + : null; + + const gatewayStatusQuery = useAuthedQuery( + ["gateway-status", boardId || "all"], + statusPath, + { + enabled: Boolean(statusPath), + refetchInterval: 15_000, + } + ); + + const gatewayStatus = gatewayStatusQuery.data ?? null; + + const deleteMutation = useAuthedMutation( + async (agent, token) => + apiRequest(`/api/v1/agents/${agent.id}`, { + method: "DELETE", + token, + }), + { + onMutate: async (agent) => { + await queryClient.cancelQueries({ queryKey: ["agents"] }); + const previous = queryClient.getQueryData(["agents"]); + queryClient.setQueryData(["agents"], (old = []) => + old.filter((item) => item.id !== agent.id) + ); + return { previous }; + }, + onError: (_error, _agent, context) => { + if (context?.previous) { + queryClient.setQueryData(["agents"], context.previous); + } + }, + onSuccess: () => { + setDeleteTarget(null); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["agents"] }); + }, + } + ); const sortedAgents = useMemo(() => [...agents], [agents]); - const loadBoards = async () => { - if (!isSignedIn) return; - try { - const token = await getToken(); - const response = await fetch(`${apiBase}/api/v1/boards`, { - headers: { Authorization: token ? `Bearer ${token}` : "" }, - }); - if (!response.ok) { - throw new Error("Unable to load boards."); - } - const data = (await response.json()) as Board[]; - setBoards(data); - if (!boardId && data.length > 0) { - setBoardId(data[0].id); - } - } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong."); - } - }; - - const loadAgents = async () => { - if (!isSignedIn) return; - setIsLoading(true); - setError(null); - try { - const token = await getToken(); - const response = await fetch(`${apiBase}/api/v1/agents`, { - headers: { - Authorization: token ? `Bearer ${token}` : "", - }, - }); - if (!response.ok) { - throw new Error("Unable to load agents."); - } - const data = (await response.json()) as Agent[]; - setAgents(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong."); - } finally { - setIsLoading(false); - } - }; - - const loadGatewayStatus = async () => { - if (!isSignedIn || !boardId) return; - setGatewayError(null); - try { - const token = await getToken(); - const response = await fetch( - `${apiBase}/api/v1/gateways/status?board_id=${boardId}`, - { headers: { Authorization: token ? `Bearer ${token}` : "" } } - ); - if (!response.ok) { - throw new Error("Unable to load gateway status."); - } - const statusData = (await response.json()) as GatewayStatus; - setGatewayStatus(statusData); - } catch (err) { - setGatewayError(err instanceof Error ? err.message : "Something went wrong."); - } - }; - - useEffect(() => { - loadBoards(); - loadAgents(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSignedIn]); - - useEffect(() => { - if (boardId) { - loadGatewayStatus(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [boardId, isSignedIn]); - - const handleDelete = async () => { - if (!deleteTarget || !isSignedIn) return; - setIsDeleting(true); - setDeleteError(null); - try { - const token = await getToken(); - const response = await fetch(`${apiBase}/api/v1/agents/${deleteTarget.id}`, { - method: "DELETE", - headers: { - Authorization: token ? `Bearer ${token}` : "", - }, - }); - if (!response.ok) { - throw new Error("Unable to delete agent."); - } - await loadAgents(); - setDeleteTarget(null); - } catch (err) { - setDeleteError(err instanceof Error ? err.message : "Something went wrong."); - } finally { - setIsDeleting(false); - } + const handleDelete = () => { + if (!deleteTarget) return; + deleteMutation.mutate(deleteTarget); }; const handleRefresh = async () => { - await loadBoards(); - await loadAgents(); - await loadGatewayStatus(); + await Promise.all([ + boardsQuery.refetch(), + agentsQuery.refetch(), + gatewayStatusQuery.refetch(), + ]); }; const columns = useMemo[]>( @@ -362,31 +323,36 @@ export default function AgentsPage() { {agents.length} agent{agents.length === 1 ? "" : "s"} total.

-
- - -
+ {agents.length > 0 ? ( +
+ + +
+ ) : null}
- {error ? ( + {agentsQuery.error ? (
- {error} + {agentsQuery.error.message}
) : null} - {agents.length === 0 && !isLoading ? ( -
- No agents yet. Create your first agent to get started. + {agents.length === 0 && !agentsQuery.isLoading ? ( +
+

No agents yet. Create your first agent to get started.

+
) : (
@@ -470,8 +436,10 @@ export default function AgentsPage() { {gatewayStatus?.error ? (

{gatewayStatus.error}

) : null} - {gatewayError ? ( -

{gatewayError}

+ {gatewayStatusQuery.error ? ( +

+ {gatewayStatusQuery.error.message} +

) : null}
@@ -483,7 +451,6 @@ export default function AgentsPage() { onOpenChange={(nextOpen) => { if (!nextOpen) { setDeleteTarget(null); - setDeleteError(null); } }} > @@ -494,17 +461,17 @@ export default function AgentsPage() { This will remove {deleteTarget?.name}. This action cannot be undone. - {deleteError ? ( + {deleteMutation.error ? (
- {deleteError} + {deleteMutation.error.message}
) : null} - diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index 63814d33..7a69f761 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -105,8 +105,8 @@ export default function EditBoardPage() { [gateways, gatewayId] ); - const loadGateways = async () => { - if (!isSignedIn) return; + const loadGateways = async (): Promise => { + if (!isSignedIn) return []; const token = await getToken(); const response = await fetch(`${apiBase}/api/v1/gateways`, { headers: { Authorization: token ? `Bearer ${token}` : "" }, @@ -539,14 +539,35 @@ export default function EditBoardPage() { disabled={isLoading} />
-
- setSkyllEnabled(event.target.checked)} - className="h-4 w-4 rounded border-slate-300 text-slate-900" - /> - Enable Skyll dynamic skills +
+
+

+ Skyll dynamic skills +

+ + skyll.app + +
+
diff --git a/frontend/src/app/boards/new/page.tsx b/frontend/src/app/boards/new/page.tsx index 3d067a53..ae03eb4b 100644 --- a/frontend/src/app/boards/new/page.tsx +++ b/frontend/src/app/boards/new/page.tsx @@ -462,14 +462,35 @@ export default function NewBoardPage() { disabled={isLoading} /> -
- setSkyllEnabled(event.target.checked)} - className="h-4 w-4 rounded border-slate-300 text-slate-900" - /> - Enable Skyll dynamic skills +
+
+

+ Skyll dynamic skills +

+ + skyll.app + +
+
diff --git a/frontend/src/app/boards/page.tsx b/frontend/src/app/boards/page.tsx index 48607bd1..40e8473c 100644 --- a/frontend/src/app/boards/page.tsx +++ b/frontend/src/app/boards/page.tsx @@ -1,21 +1,22 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; +import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs"; import { type ColumnDef, flexRender, getCoreRowModel, useReactTable, } from "@tanstack/react-table"; +import { useQueryClient } from "@tanstack/react-query"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; -import { getApiBaseUrl } from "@/lib/api-base"; +import { apiRequest, useAuthedMutation, useAuthedQuery } from "@/lib/api-query"; import { Dialog, DialogContent, @@ -31,73 +32,55 @@ type Board = { slug: string; }; -const apiBase = getApiBaseUrl(); - export default function BoardsPage() { - const { getToken, isSignedIn } = useAuth(); + const queryClient = useQueryClient(); const router = useRouter(); - 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 boardsQuery = useAuthedQuery(["boards"], "/api/v1/boards", { + refetchInterval: 30_000, + refetchOnMount: "always", + }); + + const boards = boardsQuery.data ?? []; const sortedBoards = useMemo( () => [...boards].sort((a, b) => a.name.localeCompare(b.name)), [boards] ); - const loadBoards = async () => { - if (!isSignedIn) return; - setIsLoading(true); - setError(null); - try { - const token = await getToken(); - const response = await fetch(`${apiBase}/api/v1/boards`, { - headers: { - Authorization: token ? `Bearer ${token}` : "", - }, - }); - if (!response.ok) { - throw new Error("Unable to load boards."); - } - const data = (await response.json()) as Board[]; - setBoards(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong."); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - loadBoards(); - // 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}`, { + const deleteMutation = useAuthedMutation( + async (board, token) => + apiRequest(`/api/v1/boards/${board.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); + token, + }), + { + onMutate: async (board) => { + await queryClient.cancelQueries({ queryKey: ["boards"] }); + const previous = queryClient.getQueryData(["boards"]); + queryClient.setQueryData(["boards"], (old = []) => + old.filter((item) => item.id !== board.id) + ); + return { previous }; + }, + onError: (_error, _board, context) => { + if (context?.previous) { + queryClient.setQueryData(["boards"], context.previous); + } + }, + onSuccess: () => { + setDeleteTarget(null); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["boards"] }); + }, } + ); + + const handleDelete = () => { + if (!deleteTarget) return; + deleteMutation.mutate(deleteTarget); }; const columns = useMemo[]>( @@ -181,22 +164,27 @@ export default function BoardsPage() { {sortedBoards.length === 1 ? "" : "s"} total.

- + {sortedBoards.length > 0 ? ( + + ) : null}
- {error && ( + {boardsQuery.error && (
- {error} + {boardsQuery.error.message}
)} - {sortedBoards.length === 0 && !isLoading ? ( -
- No boards yet. Create your first board to get started. + {sortedBoards.length === 0 && !boardsQuery.isLoading ? ( +
+

No boards yet. Create your first board to get started.

+
) : (
@@ -250,7 +238,6 @@ export default function BoardsPage() { onOpenChange={(nextOpen) => { if (!nextOpen) { setDeleteTarget(null); - setDeleteError(null); } }} > @@ -261,17 +248,17 @@ export default function BoardsPage() { This will remove {deleteTarget?.name}. This action cannot be undone. - {deleteError ? ( + {deleteMutation.error ? (
- {deleteError} + {deleteMutation.error.message}
) : null} - diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 9796fbb7..a33caaba 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; -import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; +import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs"; import { Area, AreaChart, @@ -22,7 +22,7 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; import MetricSparkline from "@/components/charts/metric-sparkline"; -import { getApiBaseUrl } from "@/lib/api-base"; +import { useAuthedQuery } from "@/lib/api-query"; type RangeKey = "24h" | "7d"; type BucketKey = "hour" | "day"; @@ -76,8 +76,6 @@ type DashboardMetrics = { wip: WipSeriesSet; }; -const apiBase = getApiBaseUrl(); - const hourFormatter = new Intl.DateTimeFormat("en-US", { hour: "numeric" }); const dayFormatter = new Intl.DateTimeFormat("en-US", { month: "short", @@ -251,41 +249,16 @@ function ChartCard({ } export default function DashboardPage() { - const { getToken, isSignedIn } = useAuth(); - const [metrics, setMetrics] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const metricsQuery = useAuthedQuery( + ["metrics", "dashboard", "24h"], + "/api/v1/metrics/dashboard?range=24h", + { + refetchInterval: 15_000, + refetchOnMount: "always", + }, + ); - const loadMetrics = async () => { - if (!isSignedIn) return; - setIsLoading(true); - setError(null); - try { - const token = await getToken(); - const response = await fetch( - `${apiBase}/api/v1/metrics/dashboard?range=24h`, - { - headers: { - Authorization: token ? `Bearer ${token}` : "", - }, - }, - ); - if (!response.ok) { - throw new Error("Unable to load dashboard metrics."); - } - const data = (await response.json()) as DashboardMetrics; - setMetrics(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong."); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - loadMetrics(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSignedIn]); + const metrics = metricsQuery.data ?? null; const throughputSeries = useMemo( () => (metrics ? buildSeries(metrics.throughput.primary) : []), @@ -386,13 +359,13 @@ export default function DashboardPage() {
- {error ? ( + {metricsQuery.error ? (
- {error} + {metricsQuery.error.message}
) : null} - {isLoading && !metrics ? ( + {metricsQuery.isLoading && !metrics ? (
Loading dashboard metrics…
diff --git a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx index f243de17..46408799 100644 --- a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx +++ b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx @@ -74,6 +74,13 @@ export default function EditGatewayPage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const canSubmit = + Boolean(name.trim()) && + Boolean(gatewayUrl.trim()) && + Boolean(mainSessionKey.trim()) && + Boolean(workspaceRoot.trim()) && + gatewayCheckStatus === "success"; + useEffect(() => { if (!isSignedIn || !gatewayId) return; const loadGateway = async () => { @@ -249,6 +256,39 @@ export default function EditGatewayPage() { disabled={isLoading} />
+
+ +
+ +
+
+
+ +
- -
setGatewayToken(event.target.value)} + onChange={(event) => { + setGatewayToken(event.target.value); + setGatewayCheckStatus("idle"); + setGatewayCheckMessage(null); + }} onBlur={runGatewayCheck} placeholder="Bearer token" disabled={isLoading} />
-
- - setMainSessionKey(event.target.value)} - placeholder={DEFAULT_MAIN_SESSION_KEY} - disabled={isLoading} - /> -
+
+ + { + setMainSessionKey(event.target.value); + setGatewayCheckStatus("idle"); + setGatewayCheckMessage(null); + }} + placeholder={DEFAULT_MAIN_SESSION_KEY} + disabled={isLoading} + /> +
-
- setSkyllEnabled(event.target.checked)} - className="h-4 w-4 rounded border-slate-300 text-slate-900" - /> - Enable Skyll dynamic skills -
+ {error ?

{error}

: null}
@@ -355,7 +397,7 @@ export default function EditGatewayPage() { > Back -
diff --git a/frontend/src/app/gateways/new/page.tsx b/frontend/src/app/gateways/new/page.tsx index 8db86c8b..d775d2ae 100644 --- a/frontend/src/app/gateways/new/page.tsx +++ b/frontend/src/app/gateways/new/page.tsx @@ -58,6 +58,13 @@ export default function NewGatewayPage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const canSubmit = + Boolean(name.trim()) && + Boolean(gatewayUrl.trim()) && + Boolean(mainSessionKey.trim()) && + Boolean(workspaceRoot.trim()) && + gatewayCheckStatus === "success"; + useEffect(() => { setGatewayCheckStatus("idle"); setGatewayCheckMessage(null); @@ -205,6 +212,39 @@ export default function NewGatewayPage() { disabled={isLoading} />
+
+ +
+ +
+
+
+ +
- -
setGatewayToken(event.target.value)} + onChange={(event) => { + setGatewayToken(event.target.value); + setGatewayCheckStatus("idle"); + setGatewayCheckMessage(null); + }} onBlur={runGatewayCheck} placeholder="Bearer token" disabled={isLoading} />
-
- - setMainSessionKey(event.target.value)} - placeholder={DEFAULT_MAIN_SESSION_KEY} - disabled={isLoading} - /> -
+
+ + { + setMainSessionKey(event.target.value); + setGatewayCheckStatus("idle"); + setGatewayCheckMessage(null); + }} + placeholder={DEFAULT_MAIN_SESSION_KEY} + disabled={isLoading} + /> +
-
- setSkyllEnabled(event.target.checked)} - className="h-4 w-4 rounded border-slate-300 text-slate-900" - /> - Enable Skyll dynamic skills -
+ {error ?

{error}

: null}
@@ -311,7 +353,7 @@ export default function NewGatewayPage() { > Cancel -
diff --git a/frontend/src/app/gateways/page.tsx b/frontend/src/app/gateways/page.tsx index af999977..534822d2 100644 --- a/frontend/src/app/gateways/page.tsx +++ b/frontend/src/app/gateways/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; -import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; +import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs"; import { type ColumnDef, type SortingState, @@ -12,10 +12,11 @@ import { getSortedRowModel, useReactTable, } from "@tanstack/react-table"; +import { useQueryClient } from "@tanstack/react-query"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -24,9 +25,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { getApiBaseUrl } from "@/lib/api-base"; - -const apiBase = getApiBaseUrl(); +import { apiRequest, useAuthedMutation, useAuthedQuery } from "@/lib/api-query"; type Gateway = { id: string; @@ -59,70 +58,59 @@ const formatTimestamp = (value?: string | null) => { }; export default function GatewaysPage() { - const { getToken, isSignedIn } = useAuth(); - - const [gateways, setGateways] = useState([]); + const queryClient = useQueryClient(); const [sorting, setSorting] = useState([ { id: "name", desc: false }, ]); - 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 gatewaysQuery = useAuthedQuery( + ["gateways"], + "/api/v1/gateways", + { + refetchInterval: 30_000, + refetchOnMount: "always", + } + ); + const gateways = gatewaysQuery.data ?? []; const sortedGateways = useMemo(() => [...gateways], [gateways]); - const loadGateways = async () => { - if (!isSignedIn) return; - setIsLoading(true); - setError(null); - try { - const token = await getToken(); - const response = await fetch(`${apiBase}/api/v1/gateways`, { - headers: { Authorization: token ? `Bearer ${token}` : "" }, - }); - if (!response.ok) { - throw new Error("Unable to load gateways."); - } - const data = (await response.json()) as Gateway[]; - setGateways(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong."); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - loadGateways(); - // 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/gateways/${deleteTarget.id}`, - { - method: "DELETE", - headers: { Authorization: token ? `Bearer ${token}` : "" }, + const deleteMutation = useAuthedMutation< + void, + Gateway, + { previous?: Gateway[] } + >( + async (gateway, token) => + apiRequest(`/api/v1/gateways/${gateway.id}`, { + method: "DELETE", + token, + }), + { + onMutate: async (gateway) => { + await queryClient.cancelQueries({ queryKey: ["gateways"] }); + const previous = queryClient.getQueryData(["gateways"]); + queryClient.setQueryData(["gateways"], (old = []) => + old.filter((item) => item.id !== gateway.id) + ); + return { previous }; + }, + onError: (_error, _gateway, context) => { + if (context?.previous) { + queryClient.setQueryData(["gateways"], context.previous); } - ); - if (!response.ok) { - throw new Error("Unable to delete gateway."); - } - setGateways((prev) => prev.filter((item) => item.id !== deleteTarget.id)); - setDeleteTarget(null); - } catch (err) { - setDeleteError(err instanceof Error ? err.message : "Something went wrong."); - } finally { - setIsDeleting(false); + }, + onSuccess: () => { + setDeleteTarget(null); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["gateways"] }); + }, } + ); + + const handleDelete = () => { + if (!deleteTarget) return; + deleteMutation.mutate(deleteTarget); }; const columns = useMemo[]>( @@ -181,18 +169,21 @@ export default function GatewaysPage() { id: "actions", header: "", cell: ({ row }) => ( -
- - -
+
+ + Edit + + +
), }, ], @@ -223,43 +214,38 @@ export default function GatewaysPage() {
-
-
-
-

- Gateways -

-

- Manage OpenClaw gateway connections used by boards. -

-
- +
+
+
+
+

+ Gateways +

+

+ Manage OpenClaw gateway connections used by boards +

+
+ {gateways.length > 0 ? ( + + Create gateway + + ) : null}
+
-
-
-
-

All gateways

- {isLoading ? ( - Loading… - ) : ( - - {gateways.length} total - - )} -
-
+
- - +
+ {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} + {gatewaysQuery.isLoading ? ( + + - ))} + ) : table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + ) : ( + + + + )}
+ {header.isPlaceholder ? null : flexRender( @@ -272,29 +258,78 @@ export default function GatewaysPage() { ))}
- {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} -
+ Loading… +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+
+ + + + +
+

+ No gateways yet +

+

+ Create your first gateway to connect boards and + start managing your OpenClaw connections. +

+ + Create your first gateway + +
+
- {!isLoading && gateways.length === 0 ? ( -
- No gateways yet. Create your first gateway to connect boards. -
- ) : null}
- {error ?

{error}

: null} + {gatewaysQuery.error ? ( +

+ {gatewaysQuery.error.message} +

+ ) : null} +
@@ -308,15 +343,17 @@ export default function GatewaysPage() { using it will need a new gateway assigned. - {deleteError ? ( -

{deleteError}

+ {deleteMutation.error ? ( +

+ {deleteMutation.error.message} +

) : null} - diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index ae43f771..146d8a1d 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -6,6 +6,8 @@ import type { ReactNode } from "react"; import { ClerkProvider } from "@clerk/nextjs"; import { IBM_Plex_Sans, Sora } from "next/font/google"; +import { QueryProvider } from "@/components/providers/QueryProvider"; + export const metadata: Metadata = { title: "OpenClaw Mission Control", description: "A calm command center for every task.", @@ -32,7 +34,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { - {children} + {children} diff --git a/frontend/src/components/organisms/DashboardSidebar.tsx b/frontend/src/components/organisms/DashboardSidebar.tsx index 83ef14c5..c56aa899 100644 --- a/frontend/src/components/organisms/DashboardSidebar.tsx +++ b/frontend/src/components/organisms/DashboardSidebar.tsx @@ -1,13 +1,51 @@ "use client"; import Link from "next/link"; +import { useEffect, useState } from "react"; import { usePathname } from "next/navigation"; import { BarChart3, Bot, LayoutGrid, Network } from "lucide-react"; import { cn } from "@/lib/utils"; +import { getApiBaseUrl } from "@/lib/api-base"; export function DashboardSidebar() { const pathname = usePathname(); + const [systemStatus, setSystemStatus] = useState< + "unknown" | "operational" | "degraded" + >("unknown"); + const [statusLabel, setStatusLabel] = useState("System status unavailable"); + + useEffect(() => { + let isMounted = true; + const apiBase = getApiBaseUrl(); + const checkHealth = async () => { + try { + const response = await fetch(`${apiBase}/healthz`, { cache: "no-store" }); + if (!response.ok) { + throw new Error("Health check failed"); + } + const data = (await response.json()) as { ok?: boolean }; + if (!isMounted) return; + if (data?.ok) { + setSystemStatus("operational"); + setStatusLabel("All systems operational"); + } else { + setSystemStatus("degraded"); + setStatusLabel("System degraded"); + } + } catch { + if (!isMounted) return; + setSystemStatus("degraded"); + setStatusLabel("System degraded"); + } + }; + checkHealth(); + const interval = setInterval(checkHealth, 30000); + return () => { + isMounted = false; + clearInterval(interval); + }; + }, []); return (
- - All systems operational + + {statusLabel}
diff --git a/frontend/src/components/organisms/TaskBoard.tsx b/frontend/src/components/organisms/TaskBoard.tsx index b2f04ac5..fb043fd6 100644 --- a/frontend/src/components/organisms/TaskBoard.tsx +++ b/frontend/src/components/organisms/TaskBoard.tsx @@ -9,7 +9,10 @@ type Task = { id: string; title: string; status: string; + priority: string; + description?: string | null; due_at?: string | null; + assigned_agent_id?: string | null; assignee?: string; }; diff --git a/frontend/src/components/providers/QueryProvider.tsx b/frontend/src/components/providers/QueryProvider.tsx new file mode 100644 index 00000000..eb79ee2a --- /dev/null +++ b/frontend/src/components/providers/QueryProvider.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useState } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +export function QueryProvider({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 15_000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: true, + retry: 1, + }, + mutations: { + retry: 0, + }, + }, + }) + ); + + return {children}; +} diff --git a/frontend/src/lib/api-query.ts b/frontend/src/lib/api-query.ts new file mode 100644 index 00000000..33f825a4 --- /dev/null +++ b/frontend/src/lib/api-query.ts @@ -0,0 +1,83 @@ +"use client"; + +import { useAuth } from "@clerk/nextjs"; +import { + type QueryKey, + type UseMutationOptions, + type UseQueryOptions, + useMutation, + useQuery, +} from "@tanstack/react-query"; + +import { getApiBaseUrl } from "@/lib/api-base"; + +const apiBase = getApiBaseUrl(); + +type ApiRequestOptions = { + token?: string | null; + method?: string; + body?: unknown; + headers?: HeadersInit; +}; + +export async function apiRequest( + path: string, + { token, method = "GET", body, headers }: ApiRequestOptions = {} +) { + const response = await fetch(`${apiBase}${path}`, { + method, + headers: { + ...(body ? { "Content-Type": "application/json" } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(detail || "Request failed."); + } + + if (response.status === 204) { + return null as T; + } + + return (await response.json()) as T; +} + +export function useAuthedQuery( + key: QueryKey, + path: string | null, + options: Omit< + UseQueryOptions, + "queryKey" | "queryFn" + > = {} +) { + const { getToken, isSignedIn } = useAuth(); + + return useQuery({ + queryKey: key, + enabled: Boolean(isSignedIn && path) && (options.enabled ?? true), + queryFn: async () => { + const token = await getToken(); + return apiRequest(path as string, { token }); + }, + ...options, + }); +} + +export function useAuthedMutation( + mutationFn: (variables: TVariables, token: string | null) => Promise, + options?: UseMutationOptions +) { + const { getToken } = useAuth(); + + return useMutation({ + mutationFn: async (variables) => { + const token = await getToken(); + return mutationFn(variables, token); + }, + ...options, + }); +}