mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-23 16:20:01 -04:00
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/<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 <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
@@ -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 (
|
||||
<div className="page page--wide">
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><i className="fas fa-exclamation-triangle" /></div>
|
||||
<h2 className="empty-state-title">Failed to resolve hosting nodes</h2>
|
||||
<p className="empty-state-text">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (hits === null) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (hits.length === 0) {
|
||||
return (
|
||||
<div className="page page--wide">
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><i className="fas fa-terminal" /></div>
|
||||
<h2 className="empty-state-title">Model not loaded on any worker</h2>
|
||||
<p className="empty-state-text">
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{modelId}</span> isn't currently loaded on any node in the cluster.
|
||||
Check the <Link to="/app/nodes" style={{ color: 'var(--color-primary)' }}>Nodes page</Link> to see which models are running where.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 <Navigate to={buildHref(hits[0].node.id)} replace />
|
||||
}
|
||||
|
||||
// Multiple workers host this model — let the operator pick.
|
||||
return (
|
||||
<div className="page page--wide">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
<i className="fas fa-terminal" style={{ fontSize: '0.8em', marginRight: 'var(--spacing-sm)' }} />
|
||||
{modelId}
|
||||
</h1>
|
||||
<p className="page-subtitle" style={{ marginTop: 'var(--spacing-xs)' }}>
|
||||
Hosted on {hits.length} workers — pick one to view its logs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xs)' }}>
|
||||
{hits.map(({ node, model }) => (
|
||||
<Link
|
||||
key={`${node.id}#${model.replica_index ?? 0}`}
|
||||
to={buildHref(node.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: 'var(--spacing-sm) var(--spacing-md)',
|
||||
background: 'var(--color-bg-primary)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)', textDecoration: 'none', color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{node.name || node.id}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontFamily: 'var(--font-mono)' }}>
|
||||
{node.id}{model.replica_index ? ` · replica ${model.replica_index}` : ''} · {model.state}
|
||||
</div>
|
||||
</div>
|
||||
<i className="fas fa-chevron-right" style={{ color: 'var(--color-text-muted)' }} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (distributedMode) {
|
||||
return <DistributedBackendLogsResolver modelId={modelId} fromTimestamp={fromTimestamp} />
|
||||
}
|
||||
|
||||
return <BackendLogsDetail modelId={modelId} />
|
||||
}
|
||||
|
||||
export default function BackendLogs() {
|
||||
const { modelId } = useParams()
|
||||
|
||||
if (modelId) {
|
||||
return <BackendLogsDetail modelId={decodeURIComponent(modelId)} />
|
||||
return <BackendLogsRouter modelId={decodeURIComponent(modelId)} />
|
||||
}
|
||||
|
||||
// No model specified — redirect to System page
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -220,7 +220,10 @@ function BackendTraceDetail({ trace }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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/<nodeId>/<modelId> or shows a node picker. */}
|
||||
{trace.model_name && (
|
||||
<div style={{ marginBottom: 'var(--spacing-md)' }}>
|
||||
<a
|
||||
|
||||
Reference in New Issue
Block a user