Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a2a9ba4
OSAC-2934: extract escapeCelStringLiteral into shared api/cel module
ElayAharoni Jul 28, 2026
134e0b1
OSAC-2934: add paginated provisioned-resources hooks per catalog item…
ElayAharoni Jul 28, 2026
ded0ad2
OSAC-2934: add missing single-item catalog item fetch hooks
ElayAharoni Jul 28, 2026
6ba2231
OSAC-2934: add SanitizedMarkdown component for catalog item descriptions
ElayAharoni Jul 28, 2026
190a6b1
OSAC-2934: add validation constraints summary formatter
ElayAharoni Jul 28, 2026
9e646be
OSAC-2934: add CatalogItemOverviewTab
ElayAharoni Jul 28, 2026
d74a365
OSAC-2934: add CatalogItemFieldDefinitionsTab
ElayAharoni Jul 28, 2026
2ec35e8
OSAC-2934: add CatalogItemProvisionedResourcesTab with server-side pa…
ElayAharoni Jul 28, 2026
4332bef
OSAC-2934: add CatalogItemDetailActionButtons and CatalogItemPublishT…
ElayAharoni Jul 28, 2026
dd6ea48
OSAC-2934: add catalog item detail pages and wire admin routing
ElayAharoni Jul 28, 2026
626a88c
OSAC-2934: sync i18n translations for detail page tab labels
ElayAharoni Jul 28, 2026
354417f
OSAC-2934: address code review findings — i18n, test coverage, securi…
ElayAharoni Jul 28, 2026
7ba674c
OSAC-2934: disable Delete and publish toggle with a tooltip until OSA…
ElayAharoni Jul 29, 2026
7990260
OSAC-2934: resolve template names for ComputeInstance and BareMetalIn…
ElayAharoni Jul 29, 2026
43b21ca
OSAC-2934: extract shared loading/error/not-found shell for detail pages
ElayAharoni Jul 29, 2026
efbdd1c
OSAC-2934: split provisioned resources tab per kind, derive kind inte…
ElayAharoni Jul 29, 2026
67ebcbe
OSAC-2934: extract WithTooltip to remove repeated disabled-tooltip te…
ElayAharoni Jul 29, 2026
f557254
OSAC-2934: trim id before gating and querying in template hooks
ElayAharoni Jul 29, 2026
489917d
OSAC-2934: keep whole-number hint alongside integer min/max bounds
ElayAharoni Jul 29, 2026
a9d97e8
OSAC-2934: use full Metadata fixtures instead of 'as never' casts in …
ElayAharoni Jul 29, 2026
b11719f
OSAC-2934: preserve Markdown-significant whitespace in item description
ElayAharoni Jul 29, 2026
00485b8
OSAC-2934: extract shared useCatalogItemDetailData hook
ElayAharoni Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/app-frontend/src/shell/AdminCatalogRoutes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Route, Routes } from 'react-router-dom';
import { screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { renderWithProviders } from '@osac/ui-components/test-utils/TestProviders';

import { AdminCatalogRoutes } from './AdminCatalogRoutes';

vi.mock('@osac/ui-components/pages/admin/CatalogManagementListPage', () => ({
default: () => <div>list-page</div>,
}));
vi.mock('@osac/ui-components/pages/admin/cluster/ClusterCatalogItemDetailPage', () => ({
default: () => <div>cluster-detail-page</div>,
}));
vi.mock(
'@osac/ui-components/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage',
() => ({
default: () => <div>compute-instance-detail-page</div>,
}),
);
vi.mock(
'@osac/ui-components/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage',
() => ({
default: () => <div>baremetal-instance-detail-page</div>,
}),
);

// Mirrors AppShell.tsx's real mount point (`/admin/catalog/*`) — required so the component's
// internal `<Navigate to="/admin/catalog" />` redirect resolves to a route that actually exists.
const renderAt = (path: string) =>
renderWithProviders(
<Routes>
<Route path="/admin/catalog/*" element={<AdminCatalogRoutes />} />
</Routes>,
{ routerEntries: [`/admin/catalog${path}`] },
);

describe('AdminCatalogRoutes', () => {
it('renders the list page at the index route', () => {
renderAt('/');
expect(screen.getByText('list-page')).toBeInTheDocument();
});

it('dispatches :type/:id to the cluster detail page for type=cluster', () => {
renderAt('/cluster/catalog-1');
expect(screen.getByText('cluster-detail-page')).toBeInTheDocument();
});

it('dispatches :type/:id to the compute-instance detail page for type=compute-instance', () => {
renderAt('/compute-instance/catalog-1');
expect(screen.getByText('compute-instance-detail-page')).toBeInTheDocument();
});

it('dispatches :type/:id to the baremetal-instance detail page for type=baremetal-instance', () => {
renderAt('/baremetal-instance/catalog-1');
expect(screen.getByText('baremetal-instance-detail-page')).toBeInTheDocument();
});

it('redirects to the list page for an unknown type', () => {
renderAt('/unknown-type/catalog-1');
expect(screen.getByText('list-page')).toBeInTheDocument();
});
});
22 changes: 20 additions & 2 deletions apps/app-frontend/src/shell/AdminCatalogRoutes.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
import { Route, Routes } from 'react-router-dom';
import { Navigate, Route, Routes, useParams } from 'react-router-dom';

import BareMetalInstanceCatalogItemDetailPage from '@osac/ui-components/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage';
import CatalogManagementListPage from '@osac/ui-components/pages/admin/CatalogManagementListPage';
import ClusterCatalogItemDetailPage from '@osac/ui-components/pages/admin/cluster/ClusterCatalogItemDetailPage';
import ComputeInstanceCatalogItemDetailPage from '@osac/ui-components/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage';

const CatalogItemDetailRoute = () => {
const { type } = useParams<{ type: string }>();

switch (type) {
case 'cluster':
return <ClusterCatalogItemDetailPage />;
case 'compute-instance':
return <ComputeInstanceCatalogItemDetailPage />;
case 'baremetal-instance':
return <BareMetalInstanceCatalogItemDetailPage />;
default:
return <Navigate to="/admin/catalog" replace />;
}
};

export const AdminCatalogRoutes = () => {
return (
<Routes>
<Route index element={<CatalogManagementListPage />} />
<Route path=":type/create" element={<div />} />
<Route path=":type/:id" element={<div />} />
<Route path=":type/:id" element={<CatalogItemDetailRoute />} />
<Route path=":type/:id/edit" element={<div />} />
</Routes>
);
Expand Down
27 changes: 27 additions & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"CIDR overlaps with existing subnet \"{{name}}\" ({{cidr}})": "CIDR overlaps with existing subnet \"{{name}}\" ({{cidr}})",
"Close": "Close",
"Cloud Init User Data": "Cloud Init User Data",
"Cluster": "Cluster",
"Cluster conditions": "Cluster conditions",
"Cluster node sets": "Cluster node sets",
"Cluster password": "Cluster password",
Expand Down Expand Up @@ -117,22 +118,27 @@
"Create virtual network": "Create virtual network",
"Created": "Created",
"Creator": "Creator",
"Default Value": "Default Value",
"Delete": "Delete",
"Delete {{name}}?": "Delete {{name}}?",
"Delete rule": "Delete rule",
"Delete rule?": "Delete rule?",
"Delete security group": "Delete security group",
"Delete security group?": "Delete security group?",
"Deleting": "Deleting",
"Deleting and publishing catalog items is not yet available.": "Deleting and publishing catalog items is not yet available.",
"deprecated": "deprecated",
"Description": "Description",
"Destination CIDR": "Destination CIDR",
"Details": "Details",
"Display Name": "Display Name",
"Download kubeconfig": "Download kubeconfig",
"Each host type can only be selected once": "Each host type can only be selected once",
"Edit": "Edit",
"Edit rule": "Edit rule",
"Editable": "Editable",
"Editable fields can be changed when creating from this catalog item. Fixed fields use the default value shown.": "Editable fields can be changed when creating from this catalog item. Fixed fields use the default value shown.",
"enum: [{{value}}]": "enum: [{{value}}]",
"Error": "Error",
"Error loading virtual networks": "Error loading virtual networks",
"Establishing console connection...": "Establishing console connection...",
Expand All @@ -157,6 +163,8 @@
"Failed to load graphical console viewer": "Failed to load graphical console viewer",
"Failed to load security groups": "Failed to load security groups",
"Failed to load subnets": "Failed to load subnets",
"Field definitions": "Field definitions",
"Field Definitions": "Field Definitions",
"Filter bare metal instances by name": "Filter bare metal instances by name",
"Filter by publication status": "Filter by publication status",
"Filter catalog by keyword": "Filter catalog by keyword",
Expand Down Expand Up @@ -189,7 +197,11 @@
"Loading subnets...": "Loading subnets...",
"Manage firewall rules for your virtual networks.": "Manage firewall rules for your virtual networks.",
"Manage virtual networks for your compute instances.": "Manage virtual networks for your compute instances.",
"max length: {{value}}": "max length: {{value}}",
"max: {{value}}": "max: {{value}}",
"Message": "Message",
"min length: {{value}}": "min length: {{value}}",
"min: {{value}}": "min: {{value}}",
"Name": "Name",
"Name cannot end with a hyphen": "Name cannot end with a hyphen",
"Name cannot start with a hyphen": "Name cannot start with a hyphen",
Expand All @@ -198,17 +210,20 @@
"Name must be at most 63 characters long": "Name must be at most 63 characters long",
"Name must only contain lowercase letters (a-z), digits (0-9), and hyphens (-)": "Name must only contain lowercase letters (a-z), digits (0-9), and hyphens (-)",
"Networking": "Networking",
"No": "No",
"No bare metal instances match your search.": "No bare metal instances match your search.",
"No bare metal instances yet.": "No bare metal instances yet.",
"No catalog items found": "No catalog items found",
"No catalog items have been created yet.": "No catalog items have been created yet.",
"No catalog items match your search or filter.": "No catalog items match your search or filter.",
"No catalog items match your search.": "No catalog items match your search.",
"No field definitions have been configured for this catalog item.": "No field definitions have been configured for this catalog item.",
"No inbound rules yet. Add one to allow incoming traffic.": "No inbound rules yet. Add one to allow incoming traffic.",
"No node sets added yet.": "No node sets added yet.",
"No node sets configured.": "No node sets configured.",
"No outbound rules yet. Add one to allow outgoing traffic.": "No outbound rules yet. Add one to allow outgoing traffic.",
"No published catalog items are available yet.": "No published catalog items are available yet.",
"No resources have been provisioned from this catalog item.": "No resources have been provisioned from this catalog item.",
"No security groups match your search.": "No security groups match your search.",
"No security groups yet. Create one to get started.": "No security groups yet. Create one to get started.",
"No subnets yet. Create one to get started.": "No subnets yet. Create one to get started.",
Expand All @@ -228,6 +243,8 @@
"Parent virtual network": "Parent virtual network",
"Paste a public SSH key for remote access. Supported types: ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.": "Paste a public SSH key for remote access. Supported types: ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.",
"Paste from clipboard": "Paste from clipboard",
"Path": "Path",
"pattern: {{value}}": "pattern: {{value}}",
"Paused": "Paused",
"Pod CIDR": "Pod CIDR",
"Pool size is required": "Pool size is required",
Expand All @@ -245,21 +262,26 @@
"Protocol is required": "Protocol is required",
"Provision a bare metal instance from a catalog item.": "Provision a bare metal instance from a catalog item.",
"Provision bare metal": "Provision bare metal",
"Provisioned resources": "Provisioned resources",
"Provisioned Resources": "Provisioned Resources",
"Provisioning": "Provisioning",
"Provisioning failed": "Provisioning failed",
"Public IP": "Public IP",
"Public SSH key is required": "Public SSH key is required",
"Publication status": "Publication status",
"Published": "Published",
"Pull secret": "Pull secret",
"Pull secret is required": "Pull secret is required",
"Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).": "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).",
"Release image": "Release image",
"Release image is required": "Release image is required",
"Remove node set": "Remove node set",
"Resource type": "Resource type",
"Restart": "Restart",
"Retry": "Retry",
"Running": "Running",
"Save": "Save",
"Scope": "Scope",
"Search by name": "Search by name",
"Search catalog items": "Search catalog items",
"Search security groups by name…": "Search security groups by name…",
Expand Down Expand Up @@ -287,6 +309,7 @@
"Subnets": "Subnets",
"Take over": "Take over",
"TCP": "TCP",
"Template": "Template",
"The console is available when the virtual machine is running.": "The console is available when the virtual machine is running.",
"This console is already open in another tab in this browser. Take over to continue here, or switch to that tab.": "This console is already open in another tab in this browser. Take over to continue here, or switch to that tab.",
"This field is required": "This field is required",
Expand All @@ -305,8 +328,10 @@
"User data": "User data",
"User Data is required": "User Data is required",
"User data must not exceed 64 KB.": "User data must not exceed 64 KB.",
"Validation Constraints": "Validation Constraints",
"View and manage your bare metal instances.": "View and manage your bare metal instances.",
"View password": "View password",
"Virtual Machine": "Virtual Machine",
"Virtual machine conditions": "Virtual machine conditions",
"Virtual machine summary": "Virtual machine summary",
"Virtual machines": "Virtual machines",
Expand All @@ -315,6 +340,8 @@
"Virtual Network": "Virtual Network",
"Virtual network is required": "Virtual network is required",
"Virtual networks": "Virtual networks",
"whole number": "whole number",
"Worker nodes": "Worker nodes",
"Yes": "Yes",
"You are not authorized to access this resource.": "You are not authorized to access this resource."
}
4 changes: 3 additions & 1 deletion libs/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,6 @@ export * from './osac/public/v1/field_definition_type_pb.js'
export * from './osac/public/v1/baremetal_instance_type_pb.js';
export * from './osac/public/v1/baremetal_instances_service_pb.js';
export * from './osac/public/v1/baremetal_instance_catalog_item_type_pb.js';
export * from './osac/public/v1/baremetal_instance_catalog_items_service_pb.js';
export * from './osac/public/v1/baremetal_instance_catalog_items_service_pb.js';
export * from './osac/public/v1/baremetal_instance_template_type_pb.js';
export * from './osac/public/v1/baremetal_instance_templates_service_pb.js';
2 changes: 2 additions & 0 deletions libs/ui-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"ip-address": "^10.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"rehype-sanitize": "^6.0.0",
"yup": "^1.7.1"
},
"scripts": {
Expand Down
27 changes: 27 additions & 0 deletions libs/ui-components/src/api/cel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';

import { catalogItemProvisionedResourcesFilter, escapeCelStringLiteral } from './cel';

describe('escapeCelStringLiteral', () => {
it('escapes embedded quotes for CEL string literals', () => {
expect(escapeCelStringLiteral('say "hello"')).toBe('say \\"hello\\"');
});

it('escapes backslashes for CEL string literals', () => {
expect(escapeCelStringLiteral('path\\to\\thing')).toBe('path\\\\to\\\\thing');
});
});

describe('catalogItemProvisionedResourcesFilter', () => {
it('filters resources by catalog item id', () => {
expect(catalogItemProvisionedResourcesFilter('catalog-1')).toBe(
'this.spec.catalog_item == "catalog-1"',
);
});

it('escapes CEL injection characters in the catalog item id', () => {
expect(catalogItemProvisionedResourcesFilter(`"'] || true || this.id in ['`)).toBe(
`this.spec.catalog_item == "\\"'] || true || this.id in ['"`,
);
});
});
5 changes: 5 additions & 0 deletions libs/ui-components/src/api/cel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const escapeCelStringLiteral = (value: string): string =>
value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');

export const catalogItemProvisionedResourcesFilter = (catalogItemId: string): string =>
`this.spec.catalog_item == "${escapeCelStringLiteral(catalogItemId)}"`;
1 change: 1 addition & 0 deletions libs/ui-components/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type ApiRoute =
| 'v1/subnets'
| 'v1/security_groups'
| 'v1/baremetal_instance_catalog_items'
| 'v1/baremetal_instance_templates'
| 'v1/baremetal_instances'
| 'v1/public_ips'
| 'v1/public_ip_attachments'
Expand Down
55 changes: 55 additions & 0 deletions libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createRouterTransport } from '@connectrpc/connect';
import { waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import type { BareMetalInstanceTemplate } from '@osac/types';
import { BareMetalInstanceTemplates } from '@osac/types';

import { useBareMetalInstanceTemplate } from './baremetal-instance-templates';
import { renderHookWithProviders } from '../../test-utils/TestProviders';

const template: BareMetalInstanceTemplate = {
$typeName: 'osac.public.v1.BareMetalInstanceTemplate',
id: 'tpl-bm-worker',
title: 'Bare Metal Worker',
description: '',
parameters: [],
};

describe('useBareMetalInstanceTemplate', () => {
const createTestTransport = (onGet?: (req: unknown) => void) =>
createRouterTransport((router) => {
router.service(BareMetalInstanceTemplates, {
get: (req) => {
onGet?.(req);
return { object: template };
},
});
});

it('fetches a single template by id from the Get endpoint', async () => {
const transport = createTestTransport();
const { result } = renderHookWithProviders(
() => useBareMetalInstanceTemplate('tpl-bm-worker'),
{ role: 'tenantAdmin', transport },
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject(template);
});

it('does not fetch when id is undefined', async () => {
let getCalled = false;
const transport = createTestTransport(() => {
getCalled = true;
});

renderHookWithProviders(() => useBareMetalInstanceTemplate(undefined), {
role: 'tenantAdmin',
transport,
});

await new Promise((resolve) => setTimeout(resolve, 10));
expect(getCalled).toBe(false);
});
});
15 changes: 15 additions & 0 deletions libs/ui-components/src/api/v1/baremetal-instance-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { BareMetalInstanceTemplates } from '@osac/types';

import { useApiFetch } from '../api-context';
import { apiQueryKey } from '../types';
import { useApiQuery } from '../use-api-query';

export const useBareMetalInstanceTemplate = (id: string | undefined) => {
const client = useApiFetch(BareMetalInstanceTemplates);
return useApiQuery({
queryKey: apiQueryKey('v1/baremetal_instance_templates', id ? [id] : undefined),
queryFn: () => client.get({ id: id ?? '' }),
select: (data) => data.object,
enabled: Boolean(id),
});
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading