diff --git a/calm-hub-ui/src/visualizer/components/sidebar/NodeDetails.test.tsx b/calm-hub-ui/src/visualizer/components/sidebar/NodeDetails.test.tsx new file mode 100644 index 000000000..ce585dc96 --- /dev/null +++ b/calm-hub-ui/src/visualizer/components/sidebar/NodeDetails.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { NodeDetails } from './NodeDetails.js'; +import { CalmNodeSchema } from '@finos/calm-models/types'; + +const baseNode: CalmNodeSchema = { + 'unique-id': 'node-001', + name: 'My Service', + 'node-type': 'service', + description: 'A service node', +}; + +describe('NodeDetails', () => { + it('renders node name, unique-id, and description', () => { + render(); + + expect(screen.getByText('My Service')).toBeInTheDocument(); + expect(screen.getByText('node-001')).toBeInTheDocument(); + expect(screen.getByText('A service node')).toBeInTheDocument(); + }); + + it('renders the node type badge', () => { + render(); + + expect(screen.getByText('service')).toBeInTheDocument(); + }); + + it('renders interfaces when present', () => { + const nodeWithInterfaces: CalmNodeSchema = { + ...baseNode, + interfaces: [ + { 'unique-id': 'iface-1', host: 'localhost', port: 8080 }, + ], + }; + render(); + + expect(screen.getByText('Interfaces')).toBeInTheDocument(); + expect(screen.getByText('iface-1')).toBeInTheDocument(); + expect(screen.getByText('localhost')).toBeInTheDocument(); + }); + + it('renders controls when present', () => { + const nodeWithControls: CalmNodeSchema = { + ...baseNode, + controls: { + 'security-review': { + description: 'Must pass security review', + requirements: [{ 'requirement-url': 'https://example.com' }], + }, + }, + }; + render(); + + expect(screen.getByText('Controls')).toBeInTheDocument(); + expect(screen.getByText('security-review')).toBeInTheDocument(); + expect(screen.getByText('Must pass security review')).toBeInTheDocument(); + expect(screen.getByText('1 requirement')).toBeInTheDocument(); + }); + + it('renders risk level badge when aigf metadata present', () => { + const nodeWithRisk: CalmNodeSchema = { + ...baseNode, + metadata: { + aigf: { + 'risk-level': 'high', + risks: ['Data breach risk'], + }, + }, + }; + render(); + + expect(screen.getByText('high')).toBeInTheDocument(); + expect(screen.getByText('Risks')).toBeInTheDocument(); + expect(screen.getByText('Data breach risk')).toBeInTheDocument(); + }); + + it('renders mitigations when present in aigf metadata', () => { + const nodeWithMitigations: CalmNodeSchema = { + ...baseNode, + metadata: { + aigf: { + mitigations: ['Encryption at rest'], + }, + }, + }; + render(); + + expect(screen.getByText('Mitigations')).toBeInTheDocument(); + expect(screen.getByText('Encryption at rest')).toBeInTheDocument(); + }); + + it('renders detailed architecture indicator when present', () => { + const nodeWithDetails: CalmNodeSchema = { + ...baseNode, + details: { 'detailed-architecture': 'arch-ref-001' }, + }; + render(); + + expect(screen.getByText('Has detailed architecture')).toBeInTheDocument(); + }); + + it('renders extra properties not in the known set', () => { + const nodeWithExtra = { + ...baseNode, + 'custom-field': 'custom-value', + } as CalmNodeSchema; + render(); + + expect(screen.getByText('Properties')).toBeInTheDocument(); + expect(screen.getByText('Custom Field')).toBeInTheDocument(); + expect(screen.getByText('custom-value')).toBeInTheDocument(); + }); +}); diff --git a/calm-hub-ui/src/visualizer/components/sidebar/NodeDetails.tsx b/calm-hub-ui/src/visualizer/components/sidebar/NodeDetails.tsx new file mode 100644 index 000000000..55cb512b6 --- /dev/null +++ b/calm-hub-ui/src/visualizer/components/sidebar/NodeDetails.tsx @@ -0,0 +1,60 @@ +import { CalmNodeSchema } from '@finos/calm-models/types'; +import { ZoomIn } from 'lucide-react'; +import { extractNodeType } from '../reactflow/utils/calmHelpers.js'; +import { getNodeTypeColor } from '../../../theme/helpers.js'; +import type { ControlItem } from '../../contracts/contracts.js'; +import { + Badge, RiskLevelBadge, + PropertiesSection, ControlsSection, RisksSection, MitigationsSection, InterfacesSection, + getNodeIcon, extractAigf, getExtraProperties, +} from './detail-components.js'; + +const KNOWN_FIELDS = new Set([ + 'unique-id', 'name', 'node-type', 'description', 'details', + 'interfaces', 'controls', 'metadata', +]); + +export function NodeDetails({ data }: { data: CalmNodeSchema }) { + const nodeType = extractNodeType(data) || 'Unknown'; + const Icon = getNodeIcon(nodeType); + + const aigf = extractAigf(data.metadata); + const riskLevel = aigf?.['risk-level']; + const risks = aigf?.risks || []; + const mitigations = aigf?.mitigations || []; + + const controls: Record = data.controls || {}; + const interfaces = (data.interfaces || []) as Record[]; + const detailedArch = data.details?.['detailed-architecture']; + const extraProps = getExtraProperties(data as unknown as Record, KNOWN_FIELDS); + + return ( +
+
+
+ + {riskLevel && } +
+

{data.name}

+

{data['unique-id']}

+
+ + {data.description && ( +

{data.description}

+ )} + + {detailedArch && ( +
+ + Has detailed architecture +
+ )} + + + + + + +
+ ); +} diff --git a/calm-hub-ui/src/visualizer/components/sidebar/RelationshipDetails.test.tsx b/calm-hub-ui/src/visualizer/components/sidebar/RelationshipDetails.test.tsx new file mode 100644 index 000000000..9a1e8c6ad --- /dev/null +++ b/calm-hub-ui/src/visualizer/components/sidebar/RelationshipDetails.test.tsx @@ -0,0 +1,141 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { RelationshipDetails } from './RelationshipDetails.js'; +import { CalmRelationshipSchema } from '@finos/calm-models/types'; + +const connectsRelationship: CalmRelationshipSchema = { + 'unique-id': 'rel-001', + description: 'Service to DB', + 'relationship-type': { + connects: { + source: { node: 'service-1' }, + destination: { node: 'db-1' }, + }, + }, +}; + +describe('RelationshipDetails', () => { + it('renders unique-id and description', () => { + render(); + + expect(screen.getByText('rel-001')).toBeInTheDocument(); + expect(screen.getByText('Service to DB')).toBeInTheDocument(); + }); + + it('renders connects type badge and connection diagram', () => { + render(); + + expect(screen.getByText('connects')).toBeInTheDocument(); + expect(screen.getByText('Connection')).toBeInTheDocument(); + expect(screen.getByText('service-1')).toBeInTheDocument(); + expect(screen.getByText('db-1')).toBeInTheDocument(); + }); + + it('renders protocol badge when present', () => { + const withProtocol: CalmRelationshipSchema = { + ...connectsRelationship, + protocol: 'HTTPS', + }; + render(); + + expect(screen.getByText('HTTPS')).toBeInTheDocument(); + }); + + it('renders interacts relationship type', () => { + const interactsRel: CalmRelationshipSchema = { + 'unique-id': 'rel-002', + 'relationship-type': { + interacts: { + actor: 'user-1', + nodes: ['service-1', 'service-2'], + }, + }, + }; + render(); + + expect(screen.getByText('interacts')).toBeInTheDocument(); + expect(screen.getByText('Interaction')).toBeInTheDocument(); + expect(screen.getByText('user-1')).toBeInTheDocument(); + expect(screen.getByText('service-1')).toBeInTheDocument(); + expect(screen.getByText('service-2')).toBeInTheDocument(); + }); + + it('renders deployed-in relationship type', () => { + const deployedInRel: CalmRelationshipSchema = { + 'unique-id': 'rel-003', + 'relationship-type': { + 'deployed-in': { + container: 'k8s-cluster', + nodes: ['svc-a', 'svc-b'], + }, + }, + }; + render(); + + expect(screen.getByText('deployed-in')).toBeInTheDocument(); + expect(screen.getByText('Deployment')).toBeInTheDocument(); + expect(screen.getByText('k8s-cluster')).toBeInTheDocument(); + expect(screen.getByText('svc-a')).toBeInTheDocument(); + }); + + it('renders composed-of relationship type', () => { + const composedOfRel: CalmRelationshipSchema = { + 'unique-id': 'rel-004', + 'relationship-type': { + 'composed-of': { + container: 'api-gateway', + nodes: ['router', 'auth'], + }, + }, + }; + render(); + + expect(screen.getByText('composed-of')).toBeInTheDocument(); + expect(screen.getByText('Composition')).toBeInTheDocument(); + expect(screen.getByText('api-gateway')).toBeInTheDocument(); + expect(screen.getByText('router')).toBeInTheDocument(); + }); + + it('renders risk level and risks from aigf metadata', () => { + const withRisks: CalmRelationshipSchema = { + ...connectsRelationship, + metadata: { + aigf: { + 'risk-level': 'medium', + risks: ['Latency risk'], + }, + }, + }; + render(); + + expect(screen.getByText('medium')).toBeInTheDocument(); + expect(screen.getByText('Risks')).toBeInTheDocument(); + expect(screen.getByText('Latency risk')).toBeInTheDocument(); + }); + + it('renders controls when present', () => { + const withControls: CalmRelationshipSchema = { + ...connectsRelationship, + controls: { + 'tls-required': { description: 'TLS must be enabled' }, + }, + }; + render(); + + expect(screen.getByText('Controls')).toBeInTheDocument(); + expect(screen.getByText('tls-required')).toBeInTheDocument(); + expect(screen.getByText('TLS must be enabled')).toBeInTheDocument(); + }); + + it('renders extra properties not in the known set', () => { + const withExtra = { + ...connectsRelationship, + 'custom-prop': 'custom-val', + } as CalmRelationshipSchema; + render(); + + expect(screen.getByText('Properties')).toBeInTheDocument(); + expect(screen.getByText('Custom Prop')).toBeInTheDocument(); + expect(screen.getByText('custom-val')).toBeInTheDocument(); + }); +}); diff --git a/calm-hub-ui/src/visualizer/components/sidebar/RelationshipDetails.tsx b/calm-hub-ui/src/visualizer/components/sidebar/RelationshipDetails.tsx new file mode 100644 index 000000000..02248a1a8 --- /dev/null +++ b/calm-hub-ui/src/visualizer/components/sidebar/RelationshipDetails.tsx @@ -0,0 +1,137 @@ +import { CalmRelationshipSchema } from '@finos/calm-models/types'; +import { ArrowRight, GitFork, Container, Layers } from 'lucide-react'; +import { extractRelationshipType } from '../reactflow/utils/calmHelpers.js'; +import type { ControlItem } from '../../contracts/contracts.js'; +import { + Badge, RiskLevelBadge, Section, + PropertiesSection, ControlsSection, RisksSection, MitigationsSection, + ConnectionDiagram, NodeList, + extractAigf, getExtraProperties, +} from './detail-components.js'; + +const KNOWN_FIELDS = new Set([ + 'unique-id', 'description', 'relationship-type', 'protocol', 'controls', 'metadata', +]); + +function getRelTypeInfo(relType: ReturnType) { + if (!relType) return { kind: 'unknown', icon: ArrowRight, color: '#94a3b8' }; + if ('connects' in relType) return { kind: 'connects', icon: ArrowRight, color: '#3b82f6' }; + if ('interacts' in relType) return { kind: 'interacts', icon: GitFork, color: '#8b5cf6' }; + if ('deployed-in' in relType) return { kind: 'deployed-in', icon: Container, color: '#f59e0b' }; + if ('composed-of' in relType) return { kind: 'composed-of', icon: Layers, color: '#10b981' }; + if ('options' in relType) return { kind: 'options', icon: GitFork, color: '#6366f1' }; + return { kind: 'unknown', icon: ArrowRight, color: '#94a3b8' }; +} + +function LabeledValue({ label, value }: { label: string; value: string }) { + return ( +
+ {label} +

{value}

+
+ ); +} + +function ContainerWithNodes({ title, containerLabel, container, nodesLabel, nodes }: { + title: string; + containerLabel: string; + container: string; + nodesLabel: string; + nodes: string[]; +}) { + return ( +
+
+ + +
+
+ ); +} + +function RelationshipTypeSection({ relType }: { relType: NonNullable> }) { + if ('connects' in relType && relType.connects) { + return ( +
+ +
+ ); + } + if ('interacts' in relType && relType.interacts) { + return ( + + ); + } + if ('deployed-in' in relType && relType['deployed-in']) { + return ( + + ); + } + if ('composed-of' in relType && relType['composed-of']) { + return ( + + ); + } + return null; +} + +export function RelationshipDetails({ data }: { data: CalmRelationshipSchema }) { + const relType = extractRelationshipType(data); + const { kind, icon: RelIcon, color } = getRelTypeInfo(relType); + + const aigf = extractAigf(data.metadata); + const riskLevel = aigf?.['risk-level']; + const risks = aigf?.risks || []; + const mitigations = aigf?.mitigations || []; + + const controls: Record = data.controls || {}; + const extraProps = getExtraProperties(data as unknown as Record, KNOWN_FIELDS); + + return ( +
+
+
+ + {data.protocol && ( + + {data.protocol} + + )} + {riskLevel && } +
+ {data.description && ( +

{data.description}

+ )} +

{data['unique-id']}

+
+ + {relType && } + + + + + +
+ ); +} diff --git a/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.test.tsx b/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.test.tsx index 87b0f8c02..41784b3b6 100644 --- a/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.test.tsx +++ b/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.test.tsx @@ -55,30 +55,45 @@ describe('Sidebar Component', () => { render(); expect(screen.getByText('Unknown Selected Entity')).toBeInTheDocument(); - expect(screen.queryByText('Node Details')).not.toBeInTheDocument(); - expect(screen.queryByText('Relationship Details')).not.toBeInTheDocument(); }); - it('should render edge details correctly', () => { + it('should render readable relationship details by default', () => { render(); - // Monaco Editor is mocked as a textarea, so check its value - const textarea = screen.getByTestId('monaco-editor'); + expect(screen.getByText('Relationship')).toBeInTheDocument(); + expect(screen.getByText('Edge 1')).toBeInTheDocument(); + expect(screen.getByText('edge-1')).toBeInTheDocument(); + expect(screen.getByText('connects')).toBeInTheDocument(); + }); - expect(screen.getByText('Relationship Details')).toBeInTheDocument(); - expect(textarea).toHaveValue(JSON.stringify(mockEdgeData, null, 2)); + it('should render readable node details by default', () => { + render(); + + expect(screen.getByText('Node')).toBeInTheDocument(); + expect(screen.getByText('Node 1')).toBeInTheDocument(); + expect(screen.getByText('node-1')).toBeInTheDocument(); + expect(screen.getByText('Mock Node')).toBeInTheDocument(); }); - it('should render node details dynamically based on selectedData', () => { + it('should show JSON view when JSON tab is clicked', () => { render(); - expect(screen.getByText('Node Details')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: 'JSON' })); - // Monaco Editor is mocked as a textarea, so check its value const textarea = screen.getByTestId('monaco-editor'); expect(textarea).toHaveValue(JSON.stringify(mockNodeData, null, 2)); }); + it('should switch back to details view', () => { + render(); + + fireEvent.click(screen.getByRole('tab', { name: 'JSON' })); + fireEvent.click(screen.getByRole('tab', { name: 'Details' })); + + expect(screen.getByText('Node 1')).toBeInTheDocument(); + expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument(); + }); + it('should call closeSidebar when close button is clicked', () => { render(); diff --git a/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.tsx b/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.tsx index a4aaa7743..12806d566 100644 --- a/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.tsx +++ b/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.tsx @@ -1,6 +1,9 @@ -import { IoCloseOutline, IoCubeOutline, IoGitNetworkOutline } from 'react-icons/io5'; +import { useState } from 'react'; +import { IoCloseOutline, IoCubeOutline, IoGitNetworkOutline, IoEyeOutline, IoCodeOutline } from 'react-icons/io5'; import { CalmNodeSchema, CalmRelationshipSchema } from '@finos/calm-models/types'; import { JsonRenderer } from '../../../hub/components/json-renderer/JsonRenderer.js'; +import { NodeDetails } from './NodeDetails.js'; +import { RelationshipDetails } from './RelationshipDetails.js'; import type { SidebarProps } from '../../contracts/visualizer-contracts.js'; function isCALMNode(data: CalmNodeSchema | CalmRelationshipSchema): data is CalmNodeSchema { @@ -12,13 +15,13 @@ function isCALMRelationship(data: CalmNodeSchema | CalmRelationshipSchema): data } export function Sidebar({ selectedData, closeSidebar }: SidebarProps) { + const [activeTab, setActiveTab] = useState<'details' | 'json'>('details'); const isNode = isCALMNode(selectedData); const isRelationship = isCALMRelationship(selectedData); return (
- {/* Header */}

{isNode ? ( @@ -27,32 +30,61 @@ export function Sidebar({ selectedData, closeSidebar }: SidebarProps) { ) : null} {isNode - ? 'Node Details' + ? 'Node' : isRelationship - ? 'Relationship Details' + ? 'Relationship' : 'Details'}

- +
+
+ + +
+ +
- {/* Content */} -
- {(isNode || isRelationship) ? ( -
- -
+
+ {activeTab === 'details' ? ( + (isNode || isRelationship) ? ( +
+ {isNode ? ( + + ) : ( + + )} +
+ ) : ( +
+

Unknown Selected Entity

+
+ ) ) : ( -
-

Unknown Selected Entity

+
+
)}
diff --git a/calm-hub-ui/src/visualizer/components/sidebar/detail-components.test.tsx b/calm-hub-ui/src/visualizer/components/sidebar/detail-components.test.tsx new file mode 100644 index 000000000..64165f522 --- /dev/null +++ b/calm-hub-ui/src/visualizer/components/sidebar/detail-components.test.tsx @@ -0,0 +1,168 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { + Section, Badge, RiskLevelBadge, + PropertiesSection, ControlsSection, RisksSection, MitigationsSection, + InterfacesSection, ConnectionDiagram, NodeList, +} from './detail-components.js'; +import { Box } from 'lucide-react'; + +describe('Section', () => { + it('renders title and children', () => { + render(

Content

); + + expect(screen.getByText('Test Section')).toBeInTheDocument(); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); +}); + +describe('Badge', () => { + it('renders label with background color', () => { + render(); + + const badge = screen.getByText('system'); + expect(badge.closest('span')).toHaveStyle({ backgroundColor: '#3b82f6' }); + }); +}); + +describe('RiskLevelBadge', () => { + it('renders risk level text', () => { + render(); + expect(screen.getByText('high')).toBeInTheDocument(); + }); +}); + +describe('PropertiesSection', () => { + it('returns null for empty properties', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders property keys and values', () => { + render(); + + expect(screen.getByText('My Field')).toBeInTheDocument(); + expect(screen.getByText('my-value')).toBeInTheDocument(); + }); +}); + +describe('ControlsSection', () => { + it('returns null for empty controls', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders control id and description', () => { + render( + + ); + + expect(screen.getByText('auth-check')).toBeInTheDocument(); + expect(screen.getByText('Verify auth tokens')).toBeInTheDocument(); + }); + + it('renders requirement count', () => { + render( + + ); + + expect(screen.getByText('2 requirements')).toBeInTheDocument(); + }); + + it('renders singular requirement text for one requirement', () => { + render( + + ); + + expect(screen.getByText('1 requirement')).toBeInTheDocument(); + }); +}); + +describe('RisksSection', () => { + it('returns null for empty risks', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders string risks', () => { + render(); + expect(screen.getByText('Data leak')).toBeInTheDocument(); + }); + + it('renders object risks using name', () => { + render(); + expect(screen.getByText('SQL Injection')).toBeInTheDocument(); + }); + + it('falls back to id then JSON for object risks', () => { + render(); + expect(screen.getByText('risk-id')).toBeInTheDocument(); + }); +}); + +describe('MitigationsSection', () => { + it('returns null for empty mitigations', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders string mitigations', () => { + render(); + expect(screen.getByText('Use encryption')).toBeInTheDocument(); + }); + + it('renders object mitigations using name', () => { + render(); + expect(screen.getByText('WAF')).toBeInTheDocument(); + }); +}); + +describe('InterfacesSection', () => { + it('returns null for empty interfaces', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders interface id and fields', () => { + render( + + ); + + expect(screen.getByText('iface-1')).toBeInTheDocument(); + expect(screen.getByText('localhost')).toBeInTheDocument(); + expect(screen.getByText('443')).toBeInTheDocument(); + }); + + it('generates fallback id when unique-id is missing', () => { + render(); + expect(screen.getByText('interface-0')).toBeInTheDocument(); + }); +}); + +describe('ConnectionDiagram', () => { + it('renders source and destination nodes', () => { + render(); + + expect(screen.getByText('node-a')).toBeInTheDocument(); + expect(screen.getByText('node-b')).toBeInTheDocument(); + }); +}); + +describe('NodeList', () => { + it('renders label and all nodes', () => { + render(); + + expect(screen.getByText('Connected')).toBeInTheDocument(); + expect(screen.getByText('n1')).toBeInTheDocument(); + expect(screen.getByText('n2')).toBeInTheDocument(); + expect(screen.getByText('n3')).toBeInTheDocument(); + }); +}); diff --git a/calm-hub-ui/src/visualizer/components/sidebar/detail-components.tsx b/calm-hub-ui/src/visualizer/components/sidebar/detail-components.tsx new file mode 100644 index 000000000..df7a3bfb1 --- /dev/null +++ b/calm-hub-ui/src/visualizer/components/sidebar/detail-components.tsx @@ -0,0 +1,169 @@ +import { ReactNode } from 'react'; +import { AlertTriangle, AlertCircle, Shield, ArrowRight, type LucideIcon } from 'lucide-react'; +import { getRiskLevelColor } from '../../../theme/helpers.js'; +import type { RiskItem, MitigationItem, ControlItem } from '../../contracts/contracts.js'; +import { formatFieldName } from './sidebar-utils.js'; + +export { formatFieldName, getNodeIcon, extractAigf, getExtraProperties } from './sidebar-utils.js'; +export type { AigfData } from './sidebar-utils.js'; + +export function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function Badge({ icon: Icon, label, color }: { icon: LucideIcon; label: string; color: string }) { + return ( + + + {label} + + ); +} + +export function RiskLevelBadge({ level }: { level: string }) { + return ; +} + +export function PropertiesSection({ properties }: { properties: [string, unknown][] }) { + if (properties.length === 0) return null; + return ( +
+
+ {properties.map(([key, value]) => ( +
+ {formatFieldName(key)} + {String(value)} +
+ ))} +
+
+ ); +} + +export function ControlsSection({ controls }: { controls: Record }) { + const entries = Object.entries(controls); + if (entries.length === 0) return null; + return ( +
+
+ {entries.map(([id, control]) => { + const reqCount = Array.isArray(control.requirements) ? control.requirements.length : 0; + return ( +
+
+ + {id} +
+ {control.description && ( +

{control.description}

+ )} + {reqCount > 0 && ( +

+ {reqCount} requirement{reqCount !== 1 ? 's' : ''} +

+ )} +
+ ); + })} +
+
+ ); +} + +export function RisksSection({ risks, riskLevel }: { risks: (string | RiskItem)[]; riskLevel?: string }) { + if (risks.length === 0) return null; + return ( +
+
+ {risks.map((risk, idx) => ( +
+ + + {typeof risk === 'string' ? risk : (risk.name || risk.id || JSON.stringify(risk))} + +
+ ))} +
+
+ ); +} + +export function MitigationsSection({ mitigations }: { mitigations: (string | MitigationItem)[] }) { + if (mitigations.length === 0) return null; + return ( +
+
+ {mitigations.map((mitigation, idx) => ( +
+ + + {typeof mitigation === 'string' ? mitigation : (mitigation.name || mitigation.id || JSON.stringify(mitigation))} + +
+ ))} +
+
+ ); +} + +export function InterfacesSection({ interfaces }: { interfaces: Record[] }) { + if (interfaces.length === 0) return null; + return ( +
+
+ {interfaces.map((iface, idx) => { + const ifaceId = (iface['unique-id'] as string) || `interface-${idx}`; + const otherFields = Object.entries(iface).filter(([k]) => k !== 'unique-id'); + return ( +
+

{ifaceId}

+ {otherFields.map(([k, v]) => ( +
+ {formatFieldName(k)} + {String(v)} +
+ ))} +
+ ); + })} +
+
+ ); +} + +export function ConnectionDiagram({ nodes }: { nodes: [string, string] }) { + return ( +
+ {nodes[0]} +
+
+ +
+ {nodes[1]} +
+ ); +} + +export function NodeList({ label, nodes }: { label: string; nodes: string[] }) { + return ( +
+ {label} +
+ {nodes.map(node => ( + {node} + ))} +
+
+ ); +} diff --git a/calm-hub-ui/src/visualizer/components/sidebar/sidebar-utils.test.ts b/calm-hub-ui/src/visualizer/components/sidebar/sidebar-utils.test.ts new file mode 100644 index 000000000..5b757fd63 --- /dev/null +++ b/calm-hub-ui/src/visualizer/components/sidebar/sidebar-utils.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { formatFieldName, getNodeIcon, extractAigf, getExtraProperties } from './sidebar-utils.js'; +import { User, Globe, Box, Cog, Database, Network, Users, Globe2, FileText } from 'lucide-react'; + +describe('formatFieldName', () => { + it('capitalises each word separated by hyphens', () => { + expect(formatFieldName('risk-level')).toBe('Risk Level'); + }); + + it('handles single word', () => { + expect(formatFieldName('name')).toBe('Name'); + }); + + it('handles multiple hyphens', () => { + expect(formatFieldName('some-long-field-name')).toBe('Some Long Field Name'); + }); +}); + +describe('getNodeIcon', () => { + it.each([ + ['actor', User], + ['ecosystem', Globe], + ['system', Box], + ['service', Cog], + ['database', Database], + ['datastore', Database], + ['data-store', Database], + ['network', Network], + ['ldap', Users], + ['webclient', Globe2], + ['data-asset', FileText], + ['interface', Network], + ['external-service', Globe2], + ])('returns correct icon for %s', (nodeType, expectedIcon) => { + expect(getNodeIcon(nodeType)).toBe(expectedIcon); + }); + + it('is case-insensitive', () => { + expect(getNodeIcon('Actor')).toBe(User); + expect(getNodeIcon('DATABASE')).toBe(Database); + }); + + it('returns Box as default for unknown types', () => { + expect(getNodeIcon('unknown-type')).toBe(Box); + }); +}); + +describe('extractAigf', () => { + it('returns undefined for null metadata', () => { + expect(extractAigf(null)).toBeUndefined(); + }); + + it('returns undefined for non-object metadata', () => { + expect(extractAigf('string')).toBeUndefined(); + }); + + it('returns undefined for array metadata', () => { + expect(extractAigf([1, 2])).toBeUndefined(); + }); + + it('returns undefined when aigf key is missing', () => { + expect(extractAigf({ other: 'data' })).toBeUndefined(); + }); + + it('extracts aigf data from metadata', () => { + const aigf = { 'risk-level': 'high', risks: ['risk-1'] }; + expect(extractAigf({ aigf })).toEqual(aigf); + }); +}); + +describe('getExtraProperties', () => { + it('filters out known fields', () => { + const data = { 'unique-id': '1', name: 'test', extra: 'value' }; + const known = new Set(['unique-id', 'name']); + expect(getExtraProperties(data, known)).toEqual([['extra', 'value']]); + }); + + it('returns empty array when all fields are known', () => { + const data = { a: 1, b: 2 }; + const known = new Set(['a', 'b']); + expect(getExtraProperties(data, known)).toEqual([]); + }); + + it('returns all entries when no fields are known', () => { + const data = { x: 1, y: 2 }; + expect(getExtraProperties(data, new Set())).toEqual([['x', 1], ['y', 2]]); + }); +}); diff --git a/calm-hub-ui/src/visualizer/components/sidebar/sidebar-utils.ts b/calm-hub-ui/src/visualizer/components/sidebar/sidebar-utils.ts new file mode 100644 index 000000000..99aef4148 --- /dev/null +++ b/calm-hub-ui/src/visualizer/components/sidebar/sidebar-utils.ts @@ -0,0 +1,43 @@ +import { + User, Globe, Box, Cog, Database, Network, Users, Globe2, FileText, + type LucideIcon, +} from 'lucide-react'; + +export interface AigfData { + 'risk-level'?: string; + risks?: (string | import('../../contracts/contracts.js').RiskItem)[]; + mitigations?: (string | import('../../contracts/contracts.js').MitigationItem)[]; +} + +export function formatFieldName(field: string): string { + return field.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); +} + +const NODE_ICON_MAP: Record = { + actor: User, + ecosystem: Globe, + system: Box, + service: Cog, + database: Database, + datastore: Database, + 'data-store': Database, + network: Network, + ldap: Users, + webclient: Globe2, + 'data-asset': FileText, + interface: Network, + 'external-service': Globe2, +}; + +export function getNodeIcon(nodeType: string): LucideIcon { + return NODE_ICON_MAP[nodeType.toLowerCase()] || Box; +} + +export function extractAigf(metadata: unknown): AigfData | undefined { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return undefined; + return (metadata as Record).aigf as AigfData | undefined; +} + +export function getExtraProperties(data: Record, knownFields: Set): [string, unknown][] { + return Object.entries(data).filter(([key]) => !knownFields.has(key)); +}