Skip to content

Instantly share code, notes, and snippets.

@mturley
Last active April 13, 2026 16:48
Show Gist options
  • Select an option

  • Save mturley/7001c03241bf9e1f477b2771cf6ce738 to your computer and use it in GitHub Desktop.

Select an option

Save mturley/7001c03241bf9e1f477b2771cf6ce738 to your computer and use it in GitHub Desktop.
Claude's report about reducing the friction of making new catalogs in odh-dashboard based on this slack thread: https://redhat-internal.slack.com/archives/C07C0447EAV/p1776096259238499

Catalog Abstraction Analysis: Model Catalog vs MCP Catalog

TL;DR

The real duplication is between the Model Catalog and the model-registry MCP Catalog (the gen-ai MCP catalog is a different pattern entirely -- client-side table filtering, not the filterQuery API). These two catalogs share ~70-90% of their structural patterns: context providers, filter state hooks, filter-to-query serialization, active filter chips, gallery layout, and filter sidebar. Some shared components already exist (CatalogStringFilter, EmptyModelCatalogState), but the core plumbing is copy-pasted. The MCP catalog's types and logic are a strict subset of the model catalog's.

Recommended path: Start with three low-effort extractions -- unify filtersToFilterQuery, extract a generic useCatalogFilterState hook, and collapse the duplicate filter types. Then extract shared CatalogFilterSidebar and CatalogActiveFilterChips components. This would reduce spinning up a third catalog from ~3-5 days of copy-rename-rewire to ~1-2 days of defining config + types + card component. The main challenge is that the model catalog's performance view (latency metrics, named queries, hardware config) adds complexity that the shared abstraction needs to accommodate without being burdened by.


Context

The Slack thread discusses friction in creating new catalog UIs that use the same filter_options and filterQuery APIs but with different filters and data types. The Model Catalog was built first, then the MCP Catalog was built based on it, duplicating many patterns. The question: what would it take to create a shared catalog abstraction so a third catalog could be spun up with less friction?

Current State

There are actually three catalog implementations to consider:

Catalog Location Style Backend API
Model Catalog packages/model-registry/upstream/.../pages/modelCatalog/ Gallery (cards in a grid) BFF filter_options + filterQuery API
MCP Catalog (model-registry) packages/model-registry/upstream/.../pages/mcpCatalog/ Gallery (cards in a grid) Same BFF filter_options + filterQuery API
MCP Catalog (gen-ai) packages/gen-ai/.../AIAssets/ Table with toolbar Client-side filtering, no filterQuery API

The gen-ai MCP catalog is a different beast -- it's a simple table with client-side text filters, not using the filter_options/filterQuery API at all. The real duplication is between the Model Catalog and the model-registry MCP Catalog, which share the same package and the same BFF API patterns.

Shared Infrastructure That Already Exists

Some abstraction has already been done:

Shared Component Location Used By
CatalogStringFilter shared/components/catalog/CatalogStringFilter.tsx Both catalogs' string filter components
CatalogFilterOptionsList type modelCatalogTypes.ts Model Catalog; MCP catalog defines its own McpCatalogFilterOptionsList
ScrollViewOnMount shared/components/ScrollViewOnMount.tsx Both gallery views
EmptyModelCatalogState pages/modelCatalog/EmptyModelCatalogState.tsx Both gallery views (MCP imports from model catalog)
CATALOG_STRING_FILTER_MAX_VISIBLE shared/components/catalog/constants.ts Both catalogs via CatalogStringFilter

Duplicated Patterns

1. Context Providers (high duplication)

Model Catalog: ModelCatalogContext provides filterData, setFilterData, filterOptions, filterOptionsLoaded, searchQuery, selectedSourceLabel, pagination, etc.

MCP Catalog: McpCatalogContext provides filters, setFilters, filterOptions, filterOptionsLoaded, searchQuery, selectedSourceLabel, pagination, etc.

These are structurally identical. The only real difference is:

  • Model Catalog's filterData is typed as ModelCatalogFilterStates (complex: string arrays + numbers + latency metrics)
  • MCP Catalog's filters is typed as McpCatalogFiltersState (simple: Record<McpFilterCategoryKey, string[]>)

2. Filter State Hooks (moderate duplication)

Model Catalog: useCatalogStringFilterState(filterKey) reads from ModelCatalogContext, returns { isSelected, setSelected }

MCP Catalog: useMcpFilterState(filterKey) reads from McpCatalogContext, returns { selectedValues, setSelected, isSelected }

These are nearly identical in logic. Both:

  • Read current selections from context by key
  • Provide toggle (add/remove from array) callback
  • Provide isSelected check

3. Filter-to-Query Serialization (moderate duplication)

Model Catalog: filtersToFilterQuery(filterData, options, target) in modelCatalogUtils.ts

  • Handles string arrays (IN/equality), numeric comparisons, operator lookup from namedQueries
  • Supports target='models' vs target='artifacts' for prefix stripping
  • ~120 lines of logic

MCP Catalog: mcpFiltersToFilterQuery(filters) in mcpCatalogUtils.ts

  • Handles only string arrays (IN/equality)
  • Includes backend-to-frontend key remapping
  • ~15 lines of logic

