From 834ecc36bfb141c06c20609ab67fb19da8043bf3 Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Fri, 22 May 2026 22:00:08 +0200 Subject: [PATCH] fix(react-ui): unify backend-logs entry point for distributed mode (#9949) In distributed mode the local /api/backend-logs WebSocket has nothing behind it (inference runs on workers), so the "View backend logs" link in Traces (and the action in Manage when previously not hidden) dead- ended on /app/backend-logs/. Manage worked around it by hiding the action; Traces still rendered the link. Make /app/backend-logs/:modelId the single, mode-aware entry point. A new BackendLogsRouter probes useDistributedMode and forks: - standalone: existing local WebSocket view (BackendLogsDetail). - distributed: DistributedBackendLogsResolver fans out to each node via nodesApi.getModels, filters by model_name, and routes: * 0 hits -> empty state with a link to the Nodes page. * 1 hit -> to /app/node-backend-logs//, preserving the ?from= deep-link timestamp. * N hits -> picker listing each hosting worker (node id, replica index, load state) so the operator can choose which worker's logs to view. Bare modelId in the redirect target intentionally aggregates that node's replicas via the worker's BackendLogStore, matching the existing per-node link pattern in Nodes.jsx. Revert the per-caller distributed checks now that routing is centralised: drop the hidden:distributedMode guard on Manage's Backend logs action, and remove the prop threading in Traces so the link is unconditional. Any future view that wants to link to backend logs uses the same URL and gets correct behaviour in both modes. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- core/http/react-ui/src/pages/BackendLogs.jsx | 154 ++++++++++++++++++- core/http/react-ui/src/pages/Manage.jsx | 3 +- core/http/react-ui/src/pages/Traces.jsx | 5 +- 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/core/http/react-ui/src/pages/BackendLogs.jsx b/core/http/react-ui/src/pages/BackendLogs.jsx index c3e917ecb..3f5216dbc 100644 --- a/core/http/react-ui/src/pages/BackendLogs.jsx +++ b/core/http/react-ui/src/pages/BackendLogs.jsx @@ -1,9 +1,10 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import { useParams, useSearchParams, useOutletContext, Link } from 'react-router-dom' -import { backendLogsApi } from '../utils/api' +import { useParams, useSearchParams, useOutletContext, Link, Navigate } from 'react-router-dom' +import { backendLogsApi, nodesApi } from '../utils/api' import { formatTimestamp } from '../utils/format' import { apiUrl } from '../utils/basePath' import LoadingSpinner from '../components/LoadingSpinner' +import { useDistributedMode } from '../hooks/useDistributedMode' function wsUrl(path) { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' @@ -274,11 +275,158 @@ function BackendLogsDetail({ modelId }) { ) } +// DistributedBackendLogsResolver runs only in distributed mode. The local +// /api/backend-logs WebSocket has no backend behind it here (inference lives +// on workers), so we resolve modelId → hosting node(s) and forward to the +// per-node logs page. One hit redirects automatically; multiple hits render +// a picker so the operator can pick which worker's logs to inspect. +function DistributedBackendLogsResolver({ modelId, fromTimestamp }) { + const [hits, setHits] = useState(null) // [{ node, model }] once resolved + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const nodes = await nodesApi.list() + const nodeList = Array.isArray(nodes) ? nodes : [] + // Fan out to each node and collect entries that match this model. + // Per-node failures are tolerated — a single offline worker shouldn't + // hide logs available on its peers. + const perNode = await Promise.all(nodeList.map(async (node) => { + try { + const models = await nodesApi.getModels(node.id) + const matches = (Array.isArray(models) ? models : []).filter(m => m.model_name === modelId) + return matches.map(m => ({ node, model: m })) + } catch { + return [] + } + })) + if (cancelled) return + setHits(perNode.flat()) + } catch (err) { + if (!cancelled) setError(err) + } + })() + return () => { cancelled = true } + }, [modelId]) + + if (error) { + return ( +
+
+
+

Failed to resolve hosting nodes

+

{error.message}

+
+
+ ) + } + + if (hits === null) { + return ( +
+ +
+ ) + } + + if (hits.length === 0) { + return ( +
+
+
+

Model not loaded on any worker

+

+ {modelId} isn't currently loaded on any node in the cluster. + Check the Nodes page to see which models are running where. +

+
+
+ ) + } + + // Bare model name aggregates this node's replicas via the worker's log + // store; preserve ?from= so the deep-link from a trace still scrolls to + // the right line on arrival. + const buildHref = (nodeId) => { + const base = `/app/node-backend-logs/${nodeId}/${encodeURIComponent(modelId)}` + return fromTimestamp ? `${base}?from=${encodeURIComponent(fromTimestamp)}` : base + } + + if (hits.length === 1) { + return + } + + // Multiple workers host this model — let the operator pick. + return ( +
+
+
+

+ + {modelId} +

+

+ Hosted on {hits.length} workers — pick one to view its logs. +

+
+
+
+ {hits.map(({ node, model }) => ( + +
+
{node.name || node.id}
+
+ {node.id}{model.replica_index ? ` · replica ${model.replica_index}` : ''} · {model.state} +
+
+ + + ))} +
+
+ ) +} + +// BackendLogsRouter picks between the local WebSocket view (standalone) and +// the distributed resolver. The probe runs once via useDistributedMode so a +// 503 from /api/nodes (the canonical "distributed disabled" signal) keeps the +// existing standalone path intact. +function BackendLogsRouter({ modelId }) { + const [searchParams] = useSearchParams() + const fromTimestamp = searchParams.get('from') + const { enabled: distributedMode, loading } = useDistributedMode() + + if (loading) { + return ( +
+ +
+ ) + } + + if (distributedMode) { + return + } + + return +} + export default function BackendLogs() { const { modelId } = useParams() if (modelId) { - return + return } // No model specified — redirect to System page diff --git a/core/http/react-ui/src/pages/Manage.jsx b/core/http/react-ui/src/pages/Manage.jsx index 683fadc60..4f0f6ab3a 100644 --- a/core/http/react-ui/src/pages/Manage.jsx +++ b/core/http/react-ui/src/pages/Manage.jsx @@ -660,8 +660,7 @@ export default function Manage() { { key: 'edit', icon: 'fa-pen-to-square', label: 'Edit configuration', onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`) }, { key: 'logs', icon: 'fa-terminal', label: 'Backend logs', - onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`), - hidden: distributedMode }, + onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) }, { divider: true }, { key: 'delete', icon: 'fa-trash', label: 'Delete model', danger: true, onClick: () => handleDeleteModel(model.id) }, diff --git a/core/http/react-ui/src/pages/Traces.jsx b/core/http/react-ui/src/pages/Traces.jsx index e1cc5c480..5bceb53e4 100644 --- a/core/http/react-ui/src/pages/Traces.jsx +++ b/core/http/react-ui/src/pages/Traces.jsx @@ -220,7 +220,10 @@ function BackendTraceDetail({ trace }) { )} - {/* Backend logs link */} + {/* Backend logs link — /app/backend-logs/:modelId is the unified entry + point: in standalone mode it streams local logs, in distributed mode + it resolves the model to the host worker(s) and either redirects to + /app/node-backend-logs// or shows a node picker. */} {trace.model_name && (