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 && (