The core pattern is the same: iterate filter keys, skip empties, build key='value' or key IN ('a','b') clauses, join with AND. The model catalog version is more complex because it also handles numeric filters.

4. Active Filter Chips (moderate duplication)

Model Catalog: ModelCatalogActiveFilters.tsx (~200 lines)

  • Iterates filter keys, renders ToolbarFilter with ToolbarLabel chips
  • Handles both string and numeric filters
  • Special rendering for latency filters and performance defaults
  • Undo-to-default icon for performance filters

MCP Catalog: McpCatalogActiveFilters.tsx (~65 lines)

  • Same pattern: iterates filter keys, renders ToolbarFilter with ToolbarLabel chips
  • Much simpler because all filters are string arrays

5. Gallery View (moderate duplication)

Both ModelCatalogGalleryView and McpCatalogGalleryView follow the same structure:

  1. Read context (filters, search, source label, API state)
  2. Build filterQuery from filters
  3. Call data-fetching hook with pagination + filterQuery + search
  4. Render: loading spinner -> error alert -> empty state -> Grid of cards -> "Load more" button

The model catalog version has additional complexity for performance view, sort params, and multiple empty state variants.

6. Filter Sidebar (moderate duplication)

Model Catalog: ModelCatalogFilters.tsx renders a Stack of filter components, each checking if filter options exist before rendering.

MCP Catalog: McpCatalogFilters.tsx does exactly the same thing with a data-driven FILTER_CONFIGS array.

7. String Filter Wrapper (low duplication -- already mostly shared)

Both ModelCatalogStringFilter and McpCatalogStringFilter are thin wrappers around the shared CatalogStringFilter. They just wire up the filter state hook and pass filterValues/selectedValues/onToggle. These are ~30 lines each.

8. Filter Types (moderate duplication)

Model Catalog types:

type CatalogFilterStringOption<T> = { type: 'string'; values?: T[] };
type CatalogFilterNumberOption = { type: 'number'; range?: { max?: number; min?: number } };
type CatalogFilterOptions = /* union of string and number options */;
type CatalogFilterOptionsList = { filters?: CatalogFilterOptions; namedQueries?: Record<string, NamedQuery> };
type ModelCatalogFilterStates = { /* typed string arrays + numbers per filter key */ };

MCP Catalog types:

type McpCatalogFilterStringOption = { type: 'string'; values?: string[] };
type McpCatalogFilterOptions = { [key in McpFilterCategoryKey]?: McpCatalogFilterStringOption };
type McpCatalogFilterOptionsList = { filters?: McpCatalogFilterOptions };
type McpCatalogFiltersState = { [K in McpFilterCategoryKey]?: string[] };

The MCP types are a strict subset of the model catalog types.

What a Shared Catalog Abstraction Would Look Like

