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.
+