diff --git a/core/http/react-ui/src/pages/NodeBackendLogs.jsx b/core/http/react-ui/src/pages/NodeBackendLogs.jsx index 4110713df..58e798233 100644 --- a/core/http/react-ui/src/pages/NodeBackendLogs.jsx +++ b/core/http/react-ui/src/pages/NodeBackendLogs.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import { useParams, useOutletContext, Link } from 'react-router-dom' +import { useParams, useOutletContext, Link, useNavigate } from 'react-router-dom' import { nodesApi } from '../utils/api' import { formatTimestamp } from '../utils/format' import { apiUrl } from '../utils/basePath' @@ -19,6 +19,16 @@ export default function NodeBackendLogs() { const { nodeId, modelId: rawModelId } = useParams() const modelId = decodeURIComponent(rawModelId || '') const { addToast } = useOutletContext() + const navigate = useNavigate() + + // The route param can be a bare model name ("qwen3-0.6b") OR a per-replica + // process key ("qwen3-0.6b#0"). The worker's BackendLogStore treats them + // differently — bare = aggregate across replicas, suffixed = exact replica. + // Surface that distinction so operators know what they're looking at. + const replicaSepIdx = modelId.indexOf('#') + const baseModelName = replicaSepIdx >= 0 ? modelId.slice(0, replicaSepIdx) : modelId + const replicaIndex = replicaSepIdx >= 0 ? parseInt(modelId.slice(replicaSepIdx + 1), 10) : null + const isMerged = replicaIndex === null const [lines, setLines] = useState([]) const [loading, setLoading] = useState(true) @@ -27,6 +37,10 @@ export default function NodeBackendLogs() { const [showDetails, setShowDetails] = useState(true) const [wsConnected, setWsConnected] = useState(false) const [nodeName, setNodeName] = useState('') + // Replicas of this base model on this node — drives whether the + // merged-vs-replica toggle is rendered. Single-replica deployments + // never see the toggle (no decision to make). + const [replicas, setReplicas] = useState([]) const logContainerRef = useRef(null) const wsRef = useRef(null) const reconnectTimerRef = useRef(null) @@ -43,6 +57,22 @@ export default function NodeBackendLogs() { } }, [nodeId]) + // Fetch the replica list for this base model on this node so we know + // whether to render the merged-vs-replica toggle. Cheap query; runs once + // per (nodeId, baseModelName) change. + useEffect(() => { + if (!nodeId || !baseModelName) return + nodesApi.getModels(nodeId) + .then(arr => { + const reps = (Array.isArray(arr) ? arr : []) + .filter(m => m.model_name === baseModelName) + .map(m => m.replica_index ?? 0) + .sort((a, b) => a - b) + setReplicas(reps) + }) + .catch(() => setReplicas([])) + }, [nodeId, baseModelName]) + // Auto-scroll to bottom when new lines arrive useEffect(() => { if (autoScroll && logContainerRef.current) { @@ -139,13 +169,54 @@ export default function NodeBackendLogs() { ) } + // Show the merged/per-replica toggle only when this model has > 1 replica + // on this node. Single-replica deployments don't see a control they can't + // meaningfully use. + const showReplicaToggle = replicas.length > 1 + return (

- {modelId} + {baseModelName} + {!isMerged && ( + + replica {replicaIndex} + + )} + {isMerged && replicas.length > 1 && ( + + merged · {replicas.length} replicas + + )}

Backend logs from node {nodeName || nodeId} @@ -154,6 +225,33 @@ export default function NodeBackendLogs() {

+ {showReplicaToggle && ( +
+ {replicas.map(idx => ( + + ))} +