mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
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:
@@ -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
|
||||
|
||||
168
core/http/react-ui/src/components/NodeDistributionChip.jsx
Normal file
168
core/http/react-ui/src/components/NodeDistributionChip.jsx
Normal 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)
|
||||
}
|
||||
86
core/http/react-ui/src/components/Popover.jsx
Normal file
86
core/http/react-ui/src/components/Popover.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user