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} +
+
+ + + + + + {context === 'models' ? : <> + + + } + + + + {list.map(n => ( + + + + {context === 'models' ? ( + + ) : ( + <> + + + + )} + + ))} + +
NodeStatusStateVersionDigest
{getName(n)} + + {getStatus(n)} + + {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` : ''} - - ) - })()} + )}