Skip to content
Merged
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
3 changes: 2 additions & 1 deletion calm-widgets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ Renders a system architecture as a Mermaid flowchart with optional containers (s
| `highlight-nodes` | string (CSV) | — | Nodes to visually highlight. |
| `render-node-type-shapes` | boolean | `false` | If `true`, render nodes with different Mermaid shapes based on their `node-type`. Supports built-in CALM types: `actor`, `database`, `webclient`, `service`, `system`, `messagebus`. |
| `node-type-map` | stringified JSON map | — | Custom mapping of node types to built-in shapes, e.g. `{"cache": "database", "queue": "messagebus"}`. Only used when `render-node-type-shapes` is `true`. |
| `render-interfaces` | boolean | `false` | If `true`, render each node’s `interfaces` as small interface boxes connected by dotted lines. |
| `render-interfaces` | `false \| true \| ‘related’` | `false` | Controls interface rendering. `true` renders all interfaces as small boxes connected by dotted lines. `related` renders only interfaces that appear in the active (filtered) relationships — hiding all others, including unrelated interfaces on seed nodes. Useful with `focus-interfaces` + `edges=connected` to show only the relevant interface endpoints. |
| `include-containers` | `'none' \| 'parents' \| 'all'` | `'all'` | Which containers (systems) to draw. |
| `include-children` | `'none' \| 'direct' \| 'all'` | `'all'` | When focusing container nodes, include their direct/all descendants. |
| `edges` | `'connected' \| 'seeded' \| 'all' \| 'none'` | `'connected'` | For non-flow views, expand visible set with directly connected neighbors. When flows are focused, only flow edges are shown. |
Expand Down Expand Up @@ -329,6 +329,7 @@ For more examples, see the test fixtures:
- [Basic structures](./test-fixtures/block-architecture-widget/basic-structures/)
- [Enterprise trading system](./test-fixtures/block-architecture-widget/enterprise-bank-trading/)
- [Interface variations](./test-fixtures/block-architecture-widget/interface-variations/)
- [Render related interfaces only](./test-fixtures/block-architecture-widget/render-interfaces-related/)
- [Focus flows](./test-fixtures/block-architecture-widget/focus-flows/)
- [Domain interaction](./test-fixtures/block-architecture-widget/domain-interaction/)
- [Node type shapes](./test-fixtures/block-architecture-widget/node-type-shapes/)
Expand Down
7 changes: 7 additions & 0 deletions calm-widgets/src/widgets.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,13 @@ describe('Widgets E2E - Handlebars Integration', () => {
expectToBeSameIgnoringLineEndings(result, expected);
});

it('renders only related interfaces when render-interfaces=related', () => {
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'render-interfaces-related');
const compiled = handlebars.compile(template);
const result = compiled(context);
expectToBeSameIgnoringLineEndings(result, expected);
});

it('filters architecture based on control matching by ID and properties', () => {
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'focus-controls');
const compiled = handlebars.compile(template);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,29 @@ describe('container-builder', () => {
expect(attachments).toHaveLength(0);
});

it('passes activeInterfaceIds to the node factory when provided', () => {
const capturedActiveIds: Array<Set<string> | undefined> = [];
const fakeNodeFactory: VMNodeFactory = {
createLeafNode: (node: CalmNodeCanonicalModel, _renderInterfaces: boolean, activeInterfaceIds?: Set<string>) => {
capturedActiveIds.push(activeInterfaceIds);
return { node: { id: node['unique-id'], label: node.name || node['unique-id'] }, attachments: [] };
}
};
VMFactoryProvider.setFactories(fakeNodeFactory, undefined);

const nodes: CalmNodeCanonicalModel[] = [
{ 'unique-id': 'svcA', name: 'A', 'node-type': 'service', description: '' },
{ 'unique-id': 'svcB', name: 'B', 'node-type': 'service', description: '' },
];
const activeInterfaceIds = new Set<string>(['payment-api', 'fraud-check-api']);

buildContainerForest(nodes, new Map(), new Set(), true, activeInterfaceIds);

expect(capturedActiveIds).toHaveLength(2);
expect(capturedActiveIds[0]).toBe(activeInterfaceIds);
expect(capturedActiveIds[1]).toBe(activeInterfaceIds);
});

it('creates container → container nesting when both are rendered', () => {
const fakeNodeFactory: VMNodeFactory = {
createLeafNode: (node: CalmNodeCanonicalModel) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export function buildContainerForest(
nodes: CalmNodeCanonicalModel[],
parentOf: Map<string, string>,
containerIdsToRender: Set<string>,
renderInterfaces: boolean
renderInterfaces: boolean,
activeInterfaceIds?: Set<string>
): { containers: VMContainer[]; attachments: VMAttach[]; looseNodes: VMLeafNode[] } {
const byId = new Map(nodes.map(n => [n['unique-id'], n] as const));
const attachments: VMAttach[] = [];
Expand All @@ -54,12 +55,12 @@ export function buildContainerForest(
if (pid && containerIdsToRender.has(pid)) {
const cont = vmContainers.get(pid);
if (cont) {
const { node: leafNode, attachments: nodeAttachments } = nodeFactory.createLeafNode(n, renderInterfaces);
const { node: leafNode, attachments: nodeAttachments } = nodeFactory.createLeafNode(n, renderInterfaces, activeInterfaceIds);
cont.nodes.push(leafNode);
attachments.push(...nodeAttachments);
}
} else {
const { node: leafNode, attachments: nodeAttachments } = nodeFactory.createLeafNode(n, renderInterfaces);
const { node: leafNode, attachments: nodeAttachments } = nodeFactory.createLeafNode(n, renderInterfaces, activeInterfaceIds);
looseNodes.push(leafNode);
attachments.push(...nodeAttachments);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,34 @@ describe('StandardVMNodeFactory', () => {
expect(ids).toEqual(['n4__iface__api', 'n4__iface__events']);
});

it('only renders interfaces present in activeInterfaceIds when provided', () => {
const f = new StandardVMNodeFactory();
const node: Partial<CalmNodeCanonicalModel> = {
'unique-id': 'n6',
interfaces: [
{ 'unique-id': 'payment-api', name: 'Payment API' },
{ 'unique-id': 'audit-log-iface', name: 'Audit Log' },
],
};
const activeInterfaceIds = new Set<string>(['payment-api']);
const res = f.createLeafNode(node as CalmNodeCanonicalModel, true, activeInterfaceIds);
expect(res.node.interfaces).toHaveLength(1);
expect(res.node.interfaces![0].label).toContain('Payment API');
expect(res.attachments).toHaveLength(1);
expect(res.attachments[0].to).toBe('n6__iface__payment-api');
});

it('renders no interfaces when activeInterfaceIds is empty', () => {
const f = new StandardVMNodeFactory();
const node: Partial<CalmNodeCanonicalModel> = {
'unique-id': 'n7',
interfaces: [{ 'unique-id': 'some-iface', name: 'Some' }],
};
const res = f.createLeafNode(node as CalmNodeCanonicalModel, true, new Set<string>());
expect(res.node.interfaces).toBeUndefined();
expect(res.attachments).toHaveLength(0);
});

it('ignores interfaces array if it is empty', () => {
const f = new StandardVMNodeFactory();
const node: Partial<CalmNodeCanonicalModel> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const labelFor = (n?: WithOptionalLabel, id?: string) =>
* Standard implementation of VMNodeFactory for creating leaf nodes with interface attachments
*/
export class StandardVMNodeFactory implements VMNodeFactory {
createLeafNode(node: CalmNodeCanonicalModel, renderInterfaces: boolean): { node: VMLeafNode; attachments: VMAttach[] } {
createLeafNode(node: CalmNodeCanonicalModel, renderInterfaces: boolean, activeInterfaceIds?: Set<string>): { node: VMLeafNode; attachments: VMAttach[] } {
const attachments: VMAttach[] = [];
const leaf: VMLeafNode = {
id: node['unique-id'],
Expand All @@ -23,11 +23,16 @@ export class StandardVMNodeFactory implements VMNodeFactory {
};

if (renderInterfaces && Array.isArray(node.interfaces) && node.interfaces.length > 0) {
leaf.interfaces = node.interfaces.map(itf => {
const iid = ifaceId(node['unique-id'], itf['unique-id']);
attachments.push({ from: node['unique-id'], to: iid });
return { id: iid, label: `◻ ${itf.name || itf['unique-id']}` };
});
const visibleInterfaces = activeInterfaceIds
? node.interfaces.filter(itf => activeInterfaceIds.has(itf['unique-id']))
: node.interfaces;
if (visibleInterfaces.length > 0) {
leaf.interfaces = visibleInterfaces.map(itf => {
const iid = ifaceId(node['unique-id'], itf['unique-id']);
attachments.push({ from: node['unique-id'], to: iid });
return { id: iid, label: `◻ ${itf.name || itf['unique-id']}` };
});
}
}

return { node: leaf, attachments };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { VMLeafNode, VMEdge, VMAttach, EdgeLabels } from '../../types';
* Factory interface for creating view model nodes
*/
export interface VMNodeFactory {
createLeafNode(node: CalmNodeCanonicalModel, renderInterfaces: boolean): { node: VMLeafNode; attachments: VMAttach[] };
createLeafNode(node: CalmNodeCanonicalModel, renderInterfaces: boolean, activeInterfaceIds?: Set<string>): { node: VMLeafNode; attachments: VMAttach[] };
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ describe('options-parser', () => {
expect(parseOptions({ 'render-interfaces': true }).renderInterfaces).toBe(true);
});

it('render-interfaces: accepts related as a third mode', () => {
expect(parseOptions({ 'render-interfaces': 'related' }).renderInterfaces).toBe('related');
});

it('passes through link-prefix', () => {
const out = parseOptions({ 'link-prefix': '/docs/' });
expect(out.linkPrefix).toBe('/docs/');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export function parseOptions(raw?: BlockArchOptions): NormalizedOptions {

if (raw['highlight-nodes']) o.highlightNodes = csv(raw['highlight-nodes']);
if (raw['node-types']) o.nodeTypes = csv(raw['node-types']);
if (raw['render-interfaces']) o.renderInterfaces = true;
if (raw['render-interfaces'] === 'related') o.renderInterfaces = 'related';
else if (raw['render-interfaces']) o.renderInterfaces = true;
if (raw['render-node-type-shapes']) o.renderNodeTypeShapes = true;
if (raw['collapse-relationships']) o.collapseRelationships = true;

Expand Down
29 changes: 26 additions & 3 deletions calm-widgets/src/widgets/block-architecture/core/vm-builder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CalmCoreCanonicalModel, CalmNodeCanonicalModel } from '@finos/calm-models/canonical';
import { CalmCoreCanonicalModel, CalmNodeCanonicalModel, CalmRelationshipCanonicalModel, toKindView } from '@finos/calm-models/canonical';
import { prettyLabel } from './utils';
import { BlockArchVM, NormalizedOptions, VMContainer, VMLeafNode, VMAttach, VMEdge } from '../types';
import { buildParentHierarchy, ParentHierarchyResult } from './relationship-analyzer';
Expand Down Expand Up @@ -80,11 +80,18 @@ export class BlockArchVMBuilder {
}
}

const shouldRenderInterfaces = this.options.renderInterfaces !== false;
const activeInterfaceIds: Set<string> | undefined =
this.options.renderInterfaces === 'related'
? collectActiveInterfaceIds(this.visibilityResult.filteredRels)
: undefined;

const { containers: initialContainers, attachments, looseNodes: initialLooseNodes } = buildContainerForest(
filteredNodes,
this.parentHierarchyResult.parentOf,
this.visibilityResult.containerIds,
this.options.renderInterfaces
shouldRenderInterfaces,
activeInterfaceIds
);

let looseNodes = initialLooseNodes;
Expand Down Expand Up @@ -114,7 +121,7 @@ export class BlockArchVMBuilder {
? []
: buildEdges(
this.visibilityResult.filteredRels,
this.options.renderInterfaces,
this.options.renderInterfaces !== false,
this.options.edgeLabels,
this.options.collapseRelationships,
ifaceNames,
Expand Down Expand Up @@ -194,6 +201,22 @@ export class BlockArchVMBuilder {
}
}

/**
* Collects all interface unique-ids referenced as source or destination in connects relationships.
* Collects the interface IDs referenced in active relationships; used to restrict interface rendering when render-interfaces=related.
*/
function collectActiveInterfaceIds(rels: CalmRelationshipCanonicalModel[]): Set<string> {
const ids = new Set<string>();
for (const rel of rels) {
const kind = toKindView(rel['relationship-type']);
if (kind.kind === 'connects') {
(kind.source.interfaces ?? []).forEach(id => ids.add(id));
(kind.destination.interfaces ?? []).forEach(id => ids.add(id));
}
Comment thread
markscott-ms marked this conversation as resolved.
}
return ids;
}

/**
* Factory function that maintains the original API while using the builder internally
*/
Expand Down
4 changes: 2 additions & 2 deletions calm-widgets/src/widgets/block-architecture/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export interface BlockArchOptions {
['focus-controls']?: string;
['focus-interfaces']?: string;
['highlight-nodes']?: string;
['render-interfaces']?: boolean;
['render-interfaces']?: boolean | 'related';
['render-node-type-shapes']?: boolean;
['include-containers']?: IncludeContainers;
['include-children']?: IncludeChildren;
Expand Down Expand Up @@ -105,7 +105,7 @@ export type NormalizedOptions = {
edges: Edges;
nodeTypes?: string[];
direction: Direction;
renderInterfaces: boolean;
renderInterfaces: boolean | 'related';
renderNodeTypeShapes: boolean;
edgeLabels: EdgeLabels;
collapseRelationships: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
{
"nodes": [
{
"unique-id": "payment-service",
"node-type": "service",
"name": "Payment Service",
"description": "Processes customer payments",
"interfaces": [
{
"unique-id": "payment-api",
"protocol": "HTTPS",
"port": 8080,
"path": "/api/v1/payments"
},
{
"unique-id": "audit-log-iface",
"protocol": "AMQP",
"port": 5672,
"path": "/audit"
}
]
},
{
"unique-id": "fraud-service",
"node-type": "service",
"name": "Fraud Detection Service",
"description": "Analyses transactions for fraudulent activity",
"interfaces": [
{
"unique-id": "fraud-check-api",
"protocol": "HTTPS",
"port": 9090,
"path": "/api/v1/check"
},
{
"unique-id": "risk-model-iface",
"protocol": "gRPC",
"port": 50051,
"path": "/risk"
},
{
"unique-id": "score-db-iface",
"protocol": "JDBC",
"port": 5432,
"path": "/scores"
}
]
},
{
"unique-id": "notification-service",
"node-type": "service",
"name": "Notification Service",
"description": "Sends alerts and confirmations to customers",
"interfaces": [
{
"unique-id": "email-iface",
"protocol": "SMTP",
"port": 587,
"path": "/email"
},
{
"unique-id": "sms-iface",
"protocol": "HTTPS",
"port": 443,
"path": "/sms"
}
]
}
],
"relationships": [
{
"unique-id": "payment-to-fraud",
"description": "Payment Service calls Fraud Detection before authorising a transaction",
"relationship-type": {
"connects": {
"source": {
"node": "payment-service",
"interfaces": ["payment-api"]
},
"destination": {
"node": "fraud-service",
"interfaces": ["fraud-check-api"]
}
}
}
},
{
"unique-id": "payment-to-notification",
"description": "Payment Service triggers notifications on transaction events",
"relationship-type": {
"connects": {
"source": {
"node": "payment-service",
"interfaces": ["payment-api"]
},
"destination": {
"node": "notification-service",
"interfaces": ["email-iface"]
}
}
}
}
]
}
Loading
Loading