mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-24 00:26:34 -04:00
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:
@@ -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
|
||||
|
||||
87
core/http/react-ui/src/components/FilterBar.jsx
Normal file
87
core/http/react-ui/src/components/FilterBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user