terraformgraph 1.0.2__py3-none-any.whl → 1.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- terraformgraph/__init__.py +1 -1
- terraformgraph/__main__.py +1 -1
- terraformgraph/aggregator.py +941 -300
- terraformgraph/config/aggregation_rules.yaml +276 -1
- terraformgraph/config_loader.py +9 -8
- terraformgraph/icons.py +504 -521
- terraformgraph/layout.py +580 -116
- terraformgraph/main.py +251 -48
- terraformgraph/parser.py +328 -86
- terraformgraph/renderer.py +1887 -170
- terraformgraph/terraform_tools.py +355 -0
- terraformgraph/variable_resolver.py +180 -0
- terraformgraph-1.0.4.dist-info/METADATA +386 -0
- terraformgraph-1.0.4.dist-info/RECORD +19 -0
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/licenses/LICENSE +1 -1
- terraformgraph-1.0.2.dist-info/METADATA +0 -163
- terraformgraph-1.0.2.dist-info/RECORD +0 -17
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/WHEEL +0 -0
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/entry_points.txt +0 -0
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/top_level.txt +0 -0
terraformgraph/layout.py
CHANGED
|
@@ -5,14 +5,18 @@ Computes positions for logical services in the diagram.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
|
-
from typing import Dict, List, Optional, Tuple
|
|
8
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple
|
|
9
9
|
|
|
10
|
-
from .aggregator import AggregatedResult, LogicalService
|
|
10
|
+
from .aggregator import AggregatedResult, LogicalConnection, LogicalService
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .aggregator import AvailabilityZone, VPCStructure
|
|
11
14
|
|
|
12
15
|
|
|
13
16
|
@dataclass
|
|
14
17
|
class Position:
|
|
15
18
|
"""Position and size of an element."""
|
|
19
|
+
|
|
16
20
|
x: float
|
|
17
21
|
y: float
|
|
18
22
|
width: float
|
|
@@ -22,6 +26,7 @@ class Position:
|
|
|
22
26
|
@dataclass
|
|
23
27
|
class ServiceGroup:
|
|
24
28
|
"""A visual group of services."""
|
|
29
|
+
|
|
25
30
|
group_type: str # 'aws_cloud', 'vpc', 'global'
|
|
26
31
|
name: str
|
|
27
32
|
services: List[LogicalService] = field(default_factory=list)
|
|
@@ -30,7 +35,13 @@ class ServiceGroup:
|
|
|
30
35
|
|
|
31
36
|
@dataclass
|
|
32
37
|
class LayoutConfig:
|
|
33
|
-
"""Configuration for layout engine.
|
|
38
|
+
"""Configuration for layout engine.
|
|
39
|
+
|
|
40
|
+
Supports responsive sizing based on content. Base values are scaled
|
|
41
|
+
according to the number of services and VPC complexity.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# Base dimensions (will be scaled)
|
|
34
45
|
canvas_width: int = 1400
|
|
35
46
|
canvas_height: int = 900
|
|
36
47
|
padding: int = 30
|
|
@@ -41,17 +52,86 @@ class LayoutConfig:
|
|
|
41
52
|
row_spacing: int = 100
|
|
42
53
|
column_spacing: int = 130
|
|
43
54
|
|
|
55
|
+
# Responsive scaling factors
|
|
56
|
+
min_scale: float = 0.6
|
|
57
|
+
max_scale: float = 1.5
|
|
58
|
+
|
|
59
|
+
def scaled(self, scale: float) -> "LayoutConfig":
|
|
60
|
+
"""Create a new config with scaled dimensions."""
|
|
61
|
+
clamped_scale = max(self.min_scale, min(self.max_scale, scale))
|
|
62
|
+
return LayoutConfig(
|
|
63
|
+
canvas_width=int(self.canvas_width * clamped_scale),
|
|
64
|
+
canvas_height=int(self.canvas_height * clamped_scale),
|
|
65
|
+
padding=int(self.padding * clamped_scale),
|
|
66
|
+
icon_size=int(self.icon_size * clamped_scale),
|
|
67
|
+
icon_spacing=int(self.icon_spacing * clamped_scale),
|
|
68
|
+
group_padding=int(self.group_padding * clamped_scale),
|
|
69
|
+
label_height=int(self.label_height * clamped_scale),
|
|
70
|
+
row_spacing=int(self.row_spacing * clamped_scale),
|
|
71
|
+
column_spacing=int(self.column_spacing * clamped_scale),
|
|
72
|
+
min_scale=self.min_scale,
|
|
73
|
+
max_scale=self.max_scale,
|
|
74
|
+
)
|
|
75
|
+
|
|
44
76
|
|
|
45
77
|
class LayoutEngine:
|
|
46
78
|
"""Computes positions for diagram elements."""
|
|
47
79
|
|
|
48
80
|
def __init__(self, config: Optional[LayoutConfig] = None):
|
|
49
|
-
self.
|
|
81
|
+
self.base_config = config or LayoutConfig()
|
|
82
|
+
self.config = self.base_config
|
|
83
|
+
|
|
84
|
+
def _compute_responsive_scale(self, aggregated: AggregatedResult) -> float:
|
|
85
|
+
"""Compute scale factor based on content complexity.
|
|
86
|
+
|
|
87
|
+
Factors considered:
|
|
88
|
+
- Number of services
|
|
89
|
+
- Number of AZs
|
|
90
|
+
- Number of subnets per AZ
|
|
91
|
+
- Number of VPC endpoints
|
|
92
|
+
"""
|
|
93
|
+
num_services = len(aggregated.services)
|
|
94
|
+
|
|
95
|
+
# Count VPC complexity
|
|
96
|
+
num_azs = 0
|
|
97
|
+
max_subnets_per_az = 0
|
|
98
|
+
num_endpoints = 0
|
|
99
|
+
|
|
100
|
+
if aggregated.vpc_structure:
|
|
101
|
+
num_azs = len(aggregated.vpc_structure.availability_zones)
|
|
102
|
+
for az in aggregated.vpc_structure.availability_zones:
|
|
103
|
+
max_subnets_per_az = max(max_subnets_per_az, len(az.subnets))
|
|
104
|
+
num_endpoints = (
|
|
105
|
+
len(aggregated.vpc_structure.endpoints) if aggregated.vpc_structure.endpoints else 0
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Base scale on service count (optimal for 8-12 services)
|
|
109
|
+
service_scale = 1.0
|
|
110
|
+
if num_services <= 4:
|
|
111
|
+
service_scale = 0.8
|
|
112
|
+
elif num_services <= 8:
|
|
113
|
+
service_scale = 0.9
|
|
114
|
+
elif num_services <= 15:
|
|
115
|
+
service_scale = 1.0
|
|
116
|
+
elif num_services <= 25:
|
|
117
|
+
service_scale = 1.2
|
|
118
|
+
else:
|
|
119
|
+
service_scale = 1.4
|
|
120
|
+
|
|
121
|
+
# Adjust for VPC complexity
|
|
122
|
+
vpc_scale = 1.0
|
|
123
|
+
if num_azs >= 3:
|
|
124
|
+
vpc_scale *= 1.1
|
|
125
|
+
if max_subnets_per_az >= 4:
|
|
126
|
+
vpc_scale *= 1.15
|
|
127
|
+
if num_endpoints >= 4:
|
|
128
|
+
vpc_scale *= 1.05
|
|
129
|
+
|
|
130
|
+
return service_scale * vpc_scale
|
|
50
131
|
|
|
51
132
|
def compute_layout(
|
|
52
|
-
self,
|
|
53
|
-
|
|
54
|
-
) -> Tuple[Dict[str, Position], List[ServiceGroup]]:
|
|
133
|
+
self, aggregated: AggregatedResult
|
|
134
|
+
) -> Tuple[Dict[str, Position], List[ServiceGroup], int]:
|
|
55
135
|
"""
|
|
56
136
|
Compute positions for all logical services.
|
|
57
137
|
|
|
@@ -59,26 +139,35 @@ class LayoutEngine:
|
|
|
59
139
|
- Top row: Internet-facing services (CloudFront, WAF, Route53, ACM)
|
|
60
140
|
- Middle: VPC box with ALB, ECS, EC2
|
|
61
141
|
- Bottom rows: Global services grouped by function
|
|
142
|
+
|
|
143
|
+
Dimensions are computed responsively based on content.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Tuple of (positions dict, groups list, actual_height)
|
|
62
147
|
"""
|
|
148
|
+
# Compute responsive scale and apply to config
|
|
149
|
+
scale = self._compute_responsive_scale(aggregated)
|
|
150
|
+
self.config = self.base_config.scaled(scale)
|
|
151
|
+
|
|
63
152
|
positions: Dict[str, Position] = {}
|
|
64
153
|
groups: List[ServiceGroup] = []
|
|
65
154
|
|
|
66
|
-
#
|
|
155
|
+
# Placeholder for AWS Cloud - will be updated at the end
|
|
67
156
|
aws_cloud = ServiceGroup(
|
|
68
|
-
group_type=
|
|
69
|
-
name=
|
|
157
|
+
group_type="aws_cloud",
|
|
158
|
+
name="AWS Cloud",
|
|
70
159
|
position=Position(
|
|
71
160
|
x=self.config.padding,
|
|
72
161
|
y=self.config.padding,
|
|
73
162
|
width=self.config.canvas_width - 2 * self.config.padding,
|
|
74
|
-
height=
|
|
75
|
-
)
|
|
163
|
+
height=0, # Will be calculated at the end
|
|
164
|
+
),
|
|
76
165
|
)
|
|
77
166
|
groups.append(aws_cloud)
|
|
78
167
|
|
|
79
168
|
# Categorize services for layout
|
|
80
169
|
edge_services = [] # CloudFront, WAF, Route53, ACM, Cognito
|
|
81
|
-
vpc_services = []
|
|
170
|
+
vpc_services = [] # ALB, ECS, EC2, Security
|
|
82
171
|
data_services = [] # S3, DynamoDB, MongoDB
|
|
83
172
|
messaging_services = [] # SQS, SNS, EventBridge
|
|
84
173
|
security_services = [] # KMS, Secrets, IAM
|
|
@@ -86,15 +175,24 @@ class LayoutEngine:
|
|
|
86
175
|
|
|
87
176
|
for service in aggregated.services:
|
|
88
177
|
st = service.service_type
|
|
89
|
-
if st in (
|
|
178
|
+
if st in ("cloudfront", "waf", "route53", "acm", "cognito"):
|
|
90
179
|
edge_services.append(service)
|
|
91
|
-
elif st in (
|
|
180
|
+
elif st in (
|
|
181
|
+
"alb",
|
|
182
|
+
"ecs",
|
|
183
|
+
"ec2",
|
|
184
|
+
"security_groups",
|
|
185
|
+
"security",
|
|
186
|
+
"vpc",
|
|
187
|
+
"internet_gateway",
|
|
188
|
+
"nat_gateway",
|
|
189
|
+
):
|
|
92
190
|
vpc_services.append(service)
|
|
93
|
-
elif st in (
|
|
191
|
+
elif st in ("s3", "dynamodb", "mongodb"):
|
|
94
192
|
data_services.append(service)
|
|
95
|
-
elif st in (
|
|
193
|
+
elif st in ("sqs", "sns", "eventbridge"):
|
|
96
194
|
messaging_services.append(service)
|
|
97
|
-
elif st in (
|
|
195
|
+
elif st in ("kms", "secrets", "secrets_manager", "iam"):
|
|
98
196
|
security_services.append(service)
|
|
99
197
|
else:
|
|
100
198
|
other_services.append(service)
|
|
@@ -106,90 +204,241 @@ class LayoutEngine:
|
|
|
106
204
|
x = self._center_row_start(len(edge_services))
|
|
107
205
|
for service in edge_services:
|
|
108
206
|
positions[service.id] = Position(
|
|
109
|
-
x=x, y=y_offset,
|
|
110
|
-
width=self.config.icon_size,
|
|
111
|
-
height=self.config.icon_size
|
|
207
|
+
x=x, y=y_offset, width=self.config.icon_size, height=self.config.icon_size
|
|
112
208
|
)
|
|
113
209
|
x += self.config.column_spacing
|
|
114
210
|
|
|
115
211
|
y_offset += self.config.row_spacing + 20
|
|
116
212
|
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
vpc_width = self.config.canvas_width - 2 * (self.config.padding + 50)
|
|
120
|
-
vpc_height = 180
|
|
213
|
+
# Track VPC bottom for non-VPC services layout
|
|
214
|
+
vpc_bottom_y = y_offset
|
|
121
215
|
|
|
216
|
+
# Row 2: VPC box with internal services (only if VPC resources exist)
|
|
122
217
|
# Filter out 'vpc' itself from vpc_services for positioning
|
|
123
|
-
vpc_internal = [s for s in vpc_services if s.service_type !=
|
|
218
|
+
vpc_internal = [s for s in vpc_services if s.service_type != "vpc"]
|
|
124
219
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
name='VPC',
|
|
128
|
-
services=vpc_internal,
|
|
129
|
-
position=Position(x=vpc_x, y=y_offset, width=vpc_width, height=vpc_height)
|
|
130
|
-
)
|
|
131
|
-
groups.append(vpc_group)
|
|
132
|
-
|
|
133
|
-
# Position services inside VPC
|
|
134
|
-
inner_y = y_offset + self.config.group_padding + 30
|
|
135
|
-
if vpc_internal:
|
|
136
|
-
x = self._center_row_start(len(vpc_internal), vpc_x + self.config.group_padding,
|
|
137
|
-
vpc_x + vpc_width - self.config.group_padding)
|
|
138
|
-
for service in vpc_internal:
|
|
139
|
-
positions[service.id] = Position(
|
|
140
|
-
x=x, y=inner_y,
|
|
141
|
-
width=self.config.icon_size,
|
|
142
|
-
height=self.config.icon_size
|
|
143
|
-
)
|
|
144
|
-
x += self.config.column_spacing
|
|
220
|
+
# Only create VPC box if there are VPC services OR vpc_structure exists
|
|
221
|
+
has_vpc_content = len(vpc_internal) > 0 or aggregated.vpc_structure is not None
|
|
145
222
|
|
|
146
|
-
|
|
223
|
+
if has_vpc_content:
|
|
224
|
+
vpc_x = self.config.padding + 50
|
|
225
|
+
vpc_width = self.config.canvas_width - 2 * (self.config.padding + 50)
|
|
147
226
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
for service in data_services:
|
|
152
|
-
positions[service.id] = Position(
|
|
153
|
-
x=x, y=y_offset,
|
|
154
|
-
width=self.config.icon_size,
|
|
155
|
-
height=self.config.icon_size
|
|
156
|
-
)
|
|
157
|
-
x += self.config.column_spacing
|
|
227
|
+
# Separate services: those with subnet_ids go inside subnets, others in top row
|
|
228
|
+
services_with_subnets = [s for s in vpc_internal if s.subnet_ids]
|
|
229
|
+
services_without_subnets = [s for s in vpc_internal if not s.subnet_ids]
|
|
158
230
|
|
|
159
|
-
|
|
231
|
+
# Use dynamic VPC height if vpc_structure exists
|
|
232
|
+
if aggregated.vpc_structure:
|
|
233
|
+
vpc_height = self._compute_vpc_height(
|
|
234
|
+
aggregated.vpc_structure,
|
|
235
|
+
has_vpc_services=len(services_without_subnets) > 0,
|
|
236
|
+
services_with_subnets=services_with_subnets,
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
vpc_height = 180
|
|
160
240
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
241
|
+
vpc_pos = Position(x=vpc_x, y=y_offset, width=vpc_width, height=vpc_height)
|
|
242
|
+
vpc_group = ServiceGroup(
|
|
243
|
+
group_type="vpc", name="VPC", services=vpc_internal, position=vpc_pos
|
|
244
|
+
)
|
|
245
|
+
groups.append(vpc_group)
|
|
246
|
+
|
|
247
|
+
# Position services WITHOUT subnet_ids at the TOP, above AZs
|
|
248
|
+
services_row_y = y_offset + self.config.group_padding + 30
|
|
249
|
+
if services_without_subnets:
|
|
250
|
+
x = self._center_row_start(
|
|
251
|
+
len(services_without_subnets),
|
|
252
|
+
vpc_x + self.config.group_padding,
|
|
253
|
+
vpc_x + vpc_width - self.config.group_padding,
|
|
169
254
|
)
|
|
170
|
-
|
|
255
|
+
for service in services_without_subnets:
|
|
256
|
+
positions[service.id] = Position(
|
|
257
|
+
x=x,
|
|
258
|
+
y=services_row_y,
|
|
259
|
+
width=self.config.icon_size,
|
|
260
|
+
height=self.config.icon_size,
|
|
261
|
+
)
|
|
262
|
+
x += self.config.column_spacing
|
|
263
|
+
|
|
264
|
+
# Layout VPC structure (AZs and endpoints) BELOW services
|
|
265
|
+
if aggregated.vpc_structure:
|
|
266
|
+
# AZs start below the services row
|
|
267
|
+
az_start_y = (
|
|
268
|
+
services_row_y + self.config.icon_size + 50
|
|
269
|
+
if services_without_subnets
|
|
270
|
+
else services_row_y
|
|
271
|
+
)
|
|
272
|
+
self._layout_vpc_structure(
|
|
273
|
+
aggregated.vpc_structure,
|
|
274
|
+
vpc_pos,
|
|
275
|
+
positions,
|
|
276
|
+
groups,
|
|
277
|
+
az_start_y=az_start_y,
|
|
278
|
+
services_with_subnets=services_with_subnets,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
y_offset += vpc_height + 40
|
|
282
|
+
vpc_bottom_y = y_offset
|
|
283
|
+
|
|
284
|
+
# Non-VPC services: use connection-based organic layout
|
|
285
|
+
non_vpc_services = data_services + messaging_services + security_services + other_services
|
|
286
|
+
|
|
287
|
+
if non_vpc_services:
|
|
288
|
+
y_offset = self._layout_by_connections(
|
|
289
|
+
services=non_vpc_services,
|
|
290
|
+
connections=aggregated.connections,
|
|
291
|
+
start_x=self.config.padding + 50,
|
|
292
|
+
start_y=vpc_bottom_y,
|
|
293
|
+
available_width=self.config.canvas_width - 2 * (self.config.padding + 50),
|
|
294
|
+
positions=positions,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Calculate actual height based on final positions
|
|
298
|
+
max_bottom = 0
|
|
299
|
+
for pos in positions.values():
|
|
300
|
+
bottom = pos.y + pos.height + 50 # Add padding for labels
|
|
301
|
+
max_bottom = max(max_bottom, bottom)
|
|
302
|
+
|
|
303
|
+
# Add small bottom margin (just enough for aesthetics)
|
|
304
|
+
bottom_margin = 20
|
|
305
|
+
actual_height = int(max_bottom + bottom_margin)
|
|
171
306
|
|
|
172
|
-
|
|
307
|
+
# Update AWS Cloud box height to fill the canvas (with small margin)
|
|
308
|
+
aws_cloud.position.height = actual_height - self.config.padding - bottom_margin
|
|
309
|
+
|
|
310
|
+
return positions, groups, actual_height
|
|
311
|
+
|
|
312
|
+
def _build_connection_graph(
|
|
313
|
+
self, services: List[LogicalService], connections: List[LogicalConnection]
|
|
314
|
+
) -> Dict[str, Set[str]]:
|
|
315
|
+
"""Build adjacency list of connected service types.
|
|
316
|
+
|
|
317
|
+
Returns a bidirectional graph where each service_type maps to
|
|
318
|
+
the set of service_types it's connected to.
|
|
319
|
+
"""
|
|
320
|
+
# Get service types present
|
|
321
|
+
present_types = {s.service_type for s in services}
|
|
322
|
+
|
|
323
|
+
# Build adjacency list (bidirectional)
|
|
324
|
+
graph: Dict[str, Set[str]] = {t: set() for t in present_types}
|
|
325
|
+
|
|
326
|
+
for conn in connections:
|
|
327
|
+
src, tgt = conn.source_id, conn.target_id
|
|
328
|
+
# Extract service_type from id (e.g., "lambda.Api Handler" -> "lambda")
|
|
329
|
+
src_type = src.split(".")[0] if "." in src else src
|
|
330
|
+
tgt_type = tgt.split(".")[0] if "." in tgt else tgt
|
|
331
|
+
|
|
332
|
+
if src_type in present_types and tgt_type in present_types:
|
|
333
|
+
graph[src_type].add(tgt_type)
|
|
334
|
+
graph[tgt_type].add(src_type)
|
|
335
|
+
|
|
336
|
+
return graph
|
|
337
|
+
|
|
338
|
+
def _layout_by_connections(
|
|
339
|
+
self,
|
|
340
|
+
services: List[LogicalService],
|
|
341
|
+
connections: List[LogicalConnection],
|
|
342
|
+
start_x: float,
|
|
343
|
+
start_y: float,
|
|
344
|
+
available_width: float,
|
|
345
|
+
positions: Dict[str, Position],
|
|
346
|
+
) -> float:
|
|
347
|
+
"""Position services based on their connections (organic layout).
|
|
348
|
+
|
|
349
|
+
Services with connections are placed adjacent to each other.
|
|
350
|
+
Returns the Y position after all services are placed.
|
|
351
|
+
"""
|
|
352
|
+
if not services:
|
|
353
|
+
return start_y
|
|
354
|
+
|
|
355
|
+
# Build connection graph
|
|
356
|
+
graph = self._build_connection_graph(services, connections)
|
|
357
|
+
|
|
358
|
+
# Group services by type
|
|
359
|
+
by_type: Dict[str, List[LogicalService]] = {}
|
|
360
|
+
for s in services:
|
|
361
|
+
by_type.setdefault(s.service_type, []).append(s)
|
|
362
|
+
|
|
363
|
+
# Sort types by number of connections (most connected first)
|
|
364
|
+
sorted_types = sorted(by_type.keys(), key=lambda t: len(graph.get(t, set())), reverse=True)
|
|
365
|
+
|
|
366
|
+
# Calculate grid dimensions
|
|
367
|
+
service_width = self.config.icon_size + 50 # icon + padding
|
|
368
|
+
service_height = self.config.icon_size + 50 # icon + label + padding
|
|
369
|
+
cols = max(1, int(available_width / service_width))
|
|
370
|
+
|
|
371
|
+
# Track placed service types and their grid positions
|
|
372
|
+
placed_positions: Dict[str, Tuple[int, int]] = {} # type -> (row, col)
|
|
373
|
+
grid: Dict[Tuple[int, int], str] = {} # (row, col) -> service_type
|
|
374
|
+
|
|
375
|
+
current_row = 0
|
|
376
|
+
current_col = 0
|
|
377
|
+
|
|
378
|
+
for service_type in sorted_types:
|
|
379
|
+
type_services = by_type[service_type]
|
|
380
|
+
|
|
381
|
+
# Find best position based on connections
|
|
382
|
+
connected_types = graph.get(service_type, set())
|
|
383
|
+
best_col = current_col
|
|
384
|
+
best_row = current_row
|
|
385
|
+
|
|
386
|
+
# If connected to already-placed types, try to position nearby
|
|
387
|
+
for ct in connected_types:
|
|
388
|
+
if ct in placed_positions:
|
|
389
|
+
ct_row, ct_col = placed_positions[ct]
|
|
390
|
+
# Try adjacent positions (right, below, left)
|
|
391
|
+
candidates = [
|
|
392
|
+
(ct_row, ct_col + 1),
|
|
393
|
+
(ct_row + 1, ct_col),
|
|
394
|
+
(ct_row, ct_col - 1),
|
|
395
|
+
(ct_row + 1, ct_col + 1),
|
|
396
|
+
]
|
|
397
|
+
for r, c in candidates:
|
|
398
|
+
if c >= 0 and c < cols and (r, c) not in grid:
|
|
399
|
+
best_row, best_col = r, c
|
|
400
|
+
break
|
|
401
|
+
break
|
|
402
|
+
|
|
403
|
+
# Ensure we don't go out of bounds
|
|
404
|
+
if best_col >= cols:
|
|
405
|
+
best_col = 0
|
|
406
|
+
best_row = current_row + 1
|
|
407
|
+
|
|
408
|
+
# Position all services of this type (they share the same grid cell conceptually)
|
|
409
|
+
for i, service in enumerate(type_services):
|
|
410
|
+
# Calculate actual position
|
|
411
|
+
col = best_col + i
|
|
412
|
+
row = best_row
|
|
413
|
+
if col >= cols:
|
|
414
|
+
col = col % cols
|
|
415
|
+
row += (best_col + i) // cols
|
|
416
|
+
|
|
417
|
+
x = start_x + col * service_width
|
|
418
|
+
y = start_y + row * service_height
|
|
173
419
|
|
|
174
|
-
# Row 5: Security + Other services
|
|
175
|
-
bottom_services = security_services + other_services
|
|
176
|
-
if bottom_services:
|
|
177
|
-
x = self._center_row_start(len(bottom_services))
|
|
178
|
-
for service in bottom_services:
|
|
179
420
|
positions[service.id] = Position(
|
|
180
|
-
x=x, y=
|
|
181
|
-
width=self.config.icon_size,
|
|
182
|
-
height=self.config.icon_size
|
|
421
|
+
x=x, y=y, width=self.config.icon_size, height=self.config.icon_size
|
|
183
422
|
)
|
|
184
|
-
x += self.config.column_spacing
|
|
185
423
|
|
|
186
|
-
|
|
424
|
+
grid[(row, col)] = service_type
|
|
425
|
+
current_row = max(current_row, row)
|
|
426
|
+
|
|
427
|
+
# Track where this type was placed (first position)
|
|
428
|
+
placed_positions[service_type] = (best_row, best_col)
|
|
429
|
+
|
|
430
|
+
# Update current position for next unconnected type
|
|
431
|
+
if best_col + len(type_services) >= cols:
|
|
432
|
+
current_row = best_row + 1
|
|
433
|
+
current_col = 0
|
|
434
|
+
else:
|
|
435
|
+
current_col = best_col + len(type_services)
|
|
436
|
+
|
|
437
|
+
# Return the Y position after all services
|
|
438
|
+
return start_y + (current_row + 1) * service_height + 20
|
|
187
439
|
|
|
188
440
|
def _center_row_start(
|
|
189
|
-
self,
|
|
190
|
-
num_items: int,
|
|
191
|
-
min_x: Optional[float] = None,
|
|
192
|
-
max_x: Optional[float] = None
|
|
441
|
+
self, num_items: int, min_x: Optional[float] = None, max_x: Optional[float] = None
|
|
193
442
|
) -> float:
|
|
194
443
|
"""Calculate starting X position to center items in a row."""
|
|
195
444
|
if min_x is None:
|
|
@@ -198,42 +447,257 @@ class LayoutEngine:
|
|
|
198
447
|
max_x = self.config.canvas_width - self.config.padding
|
|
199
448
|
|
|
200
449
|
available_width = max_x - min_x
|
|
201
|
-
total_items_width =
|
|
450
|
+
total_items_width = (
|
|
451
|
+
num_items * self.config.icon_size + (num_items - 1) * self.config.icon_spacing
|
|
452
|
+
)
|
|
202
453
|
return min_x + (available_width - total_items_width) / 2
|
|
203
454
|
|
|
204
|
-
def
|
|
455
|
+
def _compute_vpc_height(
|
|
205
456
|
self,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
) ->
|
|
210
|
-
"""Compute
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
457
|
+
vpc_structure: "VPCStructure",
|
|
458
|
+
has_vpc_services: bool = True,
|
|
459
|
+
services_with_subnets: Optional[List[LogicalService]] = None,
|
|
460
|
+
) -> int:
|
|
461
|
+
"""Compute VPC box height based on subnet count, services, and endpoints.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
vpc_structure: VPCStructure with availability zones, subnets, and endpoints
|
|
465
|
+
has_vpc_services: Whether there are VPC services to display in top row
|
|
466
|
+
services_with_subnets: List of services that go inside subnets (for precise calculation)
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Height in pixels for the VPC box
|
|
470
|
+
"""
|
|
471
|
+
if not vpc_structure or not vpc_structure.availability_zones:
|
|
472
|
+
return 180 # Default height
|
|
473
|
+
|
|
474
|
+
# Constants - MUST use scaled values to match compute_layout()
|
|
475
|
+
subnet_padding = 10 # Padding between subnets
|
|
476
|
+
az_header_height = 30 # Height for AZ header
|
|
477
|
+
# VPC header uses: group_padding + 30 (see compute_layout line 239)
|
|
478
|
+
vpc_header_height = self.config.group_padding + 30
|
|
479
|
+
base_padding = 40 # Bottom padding
|
|
480
|
+
# Services row uses: icon_size + 50 (see compute_layout line 254)
|
|
481
|
+
services_row_height = (self.config.icon_size + 50) if has_vpc_services else 0
|
|
482
|
+
empty_subnet_height = 60
|
|
483
|
+
# Service subnet height uses: icon_size + 56 (see _layout_subnets line 585)
|
|
484
|
+
service_subnet_height = self.config.icon_size + 56
|
|
485
|
+
|
|
486
|
+
# Calculate height needed for subnets in each AZ
|
|
487
|
+
# Find which subnets will have services
|
|
488
|
+
subnets_with_services: set = set()
|
|
489
|
+
if services_with_subnets:
|
|
490
|
+
for service in services_with_subnets:
|
|
491
|
+
for subnet_id in service.subnet_ids:
|
|
492
|
+
# Normalize subnet ID
|
|
493
|
+
if subnet_id.startswith("_state_subnet:"):
|
|
494
|
+
# Will be resolved later, assume it matches a subnet
|
|
495
|
+
subnets_with_services.add(subnet_id)
|
|
496
|
+
else:
|
|
497
|
+
subnets_with_services.add(subnet_id)
|
|
498
|
+
|
|
499
|
+
# Calculate max height needed across all AZs
|
|
500
|
+
max_az_content_height = 0
|
|
501
|
+
for az in vpc_structure.availability_zones:
|
|
502
|
+
az_content_height = 0
|
|
503
|
+
for subnet in az.subnets:
|
|
504
|
+
# Check if this subnet will have services
|
|
505
|
+
has_services = subnet.resource_id in subnets_with_services
|
|
506
|
+
# Also check by AWS ID
|
|
507
|
+
if subnet.aws_id:
|
|
508
|
+
if f"_state_subnet:{subnet.aws_id}" in subnets_with_services:
|
|
509
|
+
has_services = True
|
|
510
|
+
|
|
511
|
+
subnet_h = service_subnet_height if has_services else empty_subnet_height
|
|
512
|
+
az_content_height += subnet_h + subnet_padding
|
|
513
|
+
|
|
514
|
+
max_az_content_height = max(max_az_content_height, az_content_height)
|
|
515
|
+
|
|
516
|
+
# Total height for subnets
|
|
517
|
+
height_for_subnets = (
|
|
518
|
+
vpc_header_height
|
|
519
|
+
+ services_row_height
|
|
520
|
+
+ az_header_height
|
|
521
|
+
+ max_az_content_height
|
|
522
|
+
+ base_padding
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# Height based on endpoints (if present)
|
|
526
|
+
num_endpoints = len(vpc_structure.endpoints) if vpc_structure.endpoints else 0
|
|
527
|
+
endpoint_spacing = 72 # Match the actual spacing used in _layout_vpc_structure
|
|
528
|
+
height_for_endpoints = (
|
|
529
|
+
(
|
|
530
|
+
vpc_header_height
|
|
531
|
+
+ services_row_height
|
|
532
|
+
+ (num_endpoints * endpoint_spacing)
|
|
533
|
+
+ base_padding
|
|
534
|
+
+ 20
|
|
535
|
+
)
|
|
536
|
+
if num_endpoints > 0
|
|
537
|
+
else 0
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
return max(200, height_for_subnets, height_for_endpoints)
|
|
541
|
+
|
|
542
|
+
def _layout_vpc_structure(
|
|
543
|
+
self,
|
|
544
|
+
vpc_structure: "VPCStructure",
|
|
545
|
+
vpc_pos: Position,
|
|
546
|
+
positions: Dict[str, Position],
|
|
547
|
+
groups: List[ServiceGroup],
|
|
548
|
+
az_start_y: Optional[float] = None,
|
|
549
|
+
services_with_subnets: Optional[List[LogicalService]] = None,
|
|
550
|
+
) -> None:
|
|
551
|
+
"""Layout availability zones and endpoints within VPC.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
vpc_structure: VPCStructure with AZs and endpoints
|
|
555
|
+
vpc_pos: Position of the VPC container
|
|
556
|
+
positions: Dict to add subnet positions to
|
|
557
|
+
groups: List to add AZ groups to
|
|
558
|
+
az_start_y: Optional Y position where AZs should start (below services)
|
|
559
|
+
services_with_subnets: Services that should be placed inside their subnets
|
|
560
|
+
"""
|
|
561
|
+
if not vpc_structure:
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
num_azs = len(vpc_structure.availability_zones)
|
|
565
|
+
if num_azs == 0:
|
|
566
|
+
return
|
|
567
|
+
|
|
568
|
+
# Calculate AZ dimensions
|
|
569
|
+
az_padding = 15
|
|
570
|
+
endpoint_width = 90 # Space reserved for endpoints on right
|
|
571
|
+
available_width = vpc_pos.width - (2 * az_padding) - endpoint_width
|
|
572
|
+
az_width = (available_width - (num_azs - 1) * az_padding) / num_azs
|
|
573
|
+
|
|
574
|
+
# Build global mapping from AWS subnet IDs to resource IDs
|
|
575
|
+
# This is needed because ALBs can reference subnets from multiple AZs
|
|
576
|
+
aws_id_to_resource_id: Dict[str, str] = {}
|
|
577
|
+
for az in vpc_structure.availability_zones:
|
|
578
|
+
for subnet in az.subnets:
|
|
579
|
+
if subnet.aws_id:
|
|
580
|
+
aws_id_to_resource_id[subnet.aws_id] = subnet.resource_id
|
|
581
|
+
|
|
582
|
+
# Layout each AZ
|
|
583
|
+
az_y = az_start_y if az_start_y is not None else vpc_pos.y + 40
|
|
584
|
+
az_height = (vpc_pos.y + vpc_pos.height - 20) - az_y # Extend to bottom of VPC
|
|
585
|
+
az_x = vpc_pos.x + az_padding
|
|
586
|
+
|
|
587
|
+
for az in vpc_structure.availability_zones:
|
|
588
|
+
az_pos = Position(x=az_x, y=az_y, width=az_width, height=az_height)
|
|
589
|
+
|
|
590
|
+
# Create AZ group
|
|
591
|
+
az_group = ServiceGroup(group_type="az", name=f"AZ {az.short_name}", position=az_pos)
|
|
592
|
+
groups.append(az_group)
|
|
593
|
+
|
|
594
|
+
# Layout subnets inside this AZ
|
|
595
|
+
self._layout_subnets(
|
|
596
|
+
az, az_pos, positions, services_with_subnets, aws_id_to_resource_id
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
az_x += az_width + az_padding
|
|
600
|
+
|
|
601
|
+
# Layout VPC endpoints INSIDE the VPC (right column, within border)
|
|
602
|
+
if vpc_structure.endpoints:
|
|
603
|
+
# Position endpoints inside the VPC border, in the reserved space
|
|
604
|
+
endpoint_box_width = 80
|
|
605
|
+
endpoint_box_height = 65
|
|
606
|
+
endpoint_x = vpc_pos.x + vpc_pos.width - endpoint_width + 3 # Inside the reserved space
|
|
607
|
+
endpoint_y = az_y + 5 # Start at same level as AZs
|
|
608
|
+
endpoint_spacing = 72 # Spacing between endpoints
|
|
234
609
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
610
|
+
for endpoint in vpc_structure.endpoints:
|
|
611
|
+
positions[endpoint.resource_id] = Position(
|
|
612
|
+
x=endpoint_x, y=endpoint_y, width=endpoint_box_width, height=endpoint_box_height
|
|
613
|
+
)
|
|
614
|
+
endpoint_y += endpoint_spacing
|
|
615
|
+
|
|
616
|
+
def _layout_subnets(
|
|
617
|
+
self,
|
|
618
|
+
az: "AvailabilityZone",
|
|
619
|
+
az_pos: Position,
|
|
620
|
+
positions: Dict[str, Position],
|
|
621
|
+
services_with_subnets: Optional[List[LogicalService]] = None,
|
|
622
|
+
aws_id_to_resource_id: Optional[Dict[str, str]] = None,
|
|
623
|
+
) -> None:
|
|
624
|
+
"""Layout subnets inside an availability zone.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
az: AvailabilityZone containing subnets
|
|
628
|
+
az_pos: Position of the AZ container
|
|
629
|
+
positions: Dict to add subnet positions to
|
|
630
|
+
services_with_subnets: Services that should be placed inside their subnets
|
|
631
|
+
aws_id_to_resource_id: Mapping from AWS subnet IDs to resource IDs
|
|
632
|
+
"""
|
|
633
|
+
if not az.subnets:
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
subnet_padding = 10
|
|
637
|
+
# Base subnet height: 60px for empty subnets (enough for label and visibility)
|
|
638
|
+
# Must match _compute_vpc_height for consistency
|
|
639
|
+
subnet_height = 60
|
|
640
|
+
subnet_width = az_pos.width - (2 * subnet_padding)
|
|
641
|
+
|
|
642
|
+
subnet_y = az_pos.y + 30 # Below AZ header
|
|
643
|
+
|
|
644
|
+
# Use provided mapping or empty dict
|
|
645
|
+
if aws_id_to_resource_id is None:
|
|
646
|
+
aws_id_to_resource_id = {}
|
|
647
|
+
|
|
648
|
+
# Build mapping: subnet_resource_id -> list of services
|
|
649
|
+
services_by_subnet: Dict[str, List[LogicalService]] = {}
|
|
650
|
+
if services_with_subnets:
|
|
651
|
+
for service in services_with_subnets:
|
|
652
|
+
for subnet_id in service.subnet_ids:
|
|
653
|
+
# Handle _state_subnet: prefixed IDs (from Terraform state)
|
|
654
|
+
if subnet_id.startswith("_state_subnet:"):
|
|
655
|
+
aws_id = subnet_id[len("_state_subnet:") :]
|
|
656
|
+
# Map AWS ID to resource ID if we have it
|
|
657
|
+
if aws_id in aws_id_to_resource_id:
|
|
658
|
+
resource_id = aws_id_to_resource_id[aws_id]
|
|
659
|
+
services_by_subnet.setdefault(resource_id, []).append(service)
|
|
660
|
+
else:
|
|
661
|
+
# Direct resource ID reference (e.g., aws_subnet.public)
|
|
662
|
+
services_by_subnet.setdefault(subnet_id, []).append(service)
|
|
663
|
+
|
|
664
|
+
for subnet in az.subnets:
|
|
665
|
+
# Check if any services belong to this subnet
|
|
666
|
+
subnet_services = services_by_subnet.get(subnet.resource_id, [])
|
|
667
|
+
|
|
668
|
+
# Increase subnet height if it contains services
|
|
669
|
+
# Service box needs: icon (64px) + label padding below (36px) + top padding (8px) + margins
|
|
670
|
+
# Total service box height: 64 + 44 = 108px, add margin = 120px
|
|
671
|
+
actual_subnet_height = subnet_height
|
|
672
|
+
if subnet_services:
|
|
673
|
+
actual_subnet_height = max(
|
|
674
|
+
subnet_height, self.config.icon_size + 56
|
|
675
|
+
) # 64 + 56 = 120
|
|
676
|
+
|
|
677
|
+
positions[subnet.resource_id] = Position(
|
|
678
|
+
x=az_pos.x + subnet_padding,
|
|
679
|
+
y=subnet_y,
|
|
680
|
+
width=subnet_width,
|
|
681
|
+
height=actual_subnet_height,
|
|
682
|
+
)
|
|
238
683
|
|
|
239
|
-
|
|
684
|
+
# Position services inside this subnet
|
|
685
|
+
if subnet_services:
|
|
686
|
+
service_x = az_pos.x + subnet_padding + 15 # 15px left margin inside subnet
|
|
687
|
+
# Center service vertically, accounting for the -8px top padding from renderer
|
|
688
|
+
# Service icon is at y, but box extends 8px above, so offset by 8
|
|
689
|
+
service_y = subnet_y + 8 + (actual_subnet_height - (self.config.icon_size + 44)) / 2
|
|
690
|
+
|
|
691
|
+
for service in subnet_services:
|
|
692
|
+
# Only position if not already positioned (avoid duplicates)
|
|
693
|
+
if service.id not in positions:
|
|
694
|
+
positions[service.id] = Position(
|
|
695
|
+
x=service_x,
|
|
696
|
+
y=service_y,
|
|
697
|
+
width=self.config.icon_size,
|
|
698
|
+
height=self.config.icon_size,
|
|
699
|
+
)
|
|
700
|
+
# Space between services: icon width + box padding (16px) + gap (10px)
|
|
701
|
+
service_x += self.config.icon_size + 26
|
|
702
|
+
|
|
703
|
+
subnet_y += actual_subnet_height + subnet_padding
|