From b336d9c62683836ed9f05fe9c445f651add11fda Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 26 Apr 2026 20:33:49 +0000 Subject: [PATCH] feat(react-ui): polish Manage page with kebab menus and gallery rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the System / Manage page up to the visual standard of the Install gallery so installed models and backends stop reading like a debug dump. - Unified ResourceRow anatomy (icon, name+description, badges, status, expandable detail) shared across both tabs. - Gallery enrichment cross-references installed names against the gallery list endpoints to surface icons, descriptions, license, tags, and links with a graceful "no description" fallback for custom imports. - Header summary with four StatCards (Models / Backends / Running / Updates) — clickable to switch tab + pre-set filter. - Backends meta + development entries hidden by default; "Show meta & development" paired toggle in the FilterBar with hidden-count hint. - Kebab (three-dot) ActionMenu replaces the inline button cluster on every row; restrained until hover, keyboard-navigable, danger items separated by a divider. - Backend "Version" cell now falls back to short digest, OCI tag, or ocifile basename when no semver is set, instead of showing "—" for every OCI install. Detail panel exposes full Source URI + Digest. - Drop redundant column headers ("Actions", "On") — kebabs and toggles carry their own affordance; screen readers still get a label. - Inline System / User / Meta / Dev badges next to the backend name so the dedicated Type column doesn't reserve space for "USER" repeated. - Tightened the spacing between the System Resources card and the StatCards so they no longer crowd the RAM bar. Extracted StatCard and GalleryLoader from Nodes.jsx and Models.jsx into shared components so the visual language is one source of truth. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-4-7 [Read] [Edit] [Write] [Bash] --- core/http/react-ui/src/App.css | 298 ++++++ .../react-ui/src/components/ActionMenu.jsx | 141 +++ .../react-ui/src/components/GalleryLoader.jsx | 79 ++ .../react-ui/src/components/ManageSummary.jsx | 47 + .../react-ui/src/components/MetaBadgeRow.jsx | 30 + .../src/components/ResourceActions.jsx | 29 + .../react-ui/src/components/ResourceRow.jsx | 81 ++ .../http/react-ui/src/components/StatCard.jsx | 39 + .../src/hooks/useGalleryEnrichment.js | 53 ++ core/http/react-ui/src/pages/Manage.jsx | 893 ++++++++++++------ core/http/react-ui/src/pages/Models.jsx | 79 +- core/http/react-ui/src/pages/Nodes.jsx | 22 +- 12 files changed, 1416 insertions(+), 375 deletions(-) create mode 100644 core/http/react-ui/src/components/ActionMenu.jsx create mode 100644 core/http/react-ui/src/components/GalleryLoader.jsx create mode 100644 core/http/react-ui/src/components/ManageSummary.jsx create mode 100644 core/http/react-ui/src/components/MetaBadgeRow.jsx create mode 100644 core/http/react-ui/src/components/ResourceActions.jsx create mode 100644 core/http/react-ui/src/components/ResourceRow.jsx create mode 100644 core/http/react-ui/src/components/StatCard.jsx create mode 100644 core/http/react-ui/src/hooks/useGalleryEnrichment.js 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 ( + <> + + +
{ if (el && open) el.focus() }} + > + {visible.map((item, i) => { + if (item.divider) { + return
+ } + if (item.type === 'badge') { + return ( +
+ {item.icon &&