Skip to content

Commit 3280b9a

Browse files
committed
fix(distributed): per-replica backend logs (store aggregation + UI)
The multi-replica refactor (PR #9583) changed the worker's process key from `modelID` to `modelID#replicaIndex`, but the BackendLogStore kept the bare-modelID lookup. Result: every distributed deployment lost backend logs in the Nodes UI — single-replica too, since even the default capacity of 1 produces a `#0` suffix. Two changes wired together: * pkg/model: BackendLogStore.GetLines/Subscribe now treat a modelID without `#` as a model prefix and merge across all `modelID#N` replica buffers (timestamp-sorted for GetLines; fan-in for Subscribe). Calls with a full `modelID#N` key resolve exactly. ListModels strips replica suffixes and deduplicates so the listing surfaces one entry per loaded model. * react-ui: per-replica log streams as the default. Loaded Models table disambiguates each row with a `rep N` pill (only when the node hosts >1 replica of a model). Each row's "View logs" link routes to the per-replica process key so operators see only that replica's output. The logs page renders the replica context as a chip in the title and surfaces a segmented control — `Replica 0 / 1 / … / All merged` — when the model has multiple replicas; the merged segment uses the bare-modelID URL (delegating to the store's prefix aggregation) for the side-by-side comparison case. Single-replica deployments see no extra UI. Tests added first (TDD): the regression set in backend_log_store_test.go reproduces the bug at the exact failure point — GetLines/ListModels/Subscribe assertions all fail against the broken code, all pass against the fix. TestSubscribe_PerReplicaFilter pins the exact-key path so a future change can't silently break it. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:opus-4-7 [Edit] [Skill:critique] [Skill:audit] [Skill:polish] [Skill:distill]
1 parent 375bf19 commit 3280b9a

4 files changed

Lines changed: 438 additions & 28 deletions

File tree

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

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
2-
import { useParams, useOutletContext, Link } from 'react-router-dom'
2+
import { useParams, useOutletContext, Link, useNavigate } from 'react-router-dom'
33
import { nodesApi } from '../utils/api'
44
import { formatTimestamp } from '../utils/format'
55
import { apiUrl } from '../utils/basePath'
@@ -19,6 +19,16 @@ export default function NodeBackendLogs() {
1919
const { nodeId, modelId: rawModelId } = useParams()
2020
const modelId = decodeURIComponent(rawModelId || '')
2121
const { addToast } = useOutletContext()
22+
const navigate = useNavigate()
23+
24+
// The route param can be a bare model name ("qwen3-0.6b") OR a per-replica
25+
// process key ("qwen3-0.6b#0"). The worker's BackendLogStore treats them
26+
// differently — bare = aggregate across replicas, suffixed = exact replica.
27+
// Surface that distinction so operators know what they're looking at.
28+
const replicaSepIdx = modelId.indexOf('#')
29+
const baseModelName = replicaSepIdx >= 0 ? modelId.slice(0, replicaSepIdx) : modelId
30+
const replicaIndex = replicaSepIdx >= 0 ? parseInt(modelId.slice(replicaSepIdx + 1), 10) : null
31+
const isMerged = replicaIndex === null
2232

2333
const [lines, setLines] = useState([])
2434
const [loading, setLoading] = useState(true)
@@ -27,6 +37,10 @@ export default function NodeBackendLogs() {
2737
const [showDetails, setShowDetails] = useState(true)
2838
const [wsConnected, setWsConnected] = useState(false)
2939
const [nodeName, setNodeName] = useState('')
40+
// Replicas of this base model on this node — drives whether the
41+
// merged-vs-replica toggle is rendered. Single-replica deployments
42+
// never see the toggle (no decision to make).
43+
const [replicas, setReplicas] = useState([])
3044
const logContainerRef = useRef(null)
3145
const wsRef = useRef(null)
3246
const reconnectTimerRef = useRef(null)
@@ -43,6 +57,22 @@ export default function NodeBackendLogs() {
4357
}
4458
}, [nodeId])
4559

60+
// Fetch the replica list for this base model on this node so we know
61+
// whether to render the merged-vs-replica toggle. Cheap query; runs once
62+
// per (nodeId, baseModelName) change.
63+
useEffect(() => {
64+
if (!nodeId || !baseModelName) return
65+
nodesApi.getModels(nodeId)
66+
.then(arr => {
67+
const reps = (Array.isArray(arr) ? arr : [])
68+
.filter(m => m.model_name === baseModelName)
69+
.map(m => m.replica_index ?? 0)
70+
.sort((a, b) => a - b)
71+
setReplicas(reps)
72+
})
73+
.catch(() => setReplicas([]))
74+
}, [nodeId, baseModelName])
75+
4676
// Auto-scroll to bottom when new lines arrive
4777
useEffect(() => {
4878
if (autoScroll && logContainerRef.current) {
@@ -139,13 +169,54 @@ export default function NodeBackendLogs() {
139169
)
140170
}
141171

172+
// Show the merged/per-replica toggle only when this model has > 1 replica
173+
// on this node. Single-replica deployments don't see a control they can't
174+
// meaningfully use.
175+
const showReplicaToggle = replicas.length > 1
176+
142177
return (
143178
<div className="page page--wide">
144179
<div className="page-header">
145180
<div>
146181
<h1 className="page-title" style={{ marginBottom: 0 }}>
147182
<i className="fas fa-terminal" style={{ fontSize: '0.8em', marginRight: 'var(--spacing-sm)' }} />
148-
{modelId}
183+
{baseModelName}
184+
{!isMerged && (
185+
<span
186+
className="cell-mono"
187+
style={{
188+
marginLeft: 'var(--spacing-sm)',
189+
fontSize: '0.6875rem',
190+
fontWeight: 500,
191+
padding: '2px 8px',
192+
borderRadius: 'var(--radius-sm)',
193+
background: 'var(--color-bg-tertiary)',
194+
border: '1px solid var(--color-border-subtle)',
195+
color: 'var(--color-text-secondary)',
196+
verticalAlign: 'middle',
197+
}}
198+
>
199+
replica {replicaIndex}
200+
</span>
201+
)}
202+
{isMerged && replicas.length > 1 && (
203+
<span
204+
className="cell-mono"
205+
style={{
206+
marginLeft: 'var(--spacing-sm)',
207+
fontSize: '0.6875rem',
208+
fontWeight: 500,
209+
padding: '2px 8px',
210+
borderRadius: 'var(--radius-sm)',
211+
background: 'var(--color-bg-tertiary)',
212+
border: '1px solid var(--color-border-subtle)',
213+
color: 'var(--color-text-secondary)',
214+
verticalAlign: 'middle',
215+
}}
216+
>
217+
merged · {replicas.length} replicas
218+
</span>
219+
)}
149220
</h1>
150221
<p className="page-subtitle" style={{ marginTop: 'var(--spacing-xs)' }}>
151222
Backend logs from node <strong>{nodeName || nodeId}</strong>
@@ -154,6 +225,33 @@ export default function NodeBackendLogs() {
154225
</div>
155226
</div>
156227

228+
{showReplicaToggle && (
229+
<div role="radiogroup" aria-label="Replica scope" className="segmented" style={{ marginBottom: 'var(--spacing-sm)' }}>
230+
{replicas.map(idx => (
231+
<button
232+
key={idx}
233+
type="button"
234+
role="radio"
235+
aria-checked={replicaIndex === idx}
236+
className={`segmented__item${replicaIndex === idx ? ' is-active' : ''}`}
237+
onClick={() => navigate(`/app/node-backend-logs/${nodeId}/${encodeURIComponent(baseModelName + '#' + idx)}`)}
238+
>
239+
Replica {idx}
240+
</button>
241+
))}
242+
<button
243+
type="button"
244+
role="radio"
245+
aria-checked={isMerged}
246+
className={`segmented__item${isMerged ? ' is-active' : ''}`}
247+
onClick={() => navigate(`/app/node-backend-logs/${nodeId}/${encodeURIComponent(baseModelName)}`)}
248+
title="Show an interleaved timeline of all replicas — useful for comparing replica behavior side-by-side"
249+
>
250+
<i className="fas fa-layer-group" aria-hidden="true" /> All merged
251+
</button>
252+
</div>
253+
)}
254+
157255
{/* Toolbar */}
158256
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)', alignItems: 'center', flexWrap: 'wrap' }}>
159257
<div style={{ display: 'flex', gap: 2 }}>

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

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1198,12 +1198,38 @@ export default function Nodes() {
11981198
</tr>
11991199
</thead>
12001200
<tbody>
1201-
{models.map(m => {
1202-
const stCfg = modelStateConfig[m.state] || modelStateConfig.idle
1203-
return (
1204-
<tr key={m.id || m.model_name}>
1201+
{(() => {
1202+
// Pre-compute per-model replica counts so the disambiguation
1203+
// pill only renders when this node actually hosts >1 replica
1204+
// of the same model. Single-replica deployments stay clean.
1205+
const replicaCounts = {}
1206+
models.forEach(m => { replicaCounts[m.model_name] = (replicaCounts[m.model_name] || 0) + 1 })
1207+
return models.map(m => {
1208+
const stCfg = modelStateConfig[m.state] || modelStateConfig.idle
1209+
const showReplica = (replicaCounts[m.model_name] || 0) > 1
1210+
// Per-replica process key — what the worker stores logs under and what the
1211+
// store's GetLines/Subscribe match on for replica-scoped filtering.
1212+
const processKey = `${m.model_name}#${m.replica_index ?? 0}`
1213+
return (
1214+
<tr key={m.id || `${m.model_name}#${m.replica_index ?? 0}`}>
12051215
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>
12061216
{m.model_name}
1217+
{showReplica && (
1218+
<span
1219+
className="cell-mono"
1220+
aria-label={`replica ${m.replica_index ?? 0}`}
1221+
title={`Replica ${m.replica_index ?? 0} on this node`}
1222+
style={{
1223+
marginLeft: 8, padding: '1px 6px', borderRadius: 'var(--radius-sm)',
1224+
background: 'var(--color-bg-tertiary)',
1225+
border: '1px solid var(--color-border-subtle)',
1226+
fontSize: '0.6875rem', fontWeight: 500,
1227+
color: 'var(--color-text-secondary)',
1228+
}}
1229+
>
1230+
rep {m.replica_index ?? 0}
1231+
</span>
1232+
)}
12071233
</td>
12081234
<td>
12091235
<span style={{
@@ -1222,10 +1248,14 @@ export default function Nodes() {
12221248
href="#"
12231249
onClick={(e) => {
12241250
e.preventDefault()
1225-
navigate(`/app/node-backend-logs/${node.id}/${encodeURIComponent(m.model_name)}`)
1251+
// Send the replica-scoped process key (modelName#replicaIndex).
1252+
// The worker's BackendLogStore returns only this replica's lines
1253+
// when given the full key; a future "merged" toggle in the logs
1254+
// page can navigate to the bare modelName URL to use aggregation.
1255+
navigate(`/app/node-backend-logs/${node.id}/${encodeURIComponent(processKey)}`)
12261256
}}
12271257
style={{ fontSize: '0.75rem', color: 'var(--color-primary)' }}
1228-
title="View backend logs"
1258+
title={showReplica ? `View backend logs for replica ${m.replica_index ?? 0}` : 'View backend logs'}
12291259
>
12301260
<i className="fas fa-terminal" />
12311261
</a>
@@ -1249,7 +1279,8 @@ export default function Nodes() {
12491279
</td>
12501280
</tr>
12511281
)
1252-
})}
1282+
})
1283+
})()}
12531284
</tbody>
12541285
</table>
12551286
)}

0 commit comments

Comments
 (0)