feat(ui): shared FilterBar across the System page tabs

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.
This commit is contained in:
Ettore Di Giacinto
2026-04-19 08:46:22 +00:00
parent ee34a52c5d
commit 7a9d89fa54
3 changed files with 286 additions and 31 deletions

View File

@@ -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

View File

@@ -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 (
<div className="filter-bar-group">
{onSearchChange && (
<div className="search-bar filter-bar-group__search">
<i className="fas fa-search search-icon" />
<input
className="input"
placeholder={searchPlaceholder}
value={search ?? ''}
onChange={e => onSearchChange(e.target.value)}
aria-label={searchPlaceholder}
/>
</div>
)}
{(hasFilters || hasToggles || rightSlot) && (
<div className="filter-bar-group__row">
{hasFilters && (
<div className="filter-bar" role="tablist" aria-label="Filter">
{filters.map(f => (
<button
key={f.key}
role="tab"
aria-selected={activeFilter === f.key}
className={`filter-btn ${activeFilter === f.key ? 'active' : ''}`}
onClick={() => onFilterChange(f.key)}
>
{f.icon && <i className={`fas ${f.icon}`} style={{ marginRight: 4 }} />}
{f.label}
{typeof f.count === 'number' && (
<span className="filter-btn__count">{f.count}</span>
)}
</button>
))}
</div>
)}
{(hasToggles || rightSlot) && (
<div className="filter-bar-group__right">
{hasToggles && toggles.map(t => (
<label key={t.key} className="filter-bar-group__toggle">
<Toggle checked={t.checked} onChange={t.onChange} />
{t.icon && <i className={`fas ${t.icon}`} />}
{t.label}
</label>
))}
{rightSlot}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -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() {
</div>
{/* 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 (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
{distributedMode && (
<span className="cell-muted" title="Auto-refreshes every 10s in distributed mode so ghost models clear promptly">
<i className="fas fa-rotate" /> Last synced {lastSyncedAgo}
</span>
<FilterBar
search={modelsSearch}
onSearchChange={setModelsSearch}
searchPlaceholder="Search models by name or backend..."
filters={MODEL_FILTERS}
activeFilter={modelsFilter}
onFilterChange={setModelsFilter}
rightSlot={(
<>
{distributedMode && (
<span className="cell-muted" title="Auto-refreshes every 10s in distributed mode so ghost models clear promptly">
<i className="fas fa-rotate" /> Last synced {lastSyncedAgo}
</span>
)}
<button className="btn btn-secondary btn-sm" onClick={handleReload} disabled={reloading}>
<i className={`fas ${reloading ? 'fa-spinner fa-spin' : 'fa-rotate'}`} />
{reloading ? ' Updating...' : ' Update'}
</button>
</>
)}
<button className="btn btn-secondary btn-sm" onClick={handleReload} disabled={reloading}>
<i className={`fas ${reloading ? 'fa-spinner fa-spin' : 'fa-rotate'}`} />
{reloading ? 'Updating...' : 'Update'}
</button>
</div>
/>
{modelsLoading ? (
<div className="card" style={{ padding: 'var(--spacing-xl)', textAlign: 'center', color: 'var(--color-text-muted)' }}>
@@ -356,6 +407,12 @@ export default function Manage() {
</a>
</div>
</div>
) : visibleModels.length === 0 ? (
<div className="empty-state">
<i className="fas fa-filter" />
<p>No models match the current filter.</p>
<button className="btn btn-ghost btn-sm" onClick={() => { setModelsSearch(''); setModelsFilter('all') }}>Clear filters</button>
</div>
) : (
<div className="table-container">
<table className="table">
@@ -370,7 +427,7 @@ export default function Manage() {
</tr>
</thead>
<tbody>
{models.map(model => (
{visibleModels.map(model => (
<tr key={model.id} style={{ opacity: model.disabled ? 0.55 : 1, transition: 'opacity 0.2s' }}>
{/* Enable/Disable toggle */}
<td>
@@ -488,7 +545,8 @@ export default function Manage() {
</div>
)}
</div>
)}
)
})()}
{/* Backends Tab */}
{activeTab === 'backends' && (
@@ -503,14 +561,6 @@ export default function Manage() {
</span>
</div>
<div className="upgrade-banner__actions">
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowOnlyUpgradable(v => !v)}
title={showOnlyUpgradable ? 'Show all backends' : 'Show only backends with updates'}
>
<i className={`fas ${showOnlyUpgradable ? 'fa-filter-circle-xmark' : 'fa-filter'}`} />
{showOnlyUpgradable ? ' Show all' : ' Updates only'}
</button>
<button
className="btn btn-primary btn-sm"
onClick={handleUpgradeAll}
@@ -544,20 +594,87 @@ export default function Manage() {
</div>
</div>
) : (() => {
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 (
<div className="empty-state">
<i className="fas fa-filter" />
<p>No backends match the current filter.</p>
<button className="btn btn-ghost btn-sm" onClick={() => setShowOnlyUpgradable(false)}>Clear filter</button>
</div>
<>
<FilterBar
search={backendsSearch}
onSearchChange={setBackendsSearch}
searchPlaceholder="Search backends by name or alias..."
filters={BACKEND_FILTERS}
activeFilter={backendsFilter}
onFilterChange={setBackendsFilter}
/>
<div className="empty-state">
<i className="fas fa-filter" />
<p>No backends match the current filter.</p>
<button className="btn btn-ghost btn-sm" onClick={() => { setBackendsSearch(''); setBackendsFilter('all') }}>Clear filters</button>
</div>
</>
)
}
return (
<div className="table-container">
<>
<FilterBar
search={backendsSearch}
onSearchChange={setBackendsSearch}
searchPlaceholder="Search backends by name or alias..."
filters={BACKEND_FILTERS}
activeFilter={backendsFilter}
onFilterChange={setBackendsFilter}
/>
<div className="table-container">
<table className="table">
<thead>
<tr>
@@ -683,7 +800,8 @@ export default function Manage() {
})}
</tbody>
</table>
</div>
</div>
</>
)
})()}
</div>