mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-29 11:07:18 -04:00
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 <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
100
core/http/react-ui/src/contexts/OperationsContext.jsx
Normal file
100
core/http/react-ui/src/contexts/OperationsContext.jsx
Normal file
@@ -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 <OperationsContext.Provider value={value}>{children}</OperationsContext.Provider>
|
||||
}
|
||||
|
||||
export function useOperations() {
|
||||
const ctx = useContext(OperationsContext)
|
||||
if (!ctx) throw new Error('useOperations must be used within OperationsProvider')
|
||||
return ctx
|
||||
}
|
||||
102
core/http/react-ui/src/hooks/useOperations.js
vendored
102
core/http/react-ui/src/hooks/useOperations.js
vendored
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider>
|
||||
<BrandingProvider>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
<OperationsProvider>
|
||||
<RouterProvider router={router} />
|
||||
</OperationsProvider>
|
||||
</AuthProvider>
|
||||
</BrandingProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
Reference in New Issue
Block a user