From 333a5ad21c33a06eb032af885339e109bc1936bc Mon Sep 17 00:00:00 2001 From: Ryan Hopper-Lowe Date: Tue, 14 Jan 2025 10:44:38 -0600 Subject: [PATCH] fix: implement polling while asynchronous tool refresh is unresolved - force refresh happens asynchronously from response. Must implement polling to ensure data displayed is up to date - removes the redundant `ToolReferenceService.getToolReferencesCategoryMap` method by extracting unique code and applying it as needed in components --- ui/admin/app/components/tools/ToolCatalog.tsx | 19 ++++--- .../app/components/tools/ToolCatalogGroup.tsx | 2 +- .../components/tools/toolGrid/ToolCard.tsx | 27 +++++----- .../components/tools/toolGrid/ToolGrid.tsx | 4 +- ui/admin/app/hooks/usePollSingleTool.ts | 49 +++++++++++++++++++ ui/admin/app/lib/model/toolReferences.ts | 39 +++++++++++++++ .../lib/service/api/toolreferenceService.ts | 48 ------------------ ui/admin/app/routes/_auth.tools._index.tsx | 26 ++++++---- 8 files changed, 134 insertions(+), 80 deletions(-) create mode 100644 ui/admin/app/hooks/usePollSingleTool.ts diff --git a/ui/admin/app/components/tools/ToolCatalog.tsx b/ui/admin/app/components/tools/ToolCatalog.tsx index 60f3cf0ac..3f692bbad 100644 --- a/ui/admin/app/components/tools/ToolCatalog.tsx +++ b/ui/admin/app/components/tools/ToolCatalog.tsx @@ -5,8 +5,9 @@ import useSWR from "swr"; import { OAuthProvider } from "~/lib/model/oauthApps/oauth-helpers"; import { ToolCategory, - ToolReferenceService, -} from "~/lib/service/api/toolreferenceService"; + convertToolReferencesToCategoryMap, +} from "~/lib/model/toolReferences"; +import { ToolReferenceService } from "~/lib/service/api/toolreferenceService"; import { cn } from "~/lib/utils"; import { ToolCatalogGroup } from "~/components/tools/ToolCatalogGroup"; @@ -41,11 +42,17 @@ export function ToolCatalog({ onUpdateTools, classNames, }: ToolCatalogProps) { - const { data: toolCategories, isLoading } = useSWR( - ToolReferenceService.getToolReferencesCategoryMap.key("tool"), - () => ToolReferenceService.getToolReferencesCategoryMap("tool"), - { fallbackData: {} } + const { data: toolList, isLoading } = useSWR( + ToolReferenceService.getToolReferences.key("tool"), + () => ToolReferenceService.getToolReferences("tool"), + { fallbackData: [] } ); + + const toolCategories = useMemo( + () => convertToolReferencesToCategoryMap(toolList), + [toolList] + ); + const [search, setSearch] = useState(""); const oauthApps = useOAuthAppList(); diff --git a/ui/admin/app/components/tools/ToolCatalogGroup.tsx b/ui/admin/app/components/tools/ToolCatalogGroup.tsx index 15457038f..ae8b0c4c9 100644 --- a/ui/admin/app/components/tools/ToolCatalogGroup.tsx +++ b/ui/admin/app/components/tools/ToolCatalogGroup.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { ToolCategory } from "~/lib/service/api/toolreferenceService"; +import { ToolCategory } from "~/lib/model/toolReferences"; import { cn } from "~/lib/utils"; import { ToolItem } from "~/components/tools/ToolItem"; diff --git a/ui/admin/app/components/tools/toolGrid/ToolCard.tsx b/ui/admin/app/components/tools/toolGrid/ToolCard.tsx index 2a77d2e6a..40e46949e 100644 --- a/ui/admin/app/components/tools/toolGrid/ToolCard.tsx +++ b/ui/admin/app/components/tools/toolGrid/ToolCard.tsx @@ -29,6 +29,7 @@ import { TooltipTrigger, } from "~/components/ui/tooltip"; import { useAsync } from "~/hooks/useAsync"; +import { usePollSingleTool } from "~/hooks/usePollSingleTool"; interface ToolCardProps { tool: ToolReference; @@ -36,12 +37,14 @@ interface ToolCardProps { } export function ToolCard({ tool, onDelete }: ToolCardProps) { + const { startPolling, isPolling } = usePollSingleTool(tool.id); + const forceRefresh = useAsync( ToolReferenceService.forceRefreshToolReference, { - onSuccess: () => { + onSuccess: ({ resolved }) => { toast.success("Tool reference force refreshed"); - ToolReferenceService.getToolReferences.revalidate("tool"); + if (!resolved) startPolling(); }, } ); @@ -80,17 +83,15 @@ export function ToolCard({ tool, onDelete }: ToolCardProps) { )} - {!tool.builtin && ( - -
- {forceRefresh.isLoading && } +
+ {(forceRefresh.isLoading || isPolling) && } - - - -
+ + + + forceRefresh.execute(tool.id)}> @@ -98,7 +99,7 @@ export function ToolCard({ tool, onDelete }: ToolCardProps) { - )} +
{!tool.builtin && ( diff --git a/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx b/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx index 5648c8eac..fd0f17504 100644 --- a/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx +++ b/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useState } from "react"; -import { ToolReference } from "~/lib/model/toolReferences"; import { CustomToolsToolCategory, ToolCategoryMap, -} from "~/lib/service/api/toolreferenceService"; + ToolReference, +} from "~/lib/model/toolReferences"; import { CategoryHeader } from "~/components/tools/toolGrid/CategoryHeader"; import { CategoryTools } from "~/components/tools/toolGrid/CategoryTools"; diff --git a/ui/admin/app/hooks/usePollSingleTool.ts b/ui/admin/app/hooks/usePollSingleTool.ts new file mode 100644 index 000000000..1de5e7c76 --- /dev/null +++ b/ui/admin/app/hooks/usePollSingleTool.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import useSWR from "swr"; + +import { ToolReferenceService } from "~/lib/service/api/toolreferenceService"; + +export function usePollSingleTool(toolId: string) { + const [isPolling, setIsPolling] = useState(false); + + const { mutate: updateTools } = useSWR( + isPolling ? ToolReferenceService.getToolReferences.key("tool") : null, + ({ type }) => ToolReferenceService.getToolReferences(type), + { fallbackData: [], revalidateIfStale: false } + ); + + const getTool = useSWR( + isPolling ? ToolReferenceService.getToolReferenceById.key(toolId) : null, + ({ toolReferenceId }) => + ToolReferenceService.getToolReferenceById(toolReferenceId), + { refreshInterval: 1000 } + ); + + useEffect(() => { + if (!getTool.data) return; + + setIsPolling(!getTool.data.resolved); + + // resolved means async update is complete + if (getTool.data.resolved) { + updateTools( + (tools) => { + if (!getTool.data) return tools; + if (!tools) return [getTool.data]; + + const index = tools.findIndex((tool) => tool.id === toolId); + + const copy = [...tools]; + copy[index] = getTool.data; + return copy; + }, + { revalidate: false } + ); + } + }, [getTool.data, updateTools, toolId]); + + return { + startPolling: () => setIsPolling(true), + isPolling, + }; +} diff --git a/ui/admin/app/lib/model/toolReferences.ts b/ui/admin/app/lib/model/toolReferences.ts index 389bdae33..e6dedb318 100644 --- a/ui/admin/app/lib/model/toolReferences.ts +++ b/ui/admin/app/lib/model/toolReferences.ts @@ -29,3 +29,42 @@ export const toolReferenceToTemplate = (toolReference: ToolReference) => { args: toolReference.params, } as Template; }; + +export type ToolCategory = { + bundleTool?: ToolReference; + tools: ToolReference[]; +}; +export const UncategorizedToolCategory = "Uncategorized"; +export const CustomToolsToolCategory = "Custom Tools"; +export type ToolCategoryMap = Record; + +export function convertToolReferencesToCategoryMap( + toolReferences: ToolReference[] +) { + const result: ToolCategoryMap = {}; + + for (const toolReference of toolReferences) { + if (toolReference.deleted) { + // skip tools if marked with deleted + continue; + } + + const category = !toolReference.builtin + ? CustomToolsToolCategory + : toolReference.metadata?.category || UncategorizedToolCategory; + + if (!result[category]) { + result[category] = { + tools: [], + }; + } + + if (toolReference.metadata?.bundle === "true") { + result[category].bundleTool = toolReference; + } else { + result[category].tools.push(toolReference); + } + } + + return result; +} diff --git a/ui/admin/app/lib/service/api/toolreferenceService.ts b/ui/admin/app/lib/service/api/toolreferenceService.ts index 09598eeaa..87e7ff054 100644 --- a/ui/admin/app/lib/service/api/toolreferenceService.ts +++ b/ui/admin/app/lib/service/api/toolreferenceService.ts @@ -26,53 +26,6 @@ getToolReferences.revalidate = (type?: ToolReferenceType) => { ); }; -export type ToolCategory = { - bundleTool?: ToolReference; - tools: ToolReference[]; -}; -export const UncategorizedToolCategory = "Uncategorized"; -export const CustomToolsToolCategory = "Custom Tools"; -export type ToolCategoryMap = Record; -async function getToolReferencesCategoryMap(type?: ToolReferenceType) { - const res = await request<{ items: ToolReference[] }>({ - url: ApiRoutes.toolReferences.base({ type }).url, - errorMessage: "Failed to fetch tool references category map", - }); - - const toolReferences = res.data.items; - const result: ToolCategoryMap = {}; - - for (const toolReference of toolReferences) { - if (toolReference.deleted) { - // skip tools if marked with deleted - continue; - } - - const category = !toolReference.builtin - ? CustomToolsToolCategory - : toolReference.metadata?.category || UncategorizedToolCategory; - - if (!result[category]) { - result[category] = { - tools: [], - }; - } - - if (toolReference.metadata?.bundle === "true") { - result[category].bundleTool = toolReference; - } else { - result[category].tools.push(toolReference); - } - } - - return result; -} -getToolReferencesCategoryMap.key = (type?: ToolReferenceType) => - ({ - url: ApiRoutes.toolReferences.base({ type }).path, - responseType: "map", - }) as const; - const getToolReferenceById = async (toolReferenceId: string) => { const res = await request({ url: ApiRoutes.toolReferences.getById(toolReferenceId).url, @@ -142,7 +95,6 @@ async function deleteToolReference(id: string) { export const ToolReferenceService = { getToolReferences, - getToolReferencesCategoryMap, getToolReferenceById, createToolReference, updateToolReference, diff --git a/ui/admin/app/routes/_auth.tools._index.tsx b/ui/admin/app/routes/_auth.tools._index.tsx index c6e84e1be..5069988f1 100644 --- a/ui/admin/app/routes/_auth.tools._index.tsx +++ b/ui/admin/app/routes/_auth.tools._index.tsx @@ -1,8 +1,9 @@ import { PlusIcon, SearchIcon } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { MetaFunction } from "react-router"; import useSWR, { preload } from "swr"; +import { convertToolReferencesToCategoryMap } from "~/lib/model/toolReferences"; import { ToolReferenceService } from "~/lib/service/api/toolreferenceService"; import { RouteHandle } from "~/lib/service/routeHandles"; @@ -23,18 +24,23 @@ import { ScrollArea } from "~/components/ui/scroll-area"; export async function clientLoader() { await Promise.all([ - preload(ToolReferenceService.getToolReferencesCategoryMap.key("tool"), () => - ToolReferenceService.getToolReferencesCategoryMap("tool") + preload(ToolReferenceService.getToolReferences.key("tool"), () => + ToolReferenceService.getToolReferences("tool") ), ]); return null; } export default function Tools() { - const { data: toolCategories, mutate } = useSWR( - ToolReferenceService.getToolReferencesCategoryMap.key("tool"), - () => ToolReferenceService.getToolReferencesCategoryMap("tool"), - { fallbackData: {} } + const getTools = useSWR( + ToolReferenceService.getToolReferences.key("tool"), + () => ToolReferenceService.getToolReferences("tool"), + { fallbackData: [] } + ); + + const toolCategories = useMemo( + () => convertToolReferencesToCategoryMap(getTools.data), + [getTools.data] ); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -42,17 +48,17 @@ export default function Tools() { const [errorDialogError, setErrorDialogError] = useState(""); const handleCreateSuccess = () => { - mutate(); + getTools.mutate(); setIsDialogOpen(false); }; const handleDelete = async (id: string) => { await ToolReferenceService.deleteToolReference(id); - mutate(); + getTools.mutate(); }; const handleErrorDialogError = (error: string) => { - mutate(); + getTools.mutate(); setErrorDialogError(error); setIsDialogOpen(false); };