Tier 1: Low-Hanging Fruit (Extract What's Already Similar)

These could be extracted with minimal API changes:

1a. Generic CatalogContext<TFilterState> factory

type CatalogContextValue<TFilterState> = {
  filters: TFilterState;
  setFilters: React.Dispatch<React.SetStateAction<TFilterState>>;
  searchQuery: string;
  setSearchQuery: (q: string) => void;
  selectedSourceLabel: string | undefined;
  setSelectedSourceLabel: (label: string | undefined) => void;
  pagination: { page: number; pageSize: number; totalItems: number };
  setPage: (page: number) => void;
  setPageSize: (size: number) => void;
  filterOptions: CatalogFilterOptionsList | null;
  filterOptionsLoaded: boolean;
  filterOptionsLoadError: Error | undefined;
  catalogSources: CatalogSourceList | null;
  catalogSourcesLoaded: boolean;
  clearAllFilters: () => void;
};

Each catalog would create its context via a factory, providing its filter state type and initial state.

Effort: Medium. The model catalog context has extra fields (performanceViewEnabled, sortBy, catalogLabels) that would need to either be included in the generic type or handled via extension.

1b. Generic useCatalogStringFilterState<TContext, TKey> hook

A hook factory that takes a context reference and filter key, returns { isSelected, setSelected, selectedValues }. Both catalogs' hooks are identical in logic.

Effort: Low. The only tricky part is the type constraint on the filter key.

1c. Generic filtersToFilterQuery(filters, options) function

Extract the core serialization: iterate entries, serialize string arrays as IN/=, join with AND. The model catalog's numeric filter handling would be an optional extension.

function filtersToFilterQuery(
  filters: Record<string, string[] | number | undefined>,
  options?: {
    keyMapping?: Record<string, string>;  // frontend -> backend key mapping
    numericOperators?: Record<string, string>;  // key -> operator for numeric filters
  },
): string;

Effort: Low. The wrapInQuotes, eqFilter, inFilter helpers are already duplicated verbatim.

1d. Generic CatalogActiveFilters<TFilterKey> component

A component that iterates filter keys, renders ToolbarFilter chips. Takes a configuration of filter keys, category names, and a remove callback.

Effort: Low-Medium. The model catalog version has special latency filter rendering that would need to be handled via a render prop or callback.

Tier 2: Medium Effort (Structural Patterns)

2a. CatalogGalleryView skeleton component

A generic gallery that handles:

  • Loading/error/empty states
  • Grid layout with configurable grid spans
  • "Load more" pagination button
  • ScrollViewOnMount

Would accept render props or slots for the card component and empty state content.

Effort: Medium. The model catalog has significantly more empty state logic (performance view empty states, no-labels sections, etc.). This could be handled via render props for empty states.

2b. CatalogFilterSidebar component

A data-driven filter sidebar that takes a FilterConfig[] array and renders filter components with dividers. Both catalogs already do this pattern.

Effort: Low. The MCP catalog already uses a data-driven approach.

Tier 3: Higher Effort (Full Abstraction)

3a. createCatalog<TItem, TFilterState>() factory

A full catalog factory that produces:

  • Context provider
  • Filter hooks
  • Gallery view
  • Filter sidebar
  • Active filters
  • Route definitions

Given:

  • Item type (CatalogModel, McpServer)
  • Filter state type and initial values
  • Filter configuration (keys, labels, API key mappings)
  • Card component
  • Data fetching hook
  • Optional extensions (performance view, sort, etc.)

Effort: High. This is the ideal end state but requires careful design to handle the model catalog's extra complexity (performance filters, named queries, latency metrics, sort options) without making the abstraction unwieldy.

Duplication Quantification

Component Model Catalog Lines MCP Catalog Lines % Similar Shared Potential
Context Provider ~200 ~150 70% High
Filter State Hook ~25 ~30 90% Very High
Filter-to-Query ~120 ~20 Core logic identical High
Active Filters ~200 ~65 75% Medium-High
Gallery View ~310 ~125 60% Medium
Filter Sidebar ~70 ~60 85% Very High
String Filter Wrapper ~35 ~35 95% Already shared via CatalogStringFilter
Filter Types ~80 ~25 MCP is subset High

Recommendations

Immediate (next sprint): Extract low-hanging fruit

  1. Unify filtersToFilterQuery -- the MCP version is a subset of the model catalog version. Make the model catalog version generic enough to serve both, with optional config for numeric operators and key mappings.

  2. Extract useCatalogFilterState generic hook -- both hooks are identical in logic. Create one in shared/ that takes a context and filter key.

  3. Unify filter types -- McpCatalogFilterOptionsList is already a subset of CatalogFilterOptionsList. MCP could just use the existing type (namedQueries would be optional/unused).

Short-term (1-2 sprints): Shared catalog components

  1. Create CatalogFilterSidebar component that takes a filter config array. Both catalogs' filter sidebars are already data-driven.

  2. Create CatalogActiveFilterChips generic component. The core chip rendering logic is the same; performance filter chips could be handled via an optional render prop.

  3. Create CatalogGalleryLayout skeleton with loading/error/empty/grid/load-more pattern. Empty state variants via render props.

Medium-term: Context factory

  1. Create createCatalogContext<TFilterState>(config) factory that produces a context, provider, and standard hooks. The model catalog can extend it with performance-specific state.

What NOT to abstract

  • Card components -- ModelCatalogCard and McpCatalogCard are domain-specific and don't share meaningful structure.
  • Data fetching hooks -- useCatalogModelsBySources and useMcpServersBySourceLabel call different API endpoints with different params. The hook signatures are different enough that abstracting them would be forced.
  • Domain-specific filter logic -- Performance view, latency metrics, named query defaults, sort options. These are model-catalog-specific and should remain there as extensions on top of the shared base.
  • Route definitions -- Different routes, different URL params, different detail pages.

Impact Assessment

If we implement tiers 1 and 2, creating a third catalog (e.g., for datasets, notebooks, or any new asset type using filter_options/filterQuery) would require:

Before abstraction (current state):

  • Copy ~15 files from MCP catalog
  • Rename types, hooks, components
  • Wire up context, routes, extensions
  • Risk of divergence from model catalog patterns
  • Estimated effort: 3-5 days

After abstraction:

  • Define filter config (keys, labels, API mappings): ~1 file
  • Define types (item type, filter state type): ~1 file
  • Create card component: ~1 file
  • Create data fetching hook: ~1 file
  • Wire up createCatalogContext with config: ~1 file
  • Create page component composing shared pieces: ~1 file
  • Add routes and extensions: ~1 file
  • Estimated effort: 1-2 days

Key Insight from the Slack Thread

The thread mentions that both catalogs have "a lot of custom logic for filters." Looking at the code, the custom logic is actually well-structured -- the complexity comes from the model catalog's performance view (latency metrics, named query defaults, hardware configuration), not from the core catalog pattern. The MCP catalog is already quite clean and minimal.

The real win from abstraction isn't just reducing duplication between two catalogs -- it's making the pattern discoverable and documented. Right now, someone creating a third catalog has to reverse-engineer the pattern from the model catalog (complex, with performance-specific code tangled in) or the MCP catalog (simpler but less obvious which parts are reusable). A shared abstraction makes the pattern explicit: "here's what you need to define, here's what you get for free."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment