From 813c602bca019aa0fa9585b4b93ba341a013fd33 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 28 Feb 2026 16:25:59 +0700 Subject: [PATCH] feat(ui): migrate to TanStack Query, add server-side pagination, and improve error handling - Add @tanstack/react-query with shared cache and centralized query keys - Migrate all 14 CRUD hooks from manual useState/useCallback to useQuery/useMutation - Add WS event-driven query invalidation (sessions, traces auto-refresh on run completion) - Consolidate LlmConfigSection to delegate provider/model UI to ProviderModelSelect - Add server-side pagination for custom-tools and channel-instances (Go store + HTTP handler) - Extract shared types into dedicated files (provider, custom-tool, mcp, channel, skill, trace) - Add network error handling in HTTP client and connectivity check on login - Add disconnect banner in app layout when gateway connection is lost Co-Authored-By: Claude Opus 4.6 --- internal/http/channel_instances.go | 31 ++++- internal/http/custom_tools.go | 43 +++++-- internal/store/channel_instance_store.go | 9 ++ internal/store/custom_tool_store.go | 10 ++ internal/store/pg/channel_instances.go | 41 +++++++ internal/store/pg/custom_tools.go | 42 +++++++ ui/web/package.json | 1 + ui/web/pnpm-lock.yaml | 18 +++ ui/web/src/api/http-client.ts | 13 +- ui/web/src/components/layout/app-layout.tsx | 9 ++ .../components/providers/app-providers.tsx | 22 +++- .../src/components/providers/ws-provider.tsx | 13 +- .../shared/provider-model-select.tsx | 39 ++++-- ui/web/src/hooks/use-query-invalidation.ts | 29 +++++ ui/web/src/lib/query-keys.ts | 58 +++++++++ .../general-sections/llm-config-section.tsx | 112 ++---------------- .../pages/agents/hooks/use-agent-detail.ts | 73 +++++++----- ui/web/src/pages/agents/hooks/use-agents.ts | 71 +++++------ .../builtin-tools/hooks/use-builtin-tools.ts | 36 +++--- ui/web/src/pages/channels/channels-page.tsx | 72 ++++++----- .../channels/hooks/use-channel-instances.ts | 101 ++++++++-------- ui/web/src/pages/config/hooks/use-config.ts | 68 +++++------ ui/web/src/pages/cron/hooks/use-cron.ts | 46 ++++--- .../pages/custom-tools/custom-tools-page.tsx | 56 +++++---- .../custom-tools/hooks/use-custom-tools.ts | 90 +++++++------- ui/web/src/pages/login/token-form.tsx | 52 +++++++- ui/web/src/pages/mcp/hooks/use-mcp.ts | 88 ++++---------- .../providers/hooks/use-provider-models.ts | 43 ++----- .../pages/providers/hooks/use-providers.ts | 66 ++++------- .../src/pages/sessions/hooks/use-sessions.ts | 86 ++++++-------- ui/web/src/pages/sessions/sessions-page.tsx | 14 +-- ui/web/src/pages/skills/hooks/use-skills.ts | 54 ++++----- ui/web/src/pages/traces/hooks/use-traces.ts | 108 +++++------------ ui/web/src/pages/traces/traces-page.tsx | 69 ++--------- ui/web/src/pages/tts/hooks/use-tts-config.ts | 38 +++--- ui/web/src/types/channel.ts | 23 ++++ ui/web/src/types/custom-tool.ts | 25 ++++ ui/web/src/types/mcp.ts | 41 +++++++ ui/web/src/types/provider.ts | 25 ++++ ui/web/src/types/skill.ts | 6 + ui/web/src/types/trace.ts | 51 ++++++++ 41 files changed, 1079 insertions(+), 813 deletions(-) create mode 100644 ui/web/src/hooks/use-query-invalidation.ts create mode 100644 ui/web/src/lib/query-keys.ts create mode 100644 ui/web/src/types/channel.ts create mode 100644 ui/web/src/types/custom-tool.ts create mode 100644 ui/web/src/types/mcp.ts create mode 100644 ui/web/src/types/provider.ts create mode 100644 ui/web/src/types/skill.ts create mode 100644 ui/web/src/types/trace.ts diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index 132e76f2..795699d0 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "strconv" "github.com/google/uuid" @@ -61,19 +62,45 @@ func (h *ChannelInstancesHandler) emitCacheInvalidate() { } func (h *ChannelInstancesHandler) handleList(w http.ResponseWriter, r *http.Request) { - instances, err := h.store.ListAll(r.Context()) + opts := store.ChannelInstanceListOpts{ + Limit: 50, + Offset: 0, + } + + if v := r.URL.Query().Get("search"); v != "" { + opts.Search = v + } + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { + opts.Limit = n + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + opts.Offset = n + } + } + + instances, err := h.store.ListPaged(r.Context(), opts) if err != nil { slog.Error("channel_instances.list", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list instances"}) return } + total, _ := h.store.CountInstances(r.Context(), opts) + result := make([]map[string]interface{}, 0, len(instances)) for _, inst := range instances { result = append(result, maskInstanceHTTP(inst)) } - writeJSON(w, http.StatusOK, map[string]interface{}{"instances": result}) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "instances": result, + "total": total, + "limit": opts.Limit, + "offset": opts.Offset, + }) } func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/custom_tools.go b/internal/http/custom_tools.go index d9baebf1..bd514c5a 100644 --- a/internal/http/custom_tools.go +++ b/internal/http/custom_tools.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "strconv" "github.com/google/uuid" @@ -63,28 +64,48 @@ func (h *CustomToolsHandler) emitCacheInvalidate(key string) { } func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request) { - agentIDStr := r.URL.Query().Get("agent_id") + opts := store.CustomToolListOpts{ + Limit: 50, + Offset: 0, + } - var result []store.CustomToolDef - var err error - - if agentIDStr != "" { - agentID, parseErr := uuid.Parse(agentIDStr) - if parseErr != nil { + if v := r.URL.Query().Get("agent_id"); v != "" { + id, err := uuid.Parse(v) + if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"}) return } - result, err = h.store.ListByAgent(r.Context(), agentID) - } else { - result, err = h.store.ListAll(r.Context()) + opts.AgentID = &id + } + if v := r.URL.Query().Get("search"); v != "" { + opts.Search = v + } + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { + opts.Limit = n + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + opts.Offset = n + } } + result, err := h.store.ListPaged(r.Context(), opts) if err != nil { slog.Error("custom_tools.list", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list tools"}) return } - writeJSON(w, http.StatusOK, map[string]interface{}{"tools": result}) + + total, _ := h.store.CountTools(r.Context(), opts) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "tools": result, + "total": total, + "limit": opts.Limit, + "offset": opts.Offset, + }) } func (h *CustomToolsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { diff --git a/internal/store/channel_instance_store.go b/internal/store/channel_instance_store.go index d02f77a8..d47acedb 100644 --- a/internal/store/channel_instance_store.go +++ b/internal/store/channel_instance_store.go @@ -35,6 +35,13 @@ func IsDefaultChannelInstance(name string) bool { return false } +// ChannelInstanceListOpts configures channel instance listing with optional pagination and filtering. +type ChannelInstanceListOpts struct { + Search string + Limit int + Offset int +} + // ChannelInstanceStore manages channel instance definitions (managed mode only). type ChannelInstanceStore interface { Create(ctx context.Context, inst *ChannelInstanceData) error @@ -44,4 +51,6 @@ type ChannelInstanceStore interface { Delete(ctx context.Context, id uuid.UUID) error ListEnabled(ctx context.Context) ([]ChannelInstanceData, error) ListAll(ctx context.Context) ([]ChannelInstanceData, error) + ListPaged(ctx context.Context, opts ChannelInstanceListOpts) ([]ChannelInstanceData, error) + CountInstances(ctx context.Context, opts ChannelInstanceListOpts) (int, error) } diff --git a/internal/store/custom_tool_store.go b/internal/store/custom_tool_store.go index b4f04d6f..bd49659f 100644 --- a/internal/store/custom_tool_store.go +++ b/internal/store/custom_tool_store.go @@ -22,6 +22,14 @@ type CustomToolDef struct { CreatedBy string `json:"created_by"` } +// CustomToolListOpts configures custom tool listing with optional pagination and filtering. +type CustomToolListOpts struct { + AgentID *uuid.UUID + Search string + Limit int + Offset int +} + // CustomToolStore manages custom tool definitions (managed mode only). type CustomToolStore interface { Create(ctx context.Context, def *CustomToolDef) error @@ -31,4 +39,6 @@ type CustomToolStore interface { ListGlobal(ctx context.Context) ([]CustomToolDef, error) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]CustomToolDef, error) ListAll(ctx context.Context) ([]CustomToolDef, error) + ListPaged(ctx context.Context, opts CustomToolListOpts) ([]CustomToolDef, error) + CountTools(ctx context.Context, opts CustomToolListOpts) (int, error) } diff --git a/internal/store/pg/channel_instances.go b/internal/store/pg/channel_instances.go index 41a372fa..55e1891a 100644 --- a/internal/store/pg/channel_instances.go +++ b/internal/store/pg/channel_instances.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "log/slog" + "strings" "time" "github.com/google/uuid" @@ -190,3 +191,43 @@ func (s *PGChannelInstanceStore) ListAll(ctx context.Context) ([]store.ChannelIn } return s.scanInstances(rows) } + +func buildChannelInstanceWhere(opts store.ChannelInstanceListOpts) (string, []interface{}) { + var conditions []string + var args []interface{} + argIdx := 1 + + if opts.Search != "" { + conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d OR display_name ILIKE $%d OR channel_type ILIKE $%d)", argIdx, argIdx, argIdx)) + args = append(args, "%"+opts.Search+"%") + } + + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + return where, args +} + +func (s *PGChannelInstanceStore) ListPaged(ctx context.Context, opts store.ChannelInstanceListOpts) ([]store.ChannelInstanceData, error) { + where, args := buildChannelInstanceWhere(opts) + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + q := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances` + where + + fmt.Sprintf(" ORDER BY name OFFSET %d LIMIT %d", opts.Offset, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + return s.scanInstances(rows) +} + +func (s *PGChannelInstanceStore) CountInstances(ctx context.Context, opts store.ChannelInstanceListOpts) (int, error) { + where, args := buildChannelInstanceWhere(opts) + var count int + err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_instances"+where, args...).Scan(&count) + return count, err +} diff --git a/internal/store/pg/custom_tools.go b/internal/store/pg/custom_tools.go index 380be76c..6bc10cef 100644 --- a/internal/store/pg/custom_tools.go +++ b/internal/store/pg/custom_tools.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "log/slog" + "strings" "time" "github.com/google/uuid" @@ -191,3 +192,44 @@ func (s *PGCustomToolStore) ListAll(ctx context.Context) ([]store.CustomToolDef, } return s.scanTools(rows) } + +func buildCustomToolWhere(opts store.CustomToolListOpts) (string, []interface{}) { + conditions := []string{"enabled = true"} + var args []interface{} + argIdx := 1 + + if opts.AgentID != nil { + conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx)) + args = append(args, *opts.AgentID) + argIdx++ + } + if opts.Search != "" { + conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx)) + args = append(args, "%"+opts.Search+"%") + } + + return " WHERE " + strings.Join(conditions, " AND "), args +} + +func (s *PGCustomToolStore) ListPaged(ctx context.Context, opts store.CustomToolListOpts) ([]store.CustomToolDef, error) { + where, args := buildCustomToolWhere(opts) + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + q := `SELECT ` + customToolSelectCols + ` FROM custom_tools` + where + + fmt.Sprintf(" ORDER BY name OFFSET %d LIMIT %d", opts.Offset, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + return s.scanTools(rows) +} + +func (s *PGCustomToolStore) CountTools(ctx context.Context, opts store.CustomToolListOpts) (int, error) { + where, args := buildCustomToolWhere(opts) + var count int + err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM custom_tools"+where, args...).Scan(&count) + return count, err +} diff --git a/ui/web/package.json b/ui/web/package.json index 24736a11..c35501ee 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tailwindcss/typography": "^0.5.19", + "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index bf0477cd..e846a787 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.0) + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@19.2.4) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1284,6 +1287,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -3232,6 +3243,13 @@ snapshots: tailwindcss: 4.2.0 vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1) + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.21(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.4 + '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/table-core': 8.21.3 diff --git a/ui/web/src/api/http-client.ts b/ui/web/src/api/http-client.ts index c7ebed09..9b359866 100644 --- a/ui/web/src/api/http-client.ts +++ b/ui/web/src/api/http-client.ts @@ -78,10 +78,15 @@ export class HttpClient { } private async request(url: string, init: RequestInit): Promise { - const res = await fetch(url, { - ...init, - headers: { ...this.headers(), ...(init.headers as Record) }, - }); + let res: Response; + try { + res = await fetch(url, { + ...init, + headers: { ...this.headers(), ...(init.headers as Record) }, + }); + } catch { + throw new ApiError("NETWORK_ERROR", "Cannot connect to server. Check if the gateway is running."); + } if (!res.ok) { if (res.status === 401) { diff --git a/ui/web/src/components/layout/app-layout.tsx b/ui/web/src/components/layout/app-layout.tsx index 659ff31f..b093ee0c 100644 --- a/ui/web/src/components/layout/app-layout.tsx +++ b/ui/web/src/components/layout/app-layout.tsx @@ -1,16 +1,25 @@ import { Outlet } from "react-router"; +import { WifiOff } from "lucide-react"; import { Sidebar } from "./sidebar"; import { Topbar } from "./topbar"; import { useUiStore } from "@/stores/use-ui-store"; +import { useAuthStore } from "@/stores/use-auth-store"; export function AppLayout() { const sidebarCollapsed = useUiStore((s) => s.sidebarCollapsed); + const connected = useAuthStore((s) => s.connected); return (
+ {!connected && ( +
+ + Disconnected from gateway. Attempting to reconnect... +
+ )}
diff --git a/ui/web/src/components/providers/app-providers.tsx b/ui/web/src/components/providers/app-providers.tsx index 5d009027..c4b6e6f5 100644 --- a/ui/web/src/components/providers/app-providers.tsx +++ b/ui/web/src/components/providers/app-providers.tsx @@ -1,10 +1,26 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "./theme-provider"; import { WsProvider } from "./ws-provider"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 5 * 60_000, + retry: 1, + refetchOnWindowFocus: true, + }, + }, +}); + +export { queryClient }; + export function AppProviders({ children }: { children: React.ReactNode }) { return ( - - {children} - + + + {children} + + ); } diff --git a/ui/web/src/components/providers/ws-provider.tsx b/ui/web/src/components/providers/ws-provider.tsx index d2c54db7..794b5828 100644 --- a/ui/web/src/components/providers/ws-provider.tsx +++ b/ui/web/src/components/providers/ws-provider.tsx @@ -3,6 +3,7 @@ import { WsClient, type ConnectionState } from "@/api/ws-client"; import { HttpClient } from "@/api/http-client"; import { WsContext } from "@/hooks/use-ws"; import { useAuthStore } from "@/stores/use-auth-store"; +import { useWsQueryInvalidation } from "@/hooks/use-query-invalidation"; // In dev mode, connect directly to backend WS (bypass Vite proxy). // In production, use relative "/ws" path. @@ -55,5 +56,15 @@ export function WsProvider({ children }: { children: React.ReactNode }) { const value = useMemo(() => ({ ws, http }), [ws, http]); - return {children}; + return ( + + + {children} + + ); +} + +function WsQueryInvalidation() { + useWsQueryInvalidation(); + return null; } diff --git a/ui/web/src/components/shared/provider-model-select.tsx b/ui/web/src/components/shared/provider-model-select.tsx index bde3c182..d967bde3 100644 --- a/ui/web/src/components/shared/provider-model-select.tsx +++ b/ui/web/src/components/shared/provider-model-select.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Combobox } from "@/components/ui/combobox"; @@ -25,8 +25,14 @@ interface ProviderModelSelectProps { modelLabel?: string; providerPlaceholder?: string; modelPlaceholder?: string; - /** Show a "Check" verify button. */ + /** Show a "Check" verify button. When true, always shows. When omitted, auto-shows if savedProvider/savedModel are provided and values differ. */ showVerify?: boolean; + /** Saved provider value — when provided, verify button auto-shows on change. */ + savedProvider?: string; + /** Saved model value — when provided, verify button auto-shows on change. */ + savedModel?: string; + /** Called when verification status changes. True = save should be blocked (changed but not verified). */ + onSaveBlockedChange?: (blocked: boolean) => void; } export function ProviderModelSelect({ @@ -40,7 +46,10 @@ export function ProviderModelSelect({ modelLabel = "Model", providerPlaceholder = "Select provider", modelPlaceholder = "Enter or select model", - showVerify = false, + showVerify, + savedProvider, + savedModel, + onSaveBlockedChange, }: ProviderModelSelectProps) { const { providers } = useProviders(); const enabledProviders = providers.filter((p) => p.enabled); @@ -52,15 +61,21 @@ export function ProviderModelSelect({ const { models, loading: modelsLoading } = useProviderModels(selectedProviderId); const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify(); + const hasSavedValues = savedProvider !== undefined && savedModel !== undefined; + const llmChanged = hasSavedValues && (provider !== savedProvider || model !== savedModel); + const shouldShowVerify = showVerify ?? llmChanged; + + useEffect(() => { + resetVerify(); + }, [provider, model, resetVerify]); + + useEffect(() => { + onSaveBlockedChange?.(!!llmChanged && !verifyResult?.valid); + }, [llmChanged, verifyResult, onSaveBlockedChange]); + const handleProviderChange = (v: string) => { onProviderChange(v); onModelChange(""); - resetVerify(); - }; - - const handleModelChange = (v: string) => { - onModelChange(v); - resetVerify(); }; const handleVerify = async () => { @@ -99,12 +114,12 @@ export function ProviderModelSelect({
({ value: m.id, label: m.name }))} placeholder={modelsLoading ? "Loading models..." : modelPlaceholder} />
- {showVerify && ( + {shouldShowVerify && (
- {showVerify && verifyResult && ( + {shouldShowVerify && verifyResult && (

{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}

diff --git a/ui/web/src/hooks/use-query-invalidation.ts b/ui/web/src/hooks/use-query-invalidation.ts new file mode 100644 index 00000000..b404d490 --- /dev/null +++ b/ui/web/src/hooks/use-query-invalidation.ts @@ -0,0 +1,29 @@ +import { useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useWsEvent } from "./use-ws-event"; +import { Events } from "@/api/protocol"; +import { queryKeys } from "@/lib/query-keys"; +import type { AgentEventPayload } from "@/types/chat"; + +/** + * Listens to WebSocket events and invalidates relevant TanStack Query caches. + * Mount once at app level (e.g., in WsProvider or AppProviders). + */ +export function useWsQueryInvalidation() { + const queryClient = useQueryClient(); + + // When an agent run completes/fails → refresh sessions + traces + const handleAgentEvent = useCallback( + (payload: unknown) => { + const event = payload as AgentEventPayload; + if (!event) return; + if (event.type === "run.completed" || event.type === "run.failed") { + queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all }); + queryClient.invalidateQueries({ queryKey: queryKeys.traces.all }); + } + }, + [queryClient], + ); + + useWsEvent(Events.AGENT, handleAgentEvent); +} diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts new file mode 100644 index 00000000..3789bd9b --- /dev/null +++ b/ui/web/src/lib/query-keys.ts @@ -0,0 +1,58 @@ +export const queryKeys = { + providers: { + all: ["providers"] as const, + models: (providerId: string) => ["providers", providerId, "models"] as const, + }, + agents: { + all: ["agents"] as const, + detail: (id: string) => ["agents", id] as const, + files: (agentKey: string) => ["agents", agentKey, "files"] as const, + links: (agentId: string) => ["agents", agentId, "links"] as const, + }, + sessions: { + all: ["sessions"] as const, + list: (params: Record) => ["sessions", params] as const, + }, + traces: { + all: ["traces"] as const, + list: (params: Record) => ["traces", params] as const, + }, + customTools: { + all: ["customTools"] as const, + list: (params: Record) => ["customTools", params] as const, + }, + mcp: { + all: ["mcp"] as const, + }, + channels: { + all: ["channels"] as const, + list: (params: Record) => ["channels", params] as const, + }, + skills: { + all: ["skills"] as const, + }, + cron: { + all: ["cron"] as const, + }, + builtinTools: { + all: ["builtinTools"] as const, + }, + config: { + all: ["config"] as const, + }, + tts: { + all: ["tts"] as const, + }, + usage: { + all: ["usage"] as const, + records: (params: Record) => ["usage", "records", params] as const, + }, + delegations: { + all: ["delegations"] as const, + list: (params: Record) => ["delegations", params] as const, + }, + teams: { + all: ["teams"] as const, + detail: (id: string) => ["teams", id] as const, + }, +}; diff --git a/ui/web/src/pages/agents/agent-detail/general-sections/llm-config-section.tsx b/ui/web/src/pages/agents/agent-detail/general-sections/llm-config-section.tsx index e97a76b2..80b8ec01 100644 --- a/ui/web/src/pages/agents/agent-detail/general-sections/llm-config-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/general-sections/llm-config-section.tsx @@ -1,18 +1,6 @@ -import { useMemo, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; -import { Combobox } from "@/components/ui/combobox"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { useProviders } from "@/pages/providers/hooks/use-providers"; -import { useProviderModels } from "@/pages/providers/hooks/use-provider-models"; -import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify"; +import { ProviderModelSelect } from "@/components/shared/provider-model-select"; interface LlmConfigSectionProps { provider: string; @@ -42,97 +30,21 @@ export function LlmConfigSection({ savedModel, onSaveBlockedChange, }: LlmConfigSectionProps) { - const { providers } = useProviders(); - const enabledProviders = providers.filter((p) => p.enabled); - - const selectedProviderId = useMemo( - () => enabledProviders.find((p) => p.name === provider)?.id, - [enabledProviders, provider], - ); - const { models, loading: modelsLoading } = useProviderModels(selectedProviderId); - const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify(); - - const llmChanged = provider !== savedProvider || model !== savedModel; - - useEffect(() => { - resetVerify(); - }, [provider, model, resetVerify]); - - // Report save-blocked status to parent - useEffect(() => { - onSaveBlockedChange?.(llmChanged && !verifyResult?.valid); - }, [llmChanged, verifyResult, onSaveBlockedChange]); - - const handleVerify = async () => { - if (!selectedProviderId || !model.trim()) return; - await verify(selectedProviderId, model.trim()); - }; - return (

LLM Configuration

-
-
- - {enabledProviders.length > 0 ? ( - - ) : ( - onProviderChange(e.target.value)} - placeholder="openrouter" - /> - )} -
-
- -
-
- ({ value: m.id, label: m.name }))} - placeholder={modelsLoading ? "Loading models..." : "Enter or select model"} - /> -
- {llmChanged && ( - - )} -
- {verifyResult && ( -

- {verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"} -

- )} -
-
+
diff --git a/ui/web/src/pages/agents/hooks/use-agent-detail.ts b/ui/web/src/pages/agents/hooks/use-agent-detail.ts index 6aa542b8..e73f4502 100644 --- a/ui/web/src/pages/agents/hooks/use-agent-detail.ts +++ b/ui/web/src/pages/agents/hooks/use-agent-detail.ts @@ -1,28 +1,32 @@ -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useHttp, useWs } from "@/hooks/use-ws"; import { Methods } from "@/api/protocol"; +import { queryKeys } from "@/lib/query-keys"; import type { AgentData, BootstrapFile } from "@/types/agent"; +interface AgentDetailData { + agent: AgentData; + files: BootstrapFile[]; +} + export function useAgentDetail(agentId: string | undefined) { const http = useHttp(); const ws = useWs(); - const [agent, setAgent] = useState(null); - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); - const load = useCallback(async () => { - if (!agentId) return; - setLoading(true); - try { + const { data, isLoading: loading } = useQuery({ + queryKey: queryKeys.agents.detail(agentId ?? ""), + queryFn: async (): Promise => { // Try HTTP first (may fail with 403 if user isn't owner/shared) - let ag: AgentData | null = null; + let ag: AgentData; try { ag = await http.get(`/v1/agents/${agentId}`); } catch { // HTTP failed - construct minimal agent from agentId (which is the agent_key) ag = { - id: agentId, - agent_key: agentId, + id: agentId!, + agent_key: agentId!, owner_id: "", provider: "", model: "", @@ -35,34 +39,41 @@ export function useAgentDetail(agentId: string | undefined) { status: "active", }; } - setAgent(ag); // Load files via WS (no access control) + let files: BootstrapFile[] = []; if (ws.isConnected) { - const filesRes = await ws.call<{ files: BootstrapFile[] }>( - Methods.AGENTS_FILES_LIST, - { agentId: ag.agent_key }, - ); - setFiles(filesRes.files ?? []); + try { + const filesRes = await ws.call<{ files: BootstrapFile[] }>( + Methods.AGENTS_FILES_LIST, + { agentId: ag.agent_key }, + ); + files = filesRes.files ?? []; + } catch { + // ignore + } } - } catch { - // ignore - } finally { - setLoading(false); - } - }, [agentId, http, ws]); - useEffect(() => { - load(); - }, [load]); + return { agent: ag, files }; + }, + enabled: !!agentId, + }); + + const agent = data?.agent ?? null; + const files = data?.files ?? []; + + const invalidate = useCallback(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId ?? "") }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.all }); + }, [queryClient, agentId]); const updateAgent = useCallback( async (updates: Record) => { if (!agentId) return; await http.put(`/v1/agents/${agentId}`, updates); - load(); + await invalidate(); }, - [agentId, http, load], + [agentId, http, invalidate], ); const getFile = useCallback( @@ -85,9 +96,9 @@ export function useAgentDetail(agentId: string | undefined) { name, content, }); - load(); + await invalidate(); }, - [agent, ws, load], + [agent, ws, invalidate], ); const regenerateAgent = useCallback( @@ -103,5 +114,5 @@ export function useAgentDetail(agentId: string | undefined) { await http.post(`/v1/agents/${agentId}/resummon`); }, [agentId, http]); - return { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh: load }; + return { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh: invalidate }; } diff --git a/ui/web/src/pages/agents/hooks/use-agents.ts b/ui/web/src/pages/agents/hooks/use-agents.ts index a598e8b8..6a88fba1 100644 --- a/ui/web/src/pages/agents/hooks/use-agents.ts +++ b/ui/web/src/pages/agents/hooks/use-agents.ts @@ -1,7 +1,9 @@ -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useWs, useHttp } from "@/hooks/use-ws"; import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; +import { queryKeys } from "@/lib/query-keys"; import type { AgentData } from "@/types/agent"; interface AgentInfoWs { @@ -14,33 +16,25 @@ export function useAgents() { const ws = useWs(); const http = useHttp(); const connected = useAuthStore((s) => s.connected); - const [agents, setAgents] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { + const { data: agents = [], isLoading: loading, error: queryError } = useQuery({ + queryKey: queryKeys.agents.all, + queryFn: async () => { // Try HTTP first (returns full agent data, filtered by user access) - const res = await http.get<{ agents: AgentData[] }>("/v1/agents"); - if (res.agents && res.agents.length > 0) { - setAgents(res.agents); - setLoading(false); - return; + try { + const res = await http.get<{ agents: AgentData[] }>("/v1/agents"); + if (res.agents && res.agents.length > 0) { + return res.agents; + } + } catch { + // HTTP may fail if user doesn't have access - fall through to WS } - } catch { - // HTTP may fail if user doesn't have access - fall through to WS - } - // Fallback: WS agents.list returns all running agents (no access filter) - try { - if (!ws.isConnected) { - setLoading(false); - return; - } + // Fallback: WS agents.list returns all running agents (no access filter) + if (!ws.isConnected) return []; const res = await ws.call<{ agents: AgentInfoWs[] }>(Methods.AGENTS_LIST); - const wsAgents: AgentData[] = (res.agents ?? []).map((a) => ({ + return (res.agents ?? []).map((a): AgentData => ({ id: a.id, agent_key: a.id, owner_id: "", @@ -54,36 +48,33 @@ export function useAgents() { is_default: false, status: a.isRunning ? "running" : "idle", })); - setAgents(wsAgents); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load agents"); - } finally { - setLoading(false); - } - }, [http, ws]); + }, + enabled: connected, + }); - useEffect(() => { - if (connected) { - load(); - } - }, [connected, load]); + const error = queryError instanceof Error ? queryError.message : queryError ? "Failed to load agents" : null; + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.agents.all }), + [queryClient], + ); const createAgent = useCallback( async (data: Partial) => { const res = await http.post("/v1/agents", data); - setAgents((prev) => [...prev, res]); + await invalidate(); return res; }, - [http], + [http, invalidate], ); const deleteAgent = useCallback( async (id: string) => { await http.delete(`/v1/agents/${id}`); - setAgents((prev) => prev.filter((a) => a.id !== id)); + await invalidate(); }, - [http], + [http, invalidate], ); - return { agents, loading, error, refresh: load, createAgent, deleteAgent }; + return { agents, loading, error, refresh: invalidate, createAgent, deleteAgent }; } diff --git a/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts b/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts index fe0fbac5..0d78f676 100644 --- a/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts +++ b/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts @@ -1,5 +1,7 @@ -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; export interface BuiltinToolData { name: string; @@ -16,32 +18,28 @@ export interface BuiltinToolData { export function useBuiltinTools() { const http = useHttp(); - const [tools, setTools] = useState([]); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); - const load = useCallback(async () => { - setLoading(true); - try { + const { data: tools = [], isLoading: loading } = useQuery({ + queryKey: queryKeys.builtinTools.all, + queryFn: async () => { const res = await http.get<{ tools: BuiltinToolData[] }>("/v1/tools/builtin"); - setTools(res.tools ?? []); - } catch { - // ignore - } finally { - setLoading(false); - } - }, [http]); + return res.tools ?? []; + }, + }); - useEffect(() => { - load(); - }, [load]); + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.builtinTools.all }), + [queryClient], + ); const updateTool = useCallback( async (name: string, data: { enabled?: boolean; settings?: Record }) => { await http.put(`/v1/tools/builtin/${name}`, data); - await load(); + await invalidate(); }, - [http, load], + [http, invalidate], ); - return { tools, loading, refresh: load, updateTool }; + return { tools, loading, refresh: invalidate, updateTool }; } diff --git a/ui/web/src/pages/channels/channels-page.tsx b/ui/web/src/pages/channels/channels-page.tsx index 126e7139..da3326fe 100644 --- a/ui/web/src/pages/channels/channels-page.tsx +++ b/ui/web/src/pages/channels/channels-page.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useRef } from "react"; import { Radio, Plus, RefreshCw, Pencil, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -15,25 +15,46 @@ import { ChannelsStatusView, channelTypeLabels } from "./channels-status-view"; import { useAgents } from "@/pages/agents/hooks/use-agents"; import { useMinLoading } from "@/hooks/use-min-loading"; import { useDeferredLoading } from "@/hooks/use-deferred-loading"; -import { usePagination } from "@/hooks/use-pagination"; +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; export function ChannelsPage() { const { channels, loading: statusLoading, refresh: refreshStatus } = useChannels(); + + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [formOpen, setFormOpen] = useState(false); + const [editInstance, setEditInstance] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + + const pendingSearchRef = useRef(""); + const flushSearch = useDebouncedCallback(() => { + setDebouncedSearch(pendingSearchRef.current); + setPage(1); + }, 300); + + const handleSearchChange = (v: string) => { + setSearch(v); + pendingSearchRef.current = v; + flushSearch(); + }; + const { - instances, loading: instancesLoading, supported, + instances, total, loading: instancesLoading, supported, refresh: refreshInstances, createInstance, updateInstance, deleteInstance, - } = useChannelInstances(); + } = useChannelInstances({ + search: debouncedSearch || undefined, + limit: pageSize, + offset: (page - 1) * pageSize, + }); const { agents } = useAgents(); const loading = statusLoading || instancesLoading; const spinning = useMinLoading(loading); const showSkeleton = useDeferredLoading(loading && instances.length === 0); - - const [search, setSearch] = useState(""); - const [formOpen, setFormOpen] = useState(false); - const [editInstance, setEditInstance] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleteLoading, setDeleteLoading] = useState(false); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const refresh = () => { refreshStatus(); @@ -45,17 +66,6 @@ export function ChannelsPage() { return ; } - const filtered = instances.filter( - (inst) => - inst.name.toLowerCase().includes(search.toLowerCase()) || - (inst.display_name || "").toLowerCase().includes(search.toLowerCase()) || - inst.channel_type.toLowerCase().includes(search.toLowerCase()), - ); - - const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered); - - useEffect(() => { resetPage(); }, [search, resetPage]); - const handleCreate = async (data: ChannelInstanceInput) => { await createInstance(data); }; @@ -105,7 +115,7 @@ export function ChannelsPage() {
@@ -114,11 +124,11 @@ export function ChannelsPage() {
{showSkeleton ? ( - ) : filtered.length === 0 ? ( + ) : instances.length === 0 ? ( ) : (
@@ -134,7 +144,7 @@ export function ChannelsPage() { - {pageItems.map((inst) => { + {instances.map((inst) => { const status = getStatus(inst.name); return ( @@ -203,12 +213,12 @@ export function ChannelsPage() { { setPageSize(size); setPage(1); }} />
)} diff --git a/ui/web/src/pages/channels/hooks/use-channel-instances.ts b/ui/web/src/pages/channels/hooks/use-channel-instances.ts index c5fae18c..c73ee124 100644 --- a/ui/web/src/pages/channels/hooks/use-channel-instances.ts +++ b/ui/web/src/pages/channels/hooks/use-channel-instances.ts @@ -1,80 +1,79 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { ChannelInstanceData, ChannelInstanceInput } from "@/types/channel"; -export interface ChannelInstanceData { - id: string; - name: string; - display_name: string; - channel_type: string; - agent_id: string; - config: Record | null; - enabled: boolean; - is_default: boolean; - created_by: string; - created_at: string; - updated_at: string; +export type { ChannelInstanceData, ChannelInstanceInput }; + +export interface ChannelInstanceFilters { + search?: string; + limit?: number; + offset?: number; } -export interface ChannelInstanceInput { - name: string; - display_name?: string; - channel_type: string; - agent_id: string; - credentials?: Record; // JSON object, encrypted server-side - config?: Record; - enabled?: boolean; -} - -export function useChannelInstances() { +export function useChannelInstances(filters: ChannelInstanceFilters = {}) { const http = useHttp(); - const [instances, setInstances] = useState([]); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); const [supported, setSupported] = useState(true); // false if standalone mode (404) - const load = useCallback(async () => { - setLoading(true); - try { - const res = await http.get<{ instances: ChannelInstanceData[] }>("/v1/channels/instances"); - setInstances(res.instances ?? []); - setSupported(true); - } catch (err: unknown) { - // 404 means standalone mode — channel instances not available - if (err instanceof Error && err.message.includes("404")) { - setSupported(false); - } - } finally { - setLoading(false); - } - }, [http]); + const queryKey = queryKeys.channels.list({ ...filters }); - useEffect(() => { - load(); - }, [load]); + const { data, isLoading: loading } = useQuery({ + queryKey, + queryFn: async () => { + try { + const params: Record = {}; + if (filters.search) params.search = filters.search; + if (filters.limit) params.limit = String(filters.limit); + if (filters.offset !== undefined) params.offset = String(filters.offset); + + const res = await http.get<{ instances: ChannelInstanceData[]; total?: number }>("/v1/channels/instances", params); + setSupported(true); + return { instances: res.instances ?? [], total: res.total ?? 0 }; + } catch (err: unknown) { + // 404 means standalone mode — channel instances not available + if (err instanceof Error && err.message.includes("404")) { + setSupported(false); + } + return { instances: [], total: 0 }; + } + }, + placeholderData: (prev) => prev, + }); + + const instances = data?.instances ?? []; + const total = data?.total ?? 0; + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.channels.all }), + [queryClient], + ); const createInstance = useCallback( async (data: ChannelInstanceInput) => { const res = await http.post<{ id: string }>("/v1/channels/instances", data); - await load(); + await invalidate(); return res; }, - [http, load], + [http, invalidate], ); const updateInstance = useCallback( async (id: string, data: Partial) => { await http.put(`/v1/channels/instances/${id}`, data); - await load(); + await invalidate(); }, - [http, load], + [http, invalidate], ); const deleteInstance = useCallback( async (id: string) => { await http.delete(`/v1/channels/instances/${id}`); - await load(); + await invalidate(); }, - [http, load], + [http, invalidate], ); - return { instances, loading, supported, refresh: load, createInstance, updateInstance, deleteInstance }; + return { instances, total, loading, supported, refresh: invalidate, createInstance, updateInstance, deleteInstance }; } diff --git a/ui/web/src/pages/config/hooks/use-config.ts b/ui/web/src/pages/config/hooks/use-config.ts index 85af2e91..9c58e8f3 100644 --- a/ui/web/src/pages/config/hooks/use-config.ts +++ b/ui/web/src/pages/config/hooks/use-config.ts @@ -1,38 +1,40 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useCallback, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useWs } from "@/hooks/use-ws"; import { Methods } from "@/api/protocol"; +import { queryKeys } from "@/lib/query-keys"; + +interface ConfigData { + config: Record; + hash: string; + path: string; +} export function useConfig() { const ws = useWs(); - const [config, setConfig] = useState | null>(null); - const [hash, setHash] = useState(""); - const [configPath, setConfigPath] = useState(""); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const hashRef = useRef(""); - const load = useCallback(async () => { - if (!ws.isConnected) return; - setLoading(true); - try { - const res = await ws.call<{ - config: Record; - hash: string; - path: string; - }>(Methods.CONFIG_GET); - setConfig(res.config); - setHash(res.hash); - setConfigPath(res.path); - } catch { - // ignore - } finally { - setLoading(false); - } - }, [ws]); + const { data, isLoading: loading } = useQuery({ + queryKey: queryKeys.config.all, + queryFn: async (): Promise => { + if (!ws.isConnected) return { config: {}, hash: "", path: "" }; + const res = await ws.call(Methods.CONFIG_GET); + hashRef.current = res.hash; + return res; + }, + }); - useEffect(() => { - load(); - }, [load]); + const config = data?.config ?? null; + const hash = data?.hash ?? ""; + const configPath = data?.path ?? ""; + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.config.all }), + [queryClient], + ); const applyRaw = useCallback( async (raw: string) => { @@ -41,10 +43,10 @@ export function useConfig() { try { const res = await ws.call<{ hash: string }>(Methods.CONFIG_APPLY, { raw, - baseHash: hash, + baseHash: hashRef.current, }); - setHash(res.hash); - load(); + hashRef.current = res.hash; + await invalidate(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to apply config"); throw err; @@ -52,7 +54,7 @@ export function useConfig() { setSaving(false); } }, - [ws, hash, load], + [ws, invalidate], ); const patch = useCallback( @@ -61,7 +63,7 @@ export function useConfig() { setError(null); try { await ws.call(Methods.CONFIG_PATCH, updates); - load(); + await invalidate(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to patch config"); throw err; @@ -69,8 +71,8 @@ export function useConfig() { setSaving(false); } }, - [ws, load], + [ws, invalidate], ); - return { config, hash, configPath, loading, saving, error, refresh: load, applyRaw, patch }; + return { config, hash, configPath, loading, saving, error, refresh: invalidate, applyRaw, patch }; } diff --git a/ui/web/src/pages/cron/hooks/use-cron.ts b/ui/web/src/pages/cron/hooks/use-cron.ts index d961ba73..e5b5b845 100644 --- a/ui/web/src/pages/cron/hooks/use-cron.ts +++ b/ui/web/src/pages/cron/hooks/use-cron.ts @@ -1,6 +1,8 @@ -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useWs } from "@/hooks/use-ws"; import { Methods } from "@/api/protocol"; +import { queryKeys } from "@/lib/query-keys"; export interface CronSchedule { kind: "at" | "every" | "cron"; @@ -46,27 +48,23 @@ export interface CronRunLogEntry { export function useCron() { const ws = useWs(); - const [jobs, setJobs] = useState([]); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); - const load = useCallback(async () => { - if (!ws.isConnected) return; - setLoading(true); - try { + const { data: jobs = [], isLoading: loading } = useQuery({ + queryKey: queryKeys.cron.all, + queryFn: async () => { + if (!ws.isConnected) return []; const res = await ws.call<{ jobs: CronJob[] }>(Methods.CRON_LIST, { includeDisabled: true, }); - setJobs(res.jobs ?? []); - } catch { - // ignore - } finally { - setLoading(false); - } - }, [ws]); + return res.jobs ?? []; + }, + }); - useEffect(() => { - load(); - }, [load]); + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.cron.all }), + [queryClient], + ); const createJob = useCallback( async (params: { @@ -79,25 +77,25 @@ export function useCron() { to?: string; }) => { await ws.call(Methods.CRON_CREATE, params); - load(); + await invalidate(); }, - [ws, load], + [ws, invalidate], ); const toggleJob = useCallback( async (jobId: string, enabled: boolean) => { await ws.call(Methods.CRON_TOGGLE, { jobId, enabled }); - load(); + await invalidate(); }, - [ws, load], + [ws, invalidate], ); const deleteJob = useCallback( async (jobId: string) => { await ws.call(Methods.CRON_DELETE, { jobId }); - load(); + await invalidate(); }, - [ws, load], + [ws, invalidate], ); const runJob = useCallback( @@ -119,5 +117,5 @@ export function useCron() { [ws], ); - return { jobs, loading, refresh: load, createJob, toggleJob, deleteJob, runJob, getRunLog }; + return { jobs, loading, refresh: invalidate, createJob, toggleJob, deleteJob, runJob, getRunLog }; } diff --git a/ui/web/src/pages/custom-tools/custom-tools-page.tsx b/ui/web/src/pages/custom-tools/custom-tools-page.tsx index 545d60cf..955ba9a5 100644 --- a/ui/web/src/pages/custom-tools/custom-tools-page.tsx +++ b/ui/web/src/pages/custom-tools/custom-tools-page.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Wrench, Plus, RefreshCw, Pencil, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -12,27 +12,39 @@ import { useCustomTools, type CustomToolData, type CustomToolInput } from "./hoo import { CustomToolFormDialog } from "./custom-tool-form-dialog"; import { useMinLoading } from "@/hooks/use-min-loading"; import { useDeferredLoading } from "@/hooks/use-deferred-loading"; -import { usePagination } from "@/hooks/use-pagination"; +import { useRef } from "react"; +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; export function CustomToolsPage() { - const { tools, loading, refresh, createTool, updateTool, deleteTool } = useCustomTools(); - const spinning = useMinLoading(loading); - const showSkeleton = useDeferredLoading(loading && tools.length === 0); const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); const [formOpen, setFormOpen] = useState(false); const [editTool, setEditTool] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); - const filtered = tools.filter( - (t) => - t.name.toLowerCase().includes(search.toLowerCase()) || - t.description.toLowerCase().includes(search.toLowerCase()), - ); + const pendingSearchRef = useRef(""); + const flushSearch = useDebouncedCallback(() => { + setDebouncedSearch(pendingSearchRef.current); + setPage(1); + }, 300); - const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered); + const handleSearchChange = (v: string) => { + setSearch(v); + pendingSearchRef.current = v; + flushSearch(); + }; - useEffect(() => { resetPage(); }, [search, resetPage]); + const { tools, total, loading, refresh, createTool, updateTool, deleteTool } = useCustomTools({ + search: debouncedSearch || undefined, + limit: pageSize, + offset: (page - 1) * pageSize, + }); + const spinning = useMinLoading(loading); + const showSkeleton = useDeferredLoading(loading && tools.length === 0); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const handleCreate = async (data: CustomToolInput) => { await createTool(data); @@ -74,7 +86,7 @@ export function CustomToolsPage() {
@@ -83,11 +95,11 @@ export function CustomToolsPage() {
{showSkeleton ? ( - ) : filtered.length === 0 ? ( + ) : tools.length === 0 ? ( ) : (
@@ -103,7 +115,7 @@ export function CustomToolsPage() { - {pageItems.map((tool) => ( + {tools.map((tool) => (
@@ -150,12 +162,12 @@ export function CustomToolsPage() { { setPageSize(size); setPage(1); }} />
)} diff --git a/ui/web/src/pages/custom-tools/hooks/use-custom-tools.ts b/ui/web/src/pages/custom-tools/hooks/use-custom-tools.ts index b3dcb1da..85800de0 100644 --- a/ui/web/src/pages/custom-tools/hooks/use-custom-tools.ts +++ b/ui/web/src/pages/custom-tools/hooks/use-custom-tools.ts @@ -1,77 +1,69 @@ -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { CustomToolData, CustomToolInput } from "@/types/custom-tool"; -export interface CustomToolData { - id: string; - name: string; - description: string; - parameters: Record | null; - command: string; - working_dir: string; - timeout_seconds: number; - agent_id: string | null; - enabled: boolean; - created_by: string; - created_at: string; - updated_at: string; +export type { CustomToolData, CustomToolInput }; + +export interface CustomToolFilters { + search?: string; + limit?: number; + offset?: number; } -export interface CustomToolInput { - name: string; - description?: string; - parameters?: Record; - command: string; - working_dir?: string; - timeout_seconds?: number; - agent_id?: string; - enabled?: boolean; -} - -export function useCustomTools() { +export function useCustomTools(filters: CustomToolFilters = {}) { const http = useHttp(); - const [tools, setTools] = useState([]); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); - const load = useCallback(async () => { - setLoading(true); - try { - const res = await http.get<{ tools: CustomToolData[] }>("/v1/tools/custom"); - setTools(res.tools ?? []); - } catch { - // ignore - } finally { - setLoading(false); - } - }, [http]); + const queryKey = queryKeys.customTools.list({ ...filters }); - useEffect(() => { - load(); - }, [load]); + const { data, isLoading: loading } = useQuery({ + queryKey, + queryFn: async () => { + const params: Record = {}; + if (filters.search) params.search = filters.search; + if (filters.limit) params.limit = String(filters.limit); + if (filters.offset !== undefined) params.offset = String(filters.offset); + + const res = await http.get<{ tools: CustomToolData[]; total?: number }>("/v1/tools/custom", params); + return { tools: res.tools ?? [], total: res.total ?? 0 }; + }, + placeholderData: (prev) => prev, + }); + + const tools = data?.tools ?? []; + const total = data?.total ?? 0; + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.customTools.all }), + [queryClient], + ); const createTool = useCallback( async (data: CustomToolInput) => { const res = await http.post<{ id: string }>("/v1/tools/custom", data); - await load(); + await invalidate(); return res; }, - [http, load], + [http, invalidate], ); const updateTool = useCallback( async (id: string, data: Partial) => { await http.put(`/v1/tools/custom/${id}`, data); - await load(); + await invalidate(); }, - [http, load], + [http, invalidate], ); const deleteTool = useCallback( async (id: string) => { await http.delete(`/v1/tools/custom/${id}`); - await load(); + await invalidate(); }, - [http, load], + [http, invalidate], ); - return { tools, loading, refresh: load, createTool, updateTool, deleteTool }; + return { tools, total, loading, refresh: invalidate, createTool, updateTool, deleteTool }; } diff --git a/ui/web/src/pages/login/token-form.tsx b/ui/web/src/pages/login/token-form.tsx index e89030ab..d4801f6a 100644 --- a/ui/web/src/pages/login/token-form.tsx +++ b/ui/web/src/pages/login/token-form.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { AlertCircle } from "lucide-react"; interface TokenFormProps { onSubmit: (userId: string, token: string) => void; @@ -7,11 +8,41 @@ interface TokenFormProps { export function TokenForm({ onSubmit }: TokenFormProps) { const [userId, setUserId] = useState(""); const [token, setToken] = useState(""); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(null); - function handleSubmit(e: React.FormEvent) { + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!token.trim() || !userId.trim()) return; - onSubmit(userId.trim(), token.trim()); + + setConnecting(true); + setError(null); + + try { + // Verify connectivity and credentials before navigating + const res = await fetch("/v1/agents", { + headers: { + Authorization: `Bearer ${token.trim()}`, + "X-GoClaw-User-Id": userId.trim(), + }, + }); + + if (res.status === 401) { + setError("Invalid credentials. Check your token and user ID."); + return; + } + + if (!res.ok) { + setError(`Server returned ${res.status}. Please try again.`); + return; + } + + onSubmit(userId.trim(), token.trim()); + } catch { + setError("Cannot connect to gateway. Check if the server is running."); + } finally { + setConnecting(false); + } } return ( @@ -24,10 +55,11 @@ export function TokenForm({ onSubmit }: TokenFormProps) { id="userId" type="text" value={userId} - onChange={(e) => setUserId(e.target.value)} + onChange={(e) => { setUserId(e.target.value); setError(null); }} placeholder="your-user-id" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" autoFocus + disabled={connecting} />
@@ -39,18 +71,26 @@ export function TokenForm({ onSubmit }: TokenFormProps) { id="token" type="password" value={token} - onChange={(e) => setToken(e.target.value)} + onChange={(e) => { setToken(e.target.value); setError(null); }} placeholder="Bearer token" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + disabled={connecting} />
+ {error && ( +
+ + {error} +
+ )} + ); diff --git a/ui/web/src/pages/mcp/hooks/use-mcp.ts b/ui/web/src/pages/mcp/hooks/use-mcp.ts index 995d7002..f566f017 100644 --- a/ui/web/src/pages/mcp/hooks/use-mcp.ts +++ b/ui/web/src/pages/mcp/hooks/use-mcp.ts @@ -1,98 +1,56 @@ -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { MCPServerData, MCPServerInput, MCPAgentGrant } from "@/types/mcp"; -export interface MCPServerData { - id: string; - name: string; - display_name: string; - transport: "stdio" | "sse" | "streamable-http"; - command: string; - args: string[] | null; - url: string; - headers: Record | null; - env: Record | null; - tool_prefix: string; - timeout_sec: number; - enabled: boolean; - created_by: string; - created_at: string; - updated_at: string; -} - -export interface MCPServerInput { - name: string; - display_name?: string; - transport: string; - command?: string; - args?: string[]; - url?: string; - headers?: Record; - tool_prefix?: string; - timeout_sec?: number; - enabled?: boolean; -} - -export interface MCPAgentGrant { - id: string; - server_id: string; - agent_id: string; - enabled: boolean; - tool_allow: string[] | null; - tool_deny: string[] | null; - granted_by: string; - created_at: string; -} +export type { MCPServerData, MCPServerInput, MCPAgentGrant }; export function useMCP() { const http = useHttp(); - const [servers, setServers] = useState([]); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); - const load = useCallback(async () => { - setLoading(true); - try { + const { data: servers = [], isLoading: loading } = useQuery({ + queryKey: queryKeys.mcp.all, + queryFn: async () => { const res = await http.get<{ servers: MCPServerData[] }>("/v1/mcp/servers"); - setServers(res.servers ?? []); - } catch { - // ignore - } finally { - setLoading(false); - } - }, [http]); + return res.servers ?? []; + }, + }); - useEffect(() => { - load(); - }, [load]); + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.mcp.all }), + [queryClient], + ); const createServer = useCallback( async (data: MCPServerInput) => { const res = await http.post("/v1/mcp/servers", data); - await load(); + await invalidate(); return res; }, - [http, load], + [http, invalidate], ); const updateServer = useCallback( async (id: string, data: Partial) => { await http.put(`/v1/mcp/servers/${id}`, data); - await load(); + await invalidate(); }, - [http, load], + [http, invalidate], ); const deleteServer = useCallback( async (id: string) => { await http.delete(`/v1/mcp/servers/${id}`); - await load(); + await invalidate(); }, - [http, load], + [http, invalidate], ); const listAgentGrants = useCallback( async (_serverId: string) => { // Backend provides per-agent listing, not per-server. - // Grants dialog handles this by accepting agent IDs. return [] as MCPAgentGrant[]; }, [], @@ -127,7 +85,7 @@ export function useMCP() { return { servers, loading, - refresh: load, + refresh: invalidate, createServer, updateServer, deleteServer, diff --git a/ui/web/src/pages/providers/hooks/use-provider-models.ts b/ui/web/src/pages/providers/hooks/use-provider-models.ts index 439bfed6..6030dee1 100644 --- a/ui/web/src/pages/providers/hooks/use-provider-models.ts +++ b/ui/web/src/pages/providers/hooks/use-provider-models.ts @@ -1,40 +1,23 @@ -import { useState, useEffect, useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { ModelInfo } from "@/types/provider"; -export interface ModelInfo { - id: string; - name?: string; -} +export type { ModelInfo }; export function useProviderModels(providerId: string | undefined) { const http = useHttp(); - const [models, setModels] = useState([]); - const [loading, setLoading] = useState(false); - const load = useCallback( - async (id: string) => { - setLoading(true); - try { - const res = await http.get<{ models: ModelInfo[] }>( - `/v1/providers/${id}/models`, - ); - setModels(res.models ?? []); - } catch { - setModels([]); - } finally { - setLoading(false); - } + const { data: models = [], isLoading: loading } = useQuery({ + queryKey: queryKeys.providers.models(providerId ?? ""), + queryFn: async () => { + const res = await http.get<{ models: ModelInfo[] }>( + `/v1/providers/${providerId}/models`, + ); + return res.models ?? []; }, - [http], - ); - - useEffect(() => { - if (!providerId) { - setModels([]); - return; - } - load(providerId); - }, [providerId, load]); + enabled: !!providerId, + }); return { models, loading }; } diff --git a/ui/web/src/pages/providers/hooks/use-providers.ts b/ui/web/src/pages/providers/hooks/use-providers.ts index aae1f94e..1edf6919 100644 --- a/ui/web/src/pages/providers/hooks/use-providers.ts +++ b/ui/web/src/pages/providers/hooks/use-providers.ts @@ -1,77 +1,57 @@ -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { ProviderData, ProviderInput } from "@/types/provider"; -export interface ProviderData { - id: string; - name: string; - display_name: string; - provider_type: string; - api_base: string; - api_key: string; // masked "***" from server - enabled: boolean; - created_at: string; - updated_at: string; -} - -export interface ProviderInput { - name: string; - display_name?: string; - provider_type: string; - api_base?: string; - api_key?: string; - enabled?: boolean; -} +export type { ProviderData, ProviderInput }; export function useProviders() { const http = useHttp(); - const [providers, setProviders] = useState([]); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); - const load = useCallback(async () => { - setLoading(true); - try { + const { data: providers = [], isLoading: loading } = useQuery({ + queryKey: queryKeys.providers.all, + queryFn: async () => { const res = await http.get<{ providers: ProviderData[] }>("/v1/providers"); - setProviders(res.providers ?? []); - } catch { - // ignore - } finally { - setLoading(false); - } - }, [http]); + return res.providers ?? []; + }, + }); - useEffect(() => { - load(); - }, [load]); + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.providers.all }), + [queryClient], + ); const createProvider = useCallback( async (data: ProviderInput) => { const res = await http.post("/v1/providers", data); - await load(); + await invalidate(); return res; }, - [http, load], + [http, invalidate], ); const updateProvider = useCallback( async (id: string, data: Partial) => { await http.put(`/v1/providers/${id}`, data); - await load(); + await invalidate(); }, - [http, load], + [http, invalidate], ); const deleteProvider = useCallback( async (id: string) => { await http.delete(`/v1/providers/${id}`); - await load(); + await invalidate(); }, - [http, load], + [http, invalidate], ); return { providers, loading, - refresh: load, + refresh: invalidate, createProvider, updateProvider, deleteProvider, diff --git a/ui/web/src/pages/sessions/hooks/use-sessions.ts b/ui/web/src/pages/sessions/hooks/use-sessions.ts index c56c5b0b..b4a1813b 100644 --- a/ui/web/src/pages/sessions/hooks/use-sessions.ts +++ b/ui/web/src/pages/sessions/hooks/use-sessions.ts @@ -1,54 +1,44 @@ -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useWs } from "@/hooks/use-ws"; -import { useWsEvent } from "@/hooks/use-ws-event"; -import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; -import { Methods, Events } from "@/api/protocol"; +import { Methods } from "@/api/protocol"; +import { queryKeys } from "@/lib/query-keys"; import type { SessionInfo, SessionPreview, Message } from "@/types/session"; -import type { AgentEventPayload } from "@/types/chat"; -export function useSessions(agentFilter?: string) { +interface UseSessionsOptions { + agentFilter?: string; + limit?: number; + offset?: number; +} + +export function useSessions(opts: UseSessionsOptions = {}) { const ws = useWs(); - const [sessions, setSessions] = useState([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); + const { agentFilter, limit, offset } = opts; - const load = useCallback(async (opts?: { limit?: number; offset?: number }) => { - if (!ws.isConnected) return; - setLoading(true); - try { + const queryKey = queryKeys.sessions.list({ agentFilter, limit, offset }); + + const { data, isLoading: loading } = useQuery({ + queryKey, + queryFn: async () => { + if (!ws.isConnected) return { sessions: [] as SessionInfo[], total: 0 }; const res = await ws.call<{ sessions: SessionInfo[]; total?: number }>(Methods.SESSIONS_LIST, { agentId: agentFilter || undefined, - limit: opts?.limit, - offset: opts?.offset, + limit, + offset, }); - setSessions(res.sessions ?? []); - setTotal(res.total ?? 0); - } catch { - // ignore - } finally { - setLoading(false); - } - }, [ws, agentFilter]); - - useEffect(() => { - load(); - }, [load]); - - // Auto-refresh when any agent run completes (from any channel) - const debouncedRefresh = useDebouncedCallback(load, 2000); - - const handleAgentEvent = useCallback( - (payload: unknown) => { - const event = payload as AgentEventPayload; - if (!event) return; - if (event.type === "run.completed" || event.type === "run.failed") { - debouncedRefresh(); - } + return { sessions: res.sessions ?? [], total: res.total ?? 0 }; }, - [debouncedRefresh], - ); + placeholderData: (prev) => prev, + }); - useWsEvent(Events.AGENT, handleAgentEvent); + const sessions = data?.sessions ?? []; + const total = data?.total ?? 0; + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all }), + [queryClient], + ); const preview = useCallback( async (key: string): Promise => { @@ -66,28 +56,28 @@ export function useSessions(agentFilter?: string) { async (key: string) => { if (!ws.isConnected) return; await ws.call(Methods.SESSIONS_DELETE, { key }); - setSessions((prev) => prev.filter((s) => s.key !== key)); + await invalidate(); }, - [ws], + [ws, invalidate], ); const resetSession = useCallback( async (key: string) => { if (!ws.isConnected) return; await ws.call(Methods.SESSIONS_RESET, { key }); - load(); + await invalidate(); }, - [ws, load], + [ws, invalidate], ); const patchSession = useCallback( async (key: string, updates: { label?: string; model?: string }) => { if (!ws.isConnected) return; await ws.call(Methods.SESSIONS_PATCH, { key, ...updates }); - load(); + await invalidate(); }, - [ws, load], + [ws, invalidate], ); - return { sessions, total, loading, refresh: load, preview, deleteSession, resetSession, patchSession }; + return { sessions, total, loading, refresh: invalidate, preview, deleteSession, resetSession, patchSession }; } diff --git a/ui/web/src/pages/sessions/sessions-page.tsx b/ui/web/src/pages/sessions/sessions-page.tsx index 114d0173..94eb57c6 100644 --- a/ui/web/src/pages/sessions/sessions-page.tsx +++ b/ui/web/src/pages/sessions/sessions-page.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useParams, useNavigate } from "react-router"; import { History } from "lucide-react"; import { PageHeader } from "@/components/shared/page-header"; @@ -17,17 +17,17 @@ import type { SessionInfo } from "@/types/session"; export function SessionsPage() { const { key: detailKey } = useParams<{ key: string }>(); const navigate = useNavigate(); - const { sessions, total, loading, refresh, preview, deleteSession, resetSession } = useSessions(); - const showSkeleton = useDeferredLoading(loading && sessions.length === 0); const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); - const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const { sessions, total, loading, preview, deleteSession, resetSession } = useSessions({ + limit: pageSize, + offset: (page - 1) * pageSize, + }); + const showSkeleton = useDeferredLoading(loading && sessions.length === 0); - useEffect(() => { - refresh({ limit: pageSize, offset: (page - 1) * pageSize }); - }, [page, pageSize]); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const detailSession = detailKey ? sessions.find((s) => s.key === decodeURIComponent(detailKey)) diff --git a/ui/web/src/pages/skills/hooks/use-skills.ts b/ui/web/src/pages/skills/hooks/use-skills.ts index fb296890..b40ebd51 100644 --- a/ui/web/src/pages/skills/hooks/use-skills.ts +++ b/ui/web/src/pages/skills/hooks/use-skills.ts @@ -1,36 +1,30 @@ -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useWs, useHttp } from "@/hooks/use-ws"; import { Methods } from "@/api/protocol"; +import { queryKeys } from "@/lib/query-keys"; +import type { SkillInfo } from "@/types/skill"; -export interface SkillInfo { - id?: string; - name: string; - description: string; - source: string; -} +export type { SkillInfo }; export function useSkills() { const ws = useWs(); const http = useHttp(); - const [skills, setSkills] = useState([]); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); - const load = useCallback(async () => { - if (!ws.isConnected) return; - setLoading(true); - try { + const { data: skills = [], isLoading: loading } = useQuery({ + queryKey: queryKeys.skills.all, + queryFn: async () => { + if (!ws.isConnected) return []; const res = await ws.call<{ skills: SkillInfo[] }>(Methods.SKILLS_LIST); - setSkills(res.skills ?? []); - } catch { - // ignore - } finally { - setLoading(false); - } - }, [ws]); + return res.skills ?? []; + }, + }); - useEffect(() => { - load(); - }, [load]); + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.skills.all }), + [queryClient], + ); const getSkill = useCallback( async (name: string) => { @@ -44,20 +38,24 @@ export function useSkills() { async (file: File) => { const formData = new FormData(); formData.append("file", file); - return http.upload<{ id: string; slug: string; version: number; name: string }>( + const res = await http.upload<{ id: string; slug: string; version: number; name: string }>( "/v1/skills/upload", formData, ); + await invalidate(); + return res; }, - [http], + [http, invalidate], ); const deleteSkill = useCallback( async (id: string) => { - return http.delete<{ ok: string }>(`/v1/skills/${id}`); + const res = await http.delete<{ ok: string }>(`/v1/skills/${id}`); + await invalidate(); + return res; }, - [http], + [http, invalidate], ); - return { skills, loading, refresh: load, getSkill, uploadSkill, deleteSkill }; + return { skills, loading, refresh: invalidate, getSkill, uploadSkill, deleteSkill }; } diff --git a/ui/web/src/pages/traces/hooks/use-traces.ts b/ui/web/src/pages/traces/hooks/use-traces.ts index fa3ac35d..585e68d3 100644 --- a/ui/web/src/pages/traces/hooks/use-traces.ts +++ b/ui/web/src/pages/traces/hooks/use-traces.ts @@ -1,59 +1,12 @@ -import { useState, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { TraceData, SpanData } from "@/types/trace"; -export interface TraceData { - id: string; - parent_trace_id?: string; - agent_id?: string; - user_id: string; - session_key: string; - run_id: string; - start_time: string; - end_time?: string; - duration_ms: number; - name: string; - channel: string; - input_preview: string; - output_preview: string; - total_input_tokens: number; - total_output_tokens: number; - total_cost: number; - span_count: number; - llm_call_count: number; - tool_call_count: number; - status: string; - error?: string; - tags?: string[]; - metadata?: { total_cache_read_tokens?: number; total_cache_creation_tokens?: number }; - created_at: string; -} +export type { TraceData, SpanData }; -export interface SpanData { - id: string; - trace_id: string; - parent_span_id?: string; - agent_id?: string; - span_type: string; - name: string; - start_time: string; - end_time?: string; - duration_ms: number; - status: string; - error?: string; - model: string; - provider: string; - input_tokens: number; - output_tokens: number; - total_cost: number; - finish_reason: string; - tool_name: string; - tool_call_id: string; - input_preview: string; - output_preview: string; - metadata?: { cache_creation_tokens?: number; cache_read_tokens?: number; thinking_tokens?: number }; -} - -interface TraceFilters { +export interface TraceFilters { agentId?: string; userId?: string; status?: string; @@ -61,33 +14,34 @@ interface TraceFilters { offset?: number; } -export function useTraces() { +export function useTraces(filters: TraceFilters = {}) { const http = useHttp(); - const [traces, setTraces] = useState([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); - const load = useCallback( - async (filters: TraceFilters = {}) => { - setLoading(true); - try { - const params: Record = {}; - if (filters.agentId) params.agent_id = filters.agentId; - if (filters.userId) params.user_id = filters.userId; - if (filters.status) params.status = filters.status; - if (filters.limit) params.limit = String(filters.limit); - if (filters.offset !== undefined) params.offset = String(filters.offset); + const queryKey = queryKeys.traces.list({ ...filters }); - const res = await http.get<{ traces: TraceData[]; total?: number }>("/v1/traces", params); - setTraces(res.traces ?? []); - setTotal(res.total ?? 0); - } catch { - // ignore - } finally { - setLoading(false); - } + const { data, isLoading: loading } = useQuery({ + queryKey, + queryFn: async () => { + const params: Record = {}; + if (filters.agentId) params.agent_id = filters.agentId; + if (filters.userId) params.user_id = filters.userId; + if (filters.status) params.status = filters.status; + if (filters.limit) params.limit = String(filters.limit); + if (filters.offset !== undefined) params.offset = String(filters.offset); + + const res = await http.get<{ traces: TraceData[]; total?: number }>("/v1/traces", params); + return { traces: res.traces ?? [], total: res.total ?? 0 }; }, - [http], + placeholderData: (prev) => prev, + }); + + const traces = data?.traces ?? []; + const total = data?.total ?? 0; + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.traces.all }), + [queryClient], ); const getTrace = useCallback( @@ -101,5 +55,5 @@ export function useTraces() { [http], ); - return { traces, total, loading, load, getTrace }; + return { traces, total, loading, refresh: invalidate, getTrace }; } diff --git a/ui/web/src/pages/traces/traces-page.tsx b/ui/web/src/pages/traces/traces-page.tsx index bfaa1328..b84c8e13 100644 --- a/ui/web/src/pages/traces/traces-page.tsx +++ b/ui/web/src/pages/traces/traces-page.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState } from "react"; import { Activity, RefreshCw, Search } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -7,76 +7,33 @@ import { PageHeader } from "@/components/shared/page-header"; import { EmptyState } from "@/components/shared/empty-state"; import { Pagination } from "@/components/shared/pagination"; import { TableSkeleton } from "@/components/shared/loading-skeleton"; -import { useWsEvent } from "@/hooks/use-ws-event"; -import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; -import { Events } from "@/api/protocol"; import { formatDate, formatDuration, formatTokens, computeDurationMs } from "@/lib/format"; import { useTraces, type TraceData } from "./hooks/use-traces"; import { TraceDetailDialog } from "./trace-detail-dialog"; import { useMinLoading } from "@/hooks/use-min-loading"; import { useDeferredLoading } from "@/hooks/use-deferred-loading"; -import type { AgentEventPayload } from "@/types/chat"; export function TracesPage() { - const { traces, total, loading, load, getTrace } = useTraces(); - const spinning = useMinLoading(loading); - const showSkeleton = useDeferredLoading(loading && traces.length === 0); const [agentFilter, setAgentFilter] = useState(""); + const [appliedAgentFilter, setAppliedAgentFilter] = useState(""); const [selectedTraceId, setSelectedTraceId] = useState(null); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); + const { traces, total, loading, refresh, getTrace } = useTraces({ + agentId: appliedAgentFilter || undefined, + limit: pageSize, + offset: (page - 1) * pageSize, + }); + const spinning = useMinLoading(loading); + const showSkeleton = useDeferredLoading(loading && traces.length === 0); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); - // Ref to capture current filter for debounced callback - const agentFilterRef = useRef(agentFilter); - agentFilterRef.current = agentFilter; - const pageRef = useRef(page); - pageRef.current = page; - const pageSizeRef = useRef(pageSize); - pageSizeRef.current = pageSize; - - useEffect(() => { - load({ limit: pageSize, offset: (page - 1) * pageSize }); - }, [load, page, pageSize]); - - const handleRefresh = () => { - load({ - agentId: agentFilter || undefined, - limit: pageSize, - offset: (page - 1) * pageSize, - }); - }; - - // Auto-refresh when any agent run starts/completes (traces are created synchronously at run start) - const debouncedRefresh = useDebouncedCallback(() => { - load({ - agentId: agentFilterRef.current || undefined, - limit: pageSizeRef.current, - offset: (pageRef.current - 1) * pageSizeRef.current, - }); - }, 3000); - - const handleAgentEvent = useCallback( - (payload: unknown) => { - const event = payload as AgentEventPayload; - if (!event) return; - if ( - event.type === "run.started" || - event.type === "run.completed" || - event.type === "run.failed" - ) { - debouncedRefresh(); - } - }, - [debouncedRefresh], - ); - - useWsEvent(Events.AGENT, handleAgentEvent); - const handleFilterSubmit = (e: React.FormEvent) => { e.preventDefault(); - handleRefresh(); + setAppliedAgentFilter(agentFilter); + setPage(1); }; return ( @@ -85,7 +42,7 @@ export function TracesPage() { title="Traces" description="LLM call traces and performance data" actions={ - } diff --git a/ui/web/src/pages/tts/hooks/use-tts-config.ts b/ui/web/src/pages/tts/hooks/use-tts-config.ts index c16972b6..75373cba 100644 --- a/ui/web/src/pages/tts/hooks/use-tts-config.ts +++ b/ui/web/src/pages/tts/hooks/use-tts-config.ts @@ -1,6 +1,8 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useWs } from "@/hooks/use-ws"; import { Methods } from "@/api/protocol"; +import { queryKeys } from "@/lib/query-keys"; export interface TtsProviderConfig { api_key?: string; @@ -41,28 +43,24 @@ const DEFAULT_TTS: TtsConfig = { export function useTtsConfig() { const ws = useWs(); - const [tts, setTts] = useState(DEFAULT_TTS); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const load = useCallback(async () => { - if (!ws.isConnected) return; - setLoading(true); - try { + const { data: tts = DEFAULT_TTS, isLoading: loading } = useQuery({ + queryKey: queryKeys.tts.all, + queryFn: async () => { + if (!ws.isConnected) return DEFAULT_TTS; const res = await ws.call<{ config: Record }>(Methods.CONFIG_GET); const ttsConfig = (res.config?.tts as TtsConfig) ?? DEFAULT_TTS; - setTts({ ...DEFAULT_TTS, ...ttsConfig }); - } catch { - // ignore - } finally { - setLoading(false); - } - }, [ws]); + return { ...DEFAULT_TTS, ...ttsConfig }; + }, + }); - useEffect(() => { - load(); - }, [load]); + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.tts.all }), + [queryClient], + ); const save = useCallback( async (updates: Partial) => { @@ -70,7 +68,7 @@ export function useTtsConfig() { setError(null); try { await ws.call(Methods.CONFIG_PATCH, { tts: updates }); - await load(); + await invalidate(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to save TTS config"; setError(msg); @@ -79,8 +77,8 @@ export function useTtsConfig() { setSaving(false); } }, - [ws, load], + [ws, invalidate], ); - return { tts, loading, saving, error, refresh: load, save }; + return { tts, loading, saving, error, refresh: invalidate, save }; } diff --git a/ui/web/src/types/channel.ts b/ui/web/src/types/channel.ts new file mode 100644 index 00000000..fccb7b20 --- /dev/null +++ b/ui/web/src/types/channel.ts @@ -0,0 +1,23 @@ +export interface ChannelInstanceData { + id: string; + name: string; + display_name: string; + channel_type: string; + agent_id: string; + config: Record | null; + enabled: boolean; + is_default: boolean; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface ChannelInstanceInput { + name: string; + display_name?: string; + channel_type: string; + agent_id: string; + credentials?: Record; + config?: Record; + enabled?: boolean; +} diff --git a/ui/web/src/types/custom-tool.ts b/ui/web/src/types/custom-tool.ts new file mode 100644 index 00000000..ddfffd88 --- /dev/null +++ b/ui/web/src/types/custom-tool.ts @@ -0,0 +1,25 @@ +export interface CustomToolData { + id: string; + name: string; + description: string; + parameters: Record | null; + command: string; + working_dir: string; + timeout_seconds: number; + agent_id: string | null; + enabled: boolean; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface CustomToolInput { + name: string; + description?: string; + parameters?: Record; + command: string; + working_dir?: string; + timeout_seconds?: number; + agent_id?: string; + enabled?: boolean; +} diff --git a/ui/web/src/types/mcp.ts b/ui/web/src/types/mcp.ts new file mode 100644 index 00000000..5209b75c --- /dev/null +++ b/ui/web/src/types/mcp.ts @@ -0,0 +1,41 @@ +export interface MCPServerData { + id: string; + name: string; + display_name: string; + transport: "stdio" | "sse" | "streamable-http"; + command: string; + args: string[] | null; + url: string; + headers: Record | null; + env: Record | null; + tool_prefix: string; + timeout_sec: number; + enabled: boolean; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface MCPServerInput { + name: string; + display_name?: string; + transport: string; + command?: string; + args?: string[]; + url?: string; + headers?: Record; + tool_prefix?: string; + timeout_sec?: number; + enabled?: boolean; +} + +export interface MCPAgentGrant { + id: string; + server_id: string; + agent_id: string; + enabled: boolean; + tool_allow: string[] | null; + tool_deny: string[] | null; + granted_by: string; + created_at: string; +} diff --git a/ui/web/src/types/provider.ts b/ui/web/src/types/provider.ts new file mode 100644 index 00000000..43d364d3 --- /dev/null +++ b/ui/web/src/types/provider.ts @@ -0,0 +1,25 @@ +export interface ProviderData { + id: string; + name: string; + display_name: string; + provider_type: string; + api_base: string; + api_key: string; // masked "***" from server + enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface ProviderInput { + name: string; + display_name?: string; + provider_type: string; + api_base?: string; + api_key?: string; + enabled?: boolean; +} + +export interface ModelInfo { + id: string; + name?: string; +} diff --git a/ui/web/src/types/skill.ts b/ui/web/src/types/skill.ts new file mode 100644 index 00000000..dbf7cdd7 --- /dev/null +++ b/ui/web/src/types/skill.ts @@ -0,0 +1,6 @@ +export interface SkillInfo { + id?: string; + name: string; + description: string; + source: string; +} diff --git a/ui/web/src/types/trace.ts b/ui/web/src/types/trace.ts new file mode 100644 index 00000000..c4018650 --- /dev/null +++ b/ui/web/src/types/trace.ts @@ -0,0 +1,51 @@ +export interface TraceData { + id: string; + parent_trace_id?: string; + agent_id?: string; + user_id: string; + session_key: string; + run_id: string; + start_time: string; + end_time?: string; + duration_ms: number; + name: string; + channel: string; + input_preview: string; + output_preview: string; + total_input_tokens: number; + total_output_tokens: number; + total_cost: number; + span_count: number; + llm_call_count: number; + tool_call_count: number; + status: string; + error?: string; + tags?: string[]; + metadata?: { total_cache_read_tokens?: number; total_cache_creation_tokens?: number }; + created_at: string; +} + +export interface SpanData { + id: string; + trace_id: string; + parent_span_id?: string; + agent_id?: string; + span_type: string; + name: string; + start_time: string; + end_time?: string; + duration_ms: number; + status: string; + error?: string; + model: string; + provider: string; + input_tokens: number; + output_tokens: number; + total_cost: number; + finish_reason: string; + tool_name: string; + tool_call_id: string; + input_preview: string; + output_preview: string; + metadata?: { cache_creation_tokens?: number; cache_read_tokens?: number; thinking_tokens?: number }; +}