+ {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(
);
+
+ 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));
+}