feat(ui): NodeDistributionChip — shared per-node attribution component

Large clusters were going to break the Manage → Backends Nodes column:
the old inline logic rendered every node as a badge and would shred the
layout at >10 workers, plus the Manage → Models distribution cell had
copy-pasted its own slightly-different version.

NodeDistributionChip handles any cluster size with two render modes:
  - small (≤3 nodes): inline chips of node names, colored by health.
  - large: a single "on N nodes · M offline · K drift" summary chip;
    clicking opens a Popover with a per-node table (name, status,
    version, digest for backends; name, status, state for models).

Drift counting mirrors the backend's summarizeNodeDrift so the UI
number matches UpgradeInfo.NodeDrift. Digests are truncated to the
docker-style 12-char form with the full value preserved in the title.

Popover is a new general-purpose primitive: fixed positioning anchored
to the trigger, flips above when there's no room below, closes on
outside-click or Escape, returns focus to the trigger. Uses .card as
its surface so theming is inherited. Also useful for a future
labels-editor popup and the user menu.

Manage.jsx drops its duplicated inline Nodes-column + loaded_on cell
and uses the shared chip with context="backends" / "models"
respectively. Delete code removes ~40 lines of ad-hoc logic.
This commit is contained in:
Ettore Di Giacinto
2026-04-19 08:39:59 +00:00
parent 92b9e22dc9
commit ee34a52c5d
4 changed files with 328 additions and 47 deletions

View File

