diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css
index effea181e..2dcd2e5f0 100644
--- a/core/http/react-ui/src/App.css
+++ b/core/http/react-ui/src/App.css
@@ -6195,6 +6195,304 @@ select.input {
margin-left: 4px;
}
+/* ResourceRow — unified expandable row anatomy used by the Manage page so
+ installed models and backends share the same visual grammar as the Install
+ gallery (icon, name, description, badges, expandable detail panel). */
+.resource-row { transition: background var(--duration-fast) var(--ease-default); }
+.resource-row.is-dimmed { opacity: 0.55; transition: opacity 0.2s; }
+.resource-row.is-expanded { background: var(--color-bg-tertiary); }
+
+.resource-row__chevron-cell { width: 30px; }
+.resource-row__icon-cell { width: 64px; }
+
+.resource-row__icon {
+ width: 48px;
+ height: 48px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border-subtle);
+ background: var(--color-bg-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ flex-shrink: 0;
+}
+.resource-row__icon > img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.resource-row__icon > i {
+ font-size: 1.25rem;
+ color: var(--color-accent);
+}
+
+.resource-row__detail-row > .resource-row__detail-cell {
+ padding: 0;
+ background: var(--color-bg-primary);
+ border-top: 1px solid var(--color-border-subtle);
+}
+.resource-row__detail {
+ padding: var(--spacing-md) var(--spacing-lg);
+}
+.resource-row__detail h4 {
+ font-size: var(--text-xs);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--color-text-muted);
+ margin: 0 0 var(--spacing-sm) 0;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+.resource-row__detail h4 i { color: var(--color-accent); }
+.resource-row__detail-grid {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 6px var(--spacing-md);
+}
+.resource-row__detail-grid dt {
+ color: var(--color-text-muted);
+ font-size: var(--text-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ align-self: start;
+ padding-top: 2px;
+}
+.resource-row__detail-grid dd {
+ margin: 0;
+ font-size: var(--text-sm);
+ color: var(--color-text-primary);
+ min-width: 0;
+ word-break: break-word;
+}
+.resource-row__detail-md {
+ color: var(--color-text-secondary);
+ line-height: 1.6;
+ font-size: var(--text-sm);
+}
+.resource-row__detail-md p:first-child { margin-top: 0; }
+.resource-row__detail-md p:last-child { margin-bottom: 0; }
+
+/* Description line directly under the row name — Install gallery already
+ does this; Manage rows used to show only the bare name. */
+.resource-row__desc {
+ display: block;
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+ max-width: 42ch;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin-top: 2px;
+}
+
+/* ResourceActions — split lifecycle vs destructive with a thin divider so
+ the trash icon doesn't sit at the same eye-weight as the routine buttons.
+ Used now only for the rare row whose actions can't collapse into the
+ kebab menu (e.g. a "Protected" badge on system backends). */
+.resource-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ justify-content: flex-end;
+}
+.resource-actions__group {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+.resource-actions__divider {
+ width: 1px;
+ height: 20px;
+ background: var(--color-border-subtle);
+ margin: 0 var(--spacing-xs);
+ flex-shrink: 0;
+}
+
+/* ActionMenu — kebab trigger + popover menu. Borrows claudemaster's
+ restrained pattern: button reads as a quiet ellipsis at rest, lights up
+ on row hover, and the menu items hold typography-first weight (icon +
+ label, no fills until hover). The trigger stays at low opacity until the
+ user reaches for the row, so dense tables don't read as control panels. */
+.action-menu__trigger {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ font-size: var(--text-sm);
+ opacity: 0.45;
+ transition:
+ opacity var(--duration-fast) var(--ease-default),
+ color var(--duration-fast) var(--ease-default),
+ background var(--duration-fast) var(--ease-default),
+ border-color var(--duration-fast) var(--ease-default);
+}
+.resource-row:hover .action-menu__trigger,
+.action-menu__trigger:focus-visible,
+.action-menu__trigger.is-open {
+ opacity: 1;
+}
+.action-menu__trigger:hover,
+.action-menu__trigger.is-open {
+ background: var(--color-bg-tertiary);
+ border-color: var(--color-border-subtle);
+ color: var(--color-text-primary);
+}
+.action-menu__trigger:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: 2px;
+}
+.action-menu__trigger--compact {
+ width: 24px;
+ height: 24px;
+ font-size: var(--text-xs);
+}
+
+.action-menu {
+ display: flex;
+ flex-direction: column;
+ min-width: 200px;
+ padding: 4px;
+ outline: none;
+}
+.action-menu__item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: 8px 10px;
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--color-text-primary);
+ font-family: inherit;
+ font-size: var(--text-sm);
+ font-weight: 500;
+ text-align: left;
+ cursor: pointer;
+ width: 100%;
+ transition: background var(--duration-fast) var(--ease-default),
+ color var(--duration-fast) var(--ease-default);
+}
+.action-menu__item.is-active,
+.action-menu__item:hover:not(:disabled) {
+ background: var(--color-bg-tertiary);
+}
+.action-menu__item:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+.action-menu__icon {
+ width: 14px;
+ text-align: center;
+ color: var(--color-text-muted);
+ font-size: var(--text-xs);
+ flex-shrink: 0;
+}
+.action-menu__item.is-active .action-menu__icon,
+.action-menu__item:hover:not(:disabled) .action-menu__icon {
+ color: var(--color-text-primary);
+}
+.action-menu__label {
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.action-menu__shortcut {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--color-text-muted);
+ letter-spacing: 0.04em;
+ flex-shrink: 0;
+}
+.action-menu__item.is-danger { color: var(--color-error); }
+.action-menu__item.is-danger .action-menu__icon { color: var(--color-error); }
+.action-menu__item.is-danger:hover:not(:disabled),
+.action-menu__item.is-danger.is-active {
+ background: var(--color-error-light);
+ color: var(--color-error);
+}
+.action-menu__item.is-danger:hover:not(:disabled) .action-menu__icon,
+.action-menu__item.is-danger.is-active .action-menu__icon {
+ color: var(--color-error);
+}
+.action-menu__divider {
+ height: 1px;
+ background: var(--color-border-subtle);
+ margin: 4px 2px;
+}
+.action-menu__badge {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: 8px 10px;
+ font-size: var(--text-xs);
+ font-weight: 500;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--color-text-muted);
+}
+.action-menu__badge > i { color: var(--color-text-muted); font-size: var(--text-xs); width: 14px; text-align: center; }
+
+/* Pulse modifier — subtle ring on the Pin button when a model is pinned, so
+ pinning is visible at a glance without a second icon next to the name. */
+.btn--pulse {
+ animation: btnPulseWarning 1.6s ease-in-out infinite;
+}
+@keyframes btnPulseWarning {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(235, 203, 139, 0); }
+ 50% { box-shadow: 0 0 0 4px rgba(235, 203, 139, 0.18); }
+}
+
+/* StatCard clickable variant — the Manage summary cards double as shortcuts
+ to the relevant tab + filter, so the card needs a real button affordance. */
+.stat-card[data-clickable="true"] {
+ cursor: pointer;
+ user-select: none;
+}
+.stat-card[data-clickable="true"]:hover {
+ border-color: var(--stat-accent, var(--color-border));
+ transform: translateY(-1px);
+}
+.stat-card[data-clickable="true"]:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: 2px;
+}
+
+/* Manage summary marker — same .stat-grid layout. Top margin separates the
+ cards from the System Resources card above (otherwise they sit too close
+ to the RAM bar) and bottom margin tightens the gap to the tabs below. */
+.manage-summary {
+ margin-top: var(--spacing-xl);
+ margin-bottom: var(--spacing-lg);
+}
+
+/* Screen-reader-only label, used for table headers whose visual cell needs
+ no label (kebab-only Actions column, toggle-only Enabled column). The
+ header still announces correctly to assistive tech without making the
+ table feel like it's labelling sparse columns twice. */
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
/* Reduced motion accessibility */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
diff --git a/core/http/react-ui/src/components/ActionMenu.jsx b/core/http/react-ui/src/components/ActionMenu.jsx
new file mode 100644
index 000000000..5c58ecd78
--- /dev/null
+++ b/core/http/react-ui/src/components/ActionMenu.jsx
@@ -0,0 +1,141 @@
+import { useRef, useState, useEffect, useCallback } from 'react'
+import Popover from './Popover'
+
+// ActionMenu renders a kebab (three-dot) button that opens a popover with a
+// list of row actions. Replaces the inline cluster of icon buttons that made
+// dense tables feel like a control panel — actions stay out of the way until
+// the user reaches for them, the way Linear/Vercel/Notion handle row menus.
+//
+// Items shape:
+// { key, icon?, label, onClick, danger?, disabled?, hidden?, shortcut? }
+// { divider: true } // visual separator
+// { type: 'badge', icon?, label } // non-interactive badge row
+//
+// Hidden items are filtered out so callers can write conditional menus
+// inline (`{ key: 'stop', visible: isRunning, ... }` style) without ternaries.
+//
+// Keyboard:
+// ArrowUp / ArrowDown — move highlight (skipping dividers + badges)
+// Enter / Space — activate
+// Escape — close, return focus to trigger
+export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel, compact = false }) {
+ const triggerRef = useRef(null)
+ const [open, setOpen] = useState(false)
+ const [activeIdx, setActiveIdx] = useState(-1)
+
+ const interactive = (Array.isArray(items) ? items : []).filter(it => it && !it.divider && it.type !== 'badge' && !it.hidden)
+ const visible = (Array.isArray(items) ? items : []).filter(it => it && !it.hidden)
+
+ const close = useCallback(() => {
+ setOpen(false)
+ setActiveIdx(-1)
+ }, [])
+
+ // Move highlight to the first interactive item when opening, so keyboard
+ // users land somewhere meaningful instead of having to arrow into the menu.
+ useEffect(() => {
+ if (open && activeIdx === -1 && interactive.length > 0) {
+ setActiveIdx(0)
+ }
+ }, [open, activeIdx, interactive.length])
+
+ const handleTriggerKeyDown = (e) => {
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ e.stopPropagation()
+ setOpen(true)
+ }
+ }
+
+ const handleMenuKeyDown = (e) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ setActiveIdx(i => Math.min(interactive.length - 1, (i < 0 ? -1 : i) + 1))
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ setActiveIdx(i => Math.max(0, (i < 0 ? interactive.length : i) - 1))
+ } else if (e.key === 'Home') {
+ e.preventDefault()
+ setActiveIdx(0)
+ } else if (e.key === 'End') {
+ e.preventDefault()
+ setActiveIdx(interactive.length - 1)
+ } else if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ const item = interactive[activeIdx]
+ if (item && !item.disabled) {
+ close()
+ item.onClick?.()
+ }
+ }
+ }
+
+ if (interactive.length === 0 && !visible.some(it => it.type === 'badge')) {
+ return null
+ }
+
+ return (
+ <>
+ { e.stopPropagation(); setOpen(v => !v) }}
+ onKeyDown={handleTriggerKeyDown}
+ >
+
+
+
+ { if (el && open) el.focus() }}
+ >
+ {visible.map((item, i) => {
+ if (item.divider) {
+ return
+ }
+ if (item.type === 'badge') {
+ return (
+
+ {item.icon && }
+ {item.label}
+
+ )
+ }
+ const idx = interactive.indexOf(item)
+ const active = idx === activeIdx
+ return (
+
setActiveIdx(idx)}
+ onClick={(e) => {
+ e.stopPropagation()
+ if (item.disabled) return
+ close()
+ item.onClick?.()
+ }}
+ >
+ {item.icon && }
+ {item.label}
+ {item.shortcut && {item.shortcut} }
+
+ )
+ })}
+
+
+ >
+ )
+}
diff --git a/core/http/react-ui/src/components/GalleryLoader.jsx b/core/http/react-ui/src/components/GalleryLoader.jsx
new file mode 100644
index 000000000..304784b05
--- /dev/null
+++ b/core/http/react-ui/src/components/GalleryLoader.jsx
@@ -0,0 +1,79 @@
+import { useState, useEffect } from 'react'
+
+const LOADING_PHRASES = [
+ { text: 'Loading models...', icon: 'fa-brain' },
+ { text: 'Fetching gallery...', icon: 'fa-download' },
+ { text: 'Checking availability...', icon: 'fa-circle-check' },
+ { text: 'Almost ready...', icon: 'fa-hourglass-half' },
+ { text: 'Preparing gallery...', icon: 'fa-store' },
+]
+
+// GalleryLoader is the animated skeleton used while the gallery list loads.
+// Used by Models, Backends, and (now) the Manage page so an empty fetch state
+// reads the same everywhere instead of one tab showing pulsing dots and the
+// other showing "Loading...".
+export default function GalleryLoader() {
+ const [idx, setIdx] = useState(() => Math.floor(Math.random() * LOADING_PHRASES.length))
+ const [fade, setFade] = useState(true)
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setFade(false)
+ setTimeout(() => {
+ setIdx(prev => (prev + 1) % LOADING_PHRASES.length)
+ setFade(true)
+ }, 300)
+ }, 2800)
+ return () => clearInterval(interval)
+ }, [])
+
+ const phrase = LOADING_PHRASES[idx]
+
+ return (
+
+
+ {[0, 1, 2, 3, 4].map(i => (
+
+ ))}
+
+
+
+ {phrase.text}
+
+
+ {[0.9, 0.7, 0.5].map((opacity, i) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/ManageSummary.jsx b/core/http/react-ui/src/components/ManageSummary.jsx
new file mode 100644
index 000000000..d02e4cae9
--- /dev/null
+++ b/core/http/react-ui/src/components/ManageSummary.jsx
@@ -0,0 +1,47 @@
+import StatCard from './StatCard'
+
+// ManageSummary anchors the Manage page with the same StatCard pattern the
+// Nodes dashboard uses, so the page reads as a real overview rather than
+// "two tabs in a hat". Counts are derived in-memory by the parent — this
+// component is purely presentational. Cards are clickable and route the
+// user to the relevant tab + filter.
+export default function ManageSummary({
+ modelsCount,
+ backendsCount,
+ runningCount,
+ updatesCount,
+ onCardClick,
+}) {
+ const click = (tab, filter) => onCardClick && onCardClick(tab, filter)
+
+ return (
+
+ click('models', 'all')}
+ />
+ click('backends', 'all')}
+ />
+ 0 ? '--color-success' : undefined}
+ onClick={() => click('models', 'running')}
+ />
+ 0 ? '--color-warning' : undefined}
+ onClick={() => click('backends', updatesCount > 0 ? 'upgradable' : 'all')}
+ />
+
+ )
+}
diff --git a/core/http/react-ui/src/components/MetaBadgeRow.jsx b/core/http/react-ui/src/components/MetaBadgeRow.jsx
new file mode 100644
index 000000000..bc38b0e42
--- /dev/null
+++ b/core/http/react-ui/src/components/MetaBadgeRow.jsx
@@ -0,0 +1,30 @@
+// MetaBadgeRow renders the System / User / Meta / Dev badge cluster the same
+// way everywhere — Manage tabs and (in future) Install gallery. The badges
+// already exist as classes; this component locks down the icons + labels so
+// the same backend type doesn't read "User" in one tab and "downloaded" in
+// another.
+export default function MetaBadgeRow({ isSystem, isMeta, isDevelopment }) {
+ return (
+
+ {isSystem ? (
+
+ System
+
+ ) : (
+
+ User
+
+ )}
+ {isMeta && (
+
+ Meta
+
+ )}
+ {isDevelopment && (
+
+ Dev
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/ResourceActions.jsx b/core/http/react-ui/src/components/ResourceActions.jsx
new file mode 100644
index 000000000..bfec39f10
--- /dev/null
+++ b/core/http/react-ui/src/components/ResourceActions.jsx
@@ -0,0 +1,29 @@
+// ResourceActions groups row-level buttons into a lifecycle cluster (start,
+// stop, pin, reinstall, upgrade) and a destructive cluster (delete) with a
+// thin divider between them, so a destructive intent visually separates from
+// a routine one. Replaces the old 4px-gap row of buttons in the Manage page
+// where Stop / Pin / Delete sat shoulder-to-shoulder with no visual cue
+// telling apart "click to fiddle" from "click to throw away".
+//
+// `lifecycle` and `destructive` accept any ReactNode — typically one or more
+// s. The wrapping div stops click propagation so action clicks don't
+// also expand the row.
+export default function ResourceActions({ lifecycle, destructive }) {
+ const hasLifecycle = !!lifecycle
+ const hasDestructive = !!destructive
+ if (!hasLifecycle && !hasDestructive) return null
+
+ return (
+ e.stopPropagation()}>
+ {hasLifecycle && (
+
{lifecycle}
+ )}
+ {hasLifecycle && hasDestructive && (
+
+ )}
+ {hasDestructive && (
+
{destructive}
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/ResourceRow.jsx b/core/http/react-ui/src/components/ResourceRow.jsx
new file mode 100644
index 000000000..09fa20ed9
--- /dev/null
+++ b/core/http/react-ui/src/components/ResourceRow.jsx
@@ -0,0 +1,81 @@
+import { Fragment } from 'react'
+
+// ResourceRow renders the visible row + its conditional detail row as a pair
+// of s, so the existing .table styling keeps applying and the Manage page
+// can re-use the gallery's expand-to-detail interaction without inventing a
+// new table system. The consumer owns the cells (which pass through as
+// children) — this component only manages the click-to-expand handler, the
+// dimmed state for disabled rows, and the colSpan'd detail row beneath.
+//
+// `onToggleExpand` fires on row click only. Buttons / toggles inside cells
+// must call e.stopPropagation() (or be wrapped in an .actions-stop wrapper)
+// to avoid double-triggering the expand.
+export default function ResourceRow({
+ expanded,
+ onToggleExpand,
+ detail,
+ colSpan,
+ dimmed,
+ className = '',
+ children,
+}) {
+ return (
+
+
+ {children}
+
+ {expanded && detail && (
+
+
+ {detail}
+
+
+ )}
+
+ )
+}
+
+// ChevronCell is the small rotating chevron used as the leftmost cell of an
+// expandable row. Mirrors the Nodes/Models/Backends gallery affordance so
+// users see the same "click to expand" cue everywhere.
+export function ChevronCell({ expanded }) {
+ return (
+
+
+
+
+
+ )
+}
+
+// IconCell renders the 48px brand icon shell — the same one the Install
+// gallery uses. `icon` is the image URL (from gallery metadata); when absent
+// or broken we fall back to a FontAwesome glyph so custom-imported items
+// still get a placeholder instead of an empty square.
+export function IconCell({ icon, fallback = 'fa-cube', alt = '' }) {
+ return (
+
+
+ {icon ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+// StopPropagationCell wraps cell contents that contain interactive controls
+// (Toggle, action buttons) so a click on them doesn't also expand the row.
+export function StopPropagationCell({ children, ...props }) {
+ return (
+ e.stopPropagation()}>
+ {children}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/StatCard.jsx b/core/http/react-ui/src/components/StatCard.jsx
new file mode 100644
index 000000000..a6e1d95f8
--- /dev/null
+++ b/core/http/react-ui/src/components/StatCard.jsx
@@ -0,0 +1,39 @@
+// StatCard renders a single cluster/dashboard metric card. The left accent
+// bar + icon chip color is driven by `accentVar` (a CSS custom property name,
+// e.g. "--color-success") so the card reads as semantic without the caller
+// having to reach into colors directly. `onClick` upgrades the card to a
+// keyboard-focusable button — used by the Manage page so cards double as
+// shortcuts to the relevant tab + filter.
+export default function StatCard({ icon, label, value, color, accentVar, onClick }) {
+ const accent = color || (accentVar ? `var(${accentVar})` : 'var(--color-text-primary)')
+ const interactive = typeof onClick === 'function'
+
+ const handleKeyDown = interactive
+ ? (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ onClick(e)
+ }
+ }
+ : undefined
+
+ return (
+
+ )
+}
diff --git a/core/http/react-ui/src/hooks/useGalleryEnrichment.js b/core/http/react-ui/src/hooks/useGalleryEnrichment.js
new file mode 100644
index 000000000..bd1171eb9
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useGalleryEnrichment.js
@@ -0,0 +1,53 @@
+import { useState, useEffect, useCallback } from 'react'
+import { modelsApi, backendsApi } from '../utils/api'
+
+// useGalleryEnrichment fetches the full model + backend gallery once and
+// returns lookup helpers used by the Manage page. The Manage list APIs only
+// know name/version/alias — descriptions, icons, licenses, tags, and links
+// live on the gallery side. Cross-referencing here lets us light up the
+// installed lists with the same metadata the Install pages show, instead of
+// rendering them as bare names.
+//
+// Items not present in the gallery (custom imports, external OCI installs)
+// resolve to `null` — callers fall back to a neutral icon + "no description".
+export function useGalleryEnrichment() {
+ const [modelMap, setModelMap] = useState(() => new Map())
+ const [backendMap, setBackendMap] = useState(() => new Map())
+ const [loaded, setLoaded] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+ Promise.allSettled([
+ modelsApi.list({ items: 9999, page: 1 }),
+ backendsApi.list({ items: 9999, page: 1 }),
+ ]).then(([m, b]) => {
+ if (cancelled) return
+ const mm = new Map()
+ if (m.status === 'fulfilled') {
+ const list = m.value?.models || []
+ for (const x of list) {
+ const key = x.name || x.id
+ if (key) mm.set(key, x)
+ }
+ }
+ const bm = new Map()
+ if (b.status === 'fulfilled') {
+ const raw = b.value
+ const list = Array.isArray(raw?.backends) ? raw.backends : Array.isArray(raw) ? raw : []
+ for (const x of list) {
+ const key = x.name || x.id
+ if (key) bm.set(key, x)
+ }
+ }
+ setModelMap(mm)
+ setBackendMap(bm)
+ setLoaded(true)
+ })
+ return () => { cancelled = true }
+ }, [])
+
+ const enrichModel = useCallback((name) => (name ? modelMap.get(name) || null : null), [modelMap])
+ const enrichBackend = useCallback((name) => (name ? backendMap.get(name) || null : null), [backendMap])
+
+ return { enrichModel, enrichBackend, loaded }
+}
diff --git a/core/http/react-ui/src/pages/Manage.jsx b/core/http/react-ui/src/pages/Manage.jsx
index 9188c80ef..9851899ee 100644
--- a/core/http/react-ui/src/pages/Manage.jsx
+++ b/core/http/react-ui/src/pages/Manage.jsx
@@ -5,8 +5,15 @@ import ConfirmDialog from '../components/ConfirmDialog'
import Toggle from '../components/Toggle'
import NodeDistributionChip from '../components/NodeDistributionChip'
import FilterBar from '../components/FilterBar'
+import GalleryLoader from '../components/GalleryLoader'
+import ManageSummary from '../components/ManageSummary'
+import MetaBadgeRow from '../components/MetaBadgeRow'
+import ActionMenu from '../components/ActionMenu'
+import ResourceRow, { ChevronCell, IconCell, StopPropagationCell } from '../components/ResourceRow'
import { useModels } from '../hooks/useModels'
+import { useGalleryEnrichment } from '../hooks/useGalleryEnrichment'
import { backendControlApi, modelsApi, backendsApi, systemApi, nodesApi } from '../utils/api'
+import { renderMarkdown } from '../utils/markdown'
import {
CAP_CHAT, CAP_COMPLETION, CAP_IMAGE, CAP_VIDEO, CAP_TTS,
CAP_TRANSCRIPT, CAP_SOUND_GENERATION, CAP_FACE_RECOGNITION,
@@ -35,6 +42,10 @@ const USE_CASES = [
{ cap: CAP_RERANK, label: 'Rerank' },
]
+// Number of columns the expandable detail row spans, per tab. Kept as
+// constants so adding/removing a column doesn't silently break the colSpan.
+const MODELS_COLSPAN = 8 // chevron, toggle, icon, name, status, backend, use cases, actions
+
// formatInstalledAt renders an installed_at timestamp as a short relative/abs
// string suitable for dense tables. Returns the raw value if parsing fails so
// we never display "Invalid Date".
@@ -51,6 +62,51 @@ function formatInstalledAt(value) {
return d.toISOString().slice(0, 10)
}
+// formatInstalledAtFull returns the absolute ISO timestamp for tooltips.
+function formatInstalledAtFull(value) {
+ if (!value) return ''
+ const d = new Date(value)
+ if (isNaN(d.getTime())) return value
+ return d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC'
+}
+
+// formatBackendVersion derives a single short identifier suitable for a dense
+// "Version" cell. The runtime API doesn't carry a semver for OCI installs —
+// it has digest, uri, or gallery_url instead — so showing "—" for everything
+// imported via OCI was misleading. Order of preference: explicit version →
+// short digest → OCI tag (the part after the last colon) → ocifile basename.
+//
+// Returns { label, full } where `full` is the unabridged value to expose via
+// title attr / detail panel.
+function formatBackendVersion(metadata) {
+ if (!metadata) return { label: '—', full: '' }
+ if (metadata.version) {
+ return { label: `v${metadata.version}`, full: `version v${metadata.version}` }
+ }
+ if (metadata.digest) {
+ // sha256:7b2a044a… — show the short hex form devs are used to.
+ const m = /^(sha\d+:)?([a-f0-9]+)$/i.exec(metadata.digest)
+ if (m) {
+ const hex = m[2]
+ return { label: hex.slice(0, 12), full: metadata.digest }
+ }
+ return { label: metadata.digest.slice(0, 12), full: metadata.digest }
+ }
+ const uri = metadata.uri || ''
+ if (uri.startsWith('ocifile://')) {
+ // Local OCI tarball — show the basename, not the full path.
+ const path = uri.replace(/^ocifile:\/\//, '')
+ const base = path.split('/').pop() || path
+ return { label: base, full: uri }
+ }
+ if (uri) {
+ // Registry ref like quay.io/foo/bar:tag → show the tag, full ref on hover.
+ const tag = uri.includes(':') ? uri.slice(uri.lastIndexOf(':') + 1) : uri
+ return { label: tag, full: uri }
+ }
+ return { label: '—', full: '' }
+}
+
export default function Manage() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
@@ -58,6 +114,7 @@ export default function Manage() {
const initialTab = searchParams.get('tab') || localStorage.getItem('manage-tab') || 'models'
const [activeTab, setActiveTab] = useState(TABS.some(t => t.key === initialTab) ? initialTab : 'models')
const { models, loading: modelsLoading, refetch: refetchModels } = useModels()
+ const { enrichModel, enrichBackend } = useGalleryEnrichment()
const [loadedModelIds, setLoadedModelIds] = useState(new Set())
const [backends, setBackends] = useState([])
const [backendsLoading, setBackendsLoading] = useState(true)
@@ -68,12 +125,16 @@ export default function Manage() {
const [distributedMode, setDistributedMode] = useState(false)
const [togglingModels, setTogglingModels] = useState(new Set())
const [pinningModels, setPinningModels] = useState(new Set())
+ // Expanded row state — keyed by `${tab}:${id}` so switching tabs doesn't
+ // collide and a single row is open at a time per tab.
+ const [expandedKey, setExpandedKey] = useState(null)
// Filter state per tab. Persisted in the URL query so switching tabs
// doesn't lose the filter the operator just set.
const [modelsSearch, setModelsSearch] = useState(() => searchParams.get('mq') || '')
const [modelsFilter, setModelsFilter] = useState(() => searchParams.get('mf') || 'all')
const [backendsSearch, setBackendsSearch] = useState(() => searchParams.get('bq') || '')
const [backendsFilter, setBackendsFilter] = useState(() => searchParams.get('bf') || 'all')
+ const [showMetaDev, setShowMetaDev] = useState(() => searchParams.get('bm') === '1')
// Sync filter state into the URL so deep-links + tab switches survive.
useEffect(() => {
@@ -83,16 +144,36 @@ export default function Manage() {
setOrDelete('mf', modelsFilter)
setOrDelete('bq', backendsSearch)
setOrDelete('bf', backendsFilter)
+ if (showMetaDev) p.set('bm', '1'); else p.delete('bm')
setSearchParams(p, { replace: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [modelsSearch, modelsFilter, backendsSearch, backendsFilter])
+ }, [modelsSearch, modelsFilter, backendsSearch, backendsFilter, showMetaDev])
const handleTabChange = (tab) => {
setActiveTab(tab)
+ setExpandedKey(null)
localStorage.setItem('manage-tab', tab)
setSearchParams({ tab })
}
+ // Switch tabs and pre-set a filter — wired into the StatCards so cards
+ // double as shortcuts to a filtered slice instead of being purely visual.
+ const handleSummaryClick = (tab, filter) => {
+ setActiveTab(tab)
+ setExpandedKey(null)
+ localStorage.setItem('manage-tab', tab)
+ if (tab === 'models') setModelsFilter(filter)
+ if (tab === 'backends') setBackendsFilter(filter)
+ const p = new URLSearchParams(searchParams)
+ p.set('tab', tab)
+ setSearchParams(p, { replace: true })
+ }
+
+ const toggleExpanded = (tab, id) => {
+ const key = `${tab}:${id}`
+ setExpandedKey(prev => (prev === key ? null : key))
+ }
+
const fetchLoadedModels = useCallback(async () => {
try {
const info = await systemApi.info()
@@ -284,7 +365,6 @@ export default function Manage() {
}
const [upgradingAll, setUpgradingAll] = useState(false)
- const [showOnlyUpgradable, setShowOnlyUpgradable] = useState(false)
const handleUpgradeAll = async () => {
const names = Object.keys(upgrades)
if (names.length === 0) return
@@ -325,6 +405,12 @@ export default function Manage() {
})
}
+ // Counts for the summary header — derived in-memory; no extra API calls.
+ const runningCount = models.filter(m =>
+ !m.disabled && (loadedModelIds.has(m.id) || (Array.isArray(m.loaded_on) && m.loaded_on.length > 0))
+ ).length
+ const updatesCount = Object.keys(upgrades).length
+
return (
@@ -335,8 +421,17 @@ export default function Manage() {
{/* Resource Monitor */}
+ {/* Summary */}
+
+
{/* Tabs */}
-
+
{TABS.map(t => {
const upgradeCount = t.key === 'backends' ? Object.keys(upgrades).length : 0
return (
@@ -361,8 +456,6 @@ export default function Manage() {
{/* Models Tab */}
{activeTab === 'models' && (() => {
- // Computed filters — done here so the result is available both to
- // the FilterBar counts and to the table body.
const MODEL_FILTERS = [
{ key: 'all', label: 'All', icon: 'fa-layer-group' },
{ key: 'running', label: 'Running', icon: 'fa-circle-play' },
@@ -407,9 +500,7 @@ export default function Manage() {
/>
{modelsLoading ? (
-
- Loading models...
-
+
) : models.length === 0 ? (
@@ -440,145 +531,135 @@ export default function Manage() {
- Enabled
- Name
+
+
+ Enabled
+
+
+ Model
Status
Backend
- Use Cases
- Actions
+ Use cases
+
- {visibleModels.map(model => (
-
- {/* Enable/Disable toggle */}
-
- handleToggleModel(model.id, model.disabled)}
- disabled={togglingModels.has(model.id)}
- />
-
- {/* Name */}
-
-
-
{model.id}
- {model.pinned && (
-
- )}
-
-
{ e.preventDefault(); navigate(`/app/model-editor/${encodeURIComponent(model.id)}`) }}
- className="btn btn-secondary btn-sm"
- style={{ padding: '2px 5px', fontSize: '0.625rem' }}
- title="Edit config"
- >
-
-
- {!distributedMode && (
-
{ e.preventDefault(); navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) }}
- className="btn btn-secondary btn-sm"
- style={{ padding: '2px 5px', fontSize: '0.625rem' }}
- title="Backend logs"
- >
-
-
+ {visibleModels.map(model => {
+ const enriched = enrichModel(model.id)
+ const isExpanded = expandedKey === `models:${model.id}`
+ const isRunning = loadedModelIds.has(model.id) || (Array.isArray(model.loaded_on) && model.loaded_on.length > 0)
+ const caps = Array.isArray(model.capabilities) ? model.capabilities : []
+ const matchedCaps = USE_CASES.filter(uc => caps.includes(uc.cap) && !(uc.hideIf && caps.includes(uc.hideIf)))
+ return (
+
toggleExpanded('models', model.id)}
+ colSpan={MODELS_COLSPAN}
+ dimmed={!!model.disabled}
+ detail={(
+
+ )}
+ >
+
+
+ handleToggleModel(model.id, model.disabled)}
+ disabled={togglingModels.has(model.id)}
+ />
+
+
+
+
+ {model.id}
+ {enriched?.description && (
+
+ {enriched.description}
+
)}
-
-
- {/* Status / Distribution */}
-
-
- {model.disabled ? (
-
- Disabled
-
- ) : model.loaded_on && model.loaded_on.length > 0 ? (
- // 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
-
- ) : (
-
- Idle
-
- )}
- {model.source === 'registry-only' && (
-
- Adopted
-
- )}
-
-
- {/* Backend */}
-
- {model.backend || 'Auto'}
-
- {/* Use Cases */}
-
-
- {(() => {
- const caps = Array.isArray(model.capabilities) ? model.capabilities : []
- const matched = USE_CASES.filter(uc => caps.includes(uc.cap) && !(uc.hideIf && caps.includes(uc.hideIf)))
- if (matched.length === 0) {
- return —
- }
- return matched.map(uc => uc.route ? (
+
+
+
+ {model.disabled ? (
+
+ Disabled
+
+ ) : Array.isArray(model.loaded_on) && model.loaded_on.length > 0 ? (
+
+ ) : loadedModelIds.has(model.id) ? (
+
+ Running
+
+ ) : (
+
+ Idle
+
+ )}
+ {model.source === 'registry-only' && (
+
+ Adopted
+
+ )}
+ {model.pinned && (
+
+ Pinned
+
+ )}
+
+
+
+ {model.backend || 'Auto'}
+
+
+
-
- {/* Actions */}
-
-
- {loadedModelIds.has(model.id) && (
- handleStopModel(model.id)}
- title="Stop model"
- >
-
-
- )}
- handleTogglePinned(model.id, model.pinned)}
- disabled={pinningModels.has(model.id) || model.disabled}
- title={model.pinned ? 'Unpin model (allow idle unloading)' : 'Pin model (prevent idle unloading)'}
- style={{
- color: model.pinned ? 'var(--color-warning)' : undefined,
- }}
- >
-
-
- handleDeleteModel(model.id)}
- title="Delete model"
- >
-
-
-
-
-
- ))}
+ ))}
+
+
+
+ handleStopModel(model.id), hidden: !isRunning },
+ { key: 'pin', icon: 'fa-thumbtack',
+ label: model.pinned ? 'Unpin (allow idle unload)' : 'Pin (prevent idle unload)',
+ onClick: () => handleTogglePinned(model.id, model.pinned),
+ disabled: pinningModels.has(model.id) || !!model.disabled },
+ { key: 'edit', icon: 'fa-pen-to-square', label: 'Edit configuration',
+ onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`) },
+ { key: 'logs', icon: 'fa-terminal', label: 'Backend logs',
+ onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`),
+ hidden: distributedMode },
+ { divider: true },
+ { key: 'delete', icon: 'fa-trash', label: 'Delete model', danger: true,
+ onClick: () => handleDeleteModel(model.id) },
+ ]}
+ />
+
+
+ )
+ })}
@@ -613,9 +694,7 @@ export default function Manage() {
)}
{backendsLoading ? (
-
- Loading backends...
-
+
) : backends.length === 0 ? (
@@ -633,25 +712,35 @@ export default function Manage() {
) : (() => {
- // Count chip badges: show N in the filter buttons so operators can
- // see at a glance how their chips bucket the list.
- const upgradableCount = backends.filter(b => upgrades[b.Name]).length
- const userCount = backends.filter(b => !b.IsSystem).length
- const systemCount = backends.filter(b => b.IsSystem).length
- const metaCount = backends.filter(b => b.IsMeta).length
- const offlineCount = backends.filter(b => {
+ // A backend is "meta or development" if its installed metadata flags
+ // it OR if the gallery record does. We hide them by default so the
+ // installed list stops looking like the raw debug dump it was — the
+ // toggle exposes them on demand without reshuffling the row layout.
+ const isMetaOrDev = (b) => {
+ if (b.IsMeta) return true
+ const g = enrichBackend(b.Name)
+ return !!(g?.isMeta || g?.isDevelopment)
+ }
+ const visibleBase = backends.filter(b => showMetaDev || !isMetaOrDev(b))
+
+ // Counts AFTER the meta/dev filter so the chip numbers reflect what
+ // the user is actually about to filter into.
+ const upgradableCount = visibleBase.filter(b => upgrades[b.Name]).length
+ const userCount = visibleBase.filter(b => !b.IsSystem).length
+ const systemCount = visibleBase.filter(b => b.IsSystem).length
+ const offlineCount = visibleBase.filter(b => {
const n = b.Nodes || b.nodes || []
return n.some(x => {
const s = x.node_status || x.NodeStatus
return s && s !== 'healthy' && s !== 'draining'
})
}).length
+ const hiddenMetaCount = backends.length - visibleBase.length
const BACKEND_FILTERS = [
- { key: 'all', label: 'All', icon: 'fa-layer-group', count: backends.length },
- { key: 'user', label: 'User', icon: 'fa-download', count: userCount },
- { key: 'system', label: 'System', icon: 'fa-shield-alt', count: systemCount },
- { key: 'meta', label: 'Meta', icon: 'fa-layer-group', count: metaCount },
+ { key: 'all', label: 'All', icon: 'fa-layer-group', count: visibleBase.length },
+ { key: 'user', label: 'User', icon: 'fa-download', count: userCount },
+ { key: 'system', label: 'System', icon: 'fa-shield-alt', count: systemCount },
...(upgradableCount > 0 ? [{ key: 'upgradable', label: 'Updates', icon: 'fa-arrow-up', count: upgradableCount }] : []),
...(distributedMode && offlineCount > 0 ? [{ key: 'offline', label: 'Offline nodes', icon: 'fa-exclamation-circle', count: offlineCount }] : []),
]
@@ -664,7 +753,6 @@ export default function Manage() {
switch (backendsFilter) {
case 'user': return !b.IsSystem
case 'system': return !!b.IsSystem
- case 'meta': return !!b.IsMeta
case 'upgradable': return !!upgrades[b.Name]
case 'offline': {
const n = b.Nodes || b.nodes || []
@@ -676,35 +764,12 @@ export default function Manage() {
default: return true
}
}
- // Legacy "showOnlyUpgradable" toggle is now the 'upgradable' chip —
- // keep backward-compat by mapping it onto the new filter.
- if (showOnlyUpgradable && backendsFilter !== 'upgradable') {
- // One-shot reconciliation — the old state becomes the new chip.
- setBackendsFilter('upgradable')
- setShowOnlyUpgradable(false)
- }
- const visibleBackends = backends.filter(b => passesFilter(b) && passesSearch(b))
- if (visibleBackends.length === 0) {
- return (
- <>
-
-
-
-
No backends match the current filter.
-
{ setBackendsSearch(''); setBackendsFilter('all') }}>Clear filters
-
- >
- )
- }
- return (
- <>
+ const visibleBackends = visibleBase.filter(b => passesFilter(b) && passesSearch(b))
+ // Polished column count: chevron, icon, name+badges, version,
+ // installed, actions (+ optional nodes when distributed).
+ const colSpan = distributedMode ? 7 : 6
+
+ const filterBar = (
0 ? `Show meta & development (${hiddenMetaCount})` : 'Show meta & development',
+ icon: 'fa-flask',
+ checked: showMetaDev,
+ onChange: () => setShowMetaDev(v => !v),
+ }]}
/>
+ )
+
+ if (visibleBackends.length === 0) {
+ return (
+ <>
+ {filterBar}
+
+
+
+ No backends match the current filter.
+ {!showMetaDev && hiddenMetaCount > 0 && (
+ <> {hiddenMetaCount} meta or development backend{hiddenMetaCount === 1 ? ' is' : 's are'} hidden — toggle "Show meta & development" to reveal {hiddenMetaCount === 1 ? 'it' : 'them'}.>
+ )}
+
+
{ setBackendsSearch(''); setBackendsFilter('all') }}>Clear filters
+
+ >
+ )
+ }
+ return (
+ <>
+ {filterBar}
- Name
- Type
+
+
+ Backend
Version
{distributedMode && Nodes }
Installed
- Actions
+
- {visibleBackends.map((backend, i) => {
+ {visibleBackends.map((backend) => {
const upgradeInfo = upgrades[backend.Name]
const hasDrift = upgradeInfo?.node_drift?.length > 0
const nodes = backend.Nodes || backend.nodes || []
+ const enriched = enrichBackend(backend.Name)
+ const isExpanded = expandedKey === `backends:${backend.Name}`
+ const isDevelopment = !!(enriched?.isDevelopment)
+ const isProcessing = reinstallingBackends.has(backend.Name)
return (
-
-
-
-
- {backend.Name}
- {backend.Metadata?.alias && (
- alias: {backend.Metadata.alias}
- )}
- {backend.Metadata?.meta_backend_for && (
- for: {backend.Metadata.meta_backend_for}
- )}
-
-
-
-
- {backend.IsSystem ? (
-
- System
-
- ) : (
-
- User
-
- )}
- {backend.IsMeta && (
-
- Meta
-
- )}
-
-
-
-
- {backend.Metadata?.version ? (
- v{backend.Metadata.version}
- ) : (
- —
- )}
- {upgradeInfo && (
-
-
- {upgradeInfo.available_version ? ` v${upgradeInfo.available_version}` : ' Update available'}
-
- )}
- {hasDrift && (
- `${d.node_name}${d.version ? ' v' + d.version : ''}`).join(', ')}`}
- >
-
- {' '}Drift: {upgradeInfo.node_drift.length} node{upgradeInfo.node_drift.length === 1 ? '' : 's'}
-
- )}
-
-
- {distributedMode && (
+ toggleExpanded('backends', backend.Name)}
+ colSpan={colSpan}
+ detail={(
+
+ )}
+ >
+
+
-
+
+
+ {backend.Name}
+
+ {backend.Metadata?.alias && backend.Metadata.alias !== backend.Name && (
+ · alias {backend.Metadata.alias}
+ )}
+ {backend.Metadata?.meta_backend_for && (
+ · for {backend.Metadata.meta_backend_for}
+ )}
+
+ {(enriched?.description) && (
+
+ {enriched.description}
+
+ )}
+
- )}
-
-
- {backend.Metadata?.installed_at ? formatInstalledAt(backend.Metadata.installed_at) : '—'}
-
-
-
-
+
+ {(() => {
+ const v = formatBackendVersion(backend.Metadata)
+ return (
+
+ {v.label}
+ {upgradeInfo && (
+
+
+ {upgradeInfo.available_version ? ` v${upgradeInfo.available_version}` : ' Update'}
+
+ )}
+ {hasDrift && (
+ `${d.node_name}${d.version ? ' v' + d.version : ''}`).join(', ')}`}
+ >
+
+ {' '}Drift: {upgradeInfo.node_drift.length} node{upgradeInfo.node_drift.length === 1 ? '' : 's'}
+
+ )}
+
+ )
+ })()}
+
+ {distributedMode && (
+
+
+
+ )}
+
+
+ {backend.Metadata?.installed_at ? formatInstalledAt(backend.Metadata.installed_at) : '—'}
+
+
+
{backend.IsSystem ? (
Protected
) : (
- <>
- {upgradeInfo ? (
- handleUpgradeBackend(backend.Name)}
- disabled={reinstallingBackends.has(backend.Name)}
- >
-
- {' '}Upgrade{upgradeInfo.available_version ? ` to v${upgradeInfo.available_version}` : ''}
-
- ) : (
- handleReinstallBackend(backend.Name)}
- disabled={reinstallingBackends.has(backend.Name)}
- >
-
- {' '}Reinstall
-
- )}
- handleDeleteBackend(backend.Name)}
- title="Delete backend (removes from all nodes)"
- >
-
-
- >
+ handleUpgradeBackend(backend.Name),
+ disabled: isProcessing,
+ hidden: !upgradeInfo },
+ { key: 'reinstall', icon: 'fa-rotate', label: 'Reinstall backend',
+ onClick: () => handleReinstallBackend(backend.Name),
+ disabled: isProcessing },
+ { divider: true },
+ { key: 'delete', icon: 'fa-trash',
+ label: 'Delete backend',
+ danger: true,
+ onClick: () => handleDeleteBackend(backend.Name) },
+ ]}
+ />
)}
-
-
-
+
+
)
})}
@@ -858,3 +959,243 @@ export default function Manage() {
)
}
+
+// ModelDetail renders the expanded panel for a Models row. It pulls richer
+// fields (description, license, tags, links, files) from the gallery cache
+// when available, and falls back gracefully for items not in the gallery.
+function ModelDetail({ model, enriched, matchedCaps, distributedMode, onNavigate }) {
+ const description = enriched?.description
+ const license = enriched?.license
+ const tags = Array.isArray(enriched?.tags) ? enriched.tags : []
+ const urls = Array.isArray(enriched?.urls) ? enriched.urls : []
+ const files = Array.isArray(enriched?.additionalFiles) ? enriched.additionalFiles
+ : Array.isArray(enriched?.files) ? enriched.files
+ : []
+ const sizeDisplay = enriched?.estimated_size_display && enriched.estimated_size_display !== '0 B' ? enriched.estimated_size_display : null
+ const vramDisplay = enriched?.estimated_vram_display && enriched.estimated_vram_display !== '0 B' ? enriched.estimated_vram_display : null
+
+ return (
+
+
Details
+
+ Description
+
+ {description ? (
+
+ ) : (
+ No description available — this model isn't in the gallery.
+ )}
+
+
+ Backend
+
+ {model.backend || 'Auto'}
+
+
+ {matchedCaps.length > 0 && (<>
+ Capabilities
+
+
+
+ >)}
+
+ {(sizeDisplay || vramDisplay) && (<>
+ Size / VRAM
+
+ {sizeDisplay && Size: {sizeDisplay} }
+ {vramDisplay && VRAM: {vramDisplay} }
+
+ >)}
+
+ {license && (<>
+ License
+ {license}
+ >)}
+
+ {tags.length > 0 && (<>
+ Tags
+
+
+ {tags.map(t => {t} )}
+
+
+ >)}
+
+ {urls.length > 0 && (<>
+ Links
+
+
+ {urls.map((url, i) => (
+
+ {url}
+
+ ))}
+
+
+ >)}
+
+ {distributedMode && Array.isArray(model.loaded_on) && model.loaded_on.length > 0 && (<>
+ Distributed
+
+
+
+ >)}
+
+ {model.source && (<>
+ Source
+ {model.source}
+ >)}
+
+ {files.length > 0 && (<>
+ Files
+
+ {files.length} file{files.length === 1 ? '' : 's'}
+
+ >)}
+
+
+ )
+}
+
+// BackendDetail renders the expanded panel for a Backends row. Gallery metadata
+// (description, license, tags, repository, URLs) is layered on top of the
+// runtime state from the installed list (version, drift, per-node info).
+function BackendDetail({ backend, enriched, upgradeInfo, nodes, distributedMode }) {
+ const description = enriched?.description
+ const license = enriched?.license
+ const tags = Array.isArray(enriched?.tags) ? enriched.tags : []
+ const urls = Array.isArray(enriched?.urls) ? enriched.urls : []
+ const repository = typeof enriched?.gallery === 'string'
+ ? enriched.gallery
+ : enriched?.gallery?.name
+
+ return (
+
+
Details
+
+ Description
+
+ {description ? (
+
+ ) : (
+ No description available — this backend isn't in the gallery.
+ )}
+
+
+ {repository && (<>
+ Repository
+
+ {repository}
+
+ >)}
+
+ {license && (<>
+ License
+ {license}
+ >)}
+
+ {tags.length > 0 && (<>
+ Tags
+
+
+ {tags.map(t => {t} )}
+
+
+ >)}
+
+ {urls.length > 0 && (<>
+ Links
+
+
+ {urls.map((url, i) => (
+
+ {url}
+
+ ))}
+
+
+ >)}
+
+ {backend.Metadata?.uri && (<>
+ Source
+
+ {backend.Metadata.uri}
+
+ >)}
+
+ {backend.Metadata?.digest && (<>
+ Digest
+
+ {backend.Metadata.digest}
+
+ >)}
+
+ {backend.Metadata?.installed_at && (<>
+ Installed
+
+ {formatInstalledAt(backend.Metadata.installed_at)}
+
+ ({formatInstalledAtFull(backend.Metadata.installed_at)})
+
+
+ >)}
+
+ {backend.Metadata?.alias && (<>
+ Alias
+ {backend.Metadata.alias}
+ >)}
+
+ {backend.Metadata?.meta_backend_for && (<>
+ Meta for
+ {backend.Metadata.meta_backend_for}
+ >)}
+
+ {distributedMode && nodes.length > 0 && (<>
+ Nodes
+
+
+
+ >)}
+
+ {upgradeInfo?.node_drift?.length > 0 && (<>
+ Drift
+
+
+
+ Node Version
+
+
+ {upgradeInfo.node_drift.map((d, i) => (
+
+ {d.node_name}
+ {d.version ? `v${d.version}` : '—'}
+
+ ))}
+
+
+
+ >)}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx
index 3ba7f260c..f2dd70df7 100644
--- a/core/http/react-ui/src/pages/Models.jsx
+++ b/core/http/react-ui/src/pages/Models.jsx
@@ -6,87 +6,10 @@ import { useOperations } from '../hooks/useOperations'
import { useResources } from '../hooks/useResources'
import SearchableSelect from '../components/SearchableSelect'
import ConfirmDialog from '../components/ConfirmDialog'
+import GalleryLoader from '../components/GalleryLoader'
import React from 'react'
-const LOADING_PHRASES = [
- { text: 'Loading models...', icon: 'fa-brain' },
- { text: 'Fetching gallery...', icon: 'fa-download' },
- { text: 'Checking availability...', icon: 'fa-circle-check' },
- { text: 'Almost ready...', icon: 'fa-hourglass-half' },
- { text: 'Preparing gallery...', icon: 'fa-store' },
-]
-
-function GalleryLoader() {
- const [idx, setIdx] = useState(() => Math.floor(Math.random() * LOADING_PHRASES.length))
- const [fade, setFade] = useState(true)
-
- useEffect(() => {
- const interval = setInterval(() => {
- setFade(false)
- setTimeout(() => {
- setIdx(prev => (prev + 1) % LOADING_PHRASES.length)
- setFade(true)
- }, 300)
- }, 2800)
- return () => clearInterval(interval)
- }, [])
-
- const phrase = LOADING_PHRASES[idx]
-
- return (
-
- {/* Animated dots */}
-
- {[0, 1, 2, 3, 4].map(i => (
-
- ))}
-
- {/* Rotating phrase */}
-
-
- {phrase.text}
-
- {/* Skeleton rows */}
-
- {[0.9, 0.7, 0.5].map((opacity, i) => (
-
- ))}
-
-
-
- )
-}
-
-
const FILTERS = [
{ key: '', label: 'All', icon: 'fa-layer-group' },
{ key: 'llm', label: 'LLM', icon: 'fa-brain' },
diff --git a/core/http/react-ui/src/pages/Nodes.jsx b/core/http/react-ui/src/pages/Nodes.jsx
index 14a7a1817..0547ed789 100644
--- a/core/http/react-ui/src/pages/Nodes.jsx
+++ b/core/http/react-ui/src/pages/Nodes.jsx
@@ -5,6 +5,7 @@ import { useModels } from '../hooks/useModels'
import LoadingSpinner from '../components/LoadingSpinner'
import ConfirmDialog from '../components/ConfirmDialog'
import ImageSelector, { useImageSelector, dockerImage, dockerFlags } from '../components/ImageSelector'
+import StatCard from '../components/StatCard'
function timeAgo(dateString) {
if (!dateString) return 'never'
@@ -51,27 +52,6 @@ const modelStateConfig = {
idle: { bg: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)', border: 'var(--color-border-subtle)' },
}
-function StatCard({ icon, label, value, color, accentVar }) {
- // accentVar: optional CSS variable for the left edge + icon chip, e.g.
- // "--color-success". When unset the card reads neutral — used for simple
- // counts so they don't compete with the semantic cards for attention.
- const accent = color || (accentVar ? `var(${accentVar})` : 'var(--color-text-primary)')
- return (
-
- )
-}
-
function StepNumber({ n, bg, color }) {
return (