diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css
index ceaf381ef..2e46fd893 100644
--- a/core/http/react-ui/src/App.css
+++ b/core/http/react-ui/src/App.css
@@ -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
diff --git a/core/http/react-ui/src/components/NodeDistributionChip.jsx b/core/http/react-ui/src/components/NodeDistributionChip.jsx
new file mode 100644
index 000000000..952d8be0e
--- /dev/null
+++ b/core/http/react-ui/src/components/NodeDistributionChip.jsx
@@ -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 {emptyLabel}
+ }
+
+ 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 (
+
+ {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 (
+
+ {getName(n)}
+
+ )
+ })}
+
+ )
+ }
+
+ // 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 (
+ <>
+
+ setOpen(false)}
+ ariaLabel={context === 'models' ? 'Model distribution' : 'Backend distribution'}
+ >
+
+ Installed on {total} node{total === 1 ? '' : 's'}
+ {offline > 0 && {offline} offline}
+ {drift > 0 && {drift} drift}
+
+
+
+
+
+ | Node |
+ Status |
+ {context === 'models' ? State | : <>
+ Version |
+ Digest |
+ >}
+
+
+
+ {list.map(n => (
+
+ | {getName(n)} |
+
+
+ {getStatus(n)}
+
+ |
+ {context === 'models' ? (
+ {getState(n) || '—'} |
+ ) : (
+ <>
+ {getVersion(n) ? `v${getVersion(n)}` : '—'} |
+
+ {getDigest(n) ? shortenDigest(getDigest(n)) : '—'}
+ |
+ >
+ )}
+
+ ))}
+
+
+
+
+ >
+ )
+}
+
+// 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)
+}
diff --git a/core/http/react-ui/src/components/Popover.jsx b/core/http/react-ui/src/components/Popover.jsx
new file mode 100644
index 000000000..96a9e217e
--- /dev/null
+++ b/core/http/react-ui/src/components/Popover.jsx
@@ -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 (
+
+ {children}
+
+ )
+}
diff --git a/core/http/react-ui/src/pages/Manage.jsx b/core/http/react-ui/src/pages/Manage.jsx
index 1347c44b0..b192730ef 100644
--- a/core/http/react-ui/src/pages/Manage.jsx
+++ b/core/http/react-ui/src/pages/Manage.jsx
@@ -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() {
Disabled
) : 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.
-
- {model.loaded_on.slice(0, 3).map(l => (
-
- {l.node_name}
-
- ))}
- {model.loaded_on.length > 3 && (
- `${l.node_name} (${l.state})`).join('\n')}>
- +{model.loaded_on.length - 3}
-
- )}
-
+ // Distributed mode: surface where the model is
+ // actually loaded. Shared chip scales to any cluster
+ // size (inline for <=3, popover for larger).
+
) : loadedModelIds.has(model.id) ? (
Running
@@ -645,35 +632,7 @@ export default function Manage() {
{distributedMode && (
- {nodes.length === 0 ? (
- —
- ) : nodes.length <= 3 ? (
-
- {nodes.map(n => (
-
- {n.node_name || n.NodeName}
-
- ))}
-
- ) : (() => {
- const total = nodes.length
- const offline = nodes.filter(n => {
- const s = n.node_status || n.NodeStatus
- return s !== 'healthy' && s !== 'draining'
- }).length
- return (
- 0 ? 'badge-warning' : 'badge-info'}`}
- title={nodes.map(n => `${n.node_name || n.NodeName} (${n.node_status || n.NodeStatus})`).join('\n')}
- >
- on {total} nodes{offline > 0 ? ` · ${offline} offline` : ''}
-
- )
- })()}
+
|
)}
|