Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 834ecc3

Browse files
localai-botmudler
andauthored
fix(react-ui): unify backend-logs entry point for distributed mode (mudler#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/<modelId>. 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 -> <Navigate replace> to /app/node-backend-logs/<nodeId>/<modelId>, 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 <[email protected]> Co-authored-by: Ettore Di Giacinto <[email protected]>
1 parent 61bf34e commit 834ecc3

3 files changed

Lines changed: 156 additions & 6 deletions

File tree

core/http/react-ui/src/pages/BackendLogs.jsx

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
2-
import { useParams, useSearchParams, useOutletContext, Link } from 'react-router-dom'
3-
import { backendLogsApi } from '../utils/api'
2+
import { useParams, useSearchParams, useOutletContext, Link, Navigate } from 'react-router-dom'
3+
import { backendLogsApi, nodesApi } from '../utils/api'
44
import { formatTimestamp } from '../utils/format'
55
import { apiUrl } from '../utils/basePath'
66
import LoadingSpinner from '../components/LoadingSpinner'
7+
import { useDistributedMode } from '../hooks/useDistributedMode'
78

89
function wsUrl(path) {
910
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -274,11 +275,158 @@ function BackendLogsDetail({ modelId }) {
274275
)
275276
}
276277

278+
// DistributedBackendLogsResolver runs only in distributed mode. The local
279+
// /api/backend-logs WebSocket has no backend behind it here (inference lives
280+
// on workers), so we resolve modelId → hosting node(s) and forward to the
281+
// per-node logs page. One hit redirects automatically; multiple hits render
282+
// a picker so the operator can pick which worker's logs to inspect.
283+
function DistributedBackendLogsResolver({ modelId, fromTimestamp }) {
284+
const [hits, setHits] = useState(null) // [{ node, model }] once resolved
285+
const [error, setError] = useState(null)
286+
287+
useEffect(() => {
288+
let cancelled = false
289+
;(async () => {
290+
try {
291+
const nodes = await nodesApi.list()
292+
const nodeList = Array.isArray(nodes) ? nodes : []
293+
// Fan out to each node and collect entries that match this model.
294+
// Per-node failures are tolerated — a single offline worker shouldn't
295+
// hide logs available on its peers.
296+
const perNode = await Promise.all(nodeList.map(async (node) => {
297+
try {
298+
const models = await nodesApi.getModels(node.id)
299+
const matches = (Array.isArray(models) ? models : []).filter(m => m.model_name === modelId)
300+
return matches.map(m => ({ node, model: m }))
301+
} catch {
302+
return []
303+
}
304+
}))
305+
if (cancelled) return
306+
setHits(perNode.flat())
307+
} catch (err) {
308+
if (!cancelled) setError(err)
309+
}
310+
})()
311+
return () => { cancelled = true }
312+
}, [modelId])
313+
314+
if (error) {
315+
return (
316+
<div className="page page--wide">
317+
<div className="empty-state">
318+
<div className="empty-state-icon"><i className="fas fa-exclamation-triangle" /></div>
319+
<h2 className="empty-state-title">Failed to resolve hosting nodes</h2>
320+
<p className="empty-state-text">{error.message}</p>
321+
</div>
322+
</div>
323+
)
324+
}
325+
326+
if (hits === null) {
327+
return (
328+
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
329+
<LoadingSpinner size="lg" />
330+
</div>
331+
)
332+
}
333+
334+
if (hits.length === 0) {
335+
return (
336+
<div className="page page--wide">
337+
<div className="empty-state">
338+
<div className="empty-state-icon"><i className="fas fa-terminal" /></div>
339+
<h2 className="empty-state-title">Model not loaded on any worker</h2>
340+
<p className="empty-state-text">
341+
<span style={{ fontFamily: 'var(--font-mono)' }}>{modelId}</span> isn't currently loaded on any node in the cluster.
342+
Check the <Link to="/app/nodes" style={{ color: 'var(--color-primary)' }}>Nodes page</Link> to see which models are running where.
343+
</p>
344+
</div>
345+
</div>
346+
)
347+
}
348+
349+
// Bare model name aggregates this node's replicas via the worker's log
350+
// store; preserve ?from= so the deep-link from a trace still scrolls to
351+
// the right line on arrival.
352+
const buildHref = (nodeId) => {
353+
const base = `/app/node-backend-logs/${nodeId}/${encodeURIComponent(modelId)}`
354+
return fromTimestamp ? `${base}?from=${encodeURIComponent(fromTimestamp)}` : base
355+
}
356+
357+
if (hits.length === 1) {
358+
return <Navigate to={buildHref(hits[0].node.id)} replace />
359+
}
360+
361+
// Multiple workers host this model — let the operator pick.
362+
return (
363+
<div className="page page--wide">
364+
<div className="page-header">
365+
<div>
366+
<h1 className="page-title" style={{ marginBottom: 0 }}>
367+
<i className="fas fa-terminal" style={{ fontSize: '0.8em', marginRight: 'var(--spacing-sm)' }} />
368+
{modelId}
369+
</h1>
370+
<p className="page-subtitle" style={{ marginTop: 'var(--spacing-xs)' }}>
371+
Hosted on {hits.length} workers — pick one to view its logs.
372+
</p>
373+
</div>
374+
</div>
375+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xs)' }}>
376+
{hits.map(({ node, model }) => (
377+
<Link
378+
key={`${node.id}#${model.replica_index ?? 0}`}
379+
to={buildHref(node.id)}
380+
style={{
381+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
382+
padding: 'var(--spacing-sm) var(--spacing-md)',
383+
background: 'var(--color-bg-primary)', border: '1px solid var(--color-border)',
384+
borderRadius: 'var(--radius-md)', textDecoration: 'none', color: 'inherit',
385+
}}
386+
>
387+
<div>
388+
<div style={{ fontWeight: 500 }}>{node.name || node.id}</div>
389+
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontFamily: 'var(--font-mono)' }}>
390+
{node.id}{model.replica_index ? ` · replica ${model.replica_index}` : ''} · {model.state}
391+
</div>
392+
</div>
393+
<i className="fas fa-chevron-right" style={{ color: 'var(--color-text-muted)' }} />
394+
</Link>
395+
))}
396+
</div>
397+
</div>
398+
)
399+
}
400+
401+
// BackendLogsRouter picks between the local WebSocket view (standalone) and
402+
// the distributed resolver. The probe runs once via useDistributedMode so a
403+
// 503 from /api/nodes (the canonical "distributed disabled" signal) keeps the
404+
// existing standalone path intact.
405+
function BackendLogsRouter({ modelId }) {
406+
const [searchParams] = useSearchParams()
407+
const fromTimestamp = searchParams.get('from')
408+
const { enabled: distributedMode, loading } = useDistributedMode()
409+
410+
if (loading) {
411+
return (
412+
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
413+
<LoadingSpinner size="lg" />
414+
</div>
415+
)
416+
}
417+
418+
if (distributedMode) {
419+
return <DistributedBackendLogsResolver modelId={modelId} fromTimestamp={fromTimestamp} />
420+
}
421+
422+
return <BackendLogsDetail modelId={modelId} />
423+
}
424+
277425
export default function BackendLogs() {
278426
const { modelId } = useParams()
279427

280428
if (modelId) {
281-
return <BackendLogsDetail modelId={decodeURIComponent(modelId)} />
429+
return <BackendLogsRouter modelId={decodeURIComponent(modelId)} />
282430
}
283431

284432
// No model specified — redirect to System page

core/http/react-ui/src/pages/Manage.jsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -660,8 +660,7 @@ export default function Manage() {
660660
{ key: 'edit', icon: 'fa-pen-to-square', label: 'Edit configuration',
661661
onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`) },
662662
{ key: 'logs', icon: 'fa-terminal', label: 'Backend logs',
663-
onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`),
664-
hidden: distributedMode },
663+
onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) },
665664
{ divider: true },
666665
{ key: 'delete', icon: 'fa-trash', label: 'Delete model', danger: true,
667666
onClick: () => handleDeleteModel(model.id) },

core/http/react-ui/src/pages/Traces.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,10 @@ function BackendTraceDetail({ trace }) {
220220
</div>
221221
)}
222222

223-
{/* Backend logs link */}
223+
{/* Backend logs link — /app/backend-logs/:modelId is the unified entry
224+
point: in standalone mode it streams local logs, in distributed mode
225+
it resolves the model to the host worker(s) and either redirects to
226+
/app/node-backend-logs/<nodeId>/<modelId> or shows a node picker. */}
224227
{trace.model_name && (
225228
<div style={{ marginBottom: 'var(--spacing-md)' }}>
226229
<a

0 commit comments

Comments
 (0)