From 7a9d89fa54bf143992819d68c7a6c635757c4d33 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 19 Apr 2026 08:46:22 +0000 Subject: [PATCH] feat(ui): shared FilterBar across the System page tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Backends gallery had a nice search + chip + toggle strip; the System page had nothing, so the two surfaces felt like different apps. Lift the pattern into a reusable FilterBar and wire both System tabs through it. New component core/http/react-ui/src/components/FilterBar.jsx renders a search input, a role="tablist" chip row (aria-selected for a11y), and optional toggles / right slot. Chips support an optional `count` which the System page uses to show "User 3", "Updates 1" etc. System Models tab: search by id or backend; chips for All/Running/Idle/Disabled/Pinned plus a conditional Distributed chip in distributed mode. "Last synced" + Update button live in the right slot. System Backends tab: search by name/alias/meta-backend-for; chips for All/User/System/Meta plus conditional Updates / Offline-nodes chips when relevant. The old ad-hoc "Updates only" toggle from the upgrade banner folded into the Updates chip — one source of truth for that filter. Offline chip only appears in distributed mode when at least one backend has an unhealthy node, so the chip row stays quiet on healthy clusters. Filter state persists in URL query params (mq/mf/bq/bf) so deep links and tab switches keep the operator's filter context instead of resetting every time. Also adds an "Adopted" distribution path: when a model in /api/models/capabilities carries source="registry-only" (discovered on a worker but not configured locally), the Models tab shows a ghost chip labelled "Adopted" with hover copy explaining how to persist it — this is what closes the loop on the ghost-model story end-to-end. --- core/http/react-ui/src/App.css | 50 +++++ .../react-ui/src/components/FilterBar.jsx | 87 +++++++++ core/http/react-ui/src/pages/Manage.jsx | 180 +++++++++++++++--- 3 files changed, 286 insertions(+), 31 deletions(-) create mode 100644 core/http/react-ui/src/components/FilterBar.jsx diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 2e46fd893..03c448243 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -1723,6 +1723,56 @@ select.input { color: var(--color-text-primary); } +/* Shared FilterBar layout — search strip + chip row + toggle strip. Lives + outside the .filter-bar chip row so the padding and wrapping behavior is + consistent between the Backends gallery and the System tabs. */ +.filter-bar-group { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-md); +} +.filter-bar-group__search { + min-width: 200px; + flex: 1; +} +.filter-bar-group__row { + display: flex; + gap: var(--spacing-md); + align-items: center; + flex-wrap: wrap; +} +.filter-bar-group__right { + display: flex; + gap: var(--spacing-md); + align-items: center; + flex-wrap: wrap; + padding-left: var(--spacing-md); + border-left: 1px solid var(--color-border-subtle); +} +.filter-bar-group__toggle { + display: flex; + align-items: center; + gap: var(--spacing-xs); + font-size: var(--text-xs); + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; + white-space: nowrap; +} +.filter-btn__count { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 6px; + min-width: 18px; + padding: 0 5px; + background: color-mix(in srgb, currentColor 18%, transparent); + border-radius: var(--radius-full); + font-size: 0.625rem; + font-weight: 600; +} + /* 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 diff --git a/core/http/react-ui/src/components/FilterBar.jsx b/core/http/react-ui/src/components/FilterBar.jsx new file mode 100644 index 000000000..95f7b2135 --- /dev/null +++ b/core/http/react-ui/src/components/FilterBar.jsx @@ -0,0 +1,87 @@ +import Toggle from './Toggle' + +// FilterBar is the shared search + chip filter + toggles control strip that +// the Backends gallery pioneered. Pulled into its own component so the System +// page's two tabs stop looking like a different app — matching visual +// grammar + matching keyboard behavior. +// +// Props: +// search: controlled value for the search input. +// onSearchChange: (value) => void; null disables the search input entirely. +// searchPlaceholder: placeholder for the search input. +// filters: [{ key, label, icon }]; activeFilter is compared by key. +// Omit to hide the chip row. +// activeFilter: currently-selected filter key (use '' for "all" if +// that's the first entry in `filters`). +// onFilterChange: (key) => void. +// toggles: [{ key, label, icon?, checked, onChange }]; optional +// right-side toggle group (e.g. "Show all", "Development"). +// rightSlot: arbitrary element rendered after the toggles — use for +// sort controls or extra buttons. +export default function FilterBar({ + search, + onSearchChange, + searchPlaceholder = 'Search...', + filters, + activeFilter, + onFilterChange, + toggles, + rightSlot, +}) { + const hasFilters = Array.isArray(filters) && filters.length > 0 + const hasToggles = Array.isArray(toggles) && toggles.length > 0 + + return ( +
+ {onSearchChange && ( +
+ + onSearchChange(e.target.value)} + aria-label={searchPlaceholder} + /> +
+ )} + + {(hasFilters || hasToggles || rightSlot) && ( +
+ {hasFilters && ( +
+ {filters.map(f => ( + + ))} +
+ )} + + {(hasToggles || rightSlot) && ( +
+ {hasToggles && toggles.map(t => ( + + ))} + {rightSlot} +
+ )} +
+ )} +
+ ) +} diff --git a/core/http/react-ui/src/pages/Manage.jsx b/core/http/react-ui/src/pages/Manage.jsx index b192730ef..3f11a7744 100644 --- a/core/http/react-ui/src/pages/Manage.jsx +++ b/core/http/react-ui/src/pages/Manage.jsx @@ -4,6 +4,7 @@ import ResourceMonitor from '../components/ResourceMonitor' import ConfirmDialog from '../components/ConfirmDialog' import Toggle from '../components/Toggle' import NodeDistributionChip from '../components/NodeDistributionChip' +import FilterBar from '../components/FilterBar' import { useModels } from '../hooks/useModels' import { backendControlApi, modelsApi, backendsApi, systemApi, nodesApi } from '../utils/api' @@ -45,6 +46,24 @@ export default function Manage() { const [distributedMode, setDistributedMode] = useState(false) const [togglingModels, setTogglingModels] = useState(new Set()) const [pinningModels, setPinningModels] = useState(new Set()) + // 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') + + // Sync filter state into the URL so deep-links + tab switches survive. + useEffect(() => { + const p = new URLSearchParams(searchParams) + const setOrDelete = (k, v) => { if (v && v !== 'all') p.set(k, v); else p.delete(k) } + setOrDelete('mq', modelsSearch) + setOrDelete('mf', modelsFilter) + setOrDelete('bq', backendsSearch) + setOrDelete('bf', backendsFilter) + setSearchParams(p, { replace: true }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modelsSearch, modelsFilter, backendsSearch, backendsFilter]) const handleTabChange = (tab) => { setActiveTab(tab) @@ -319,19 +338,51 @@ export default function Manage() { {/* Models Tab */} - {activeTab === 'models' && ( + {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' }, + { key: 'idle', label: 'Idle', icon: 'fa-pause' }, + { key: 'disabled', label: 'Disabled', icon: 'fa-ban' }, + { key: 'pinned', label: 'Pinned', icon: 'fa-thumbtack' }, + ...(distributedMode ? [{ key: 'distributed', label: 'Distributed', icon: 'fa-server' }] : []), + ] + const passesFilter = (m) => { + if (modelsFilter === 'running') return !m.disabled && (loadedModelIds.has(m.id) || (m.loaded_on && m.loaded_on.length > 0)) + if (modelsFilter === 'idle') return !m.disabled && !loadedModelIds.has(m.id) && !(m.loaded_on && m.loaded_on.length > 0) + if (modelsFilter === 'disabled') return !!m.disabled + if (modelsFilter === 'pinned') return !!m.pinned + if (modelsFilter === 'distributed') return Array.isArray(m.loaded_on) && m.loaded_on.length > 0 + return true + } + const q = modelsSearch.trim().toLowerCase() + const passesSearch = (m) => !q || (m.id || '').toLowerCase().includes(q) || (m.backend || '').toLowerCase().includes(q) + const visibleModels = models.filter(m => passesFilter(m) && passesSearch(m)) + return (
-
- {distributedMode && ( - - Last synced {lastSyncedAgo} - + + {distributedMode && ( + + Last synced {lastSyncedAgo} + + )} + + )} - -
+ /> {modelsLoading ? (
@@ -356,6 +407,12 @@ export default function Manage() {
+ ) : visibleModels.length === 0 ? ( +
+ +

No models match the current filter.

+ +
) : (
@@ -370,7 +427,7 @@ export default function Manage() { - {models.map(model => ( + {visibleModels.map(model => ( {/* Enable/Disable toggle */}
@@ -488,7 +545,8 @@ export default function Manage() { )} - )} + ) + })()} {/* Backends Tab */} {activeTab === 'backends' && ( @@ -503,14 +561,6 @@ export default function Manage() {
-
) : (() => { - const visibleBackends = showOnlyUpgradable - ? backends.filter(b => upgrades[b.Name]) - : backends + // 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 => { + 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 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 }, + ...(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 }] : []), + ] + const q = backendsSearch.trim().toLowerCase() + const passesSearch = (b) => !q + || (b.Name || '').toLowerCase().includes(q) + || (b.Metadata?.alias || '').toLowerCase().includes(q) + || (b.Metadata?.meta_backend_for || '').toLowerCase().includes(q) + const passesFilter = (b) => { + 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 || [] + return n.some(x => { + const s = x.node_status || x.NodeStatus + return s && s !== 'healthy' && s !== 'draining' + }) + } + 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.

- -
+ <> + +
+ +

No backends match the current filter.

+ +
+ ) } return ( -
+ <> + +
@@ -683,7 +800,8 @@ export default function Manage() { })}
-
+
+ ) })()}