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 ( +
No models match the current filter.
+ +|
@@ -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. - -
+
+
+ >
)
}
return (
- No backends match the current filter. + +
+ <>
+
+ >
)
})()}
|