@@ -1723,6 +1723,74 @@ select.input {
color: var(--color-text-primary);
}
/* Popover — floating surface anchored to a trigger element. Uses the .card
base so theming is free, adds z-index + fixed-position + scroll cap so it
behaves on tables with many rows. Kept deliberately unstyled beyond that
— content is expected to provide its own header/body structure. */
.popover {
position: fixed;
z-index: 200;
min-width: 260px;
max-width: min(420px, 95vw);
max-height: min(420px, 70vh);
display: flex;
flex-direction: column;
padding: 0; /* sections provide their own padding */
overflow: hidden;
box-shadow: var(--shadow-lg);
animation: popoverIn var(--duration-fast) var(--ease-default);
}
@keyframes popoverIn {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.popover__header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--color-border-subtle);
font-size: var(--text-sm);
}
.popover__scroll {
overflow: auto;
padding: 0;
}
.popover__table {
margin: 0;
width: 100%;
}
.popover__table th {
position: sticky;
top: 0;
background: var(--color-bg-raised, var(--color-bg-secondary));
z-index: 1;
}
/* Inline-table chip trigger — looks like a badge but is a button (cursor,
focus ring inherited from global :focus-visible). */
.chip-trigger {
border: none;
cursor: pointer;
font-family: inherit;
}
.chip-trigger:hover {
filter: brightness(1.08);
}
/* Truncate + ellipsize a long cell (e.g. OCI digest) without breaking the
table layout. Tooltip preserves the full value. */
.cell-truncate {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Compact empty-state used inside expanded drawer sections (e.g. "No
models loaded on this node"). Dimmer than the page-level .empty-state
because it lives inside another container and shouldn't compete with

View File

@@ -0,0 +1,168 @@
import { useRef, useState } from 'react'
import Popover from './Popover'
// NodeDistributionChip shows where something is installed/loaded across a
// cluster. Used by both Manage → Backends (per-row Nodes column, data =
// gallery NodeBackendRef with version/digest) and by the Models tab (data =
// LoadedOn with state/status). Supports arbitrary cluster size — small
// clusters render node-name chips inline, larger clusters collapse to a
// summary chip and reveal the full per-node table in a popover on click.
//
// Field names are intentionally forgiving: both {node_name, node_status} and
// {NodeName, NodeStatus} are supported so the component works whether it's
// reading directly off the JSON or off a hydrated class.
//
// Props:
// nodes: array of node refs (see shape below).
// compactThreshold: max nodes to render inline before collapsing (default 3).
// context: 'backends' (default) shows version/digest; 'models'
// shows state.
// emptyLabel: what to render when nodes is empty (default "—").
export default function NodeDistributionChip({
nodes,
compactThreshold = 3,
context = 'backends',
emptyLabel = '—',
}) {
const triggerRef = useRef(null)
const [open, setOpen] = useState(false)
const list = Array.isArray(nodes) ? nodes : []
if (list.length === 0) {
return <span className="cell-muted">{emptyLabel}</span>
}
const getName = n => n.node_name ?? n.NodeName ?? ''
const getStatus = n => n.node_status ?? n.NodeStatus ?? ''
const getState = n => n.state ?? n.State ?? ''
const getVersion = n => n.version ?? n.Version ?? ''
const getDigest = n => n.digest ?? n.Digest ?? ''
// Inline mode: render every node as its own chip. Good for small clusters
// where seeing the names directly is more useful than a summary.
if (list.length <= compactThreshold) {
return (
<div className="badge-row">
{list.map(n => {
const status = getStatus(n)
const variant = status === 'healthy' ? 'badge-success'
: status === 'draining' ? 'badge-info'
: 'badge-warning'
const title = context === 'models'
? `${getName(n)}${getState(n)} (${status})`
: `${getName(n)}${status}${getVersion(n) ? ` · v${getVersion(n)}` : ''}`
return (
<span key={n.node_id ?? n.NodeID ?? getName(n)} className={`badge ${variant}`} title={title}>
<i className="fas fa-server" /> {getName(n)}
</span>
)
})}
</div>
)
}
// Summary mode for anything bigger. Count unhealthy/offline explicitly so
// the chip tells an operator at-a-glance whether to click in. "Drift" for
// backends = more than one (version, digest) tuple across healthy nodes.
const total = list.length
const offline = list.filter(n => {
const s = getStatus(n)
return s !== 'healthy' && s !== 'draining'
}).length
const drift = context === 'backends' ? countDrift(list) : 0
const severity = offline > 0 || drift > 0 ? 'badge-warning' : 'badge-info'
return (
<>
<button
ref={triggerRef}
type="button"
className={`badge ${severity} chip-trigger`}
aria-expanded={open}
aria-haspopup="dialog"
onClick={e => { e.stopPropagation(); setOpen(v => !v) }}
>
<i className="fas fa-server" />
{' '}on {total} node{total === 1 ? '' : 's'}
{offline > 0 ? ` · ${offline} offline` : ''}
{drift > 0 ? ` · ${drift} drift` : ''}
</button>
<Popover
anchor={triggerRef}
open={open}
onClose={() => setOpen(false)}
ariaLabel={context === 'models' ? 'Model distribution' : 'Backend distribution'}
>
<div className="popover__header">
<strong>Installed on {total} node{total === 1 ? '' : 's'}</strong>
{offline > 0 && <span className="badge badge-warning">{offline} offline</span>}
{drift > 0 && <span className="badge badge-warning">{drift} drift</span>}
</div>
<div className="popover__scroll">
<table className="table popover__table">
<thead>
<tr>
<th>Node</th>
<th>Status</th>
{context === 'models' ? <th>State</th> : <>
<th>Version</th>
<th>Digest</th>
</>}
</tr>
</thead>
<tbody>
{list.map(n => (
<tr key={n.node_id ?? n.NodeID ?? getName(n)}>
<td className="cell-mono">{getName(n)}</td>
<td>
<span className={`badge ${getStatus(n) === 'healthy' ? 'badge-success' : 'badge-warning'}`}>
{getStatus(n)}
</span>
</td>
{context === 'models' ? (
<td className="cell-mono">{getState(n) || '—'}</td>
) : (
<>
<td className="cell-mono">{getVersion(n) ? `v${getVersion(n)}` : '—'}</td>
<td className="cell-mono cell-truncate" title={getDigest(n)}>
{getDigest(n) ? shortenDigest(getDigest(n)) : '—'}
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
</Popover>
</>
)
}
// countDrift counts nodes whose (version, digest) disagrees with the cluster
// majority. Mirrors the backend summarizeNodeDrift logic so the UI number
// matches what CheckUpgradesAgainst emits in UpgradeInfo.NodeDrift.
function countDrift(nodes) {
if (nodes.length <= 1) return 0
const counts = new Map()
for (const n of nodes) {
const key = `${n.version ?? n.Version ?? ''}|${n.digest ?? n.Digest ?? ''}`
counts.set(key, (counts.get(key) || 0) + 1)
}
if (counts.size === 1) return 0 // unanimous
let topKey = ''
let topCount = 0
for (const [k, v] of counts.entries()) {
if (v > topCount) { topKey = k; topCount = v }
}
return nodes.length - topCount
}
// shortenDigest trims a full OCI digest to the common 12-char form used in
// docker/oci tooling. Falls back to the raw value if it doesn't match.
function shortenDigest(digest) {
const m = /^(sha\d+:)?([a-f0-9]+)$/i.exec(digest)
if (!m) return digest
const hex = m[2]
return (m[1] ?? '') + hex.slice(0, 12)
}

View File

@@ -0,0 +1,86 @@
import { useEffect, useRef, useState, useCallback } from 'react'
// Minimal popover: positions itself below-right of the trigger's bounding box,
// flips above when there isn't room below, closes on outside click or Escape,
// returns focus to the trigger. Uses the existing .card surface so it picks
// up theme/border/shadow automatically — no new theming work.
//
// Props:
// anchor: ref to the trigger DOMElement (required)
// open: boolean
// onClose: () => void
// children: popover body
// ariaLabel: accessible label for the dialog
export default function Popover({ anchor, open, onClose, children, ariaLabel }) {
const popoverRef = useRef(null)
const [pos, setPos] = useState({ top: 0, left: 0, flipped: false })
// Compute position from the anchor's bounding box whenever we open or the
// viewport changes. 240px is the minimum width we'll reserve; bigger content
// grows naturally.
const reposition = useCallback(() => {
if (!anchor?.current) return
const rect = anchor.current.getBoundingClientRect()
const popoverHeight = popoverRef.current?.offsetHeight ?? 0
const spaceBelow = window.innerHeight - rect.bottom
const flipped = popoverHeight > spaceBelow - 16 && rect.top > popoverHeight
const top = flipped ? rect.top - popoverHeight - 8 : rect.bottom + 8
// Prefer left-aligned; clamp so we don't go off-screen right.
const left = Math.min(rect.left, window.innerWidth - 320)
setPos({ top, left: Math.max(8, left), flipped })
}, [anchor])
useEffect(() => {
if (!open) return
reposition()
window.addEventListener('resize', reposition)
window.addEventListener('scroll', reposition, true)
return () => {
window.removeEventListener('resize', reposition)
window.removeEventListener('scroll', reposition, true)
}
}, [open, reposition])
// Close on outside click or Escape. Mousedown (not click) so the close
// happens before a parent handler could re-trigger us.
useEffect(() => {
if (!open) return
const onMouseDown = (e) => {
if (popoverRef.current && !popoverRef.current.contains(e.target) && !anchor?.current?.contains(e.target)) {
onClose()
}
}
const onKey = (e) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', onMouseDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('keydown', onKey)
}
}, [open, onClose, anchor])
// Return focus to the trigger when the popover closes — keyboard users
// shouldn't have to tab back through the whole page to find their spot.
useEffect(() => {
if (!open && anchor?.current) {
// requestAnimationFrame so the close is painted before focus jumps;
// otherwise screen readers announce the trigger mid-transition.
const raf = requestAnimationFrame(() => anchor.current?.focus?.())
return () => cancelAnimationFrame(raf)
}
}, [open, anchor])
if (!open) return null
return (
<div
ref={popoverRef}
role="dialog"
aria-label={ariaLabel}
className="popover card"
style={{ top: pos.top, left: pos.left }}
>
{children}
</div>
)
}

View File

@@ -3,6 +3,7 @@ import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom
import ResourceMonitor from '../components/ResourceMonitor'
import ConfirmDialog from '../components/ConfirmDialog'
import Toggle from '../components/Toggle'
import NodeDistributionChip from '../components/NodeDistributionChip'
import { useModels } from '../hooks/useModels'
import { backendControlApi, modelsApi, backendsApi, systemApi, nodesApi } from '../utils/api'
@@ -418,24 +419,10 @@ export default function Manage() {
<i className="fas fa-ban" /> Disabled
</span>
) : model.loaded_on && model.loaded_on.length > 0 ? (
// Distributed: surface where the model is actually loaded
// so operators don't have to expand each node manually.
<div className="badge-row">
{model.loaded_on.slice(0, 3).map(l => (
<span
key={l.node_id}
className={`badge ${l.state === 'loaded' ? 'badge-success' : l.state === 'loading' ? 'badge-info' : 'badge-warning'}`}
title={`${l.node_name}${l.state} (${l.node_status})`}
>
<i className="fas fa-server" /> {l.node_name}
</span>
))}
{model.loaded_on.length > 3 && (
<span className="badge" title={model.loaded_on.map(l => `${l.node_name} (${l.state})`).join('\n')}>
+{model.loaded_on.length - 3}
</span>
)}
</div>
// Distributed mode: surface where the model is
// actually loaded. Shared chip scales to any cluster
// size (inline for <=3, popover for larger).
<NodeDistributionChip nodes={model.loaded_on} context="models" />
) : loadedModelIds.has(model.id) ? (
<span className="badge badge-success">
<i className="fas fa-circle" style={{ fontSize: '6px' }} /> Running
@@ -645,35 +632,7 @@ export default function Manage() {
</td>
{distributedMode && (
<td>
{nodes.length === 0 ? (
<span className="cell-muted"></span>
) : nodes.length <= 3 ? (
<div className="badge-row">
{nodes.map(n => (
<span
key={n.node_id || n.NodeID}
className={`badge ${(n.node_status || n.NodeStatus) === 'healthy' ? 'badge-success' : 'badge-warning'}`}
title={`${n.node_name || n.NodeName}${n.node_status || n.NodeStatus}`}
>
<i className="fas fa-server" /> {n.node_name || n.NodeName}
</span>
))}
</div>
) : (() => {
const total = nodes.length
const offline = nodes.filter(n => {
const s = n.node_status || n.NodeStatus
return s !== 'healthy' && s !== 'draining'
}).length
return (
<span
className={`badge ${offline > 0 ? 'badge-warning' : 'badge-info'}`}
title={nodes.map(n => `${n.node_name || n.NodeName} (${n.node_status || n.NodeStatus})`).join('\n')}
>
<i className="fas fa-server" /> on {total} nodes{offline > 0 ? ` · ${offline} offline` : ''}
</span>
)
})()}
<NodeDistributionChip nodes={nodes} context="backends" />
</td>
)}
<td>