diff --git a/calm-hub-ui/src/visualizer/components/drawer/Drawer.test.tsx b/calm-hub-ui/src/visualizer/components/drawer/Drawer.test.tsx index b73a15de9..1d038f0df 100644 --- a/calm-hub-ui/src/visualizer/components/drawer/Drawer.test.tsx +++ b/calm-hub-ui/src/visualizer/components/drawer/Drawer.test.tsx @@ -83,7 +83,105 @@ const calmData = { }, }; -describe('Drawer', () => { +const calmPatternData = { + name: 'Converted Pattern', + calmType: 'Patterns', + id: 'pattern-1', + version: '1.0', + data: { + type: "object", + title: "Converted Pattern", + required: ["nodes", "relationships"], + properties: { + nodes: { + prefixItems: [ + { + type: 'object', + properties: { + 'unique-id': { + const: 'n1' + }, + name: { + const: 'Node 1' + }, + description: { + const: 'desc1' + }, + 'node-type': { + const: 'typeA' + }, + } + }, + { + type: 'object', + properties: { + 'unique-id': { + const: 'n2' + }, + name: { + const: 'Node 2' + }, + description: { + const: 'desc2' + }, + 'node-type': { + const: 'typeB' + }, + } + }, + ] + }, + relationships: { + prefixItems: [ + { + properties: { + 'unique-id': { + const: 'r1' + }, + description: { + const: 'rel1' + }, + 'relationship-type': { + const: { + interacts: { + actor: 'n1', + nodes: ['n2'], + } + }, + }, + }, + required: [ + 'description' + ] + }, + { + properties: { + 'unique-id': { + const: 'r2' + }, + description: { + const: 'rel2' + }, + 'relationship-type': { + const: { + 'composed-of': { + container: 'n1', + nodes: ['n2'], + } + }, + }, + }, + required: [ + 'description' + ] + }, + ] + } + } + } +}; + +describe('Drawer with CALM schema data', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -111,3 +209,17 @@ describe('Drawer', () => { expect(checkbox).toBeInTheDocument(); }); }); + +describe('Drawer with CALM pattern data', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders VisualizerContainer when provided a calm pattern', () => { + render(); + expect(screen.getByTestId('visualizer-container')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('Converted Pattern/pattern-1/1.0')).toBeInTheDocument(); + }); +}); diff --git a/calm-hub-ui/src/visualizer/components/drawer/Drawer.tsx b/calm-hub-ui/src/visualizer/components/drawer/Drawer.tsx index 8058cec94..91d0e1aef 100644 --- a/calm-hub-ui/src/visualizer/components/drawer/Drawer.tsx +++ b/calm-hub-ui/src/visualizer/components/drawer/Drawer.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useState } from 'react'; -import { CalmArchitectureSchema } from '../../../../../calm-models/src/types/core-types.js'; +import { CalmArchitectureSchema, CalmPatternSchema } from '../../../../../calm-models/src/types/core-types.js'; import { CytoscapeNode, CytoscapeEdge } from '../../contracts/contracts.js'; import { VisualizerContainer } from '../visualizer-container/VisualizerContainer.js'; import { Data } from '../../../model/calm.js'; import { useDropzone } from 'react-dropzone'; import { convertCalmToCytoscape } from '../../services/calm-to-cytoscape-converter.js'; import { Sidebar } from '../sidebar/Sidebar.js'; +import { convertCalmPatternToCalm, isCalmPatternSchema } from '../../services/calm-pattern-to-cytoscape-converter.js'; interface DrawerProps { data?: Data; // Optional data prop passed in from CALM Hub if user navigates from there @@ -13,7 +14,7 @@ interface DrawerProps { export function Drawer({ data }: DrawerProps) { const [title, setTitle] = useState(''); - const [calmInstance, setCALMInstance] = useState(undefined); + const [calmInstance, setCALMInstance] = useState(undefined); const [fileInstance, setFileInstance] = useState(undefined); const [selectedItem, setSelectedItem] = useState(null); @@ -32,7 +33,7 @@ export function Drawer({ data }: DrawerProps) { if (data?.name && data?.id && data?.version) { setTitle(data.name + '/' + data.id + '/' + data.version); } - setCALMInstance((fileInstance as CalmArchitectureSchema) ?? data?.data); + setCALMInstance((fileInstance as CalmArchitectureSchema | CalmPatternSchema) ?? data?.data); }, [fileInstance, data]); function closeSidebar() { @@ -46,7 +47,8 @@ export function Drawer({ data }: DrawerProps) { return `${data.name}/${data.calmType}/${data.id}/${data.version}`; } - const { edges, nodes } = convertCalmToCytoscape(calmInstance); + const { edges, nodes } = convertCalmToCytoscape(isCalmPatternSchema(calmInstance) ? convertCalmPatternToCalm(calmInstance) : calmInstance as CalmArchitectureSchema); + return (
diff --git a/calm-hub-ui/src/visualizer/contracts/calm-pattern-contracts.ts b/calm-hub-ui/src/visualizer/contracts/calm-pattern-contracts.ts new file mode 100644 index 000000000..0b24477ee --- /dev/null +++ b/calm-hub-ui/src/visualizer/contracts/calm-pattern-contracts.ts @@ -0,0 +1,96 @@ +export type IndividualPrefixItem

= Record> = { + type: string; + properties: P; +}; + +type AnyOfPrefixItem = Record> = { + anyOf: IndividualPrefixItem[]; +} + +type OneOfPrefixItem = Record> = { + oneOf: IndividualPrefixItem[]; +}; + +export type PrefixItem = Record> = IndividualPrefixItem | AnyOfPrefixItem | OneOfPrefixItem; + +type PatternProperties = { + type: string, + minItems?: number, + maxItems?: number, + prefixItems: T[], +}; + +export type NodeProperties = { + "unique-id": { + const: string + }, + name: { + const: string + }, + description: { + const: string + }, + "node-type": { + const: string + }, + interfaces?: PatternProperties, + controls?: IndividualPrefixItem, +} + +export type NodePrefixItem = PrefixItem; + +type RelationshipTypeDescription = { + connects?: { + source: { + node: string, + }, + destination: { + node: string + } + }, + interacts?: { + actor: string, + nodes: string[] + }, + 'deployed-in'?: { + container: string, + nodes: string[] + }, + 'composed-of'?: { + container: string, + nodes: string[] + }, +} + +export type RelationshipProperties = { + "unique-id": { + const: string + }, + description: { + const: string + }, + protocol?: { + const: string + }, + 'relationship-type': { + const: RelationshipTypeDescription, + }, + controls?: PrefixItem, +} + +export type RelationshipPrefixItem = PrefixItem; + +export type CalmPatternSchema = { + type: string, + title: string, + description?: string, + properties: { + nodes: PatternProperties, + relationships: PatternProperties, + metadata?: PatternProperties, + controls?: PatternProperties, + flows?: PatternProperties, + adrs?: PatternProperties, + } + required: string[], +} \ No newline at end of file diff --git a/calm-hub-ui/src/visualizer/services/calm-pattern-to-cytoscape-converter.spec.ts b/calm-hub-ui/src/visualizer/services/calm-pattern-to-cytoscape-converter.spec.ts new file mode 100644 index 000000000..86c4f375d --- /dev/null +++ b/calm-hub-ui/src/visualizer/services/calm-pattern-to-cytoscape-converter.spec.ts @@ -0,0 +1,732 @@ +import { describe, expect, it } from "vitest"; +import { convertCalmPatternToCalm, isCalmPatternSchema } from "./calm-pattern-to-cytoscape-converter.js"; +import { CalmPatternSchema } from "../contracts/calm-pattern-contracts.js"; + +describe('isCalmPatternSchema', () => { + it('should return true for a valid CalmPatternSchema', () => { + const validPattern = { + type: "object", + title: "Sample Pattern", + required: ["nodes", "relationships"], + properties: { + nodes: { + prefixItems: [] + }, + relationships: { + prefixItems: [] + } + } + }; + expect(isCalmPatternSchema(validPattern)).toBe(true); + }); + + it('should return false when the type property is missing', () => { + const invalidPattern = { + title: "Sample Pattern", + required: ["nodes", "relationships"], + properties: { + nodes: { + prefixItems: [] + }, + relationships: { + prefixItems: [] + } + } + }; + expect(isCalmPatternSchema(invalidPattern)).toBe(false); + }); + + it('should return false when the title property is missing', () => { + const invalidPattern = { + type: "object", + required: ["nodes", "relationships"], + properties: { + nodes: { + prefixItems: [] + }, + relationships: { + prefixItems: [] + } + } + }; + expect(isCalmPatternSchema(invalidPattern)).toBe(false); + }); + + it('should return false when the required property is not an array', () => { + const invalidPattern = { + type: "object", + title: "Sample Pattern", + required: "nodes, relationships", + properties: { + nodes: { + prefixItems: [] + }, + relationships: { + prefixItems: [] + } + } + }; + expect(isCalmPatternSchema(invalidPattern)).toBe(false); + }); + + it('should return false when the nodes property is missing', () => { + const invalidPattern = { + type: 'object', + title: 'Sample Pattern', + required: ['nodes', 'relationships'], + properties: { + relationships: { + prefixItems: [] + } + } + }; + expect(isCalmPatternSchema(invalidPattern)).toBe(false); + }); + + it('should return false when the relationships property is missing', () => { + const invalidPattern = { + type: 'object', + title: 'Sample Pattern', + required: ['nodes', 'relationships'], + properties: { + nodes: { + prefixItems: [] + } + } + }; + expect(isCalmPatternSchema(invalidPattern)).toBe(false); + }); + + describe('convertCalmPatternToCalm', () => { + it('should default an undefined pattern correctly', () => { + expect(convertCalmPatternToCalm(undefined)).toEqual({ + nodes: undefined, + relationships: undefined, + metadata: undefined, + controls: undefined, + }); + }); + + it('should map nodes correctly', () => { + const calmPattern = { + properties: { + nodes: { + prefixItems: [ + { + type: 'object', + properties: { + 'unique-id': { + const: 'attendees' + }, + name: { + const: 'Attendees Service' + }, + description: { + const: 'The attendees service, or a placeholder for another application' + }, + 'node-type': { + const: 'service' + }, + 'interfaces': { + type: 'array', + minItems: 2, + maxItems: 2, + prefixItems: [ + { + properties: { + 'unique-id': { + const: 'attendees-image' + } + } + }, + { + properties: { + 'unique-id': { + const: 'attendees-port' + } + } + } + ] + } + } + }, + { + type: 'object', + properties: { + 'unique-id': { + const: 'k8s-cluster' + }, + name: { + const: 'Kubernetes Cluster' + }, + description: { + const: 'Kubernetes Cluster with network policy rules enabled' + }, + 'node-type': { + const: 'system' + }, + controls: { + properties: { + security: { + type: 'object', + properties: { + description: { + const: 'Security requirements for the Kubernetes cluster' + }, + requirements: { + type: 'array', + minItems: 1, + maxItems: 1, + prefixItems: [ + { + properties: { + 'requirement-url': { + const: 'https://calm.finos.org/getting-started/controls/micro-segmentation.requirement.json' + }, + 'config-url': { + const: 'https://calm.finos.org/getting-started/controls/micro-segmentation.config.json' + } + } + } + ] + } + } + } + } + } + } + } + ] + }, + relationships: { + prefixItems: [] + } + } + }; + const calmArchitecture = convertCalmPatternToCalm(calmPattern as unknown as CalmPatternSchema); + expect(calmArchitecture.nodes).toHaveLength(2); + expect(calmArchitecture.nodes?.[0]).toEqual({ + controls: undefined, + description: 'The attendees service, or a placeholder for another application', + 'interfaces': [ + { + 'unique-id': 'attendees-image', + }, + { + 'unique-id': 'attendees-port', + }, + ], + name: 'Attendees Service', + 'node-type': 'service', + 'unique-id': 'attendees', + }); + expect(calmArchitecture.nodes?.[1]).toEqual({ + controls: { + security: { + description: 'Security requirements for the Kubernetes cluster', + requirements: [ + { + 'config-url': 'https://calm.finos.org/getting-started/controls/micro-segmentation.config.json', + 'requirement-url': 'https://calm.finos.org/getting-started/controls/micro-segmentation.requirement.json', + }, + ], + }, + }, + description: 'Kubernetes Cluster with network policy rules enabled', + interfaces: undefined, + name: 'Kubernetes Cluster', + 'node-type': 'system', + 'unique-id': 'k8s-cluster', + }); + }); + + it('should map relationships correctly', () => { + const calmPattern = { + properties: { + nodes: { + prefixItems: [] + }, + relationships: { + prefixItems: [ + { + type: 'object', + properties: { + 'unique-id': { + const: 'load-balancer-attendees' + }, + description: { + const: 'Forward' + }, + protocol: { + const: 'mTLS' + }, + 'relationship-type': { + const: { + connects: { + source: { + node: 'load-balancer' + }, + destination: { + node: 'attendees' + } + } + } + }, + controls: { + properties: { + security: { + type: 'object', + properties: { + description: { + const: 'Security Controls for the connection' + }, + requirements: { + type: 'array', + minItems: 1, + maxItems: 1, + prefixItems: [ + { + properties: { + 'requirement-url': { + const: 'https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json' + }, + 'config-url': { + const: 'https://calm.finos.org/getting-started/controls/permitted-connection-http.config.json' + } + }, + required: [ + 'config-url' + ] + } + ] + } + } + } + } + } + }, + "required": [ + "description" + ] + }, + { + properties: { + 'unique-id': { + const: 'deployed-in-k8s-cluster' + }, + description: { + const: 'Components deployed on the k8s cluster' + }, + 'relationship-type': { + const: { + 'deployed-in': { + container: 'k8s-cluster', + nodes: [ + 'load-balancer', + 'attendees', + 'attendees-store' + ] + } + } + } + }, + required: [ + 'description' + ] + } + ] + } + } + }; + const calmArchitecture = convertCalmPatternToCalm(calmPattern as unknown as CalmPatternSchema); + expect(calmArchitecture.relationships).toHaveLength(2); + expect(calmArchitecture.relationships?.[0]).toEqual({ + controls: { + security: { + description: 'Security Controls for the connection', + requirements: [ + { + 'config-url': 'https://calm.finos.org/getting-started/controls/permitted-connection-http.config.json', + 'requirement-url': 'https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json', + }, + ], + }, + }, + description: 'Forward', + protocol: 'mTLS', + 'relationship-type': { + connects: { + destination: { + node: 'attendees', + }, + source: { + node: 'load-balancer', + }, + }, + }, + 'unique-id': 'load-balancer-attendees', + }); + expect(calmArchitecture.relationships?.[1]).toEqual({ + controls: undefined, + description: 'Components deployed on the k8s cluster', + protocol: undefined, + 'relationship-type': { + 'deployed-in': { + container: 'k8s-cluster', + nodes: [ + 'load-balancer', + 'attendees', + 'attendees-store', + ], + }, + }, + 'unique-id': 'deployed-in-k8s-cluster', + } + ); + }); + + it('should map metadata correctly', () => { + const calmPattern = { + properties: { + nodes: { + prefixItems: [] + }, + relationships: { + prefixItems: [] + }, + metadata: { + type: 'array', + minItems: 1, + maxItems: 1, + prefixItems: [{ + type: 'object', + properties: { + kubernetes: { + type: 'object', + properties: { + namespace: { + const: 'conference' + } + }, + required: [ + 'namespace' + ] + } + }, + required: [ + 'kubernetes' + ] + }] + + } + } + }; + const calmArchitecture = convertCalmPatternToCalm(calmPattern as unknown as CalmPatternSchema); + expect(calmArchitecture.metadata).toEqual([ + { + kubernetes: { + namespace: 'conference' + } + } + ]); + }); + + it('should map controls correctly', () => { + const calmPattern = { + properties: { + nodes: { + prefixItems: [] + }, + relationships: { + prefixItems: [] + }, + controls: { + type: 'array', + minItems: 1, + maxItems: 1, + prefixItems: [{ + type: 'object', + properties: { + governance: { + type: 'object', + properties: { + description: { + const: 'Governance controls for the architecture' + } + }, + required: [ + 'description' + ] + } + }, + required: [ + 'governance' + ] + }] + } + } + }; + const calmArchitecture = convertCalmPatternToCalm(calmPattern as unknown as CalmPatternSchema); + expect(calmArchitecture.controls).toEqual([ + { + governance: { + description: 'Governance controls for the architecture' + } + } + ]); + }); + + it('should handle oneOf structures correctly', () => { + const calmPattern = { + title: "Application A/B/C + Database Pattern", + type: "object", + properties: { + nodes: { + type: "array", + minItems: 3, + maxItems: 3, + prefixItems: [ + { + oneOf: [ + { + type: "object", + properties: { + 'unique-id': { + const: "application-a" + }, + name: { + const: "Application A" + }, + description: { + const: "Application A, optionally used in this architecture" + }, + 'node-type': { + const: "service" + } + } + }, + { + type: "object", + properties: { + 'unique-id': { + const: "application-b" + }, + name: { + const: "Application B" + }, + description: { + const: "Application B, optionally used in this architecture" + }, + 'node-type': { + const: "service" + } + } + } + ] + }, + ] + }, + "relationships": { + type: "array", + minItems: 3, + maxItems: 3, + prefixItems: [ + { + oneOf: [ + { + type: "object", + properties: { + 'unique-id': { + const: "application-a-to-c" + }, + description: { + const: "Application A connects to Application C" + }, + 'relationship-type': { + const: { + connects: { + source: { node: "application-a" }, + destination: { node: "application-c" } + } + } + } + } + }, + { + type: "object", + properties: { + 'unique-id': { + const: "application-b-to-c" + }, + description: { + const: "Application B connects to Application C" + }, + 'relationship-type': { + const: { + connects: { + source: { node: "application-b" }, + destination: { node: "application-c" } + } + } + } + } + } + ] + } + ] + } + }, + required: [ + "nodes", + "relationships" + ] + }; + const calmArchitecture = convertCalmPatternToCalm(calmPattern); + expect(calmArchitecture.nodes).toHaveLength(1); + expect(calmArchitecture.nodes?.[0]).toEqual({ + 'unique-id': 'application-a', + name: 'Application A', + description: 'Application A, optionally used in this architecture', + 'node-type': 'service', + }); + expect(calmArchitecture.relationships).toHaveLength(1); + expect(calmArchitecture.relationships?.[0]).toEqual({ + 'unique-id': 'application-a-to-c', + description: 'Application A connects to Application C', + 'relationship-type': { + connects: { + source: { node: 'application-a' }, + destination: { node: 'application-c' }, + }, + }, + }); + }); + + it('should handle anyOf structures correctly', () => { + const calmPattern = { + title: "Application + Database A and/or B Pattern", + type: "object", + properties: { + nodes: { + type: "array", + minItems: 1, + maxItems: 3, + prefixItems: [ + { + anyOf: [ + { + type: "object", + properties: { + 'unique-id': { + const: "database-a" + }, + name: { + const: "Database A" + }, + description: { + const: "Database A, optionally used in this architecture" + }, + 'node-type': { + const: "database" + } + } + }, + { + type: "object", + properties: { + 'unique-id': { + const: "database-b" + }, + name: { + const: "Database B" + }, + description: { + const: "Database B, optionally used in this architecture" + }, + 'node-type': { + const: "database" + } + } + } + ] + } + ] + }, + relationships: { + type: "array", + minItems: 1, + maxItems: 3, + prefixItems: [ + { + anyOf: [ + { + type: "object", + properties: { + 'unique-id': { + const: "application-database-a" + }, + description: { + const: "Application connects to Database A" + }, + 'relationship-type': { + const: { + connects: { + source: { node: "application" }, + destination: { node: "database-a" } + } + } + } + } + }, + { + type: "object", + properties: { + 'unique-id': { + const: "application-database-b" + }, + description: { + const: "Application connects to Database B" + }, + 'relationship-type': { + const: { + connects: { + source: { node: "application" }, + destination: { node: "database-b" } + } + } + } + } + } + ] + } + ] + } + }, + required: [ + "nodes", + "relationships" + ] + } + + const calmArchitecture = convertCalmPatternToCalm(calmPattern); + expect(calmArchitecture.nodes).toHaveLength(1); + expect(calmArchitecture.nodes?.[0]).toEqual({ + 'unique-id': 'database-a', + name: 'Database A', + description: 'Database A, optionally used in this architecture', + 'node-type': 'database', + }); + expect(calmArchitecture.relationships).toHaveLength(1); + expect(calmArchitecture.relationships?.[0]).toEqual({ + 'unique-id': 'application-database-a', + description: 'Application connects to Database A', + 'relationship-type': { + connects: { + source: { node: 'application' }, + destination: { node: 'database-a' } + }, + }, + }); + }); + }); +}); \ No newline at end of file diff --git a/calm-hub-ui/src/visualizer/services/calm-pattern-to-cytoscape-converter.ts b/calm-hub-ui/src/visualizer/services/calm-pattern-to-cytoscape-converter.ts new file mode 100644 index 000000000..fbe5765cc --- /dev/null +++ b/calm-hub-ui/src/visualizer/services/calm-pattern-to-cytoscape-converter.ts @@ -0,0 +1,67 @@ +import { CalmControlsSchema } from "../../../../calm-models/src/types/control-types.js"; +import { CalmArchitectureSchema, CalmNodeSchema, CalmRelationshipSchema } from "../../../../calm-models/src/types/core-types.js"; +import { CalmMetadataSchema } from "../../../../calm-models/src/types/metadata-types.js"; +import { CalmPatternSchema, IndividualPrefixItem, PrefixItem } from "../contracts/calm-pattern-contracts.js"; + +export function isCalmPatternSchema(value: unknown): value is CalmPatternSchema { + const castedValue = value as CalmPatternSchema; + let check = castedValue != null; + check = check && castedValue.type != null && typeof castedValue.type === 'string'; + check = check && castedValue.title != null && typeof castedValue.title === 'string'; + check = check && castedValue.required != null && Array.isArray(castedValue.required); + check = check && castedValue.properties != null; + check = check && Object.keys(castedValue.properties).includes('nodes'); + check = check && Object.keys(castedValue.properties).includes('relationships'); + return check; +} + +function extractIndividualPrefixItem(item: PrefixItem): IndividualPrefixItem { + if ('oneOf' in item) { + // Assuming we want the properties of the first item in oneOf for simplicity + return item.oneOf[0]; + } + if ('anyOf' in item) { + // Assuming we want the properties of the first item in anyOf for simplicity + return item.anyOf[0]; + } + return item; +} + +function extractPropertiesFromPrefixItem(item: PrefixItem): Record { + const properties = extractIndividualPrefixItem(item).properties; + const result: Record = {}; + + if (properties == null || typeof properties !== 'object') { + return result; + } + + Object.keys(properties).forEach(key => { + const prop = properties[key]; + if (prop == null || typeof prop !== 'object') { + return; + } + if ('const' in prop) { + result[key] = prop.const; + } else if ('prefixItems' in prop && Array.isArray(prop.prefixItems)) { + result[key] = prop.prefixItems.map(extractPropertiesFromPrefixItem); + } else { + result[key] = extractPropertiesFromPrefixItem(prop as PrefixItem); + } + }) + + return result; +} + +export function convertCalmPatternToCalm(pattern?: CalmPatternSchema): CalmArchitectureSchema { + const calmNodes = pattern?.properties.nodes.prefixItems.map(extractPropertiesFromPrefixItem); + const calmRelationships = pattern?.properties.relationships.prefixItems.map(extractPropertiesFromPrefixItem); + const metadata = pattern?.properties.metadata?.prefixItems.map(extractPropertiesFromPrefixItem); + const controls = pattern?.properties.controls?.prefixItems.map(extractPropertiesFromPrefixItem); + + return { + nodes: calmNodes as CalmNodeSchema[], + relationships: calmRelationships as CalmRelationshipSchema[], + metadata: metadata as CalmMetadataSchema | undefined, + controls: controls as CalmControlsSchema | undefined, + } +} \ No newline at end of file