From 893e69cbf8d11cd7a2a0cba66153b04e2f4b8fbb Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Wed, 27 May 2026 16:39:09 +0200 Subject: [PATCH] fix(react-ui): share single /api/operations poller across consumers (#10029) useOperations() spun up its own setInterval per hook instance, so on pages like /app/models the OperationsBar in App.jsx plus the page's own useOperations() call each polled /api/operations at 1 Hz - 2 RPS sustained for the whole session, repeated on Backends and Chat. Lift the poller into an OperationsProvider mounted under AuthProvider so all consumers (OperationsBar, Models, Backends, Chat) share one timer. The hook file re-exports from the context to keep call sites unchanged. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- .../src/contexts/OperationsContext.jsx | 100 +++++++++++++++++ core/http/react-ui/src/hooks/useOperations.js | 102 +----------------- core/http/react-ui/src/main.jsx | 5 +- 3 files changed, 108 insertions(+), 99 deletions(-) create mode 100644 core/http/react-ui/src/contexts/OperationsContext.jsx diff --git a/core/http/react-ui/src/contexts/OperationsContext.jsx b/core/http/react-ui/src/contexts/OperationsContext.jsx new file mode 100644 index 000000000..716fd8ada --- /dev/null +++ b/core/http/react-ui/src/contexts/OperationsContext.jsx @@ -0,0 +1,100 @@ +import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react' +import { operationsApi } from '../utils/api' +import { useAuth } from '../context/AuthContext' + +// Serialize ops into a stable comparison key. Each op is a flat map of +// primitives, so JSON.stringify is good enough and stable as long as the +// server emits keys in the same order (Go's map iteration into JSON happens +// to be stable here because we build an explicit map[string]any). +function serializeOps(ops) { + return JSON.stringify(ops) +} + +const OperationsContext = createContext(null) + +// Single shared poller for /api/operations. Before this provider existed, +// each useOperations() call ran its own setInterval; with OperationsBar +// always mounted plus the per-page consumers (Models, Backends, Chat), the +// browser was firing 2-3 polls per second against the API for the lifetime +// of the session. +export function OperationsProvider({ children, pollInterval = 1000 }) { + const [operations, setOperations] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const { isAdmin } = useAuth() + const intervalRef = useRef(null) + const lastSerializedRef = useRef('[]') + + const fetchOperations = useCallback(async () => { + if (!isAdmin) { + setLoading((prev) => (prev ? false : prev)) + return + } + try { + const data = await operationsApi.list() + const ops = data?.operations || (Array.isArray(data) ? data : []) + + const serialized = serializeOps(ops) + if (serialized !== lastSerializedRef.current) { + lastSerializedRef.current = serialized + setOperations(ops) + } + + setError((prev) => (prev === null ? prev : null)) + } catch (err) { + setError((prev) => (prev === err.message ? prev : err.message)) + } finally { + setLoading((prev) => (prev ? false : prev)) + } + }, [isAdmin]) + + useEffect(() => { + if (!isAdmin) return + fetchOperations() + intervalRef.current = setInterval(fetchOperations, pollInterval) + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [fetchOperations, pollInterval, isAdmin]) + + const cancelOperation = useCallback(async (jobID) => { + try { + await operationsApi.cancel(jobID) + await fetchOperations() + } catch (err) { + setError(err.message) + } + }, [fetchOperations]) + + const dismissFailedOp = useCallback(async (opId) => { + try { + const op = operations.find((o) => o.id === opId) + if (op?.jobID) { + await operationsApi.dismiss(op.jobID) + await fetchOperations() + } + } catch { + // Ignore dismiss errors + } + }, [operations, fetchOperations]) + + const value = { + operations, + loading, + error, + cancelOperation, + dismissFailedOp, + refetch: fetchOperations, + } + + return {children} +} + +export function useOperations() { + const ctx = useContext(OperationsContext) + if (!ctx) throw new Error('useOperations must be used within OperationsProvider') + return ctx +} diff --git a/core/http/react-ui/src/hooks/useOperations.js b/core/http/react-ui/src/hooks/useOperations.js index 56c2ad846..a4d65f4ad 100644 --- a/core/http/react-ui/src/hooks/useOperations.js +++ b/core/http/react-ui/src/hooks/useOperations.js @@ -1,98 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react' -import { operationsApi } from '../utils/api' -import { useAuth } from '../context/AuthContext' - -// Serialize ops into a stable comparison key. Each op is a flat map of -// primitives, so JSON.stringify is good enough and stable as long as the -// server emits keys in the same order (Go's map iteration into JSON happens -// to be stable here because we build an explicit map[string]any). -function serializeOps(ops) { - return JSON.stringify(ops) -} - -export function useOperations(pollInterval = 1000) { - const [operations, setOperations] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const intervalRef = useRef(null) - const { isAdmin } = useAuth() - - const previousCountRef = useRef(0) - const onAllCompleteRef = useRef(null) - // Track the last payload we wrote into state. Each poll otherwise produces - // a fresh array reference even when nothing changed, and that re-render - // ripples into the Chat page — wiping the user's text selection mid-read - // (#9904). - const lastSerializedRef = useRef('[]') - - const fetchOperations = useCallback(async () => { - if (!isAdmin) { - setLoading((prev) => (prev ? false : prev)) - return - } - try { - const data = await operationsApi.list() - const ops = data?.operations || (Array.isArray(data) ? data : []) - - const serialized = serializeOps(ops) - if (serialized !== lastSerializedRef.current) { - lastSerializedRef.current = serialized - setOperations(ops) - } - - // Separate active (non-failed) operations from failed ones - const activeOps = ops.filter(op => !op.error) - const failedOps = ops.filter(op => op.error) - - // Notify when all operations complete (no active or failed remaining) - if (previousCountRef.current > 0 && activeOps.length === 0 && failedOps.length === 0) { - onAllCompleteRef.current?.() - } - previousCountRef.current = activeOps.length - - setError((prev) => (prev === null ? prev : null)) - } catch (err) { - setError((prev) => (prev === err.message ? prev : err.message)) - } finally { - setLoading((prev) => (prev ? false : prev)) - } - }, [isAdmin]) - - const cancelOperation = useCallback(async (jobID) => { - try { - await operationsApi.cancel(jobID) - await fetchOperations() - } catch (err) { - setError(err.message) - } - }, [fetchOperations]) - - // Dismiss a failed operation (acknowledge the error and remove it) - const dismissFailedOp = useCallback(async (opId) => { - try { - const op = operations.find(o => o.id === opId) - if (op?.jobID) { - await operationsApi.dismiss(op.jobID) - await fetchOperations() - } - } catch { - // Ignore dismiss errors - } - }, [operations, fetchOperations]) - - useEffect(() => { - if (!isAdmin) return - fetchOperations() - intervalRef.current = setInterval(fetchOperations, pollInterval) - return () => { - if (intervalRef.current) clearInterval(intervalRef.current) - } - }, [fetchOperations, pollInterval, isAdmin]) - - // Allow callers to register a callback for when all operations finish - const onAllComplete = useCallback((cb) => { - onAllCompleteRef.current = cb - }, []) - - return { operations, loading, error, cancelOperation, dismissFailedOp, refetch: fetchOperations, onAllComplete } -} +// useOperations now lives in OperationsContext so all consumers +// (OperationsBar, Models, Backends, Chat) share a single poller instead +// of each spinning up its own setInterval against /api/operations. +export { useOperations } from '../contexts/OperationsContext' diff --git a/core/http/react-ui/src/main.jsx b/core/http/react-ui/src/main.jsx index ec62ed766..2cf7acef6 100644 --- a/core/http/react-ui/src/main.jsx +++ b/core/http/react-ui/src/main.jsx @@ -4,6 +4,7 @@ import { RouterProvider } from 'react-router-dom' import { ThemeProvider } from './contexts/ThemeContext' import { BrandingProvider } from './contexts/BrandingContext' import { AuthProvider } from './context/AuthContext' +import { OperationsProvider } from './contexts/OperationsContext' import { router } from './router' import './i18n' import '@fortawesome/fontawesome-free/css/all.min.css' @@ -30,7 +31,9 @@ createRoot(document.getElementById('root')).render( - + + +