Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
113 changes: 113 additions & 0 deletions calm-hub-ui/src/visualizer/components/sidebar/NodeDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<NodeDetails data={baseNode} />);

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(<NodeDetails data={baseNode} />);

expect(screen.getByText('service')).toBeInTheDocument();
});

it('renders interfaces when present', () => {
const nodeWithInterfaces: CalmNodeSchema = {
...baseNode,
interfaces: [
{ 'unique-id': 'iface-1', host: 'localhost', port: 8080 },
],
};
render(<NodeDetails data={nodeWithInterfaces} />);

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(<NodeDetails data={nodeWithControls} />);

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(<NodeDetails data={nodeWithRisk} />);

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(<NodeDetails data={nodeWithMitigations} />);

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(<NodeDetails data={nodeWithDetails} />);

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(<NodeDetails data={nodeWithExtra} />);

expect(screen.getByText('Properties')).toBeInTheDocument();
expect(screen.getByText('Custom Field')).toBeInTheDocument();
expect(screen.getByText('custom-value')).toBeInTheDocument();
});
});
60 changes: 60 additions & 0 deletions calm-hub-ui/src/visualizer/components/sidebar/NodeDetails.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ControlItem> = data.controls || {};
const interfaces = (data.interfaces || []) as Record<string, unknown>[];
const detailedArch = data.details?.['detailed-architecture'];
const extraProps = getExtraProperties(data as unknown as Record<string, unknown>, KNOWN_FIELDS);

return (
<div className="flex flex-col gap-3 p-4 overflow-auto">
<div>
<div className="flex items-center gap-2 mb-1">
<Badge icon={Icon} label={nodeType} color={getNodeTypeColor(nodeType)} />
{riskLevel && <RiskLevelBadge level={riskLevel} />}
</div>
<h3 className="text-lg font-bold text-base-content mt-2">{data.name}</h3>
<p className="text-xs text-base-content/40 font-mono">{data['unique-id']}</p>
</div>

{data.description && (
<p className="text-sm text-base-content/70 leading-relaxed">{data.description}</p>
)}

{detailedArch && (
<div className="flex items-center gap-2 text-xs text-accent font-medium bg-accent/10 rounded-lg px-3 py-2">
<ZoomIn className="w-3.5 h-3.5" />
Has detailed architecture
</div>
)}

<PropertiesSection properties={extraProps} />
<InterfacesSection interfaces={interfaces} />
<ControlsSection controls={controls} />
<RisksSection risks={risks} riskLevel={riskLevel} />
<MitigationsSection mitigations={mitigations} />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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(<RelationshipDetails data={connectsRelationship} />);

expect(screen.getByText('rel-001')).toBeInTheDocument();
expect(screen.getByText('Service to DB')).toBeInTheDocument();
});

it('renders connects type badge and connection diagram', () => {
render(<RelationshipDetails data={connectsRelationship} />);

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(<RelationshipDetails data={withProtocol} />);

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(<RelationshipDetails data={interactsRel} />);

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(<RelationshipDetails data={deployedInRel} />);

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(<RelationshipDetails data={composedOfRel} />);

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(<RelationshipDetails data={withRisks} />);

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(<RelationshipDetails data={withControls} />);

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(<RelationshipDetails data={withExtra} />);

expect(screen.getByText('Properties')).toBeInTheDocument();
expect(screen.getByText('Custom Prop')).toBeInTheDocument();
expect(screen.getByText('custom-val')).toBeInTheDocument();
});
});
Loading
Loading