terraformgraph 1.0.3__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/__main__.py +1 -1
- terraformgraph/aggregator.py +941 -44
- 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 +323 -84
- terraformgraph/renderer.py +1864 -167
- 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.3.dist-info → terraformgraph-1.0.4.dist-info}/licenses/LICENSE +1 -1
- terraformgraph-1.0.3.dist-info/METADATA +0 -163
- terraformgraph-1.0.3.dist-info/RECORD +0 -17
- {terraformgraph-1.0.3.dist-info → terraformgraph-1.0.4.dist-info}/WHEEL +0 -0
- {terraformgraph-1.0.3.dist-info → terraformgraph-1.0.4.dist-info}/entry_points.txt +0 -0
- {terraformgraph-1.0.3.dist-info → terraformgraph-1.0.4.dist-info}/top_level.txt +0 -0
terraformgraph/renderer.py
CHANGED
|
@@ -8,13 +8,17 @@ Generates interactive HTML diagrams with:
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import html
|
|
11
|
+
import json
|
|
11
12
|
import re
|
|
12
|
-
from typing import Dict, List, Optional
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
13
14
|
|
|
14
|
-
from .aggregator import AggregatedResult, LogicalConnection, LogicalService
|
|
15
|
+
from .aggregator import AggregatedResult, LogicalConnection, LogicalService, ResourceAggregator
|
|
15
16
|
from .icons import IconMapper
|
|
16
17
|
from .layout import LayoutConfig, Position, ServiceGroup
|
|
17
18
|
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .aggregator import Subnet, VPCEndpoint, VPCStructure
|
|
21
|
+
|
|
18
22
|
|
|
19
23
|
class SVGRenderer:
|
|
20
24
|
"""Renders infrastructure diagrams as SVG."""
|
|
@@ -28,52 +32,109 @@ class SVGRenderer:
|
|
|
28
32
|
services: List[LogicalService],
|
|
29
33
|
positions: Dict[str, Position],
|
|
30
34
|
connections: List[LogicalConnection],
|
|
31
|
-
groups: List[ServiceGroup]
|
|
35
|
+
groups: List[ServiceGroup],
|
|
36
|
+
vpc_structure: Optional["VPCStructure"] = None,
|
|
37
|
+
actual_height: Optional[int] = None,
|
|
32
38
|
) -> str:
|
|
33
39
|
"""Generate SVG content for the diagram."""
|
|
34
40
|
svg_parts = []
|
|
35
41
|
|
|
36
|
-
#
|
|
37
|
-
|
|
42
|
+
# Use actual height if provided (from layout engine), otherwise use config
|
|
43
|
+
canvas_height = actual_height if actual_height else self.config.canvas_height
|
|
44
|
+
|
|
45
|
+
# SVG header with responsive viewBox
|
|
46
|
+
# width="100%" allows SVG to scale to container, preserveAspectRatio maintains proportions
|
|
47
|
+
svg_parts.append(
|
|
48
|
+
f"""<svg id="diagram-svg" xmlns="http://www.w3.org/2000/svg"
|
|
38
49
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
39
|
-
viewBox="0 0 {self.config.canvas_width} {
|
|
40
|
-
width="
|
|
50
|
+
viewBox="0 0 {self.config.canvas_width} {canvas_height}"
|
|
51
|
+
width="100%" preserveAspectRatio="xMidYMin meet"
|
|
52
|
+
style="max-width: {self.config.canvas_width}px;">"""
|
|
53
|
+
)
|
|
41
54
|
|
|
42
55
|
# Defs for arrows and filters
|
|
43
56
|
svg_parts.append(self._render_defs())
|
|
44
57
|
|
|
45
58
|
# Background
|
|
46
|
-
svg_parts.append(
|
|
59
|
+
svg_parts.append("""<rect width="100%" height="100%" fill="#f8f9fa"/>""")
|
|
47
60
|
|
|
48
|
-
# Render groups (AWS Cloud, VPC)
|
|
61
|
+
# Render groups (AWS Cloud, VPC, AZ)
|
|
49
62
|
for group in groups:
|
|
50
63
|
svg_parts.append(self._render_group(group))
|
|
51
64
|
|
|
52
|
-
#
|
|
65
|
+
# Build mapping from AWS subnet IDs to resource IDs for state-based lookups
|
|
66
|
+
aws_id_to_resource_id: Dict[str, str] = {}
|
|
67
|
+
if vpc_structure:
|
|
68
|
+
for az in vpc_structure.availability_zones:
|
|
69
|
+
for subnet in az.subnets:
|
|
70
|
+
if subnet.aws_id:
|
|
71
|
+
aws_id_to_resource_id[subnet.aws_id] = subnet.resource_id
|
|
72
|
+
|
|
73
|
+
# Render subnets layer (below connections)
|
|
74
|
+
if vpc_structure:
|
|
75
|
+
svg_parts.append('<g id="subnets-layer">')
|
|
76
|
+
for az in vpc_structure.availability_zones:
|
|
77
|
+
for subnet in az.subnets:
|
|
78
|
+
if subnet.resource_id in positions:
|
|
79
|
+
svg_parts.append(
|
|
80
|
+
self._render_subnet(
|
|
81
|
+
subnet.resource_id, positions[subnet.resource_id], subnet
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
svg_parts.append("</g>")
|
|
85
|
+
|
|
86
|
+
# Build service_type_map for connection rendering
|
|
87
|
+
service_type_map: Dict[str, str] = {}
|
|
88
|
+
for service in services:
|
|
89
|
+
service_type_map[service.id] = service.service_type
|
|
90
|
+
|
|
91
|
+
# Connections container - render individual connections
|
|
53
92
|
svg_parts.append('<g id="connections-layer">')
|
|
54
|
-
for
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
93
|
+
for connection in connections:
|
|
94
|
+
source_pos = positions.get(connection.source_id)
|
|
95
|
+
target_pos = positions.get(connection.target_id)
|
|
96
|
+
if source_pos and target_pos:
|
|
97
|
+
svg_parts.append(
|
|
98
|
+
self._render_connection(
|
|
99
|
+
source_pos, target_pos, connection, service_type_map
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
svg_parts.append("</g>")
|
|
103
|
+
|
|
104
|
+
# Render VPC endpoints layer
|
|
105
|
+
if vpc_structure:
|
|
106
|
+
svg_parts.append('<g id="endpoints-layer">')
|
|
107
|
+
for endpoint in vpc_structure.endpoints:
|
|
108
|
+
if endpoint.resource_id in positions:
|
|
109
|
+
svg_parts.append(
|
|
110
|
+
self._render_vpc_endpoint(
|
|
111
|
+
endpoint.resource_id, positions[endpoint.resource_id], endpoint
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
svg_parts.append("</g>")
|
|
62
115
|
|
|
63
116
|
# Services layer
|
|
64
117
|
svg_parts.append('<g id="services-layer">')
|
|
65
118
|
for service in services:
|
|
66
119
|
if service.id in positions:
|
|
67
|
-
svg_parts.append(
|
|
68
|
-
|
|
120
|
+
svg_parts.append(
|
|
121
|
+
self._render_service(
|
|
122
|
+
service,
|
|
123
|
+
positions[service.id],
|
|
124
|
+
aws_id_to_resource_id,
|
|
125
|
+
positions,
|
|
126
|
+
vpc_structure,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
svg_parts.append("</g>")
|
|
69
130
|
|
|
70
|
-
svg_parts.append(
|
|
131
|
+
svg_parts.append("</svg>")
|
|
71
132
|
|
|
72
|
-
return
|
|
133
|
+
return "\n".join(svg_parts)
|
|
73
134
|
|
|
74
135
|
def _render_defs(self) -> str:
|
|
75
136
|
"""Render SVG definitions (markers, filters)."""
|
|
76
|
-
return
|
|
137
|
+
return """
|
|
77
138
|
<defs>
|
|
78
139
|
<marker id="arrowhead" markerWidth="10" markerHeight="7"
|
|
79
140
|
refX="9" refY="3.5" orient="auto">
|
|
@@ -87,27 +148,39 @@ class SVGRenderer:
|
|
|
87
148
|
refX="9" refY="3.5" orient="auto">
|
|
88
149
|
<polygon points="0 0, 10 3.5, 0 7" fill="#E7157B"/>
|
|
89
150
|
</marker>
|
|
151
|
+
<marker id="arrowhead-network" markerWidth="10" markerHeight="7"
|
|
152
|
+
refX="9" refY="3.5" orient="auto">
|
|
153
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#0d7c3f"/>
|
|
154
|
+
</marker>
|
|
155
|
+
<marker id="arrowhead-security" markerWidth="10" markerHeight="7"
|
|
156
|
+
refX="9" refY="3.5" orient="auto">
|
|
157
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#d97706"/>
|
|
158
|
+
</marker>
|
|
90
159
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
91
160
|
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
|
|
92
161
|
</filter>
|
|
93
162
|
</defs>
|
|
94
|
-
|
|
163
|
+
"""
|
|
95
164
|
|
|
96
165
|
def _render_group(self, group: ServiceGroup) -> str:
|
|
97
|
-
"""Render a group container (AWS Cloud, VPC)."""
|
|
166
|
+
"""Render a group container (AWS Cloud, VPC, AZ)."""
|
|
98
167
|
if not group.position:
|
|
99
|
-
return
|
|
168
|
+
return ""
|
|
100
169
|
|
|
101
170
|
pos = group.position
|
|
102
171
|
|
|
172
|
+
# Handle AZ groups with special rendering
|
|
173
|
+
if group.group_type == "az":
|
|
174
|
+
return self._render_az(group)
|
|
175
|
+
|
|
103
176
|
colors = {
|
|
104
|
-
|
|
105
|
-
|
|
177
|
+
"aws_cloud": ("#232f3e", "#ffffff", "#232f3e"),
|
|
178
|
+
"vpc": ("#8c4fff", "#faf8ff", "#8c4fff"),
|
|
106
179
|
}
|
|
107
180
|
|
|
108
|
-
border_color, bg_color, text_color = colors.get(group.group_type, (
|
|
181
|
+
border_color, bg_color, text_color = colors.get(group.group_type, ("#666", "#fff", "#666"))
|
|
109
182
|
|
|
110
|
-
return f
|
|
183
|
+
return f"""
|
|
111
184
|
<g class="group group-{group.group_type}" data-group-type="{group.group_type}">
|
|
112
185
|
<rect class="group-bg" x="{pos.x}" y="{pos.y}" width="{pos.width}" height="{pos.height}"
|
|
113
186
|
fill="{bg_color}" stroke="{border_color}" stroke-width="2"
|
|
@@ -118,42 +191,225 @@ class SVGRenderer:
|
|
|
118
191
|
font-family="Arial, sans-serif" font-size="14" font-weight="bold"
|
|
119
192
|
fill="{text_color}">{html.escape(group.name)}</text>
|
|
120
193
|
</g>
|
|
121
|
-
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def _render_az(self, group: ServiceGroup) -> str:
|
|
197
|
+
"""Render an Availability Zone container with dashed border."""
|
|
198
|
+
if not group.position:
|
|
199
|
+
return ""
|
|
200
|
+
|
|
201
|
+
pos = group.position
|
|
202
|
+
border_color = "#ff9900" # AWS orange for AZ
|
|
203
|
+
bg_color = "#fff8f0" # Light orange background
|
|
204
|
+
text_color = "#ff9900"
|
|
205
|
+
|
|
206
|
+
return f"""
|
|
207
|
+
<g class="group group-az" data-group-type="az">
|
|
208
|
+
<rect class="az-bg" x="{pos.x}" y="{pos.y}" width="{pos.width}" height="{pos.height}"
|
|
209
|
+
fill="{bg_color}" stroke="{border_color}" stroke-width="1.5"
|
|
210
|
+
stroke-dasharray="5,3" rx="8" ry="8"/>
|
|
211
|
+
<text x="{pos.x + 10}" y="{pos.y + 18}"
|
|
212
|
+
font-family="Arial, sans-serif" font-size="12" font-weight="bold"
|
|
213
|
+
fill="{text_color}">{html.escape(group.name)}</text>
|
|
214
|
+
</g>
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def _render_subnet(self, subnet_id: str, pos: Position, subnet_info: "Subnet") -> str:
|
|
218
|
+
"""Render a colored subnet box.
|
|
219
|
+
|
|
220
|
+
Colors:
|
|
221
|
+
- public: green
|
|
222
|
+
- private: blue
|
|
223
|
+
- database: yellow/gold
|
|
224
|
+
- unknown: gray
|
|
225
|
+
"""
|
|
226
|
+
colors = {
|
|
227
|
+
"public": ("#22a06b", "#e3fcef"), # Green
|
|
228
|
+
"private": ("#0052cc", "#deebff"), # Blue
|
|
229
|
+
"database": ("#ff991f", "#fffae6"), # Yellow/Gold
|
|
230
|
+
"unknown": ("#6b778c", "#f4f5f7"), # Gray
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
border_color, bg_color = colors.get(subnet_info.subnet_type, colors["unknown"])
|
|
234
|
+
|
|
235
|
+
rt_label = ""
|
|
236
|
+
if subnet_info.route_table_name:
|
|
237
|
+
rt_label = f"""
|
|
238
|
+
<text x="{pos.x + pos.width - 8}" y="{pos.y + pos.height/2 + 16}"
|
|
239
|
+
font-family="Arial, sans-serif" font-size="9" fill="#999"
|
|
240
|
+
text-anchor="end" opacity="0.6">
|
|
241
|
+
RT: {html.escape(subnet_info.route_table_name)}
|
|
242
|
+
</text>"""
|
|
243
|
+
|
|
244
|
+
return f"""
|
|
245
|
+
<g class="subnet subnet-{subnet_info.subnet_type}" data-subnet-id="{html.escape(subnet_id)}"
|
|
246
|
+
data-min-x="{pos.x}" data-min-y="{pos.y}"
|
|
247
|
+
data-max-x="{pos.x + pos.width}" data-max-y="{pos.y + pos.height}">
|
|
248
|
+
<rect x="{pos.x}" y="{pos.y}" width="{pos.width}" height="{pos.height}"
|
|
249
|
+
fill="{bg_color}" stroke="{border_color}" stroke-width="1.5" rx="4" ry="4"/>
|
|
250
|
+
<text x="{pos.x + 8}" y="{pos.y + pos.height/2 + 4}"
|
|
251
|
+
font-family="Arial, sans-serif" font-size="11" fill="{border_color}">
|
|
252
|
+
{html.escape(subnet_info.name)}
|
|
253
|
+
</text>
|
|
254
|
+
<text x="{pos.x + pos.width - 8}" y="{pos.y + pos.height/2 + 4}"
|
|
255
|
+
font-family="Arial, sans-serif" font-size="10" fill="{border_color}"
|
|
256
|
+
text-anchor="end" opacity="0.7">
|
|
257
|
+
{html.escape(subnet_info.cidr_block) if subnet_info.cidr_block else subnet_info.subnet_type}
|
|
258
|
+
</text>{rt_label}
|
|
259
|
+
</g>
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
def _render_vpc_endpoint(
|
|
263
|
+
self, endpoint_id: str, pos: Position, endpoint_info: "VPCEndpoint"
|
|
264
|
+
) -> str:
|
|
265
|
+
"""Render a VPC endpoint with AWS icon and service name.
|
|
266
|
+
|
|
267
|
+
Colors:
|
|
268
|
+
- gateway: green (S3, DynamoDB)
|
|
269
|
+
- interface: blue (ECR, CloudWatch, SSM, etc.)
|
|
270
|
+
"""
|
|
271
|
+
# Colors by type
|
|
272
|
+
colors = {
|
|
273
|
+
"gateway": ("#22a06b", "#e3fcef"), # Green
|
|
274
|
+
"interface": ("#0052cc", "#deebff"), # Blue
|
|
275
|
+
}
|
|
276
|
+
border_color, bg_color = colors.get(endpoint_info.endpoint_type, colors["interface"])
|
|
277
|
+
|
|
278
|
+
# Extract clean service name
|
|
279
|
+
service_name = endpoint_info.service
|
|
280
|
+
if "." in service_name:
|
|
281
|
+
service_name = service_name.split(".")[0]
|
|
282
|
+
service_display = service_name.upper()
|
|
283
|
+
|
|
284
|
+
# Type label
|
|
285
|
+
type_label = "Gateway" if endpoint_info.endpoint_type == "gateway" else "Interface"
|
|
286
|
+
|
|
287
|
+
# Box dimensions
|
|
288
|
+
box_width = pos.width
|
|
289
|
+
box_height = pos.height
|
|
122
290
|
|
|
123
|
-
|
|
291
|
+
# Center positions
|
|
292
|
+
cx = pos.x + box_width / 2
|
|
293
|
+
|
|
294
|
+
# Try to get official AWS VPC Endpoints icon
|
|
295
|
+
icon_svg = self.icon_mapper.get_icon_svg("aws_vpc_endpoint", 48)
|
|
296
|
+
icon_content = None
|
|
297
|
+
|
|
298
|
+
# Check if we got a real icon (not the fallback with "RES" text)
|
|
299
|
+
if icon_svg and "Endpoints" in icon_svg:
|
|
300
|
+
icon_content = self._extract_svg_content(icon_svg)
|
|
301
|
+
|
|
302
|
+
if icon_content:
|
|
303
|
+
# Use official AWS icon
|
|
304
|
+
icon_size = 32
|
|
305
|
+
return f"""
|
|
306
|
+
<g class="vpc-endpoint endpoint-{endpoint_info.endpoint_type}" data-endpoint-id="{html.escape(endpoint_id)}">
|
|
307
|
+
<rect x="{pos.x}" y="{pos.y}" width="{box_width}" height="{box_height}"
|
|
308
|
+
fill="white" stroke="#e0e0e0" stroke-width="1" rx="6" ry="6"
|
|
309
|
+
filter="url(#shadow)"/>
|
|
310
|
+
<svg x="{cx - icon_size/2}" y="{pos.y + 6}" width="{icon_size}" height="{icon_size}" viewBox="0 0 48 48">
|
|
311
|
+
{icon_content}
|
|
312
|
+
</svg>
|
|
313
|
+
<text x="{cx}" y="{pos.y + 48}"
|
|
314
|
+
font-family="Arial, sans-serif" font-size="10" fill="#333"
|
|
315
|
+
text-anchor="middle" font-weight="bold">
|
|
316
|
+
{html.escape(service_display)}
|
|
317
|
+
</text>
|
|
318
|
+
<text x="{cx}" y="{pos.y + 60}"
|
|
319
|
+
font-family="Arial, sans-serif" font-size="8" fill="#666"
|
|
320
|
+
text-anchor="middle">
|
|
321
|
+
{type_label}
|
|
322
|
+
</text>
|
|
323
|
+
<title>{html.escape(endpoint_info.name)} ({endpoint_info.endpoint_type} endpoint for {service_name})</title>
|
|
324
|
+
</g>
|
|
325
|
+
"""
|
|
326
|
+
else:
|
|
327
|
+
# Fallback: colored box with service name
|
|
328
|
+
return f"""
|
|
329
|
+
<g class="vpc-endpoint endpoint-{endpoint_info.endpoint_type}" data-endpoint-id="{html.escape(endpoint_id)}">
|
|
330
|
+
<rect x="{pos.x}" y="{pos.y}" width="{box_width}" height="{box_height}"
|
|
331
|
+
fill="{bg_color}" stroke="{border_color}" stroke-width="1.5" rx="6" ry="6"
|
|
332
|
+
filter="url(#shadow)"/>
|
|
333
|
+
<text x="{cx}" y="{pos.y + box_height/2 - 6}"
|
|
334
|
+
font-family="Arial, sans-serif" font-size="11" fill="{border_color}"
|
|
335
|
+
text-anchor="middle" font-weight="bold">
|
|
336
|
+
{html.escape(service_display)}
|
|
337
|
+
</text>
|
|
338
|
+
<text x="{cx}" y="{pos.y + box_height/2 + 8}"
|
|
339
|
+
font-family="Arial, sans-serif" font-size="9" fill="{border_color}"
|
|
340
|
+
text-anchor="middle" opacity="0.7">
|
|
341
|
+
{type_label}
|
|
342
|
+
</text>
|
|
343
|
+
<title>{html.escape(endpoint_info.name)} ({endpoint_info.endpoint_type} endpoint for {service_name})</title>
|
|
344
|
+
</g>
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
def _render_service(
|
|
348
|
+
self,
|
|
349
|
+
service: LogicalService,
|
|
350
|
+
pos: Position,
|
|
351
|
+
aws_id_to_resource_id: Optional[Dict[str, str]] = None,
|
|
352
|
+
all_positions: Optional[Dict[str, Position]] = None,
|
|
353
|
+
vpc_structure: Optional["VPCStructure"] = None,
|
|
354
|
+
) -> str:
|
|
124
355
|
"""Render a draggable logical service with its icon."""
|
|
125
356
|
icon_svg = self.icon_mapper.get_icon_svg(service.icon_resource_type, 48)
|
|
126
357
|
color = self.icon_mapper.get_category_color(service.icon_resource_type)
|
|
127
358
|
|
|
128
359
|
# Count badge
|
|
129
|
-
count_badge =
|
|
360
|
+
count_badge = ""
|
|
130
361
|
if service.count > 1:
|
|
131
|
-
count_badge = f
|
|
362
|
+
count_badge = f"""
|
|
132
363
|
<circle class="count-badge" cx="{pos.width - 8}" cy="8" r="12"
|
|
133
364
|
fill="{color}" stroke="white" stroke-width="2"/>
|
|
134
365
|
<text class="count-text" x="{pos.width - 8}" y="12"
|
|
135
366
|
font-family="Arial, sans-serif" font-size="11" fill="white"
|
|
136
367
|
text-anchor="middle" font-weight="bold">{service.count}</text>
|
|
137
|
-
|
|
368
|
+
"""
|
|
138
369
|
|
|
139
370
|
resource_count = len(service.resources)
|
|
140
371
|
tooltip = f"{service.name} ({resource_count} resources)"
|
|
141
372
|
|
|
142
373
|
# Determine if this is a VPC service
|
|
143
|
-
is_vpc_service =
|
|
374
|
+
is_vpc_service = "true" if service.is_vpc_resource else "false"
|
|
375
|
+
|
|
376
|
+
# Determine subnet constraint directly from service.subnet_ids
|
|
377
|
+
# This ensures the drag constraint matches the service's actual subnet assignment
|
|
378
|
+
subnet_attr = ""
|
|
379
|
+
if service.subnet_ids and vpc_structure and all_positions:
|
|
380
|
+
# Map from AWS IDs to resource IDs for state-based lookups
|
|
381
|
+
for subnet_id in service.subnet_ids:
|
|
382
|
+
resolved_id = subnet_id
|
|
383
|
+
# Handle _state_subnet: prefixed IDs (from Terraform state)
|
|
384
|
+
if subnet_id.startswith("_state_subnet:") and aws_id_to_resource_id:
|
|
385
|
+
aws_id = subnet_id[len("_state_subnet:") :]
|
|
386
|
+
resolved_id = aws_id_to_resource_id.get(aws_id)
|
|
387
|
+
|
|
388
|
+
# Find the subnet that contains this service's position
|
|
389
|
+
if resolved_id and resolved_id in all_positions:
|
|
390
|
+
subnet_pos = all_positions[resolved_id]
|
|
391
|
+
# Check if service position is inside this subnet
|
|
392
|
+
if (
|
|
393
|
+
subnet_pos.x <= pos.x <= subnet_pos.x + subnet_pos.width
|
|
394
|
+
and subnet_pos.y <= pos.y <= subnet_pos.y + subnet_pos.height
|
|
395
|
+
):
|
|
396
|
+
subnet_attr = f'data-subnet-id="{html.escape(resolved_id)}"'
|
|
397
|
+
break
|
|
144
398
|
|
|
145
399
|
if icon_svg:
|
|
146
400
|
icon_content = self._extract_svg_content(icon_svg)
|
|
401
|
+
icon_viewbox = self._extract_svg_viewbox(icon_svg)
|
|
147
402
|
|
|
148
|
-
svg = f
|
|
403
|
+
svg = f"""
|
|
149
404
|
<g class="service draggable" data-service-id="{html.escape(service.id)}"
|
|
150
|
-
data-
|
|
405
|
+
data-service-type="{html.escape(service.service_type)}"
|
|
406
|
+
data-tooltip="{html.escape(tooltip)}" data-is-vpc="{is_vpc_service}" {subnet_attr}
|
|
151
407
|
transform="translate({pos.x}, {pos.y})" style="cursor: grab;">
|
|
152
408
|
<rect class="service-bg" x="-8" y="-8"
|
|
153
409
|
width="{pos.width + 16}" height="{pos.height + 36}"
|
|
154
410
|
fill="white" stroke="#e0e0e0" stroke-width="1" rx="8" ry="8"
|
|
155
411
|
filter="url(#shadow)"/>
|
|
156
|
-
<svg class="service-icon" width="{pos.width}" height="{pos.height}" viewBox="
|
|
412
|
+
<svg class="service-icon" width="{pos.width}" height="{pos.height}" viewBox="{icon_viewbox}">
|
|
157
413
|
{icon_content}
|
|
158
414
|
</svg>
|
|
159
415
|
<text class="service-label" x="{pos.width/2}" y="{pos.height + 16}"
|
|
@@ -163,11 +419,12 @@ class SVGRenderer:
|
|
|
163
419
|
</text>
|
|
164
420
|
{count_badge}
|
|
165
421
|
</g>
|
|
166
|
-
|
|
422
|
+
"""
|
|
167
423
|
else:
|
|
168
|
-
svg = f
|
|
424
|
+
svg = f"""
|
|
169
425
|
<g class="service draggable" data-service-id="{html.escape(service.id)}"
|
|
170
|
-
data-
|
|
426
|
+
data-service-type="{html.escape(service.service_type)}"
|
|
427
|
+
data-tooltip="{html.escape(tooltip)}" data-is-vpc="{is_vpc_service}" {subnet_attr}
|
|
171
428
|
transform="translate({pos.x}, {pos.y})" style="cursor: grab;">
|
|
172
429
|
<rect class="service-bg" x="-8" y="-8"
|
|
173
430
|
width="{pos.width + 16}" height="{pos.height + 36}"
|
|
@@ -185,34 +442,47 @@ class SVGRenderer:
|
|
|
185
442
|
</text>
|
|
186
443
|
{count_badge}
|
|
187
444
|
</g>
|
|
188
|
-
|
|
445
|
+
"""
|
|
189
446
|
|
|
190
447
|
return svg
|
|
191
448
|
|
|
192
449
|
def _extract_svg_content(self, svg_string: str) -> str:
|
|
193
450
|
"""Extract the inner content of an SVG, removing outer tags."""
|
|
194
|
-
svg_string = re.sub(r
|
|
195
|
-
match = re.search(r
|
|
451
|
+
svg_string = re.sub(r"<\?xml[^?]*\?>\s*", "", svg_string)
|
|
452
|
+
match = re.search(r"<svg[^>]*>(.*)</svg>", svg_string, re.DOTALL)
|
|
453
|
+
if match:
|
|
454
|
+
return match.group(1)
|
|
455
|
+
return ""
|
|
456
|
+
|
|
457
|
+
def _extract_svg_viewbox(self, svg_string: str) -> str:
|
|
458
|
+
"""Extract the viewBox attribute from an SVG string."""
|
|
459
|
+
match = re.search(r'viewBox=["\']([^"\']+)["\']', svg_string)
|
|
196
460
|
if match:
|
|
197
461
|
return match.group(1)
|
|
198
|
-
|
|
462
|
+
# Default to 64 64 for Architecture icons
|
|
463
|
+
return "0 0 64 64"
|
|
199
464
|
|
|
200
465
|
def _render_connection(
|
|
201
466
|
self,
|
|
202
467
|
source_pos: Position,
|
|
203
468
|
target_pos: Position,
|
|
204
|
-
connection: LogicalConnection
|
|
469
|
+
connection: LogicalConnection,
|
|
470
|
+
service_type_map: Optional[Dict[str, str]] = None,
|
|
205
471
|
) -> str:
|
|
206
472
|
"""Render a connection line between services."""
|
|
207
473
|
styles = {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
474
|
+
"data_flow": ("#3B48CC", "", "url(#arrowhead-data)"),
|
|
475
|
+
"trigger": ("#E7157B", "", "url(#arrowhead-trigger)"),
|
|
476
|
+
"encrypt": ("#6c757d", "4,4", "url(#arrowhead)"),
|
|
477
|
+
"network_flow": ("#0d7c3f", "", "url(#arrowhead-network)"),
|
|
478
|
+
"security_rule": ("#d97706", "2,4", "url(#arrowhead-security)"),
|
|
479
|
+
"default": ("#999999", "", "url(#arrowhead)"),
|
|
212
480
|
}
|
|
213
481
|
|
|
214
|
-
stroke_color, stroke_dash, marker = styles.get(
|
|
215
|
-
|
|
482
|
+
stroke_color, stroke_dash, marker = styles.get(
|
|
483
|
+
connection.connection_type, styles["default"]
|
|
484
|
+
)
|
|
485
|
+
dash_attr = f'stroke-dasharray="{stroke_dash}"' if stroke_dash else ""
|
|
216
486
|
|
|
217
487
|
# Calculate initial path
|
|
218
488
|
half_size = self.config.icon_size / 2
|
|
@@ -244,23 +514,30 @@ class SVGRenderer:
|
|
|
244
514
|
mid_y = (sy + ty) / 2
|
|
245
515
|
path = f"M {sx} {sy} Q {mid_x} {sy}, {mid_x} {mid_y} T {tx} {ty}"
|
|
246
516
|
|
|
247
|
-
label = connection.label or
|
|
248
|
-
|
|
517
|
+
label = connection.label or ""
|
|
518
|
+
source_type = ""
|
|
519
|
+
target_type = ""
|
|
520
|
+
if service_type_map:
|
|
521
|
+
source_type = service_type_map.get(connection.source_id, "")
|
|
522
|
+
target_type = service_type_map.get(connection.target_id, "")
|
|
523
|
+
return f"""
|
|
249
524
|
<g class="connection" data-source="{html.escape(connection.source_id)}"
|
|
250
525
|
data-target="{html.escape(connection.target_id)}"
|
|
526
|
+
data-source-type="{html.escape(source_type)}"
|
|
527
|
+
data-target-type="{html.escape(target_type)}"
|
|
251
528
|
data-conn-type="{connection.connection_type}"
|
|
252
529
|
data-label="{html.escape(label)}">
|
|
253
530
|
<path class="connection-hitarea" d="{path}" fill="none" stroke="transparent" stroke-width="15"/>
|
|
254
531
|
<path class="connection-path" d="{path}" fill="none" stroke="{stroke_color}"
|
|
255
532
|
stroke-width="1.5" {dash_attr} marker-end="{marker}" opacity="0.7"/>
|
|
256
533
|
</g>
|
|
257
|
-
|
|
534
|
+
"""
|
|
258
535
|
|
|
259
536
|
|
|
260
537
|
class HTMLRenderer:
|
|
261
538
|
"""Wraps SVG in interactive HTML with drag-and-drop and export."""
|
|
262
539
|
|
|
263
|
-
HTML_TEMPLATE =
|
|
540
|
+
HTML_TEMPLATE = """<!DOCTYPE html>
|
|
264
541
|
<html lang="en">
|
|
265
542
|
<head>
|
|
266
543
|
<meta charset="UTF-8">
|
|
@@ -272,7 +549,7 @@ class HTMLRenderer:
|
|
|
272
549
|
margin: 0;
|
|
273
550
|
padding: 20px;
|
|
274
551
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
275
|
-
background:
|
|
552
|
+
background: #2d2d2d;
|
|
276
553
|
min-height: 100vh;
|
|
277
554
|
}}
|
|
278
555
|
.container {{
|
|
@@ -349,7 +626,7 @@ class HTMLRenderer:
|
|
|
349
626
|
background: #dee2e6;
|
|
350
627
|
}}
|
|
351
628
|
.diagram-container {{
|
|
352
|
-
background:
|
|
629
|
+
background: #f8f9fa;
|
|
353
630
|
border-radius: 12px;
|
|
354
631
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
355
632
|
overflow: hidden;
|
|
@@ -385,13 +662,48 @@ class HTMLRenderer:
|
|
|
385
662
|
border-color: #ccc;
|
|
386
663
|
}}
|
|
387
664
|
.diagram-wrapper {{
|
|
388
|
-
padding:
|
|
389
|
-
overflow:
|
|
390
|
-
max-height: 70vh;
|
|
665
|
+
padding: 10px;
|
|
666
|
+
overflow: visible;
|
|
391
667
|
}}
|
|
392
668
|
.diagram-wrapper svg {{
|
|
393
669
|
display: block;
|
|
394
670
|
margin: 0 auto;
|
|
671
|
+
width: 100%;
|
|
672
|
+
height: auto;
|
|
673
|
+
max-height: none;
|
|
674
|
+
}}
|
|
675
|
+
@media (max-width: 1200px) {{
|
|
676
|
+
.header {{
|
|
677
|
+
flex-direction: column;
|
|
678
|
+
gap: 15px;
|
|
679
|
+
}}
|
|
680
|
+
.header-right {{
|
|
681
|
+
flex-direction: column;
|
|
682
|
+
width: 100%;
|
|
683
|
+
}}
|
|
684
|
+
.stats {{
|
|
685
|
+
justify-content: center;
|
|
686
|
+
}}
|
|
687
|
+
.export-buttons {{
|
|
688
|
+
justify-content: center;
|
|
689
|
+
}}
|
|
690
|
+
}}
|
|
691
|
+
@media (max-width: 768px) {{
|
|
692
|
+
body {{
|
|
693
|
+
padding: 10px;
|
|
694
|
+
}}
|
|
695
|
+
.header h1 {{
|
|
696
|
+
font-size: 18px;
|
|
697
|
+
}}
|
|
698
|
+
.stats {{
|
|
699
|
+
gap: 15px;
|
|
700
|
+
}}
|
|
701
|
+
.stat-value {{
|
|
702
|
+
font-size: 20px;
|
|
703
|
+
}}
|
|
704
|
+
.legend-grid {{
|
|
705
|
+
grid-template-columns: 1fr;
|
|
706
|
+
}}
|
|
395
707
|
}}
|
|
396
708
|
.service.dragging {{
|
|
397
709
|
opacity: 0.8;
|
|
@@ -430,6 +742,47 @@ class HTMLRenderer:
|
|
|
430
742
|
fill: none;
|
|
431
743
|
cursor: pointer;
|
|
432
744
|
}}
|
|
745
|
+
/* Spoke connection styles */
|
|
746
|
+
.spoke-connection {{
|
|
747
|
+
cursor: pointer;
|
|
748
|
+
transition: opacity 0.2s;
|
|
749
|
+
}}
|
|
750
|
+
.spoke-connection:hover .spoke-path {{
|
|
751
|
+
stroke-width: 4 !important;
|
|
752
|
+
opacity: 1 !important;
|
|
753
|
+
}}
|
|
754
|
+
.spoke-connection.highlighted .spoke-path {{
|
|
755
|
+
stroke-width: 4 !important;
|
|
756
|
+
opacity: 1 !important;
|
|
757
|
+
}}
|
|
758
|
+
.spoke-connection.dimmed {{
|
|
759
|
+
opacity: 0.15 !important;
|
|
760
|
+
}}
|
|
761
|
+
.spoke-hitarea {{
|
|
762
|
+
stroke: transparent;
|
|
763
|
+
stroke-width: 20;
|
|
764
|
+
fill: none;
|
|
765
|
+
cursor: pointer;
|
|
766
|
+
}}
|
|
767
|
+
/* Spoke rays - subtle lines from edge point to service icons */
|
|
768
|
+
.spoke-ray {{
|
|
769
|
+
stroke: #bbb;
|
|
770
|
+
stroke-width: 1;
|
|
771
|
+
opacity: 0.3;
|
|
772
|
+
transition: opacity 0.2s, stroke 0.2s;
|
|
773
|
+
}}
|
|
774
|
+
.spoke-rays.highlighted .spoke-ray {{
|
|
775
|
+
opacity: 0.6;
|
|
776
|
+
stroke: #888;
|
|
777
|
+
}}
|
|
778
|
+
.spoke-ray.highlighted {{
|
|
779
|
+
opacity: 0.8 !important;
|
|
780
|
+
stroke: #666 !important;
|
|
781
|
+
stroke-width: 1.5 !important;
|
|
782
|
+
}}
|
|
783
|
+
.spoke-ray.dimmed {{
|
|
784
|
+
opacity: 0.1 !important;
|
|
785
|
+
}}
|
|
433
786
|
.legend {{
|
|
434
787
|
margin-top: 20px;
|
|
435
788
|
padding: 20px 25px;
|
|
@@ -465,9 +818,23 @@ class HTMLRenderer:
|
|
|
465
818
|
font-size: 13px;
|
|
466
819
|
}}
|
|
467
820
|
.legend-line {{
|
|
468
|
-
width:
|
|
469
|
-
height:
|
|
470
|
-
|
|
821
|
+
width: 36px;
|
|
822
|
+
height: 14px;
|
|
823
|
+
flex-shrink: 0;
|
|
824
|
+
}}
|
|
825
|
+
.legend-line svg {{
|
|
826
|
+
display: block;
|
|
827
|
+
}}
|
|
828
|
+
.legend-box {{
|
|
829
|
+
width: 24px;
|
|
830
|
+
height: 16px;
|
|
831
|
+
border-radius: 3px;
|
|
832
|
+
border: 1.5px solid;
|
|
833
|
+
}}
|
|
834
|
+
.legend-circle {{
|
|
835
|
+
width: 20px;
|
|
836
|
+
height: 20px;
|
|
837
|
+
border-radius: 50%;
|
|
471
838
|
}}
|
|
472
839
|
.tooltip {{
|
|
473
840
|
position: fixed;
|
|
@@ -541,6 +908,168 @@ class HTMLRenderer:
|
|
|
541
908
|
margin-top: 8px;
|
|
542
909
|
font-size: 11px;
|
|
543
910
|
}}
|
|
911
|
+
/* ============ AGGREGATION UI ============ */
|
|
912
|
+
.aggregation-panel {{
|
|
913
|
+
margin-top: 15px;
|
|
914
|
+
padding: 15px 20px;
|
|
915
|
+
background: white;
|
|
916
|
+
border-radius: 12px;
|
|
917
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
918
|
+
display: flex;
|
|
919
|
+
align-items: center;
|
|
920
|
+
flex-wrap: wrap;
|
|
921
|
+
gap: 10px;
|
|
922
|
+
}}
|
|
923
|
+
.aggregation-panel-label {{
|
|
924
|
+
font-size: 13px;
|
|
925
|
+
font-weight: 600;
|
|
926
|
+
color: #232f3e;
|
|
927
|
+
margin-right: 5px;
|
|
928
|
+
}}
|
|
929
|
+
.aggregation-chip {{
|
|
930
|
+
display: inline-flex;
|
|
931
|
+
align-items: center;
|
|
932
|
+
gap: 6px;
|
|
933
|
+
padding: 6px 14px;
|
|
934
|
+
border-radius: 20px;
|
|
935
|
+
font-size: 12px;
|
|
936
|
+
font-weight: 500;
|
|
937
|
+
cursor: pointer;
|
|
938
|
+
transition: all 0.2s;
|
|
939
|
+
user-select: none;
|
|
940
|
+
border: 2px solid;
|
|
941
|
+
}}
|
|
942
|
+
.aggregation-chip.active {{
|
|
943
|
+
color: white;
|
|
944
|
+
}}
|
|
945
|
+
.aggregation-chip.inactive {{
|
|
946
|
+
background: transparent;
|
|
947
|
+
}}
|
|
948
|
+
.aggregation-chip:hover {{
|
|
949
|
+
transform: translateY(-1px);
|
|
950
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
951
|
+
}}
|
|
952
|
+
.aggregation-chip .chip-check {{
|
|
953
|
+
font-size: 11px;
|
|
954
|
+
}}
|
|
955
|
+
/* Aggregate node styles */
|
|
956
|
+
.aggregate-node .service-bg {{
|
|
957
|
+
stroke-dasharray: 6,3 !important;
|
|
958
|
+
stroke-width: 2 !important;
|
|
959
|
+
}}
|
|
960
|
+
.aggregate-node {{
|
|
961
|
+
cursor: pointer;
|
|
962
|
+
}}
|
|
963
|
+
.aggregate-badge {{
|
|
964
|
+
pointer-events: none;
|
|
965
|
+
}}
|
|
966
|
+
/* Aggregate connection styles */
|
|
967
|
+
.aggregate-connection .connection-path {{
|
|
968
|
+
opacity: 0.6;
|
|
969
|
+
}}
|
|
970
|
+
.aggregate-connection .multiplicity-label {{
|
|
971
|
+
font-family: Arial, sans-serif;
|
|
972
|
+
font-size: 10px;
|
|
973
|
+
font-weight: bold;
|
|
974
|
+
fill: #666;
|
|
975
|
+
}}
|
|
976
|
+
/* Popover styles */
|
|
977
|
+
.aggregate-popover {{
|
|
978
|
+
position: fixed;
|
|
979
|
+
background: white;
|
|
980
|
+
border-radius: 10px;
|
|
981
|
+
box-shadow: 0 8px 30px rgba(0,0,0,0.2);
|
|
982
|
+
z-index: 1500;
|
|
983
|
+
max-height: 300px;
|
|
984
|
+
overflow-y: auto;
|
|
985
|
+
min-width: 220px;
|
|
986
|
+
padding: 8px 0;
|
|
987
|
+
}}
|
|
988
|
+
.aggregate-popover-header {{
|
|
989
|
+
padding: 8px 16px;
|
|
990
|
+
font-size: 12px;
|
|
991
|
+
font-weight: 600;
|
|
992
|
+
color: #666;
|
|
993
|
+
border-bottom: 1px solid #eee;
|
|
994
|
+
text-transform: uppercase;
|
|
995
|
+
}}
|
|
996
|
+
.aggregate-popover-item {{
|
|
997
|
+
display: flex;
|
|
998
|
+
align-items: center;
|
|
999
|
+
gap: 10px;
|
|
1000
|
+
padding: 8px 16px;
|
|
1001
|
+
cursor: pointer;
|
|
1002
|
+
font-size: 13px;
|
|
1003
|
+
color: #333;
|
|
1004
|
+
transition: background 0.15s;
|
|
1005
|
+
}}
|
|
1006
|
+
.aggregate-popover-item:hover {{
|
|
1007
|
+
background: #f5f5f5;
|
|
1008
|
+
}}
|
|
1009
|
+
.aggregate-popover-item.selected {{
|
|
1010
|
+
background: #ede7f6;
|
|
1011
|
+
color: #6200ea;
|
|
1012
|
+
font-weight: 500;
|
|
1013
|
+
}}
|
|
1014
|
+
.aggregate-popover-item svg {{
|
|
1015
|
+
width: 24px;
|
|
1016
|
+
height: 24px;
|
|
1017
|
+
flex-shrink: 0;
|
|
1018
|
+
}}
|
|
1019
|
+
/* Transition animations for aggregation */
|
|
1020
|
+
.service.agg-hidden {{
|
|
1021
|
+
display: none !important;
|
|
1022
|
+
}}
|
|
1023
|
+
.connection.agg-hidden {{
|
|
1024
|
+
display: none !important;
|
|
1025
|
+
}}
|
|
1026
|
+
.connection.conn-type-hidden {{
|
|
1027
|
+
display: none !important;
|
|
1028
|
+
}}
|
|
1029
|
+
/* ============ CONNECTION TYPE FILTER UI ============ */
|
|
1030
|
+
.conn-filter-panel {{
|
|
1031
|
+
margin-top: 15px;
|
|
1032
|
+
padding: 15px 20px;
|
|
1033
|
+
background: white;
|
|
1034
|
+
border-radius: 12px;
|
|
1035
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
1036
|
+
display: flex;
|
|
1037
|
+
align-items: center;
|
|
1038
|
+
flex-wrap: wrap;
|
|
1039
|
+
gap: 10px;
|
|
1040
|
+
}}
|
|
1041
|
+
.conn-filter-panel-label {{
|
|
1042
|
+
font-size: 13px;
|
|
1043
|
+
font-weight: 600;
|
|
1044
|
+
color: #232f3e;
|
|
1045
|
+
margin-right: 5px;
|
|
1046
|
+
}}
|
|
1047
|
+
.conn-filter-chip {{
|
|
1048
|
+
display: inline-flex;
|
|
1049
|
+
align-items: center;
|
|
1050
|
+
gap: 6px;
|
|
1051
|
+
padding: 6px 14px;
|
|
1052
|
+
border-radius: 20px;
|
|
1053
|
+
font-size: 12px;
|
|
1054
|
+
font-weight: 500;
|
|
1055
|
+
cursor: pointer;
|
|
1056
|
+
transition: all 0.2s;
|
|
1057
|
+
user-select: none;
|
|
1058
|
+
border: 2px solid;
|
|
1059
|
+
}}
|
|
1060
|
+
.conn-filter-chip.active {{
|
|
1061
|
+
color: white;
|
|
1062
|
+
}}
|
|
1063
|
+
.conn-filter-chip.inactive {{
|
|
1064
|
+
background: transparent;
|
|
1065
|
+
}}
|
|
1066
|
+
.conn-filter-chip:hover {{
|
|
1067
|
+
transform: translateY(-1px);
|
|
1068
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
1069
|
+
}}
|
|
1070
|
+
.conn-filter-chip .chip-check {{
|
|
1071
|
+
font-size: 11px;
|
|
1072
|
+
}}
|
|
544
1073
|
</style>
|
|
545
1074
|
</head>
|
|
546
1075
|
<body>
|
|
@@ -584,6 +1113,14 @@ class HTMLRenderer:
|
|
|
584
1113
|
{svg_content}
|
|
585
1114
|
</div>
|
|
586
1115
|
</div>
|
|
1116
|
+
<div class="aggregation-panel" id="aggregation-panel" style="display:none;">
|
|
1117
|
+
<span class="aggregation-panel-label">Aggregation:</span>
|
|
1118
|
+
<div id="aggregation-chips" style="display:flex;flex-wrap:wrap;gap:8px;"></div>
|
|
1119
|
+
</div>
|
|
1120
|
+
<div class="conn-filter-panel" id="conn-filter-panel">
|
|
1121
|
+
<span class="conn-filter-panel-label">Connections:</span>
|
|
1122
|
+
<div id="conn-filter-chips" style="display:flex;flex-wrap:wrap;gap:8px;"></div>
|
|
1123
|
+
</div>
|
|
587
1124
|
<div class="legend">
|
|
588
1125
|
<h3>Legend</h3>
|
|
589
1126
|
<div class="legend-grid">
|
|
@@ -591,30 +1128,63 @@ class HTMLRenderer:
|
|
|
591
1128
|
<h4>Connection Types</h4>
|
|
592
1129
|
<div class="legend-items">
|
|
593
1130
|
<div class="legend-item">
|
|
594
|
-
<div class="legend-line"
|
|
1131
|
+
<div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-data" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#3B48CC"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#3B48CC" stroke-width="2" marker-end="url(#lm-data)"/></svg></div>
|
|
595
1132
|
<span>Data Flow</span>
|
|
596
1133
|
</div>
|
|
597
1134
|
<div class="legend-item">
|
|
598
|
-
<div class="legend-line"
|
|
1135
|
+
<div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-trigger" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#E7157B"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#E7157B" stroke-width="2" marker-end="url(#lm-trigger)"/></svg></div>
|
|
599
1136
|
<span>Event Trigger</span>
|
|
600
1137
|
</div>
|
|
601
1138
|
<div class="legend-item">
|
|
602
|
-
<div class="legend-line"
|
|
1139
|
+
<div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-encrypt" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#6c757d"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#6c757d" stroke-width="2" stroke-dasharray="4,3" marker-end="url(#lm-encrypt)"/></svg></div>
|
|
603
1140
|
<span>Encryption</span>
|
|
604
1141
|
</div>
|
|
605
1142
|
<div class="legend-item">
|
|
606
|
-
<div class="legend-line"
|
|
1143
|
+
<div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-ref" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#999"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#999" stroke-width="2" marker-end="url(#lm-ref)"/></svg></div>
|
|
607
1144
|
<span>Reference</span>
|
|
608
1145
|
</div>
|
|
1146
|
+
<div class="legend-item">
|
|
1147
|
+
<div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-network" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#0d7c3f"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#0d7c3f" stroke-width="2" marker-end="url(#lm-network)"/></svg></div>
|
|
1148
|
+
<span>Network Flow</span>
|
|
1149
|
+
</div>
|
|
1150
|
+
<div class="legend-item">
|
|
1151
|
+
<div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-security" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#d97706"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#d97706" stroke-width="2" stroke-dasharray="2,4" marker-end="url(#lm-security)"/></svg></div>
|
|
1152
|
+
<span>Security Rule</span>
|
|
1153
|
+
</div>
|
|
609
1154
|
</div>
|
|
610
1155
|
</div>
|
|
611
1156
|
<div class="legend-section">
|
|
612
|
-
<h4>
|
|
1157
|
+
<h4>Subnet Types</h4>
|
|
613
1158
|
<div class="legend-items">
|
|
1159
|
+
<div class="legend-item">
|
|
1160
|
+
<div class="legend-box" style="background: #e3fcef; border-color: #22a06b;"></div>
|
|
1161
|
+
<span>Public Subnet</span>
|
|
1162
|
+
</div>
|
|
1163
|
+
<div class="legend-item">
|
|
1164
|
+
<div class="legend-box" style="background: #deebff; border-color: #0052cc;"></div>
|
|
1165
|
+
<span>Private Subnet</span>
|
|
1166
|
+
</div>
|
|
1167
|
+
<div class="legend-item">
|
|
1168
|
+
<div class="legend-box" style="background: #fffae6; border-color: #ff991f;"></div>
|
|
1169
|
+
<span>Database Subnet</span>
|
|
1170
|
+
</div>
|
|
1171
|
+
</div>
|
|
1172
|
+
</div>
|
|
1173
|
+
<div class="legend-section">
|
|
1174
|
+
<h4>VPC Endpoints</h4>
|
|
1175
|
+
<div class="legend-items">
|
|
1176
|
+
<div class="legend-item"><span><strong>Gateway</strong> — S3, DynamoDB</span></div>
|
|
1177
|
+
<div class="legend-item"><span><strong>Interface</strong> — ECR, Logs, etc.</span></div>
|
|
1178
|
+
</div>
|
|
1179
|
+
</div>
|
|
1180
|
+
<div class="legend-section">
|
|
1181
|
+
<h4>Interactions</h4>
|
|
1182
|
+
<div class="legend-items">
|
|
1183
|
+
<div class="legend-item">Click service to highlight connections</div>
|
|
1184
|
+
<div class="legend-item">Click connection to highlight endpoints</div>
|
|
614
1185
|
<div class="legend-item">Drag icons to reposition</div>
|
|
615
|
-
<div class="legend-item">
|
|
616
|
-
<div class="legend-item">
|
|
617
|
-
<div class="legend-item">Export as PNG or JPG for sharing</div>
|
|
1186
|
+
<div class="legend-item">Toggle connection types and aggregation</div>
|
|
1187
|
+
<div class="legend-item">Save/Load to persist layout</div>
|
|
618
1188
|
</div>
|
|
619
1189
|
</div>
|
|
620
1190
|
</div>
|
|
@@ -634,6 +1204,10 @@ class HTMLRenderer:
|
|
|
634
1204
|
</div>
|
|
635
1205
|
</div>
|
|
636
1206
|
|
|
1207
|
+
<script>
|
|
1208
|
+
// Aggregation configuration (injected by Python)
|
|
1209
|
+
const AGGREGATION_CONFIG = {aggregation_config_json};
|
|
1210
|
+
</script>
|
|
637
1211
|
<script>
|
|
638
1212
|
// Service positions storage
|
|
639
1213
|
const servicePositions = {{}};
|
|
@@ -647,6 +1221,8 @@ class HTMLRenderer:
|
|
|
647
1221
|
initHighlighting();
|
|
648
1222
|
updateAllConnections();
|
|
649
1223
|
saveOriginalPositions();
|
|
1224
|
+
initAggregation();
|
|
1225
|
+
initConnectionTypeFilter();
|
|
650
1226
|
}});
|
|
651
1227
|
|
|
652
1228
|
function saveOriginalPositions() {{
|
|
@@ -666,22 +1242,23 @@ class HTMLRenderer:
|
|
|
666
1242
|
let dragging = null;
|
|
667
1243
|
let offset = {{ x: 0, y: 0 }};
|
|
668
1244
|
|
|
669
|
-
|
|
670
|
-
|
|
1245
|
+
// Use event delegation on SVG for mousedown to handle dynamically created nodes
|
|
1246
|
+
svg.addEventListener('mousedown', (e) => {{
|
|
1247
|
+
const target = e.target.closest('.service.draggable');
|
|
1248
|
+
if (target) startDrag(e, target);
|
|
671
1249
|
}});
|
|
672
|
-
|
|
673
1250
|
svg.addEventListener('mousemove', drag);
|
|
674
1251
|
svg.addEventListener('mouseup', endDrag);
|
|
675
1252
|
svg.addEventListener('mouseleave', endDrag);
|
|
676
1253
|
|
|
677
|
-
function startDrag(e) {{
|
|
1254
|
+
function startDrag(e, targetEl) {{
|
|
678
1255
|
e.preventDefault();
|
|
679
1256
|
|
|
680
1257
|
// Guard against null CTM (can happen during rendering)
|
|
681
1258
|
const ctm = svg.getScreenCTM();
|
|
682
1259
|
if (!ctm) return;
|
|
683
1260
|
|
|
684
|
-
dragging =
|
|
1261
|
+
dragging = targetEl;
|
|
685
1262
|
dragging.classList.add('dragging');
|
|
686
1263
|
dragging.style.cursor = 'grabbing';
|
|
687
1264
|
|
|
@@ -725,8 +1302,23 @@ class HTMLRenderer:
|
|
|
725
1302
|
let newX = svgP.x - offset.x;
|
|
726
1303
|
let newY = svgP.y - offset.y;
|
|
727
1304
|
|
|
728
|
-
//
|
|
729
|
-
|
|
1305
|
+
// Check if service belongs to a specific subnet
|
|
1306
|
+
const subnetId = dragging.dataset.subnetId;
|
|
1307
|
+
if (subnetId) {{
|
|
1308
|
+
// Constrain to subnet bounds
|
|
1309
|
+
const subnetGroup = document.querySelector(`.subnet[data-subnet-id="${{subnetId}}"]`);
|
|
1310
|
+
if (subnetGroup) {{
|
|
1311
|
+
const padding = 10;
|
|
1312
|
+
const minX = parseFloat(subnetGroup.dataset.minX) + padding;
|
|
1313
|
+
const minY = parseFloat(subnetGroup.dataset.minY) + padding;
|
|
1314
|
+
const maxX = parseFloat(subnetGroup.dataset.maxX) - iconSize - padding;
|
|
1315
|
+
const maxY = parseFloat(subnetGroup.dataset.maxY) - iconSize - padding;
|
|
1316
|
+
|
|
1317
|
+
newX = Math.max(minX, Math.min(maxX, newX));
|
|
1318
|
+
newY = Math.max(minY, Math.min(maxY, newY));
|
|
1319
|
+
}}
|
|
1320
|
+
}} else if (dragging.dataset.isVpc === 'true') {{
|
|
1321
|
+
// Constrain to VPC bounds
|
|
730
1322
|
const vpcGroup = document.querySelector('.group-vpc .group-bg');
|
|
731
1323
|
if (vpcGroup) {{
|
|
732
1324
|
const minX = parseFloat(vpcGroup.dataset.minX) + 20;
|
|
@@ -736,18 +1328,75 @@ class HTMLRenderer:
|
|
|
736
1328
|
|
|
737
1329
|
newX = Math.max(minX, Math.min(maxX, newX));
|
|
738
1330
|
newY = Math.max(minY, Math.min(maxY, newY));
|
|
1331
|
+
|
|
1332
|
+
// VPC services without subnet assignment cannot enter subnet areas
|
|
1333
|
+
if (!dragging.dataset.subnetId) {{
|
|
1334
|
+
document.querySelectorAll('.subnet').forEach(sub => {{
|
|
1335
|
+
const sMinX = parseFloat(sub.dataset.minX);
|
|
1336
|
+
const sMinY = parseFloat(sub.dataset.minY);
|
|
1337
|
+
const sMaxX = parseFloat(sub.dataset.maxX);
|
|
1338
|
+
const sMaxY = parseFloat(sub.dataset.maxY);
|
|
1339
|
+
const nodeR = newX + iconSize;
|
|
1340
|
+
const nodeB = newY + iconSize;
|
|
1341
|
+
if (nodeR > sMinX && newX < sMaxX && nodeB > sMinY && newY < sMaxY) {{
|
|
1342
|
+
const dL = Math.abs(nodeR - sMinX);
|
|
1343
|
+
const dR = Math.abs(newX - sMaxX);
|
|
1344
|
+
const dT = Math.abs(nodeB - sMinY);
|
|
1345
|
+
const dB = Math.abs(newY - sMaxY);
|
|
1346
|
+
const m = Math.min(dL, dR, dT, dB);
|
|
1347
|
+
if (m === dT) newY = sMinY - iconSize;
|
|
1348
|
+
else if (m === dB) newY = sMaxY;
|
|
1349
|
+
else if (m === dL) newX = sMinX - iconSize;
|
|
1350
|
+
else newX = sMaxX;
|
|
1351
|
+
}}
|
|
1352
|
+
}});
|
|
1353
|
+
}}
|
|
739
1354
|
}}
|
|
740
1355
|
}} else {{
|
|
741
|
-
//
|
|
1356
|
+
// AWS Cloud bounds - expandable downward
|
|
742
1357
|
const cloudGroup = document.querySelector('.group-aws_cloud .group-bg');
|
|
743
1358
|
if (cloudGroup) {{
|
|
744
1359
|
const minX = parseFloat(cloudGroup.dataset.minX) + 20;
|
|
745
1360
|
const minY = parseFloat(cloudGroup.dataset.minY) + 40;
|
|
746
1361
|
const maxX = parseFloat(cloudGroup.dataset.maxX) - iconSize - 20;
|
|
747
|
-
const
|
|
1362
|
+
const currentMaxY = parseFloat(cloudGroup.dataset.maxY);
|
|
748
1363
|
|
|
1364
|
+
// Constrain X and minY, but allow expansion downward
|
|
749
1365
|
newX = Math.max(minX, Math.min(maxX, newX));
|
|
750
|
-
newY = Math.max(minY,
|
|
1366
|
+
newY = Math.max(minY, newY);
|
|
1367
|
+
|
|
1368
|
+
// Prevent global services from entering the VPC area
|
|
1369
|
+
const vpcBg = document.querySelector('.group-vpc .group-bg');
|
|
1370
|
+
if (vpcBg) {{
|
|
1371
|
+
const vpcMinX = parseFloat(vpcBg.dataset.minX);
|
|
1372
|
+
const vpcMinY = parseFloat(vpcBg.dataset.minY);
|
|
1373
|
+
const vpcMaxX = parseFloat(vpcBg.dataset.maxX);
|
|
1374
|
+
const vpcMaxY = parseFloat(vpcBg.dataset.maxY);
|
|
1375
|
+
const nodeRight = newX + iconSize;
|
|
1376
|
+
const nodeBottom = newY + iconSize;
|
|
1377
|
+
|
|
1378
|
+
// Check if node overlaps VPC box
|
|
1379
|
+
if (nodeRight > vpcMinX && newX < vpcMaxX &&
|
|
1380
|
+
nodeBottom > vpcMinY && newY < vpcMaxY) {{
|
|
1381
|
+
// Push to nearest edge outside VPC
|
|
1382
|
+
const distLeft = Math.abs(nodeRight - vpcMinX);
|
|
1383
|
+
const distRight = Math.abs(newX - vpcMaxX);
|
|
1384
|
+
const distTop = Math.abs(nodeBottom - vpcMinY);
|
|
1385
|
+
const distBottom = Math.abs(newY - vpcMaxY);
|
|
1386
|
+
const minDist = Math.min(distLeft, distRight, distTop, distBottom);
|
|
1387
|
+
|
|
1388
|
+
if (minDist === distTop) newY = vpcMinY - iconSize;
|
|
1389
|
+
else if (minDist === distBottom) newY = vpcMaxY;
|
|
1390
|
+
else if (minDist === distLeft) newX = vpcMinX - iconSize;
|
|
1391
|
+
else newX = vpcMaxX;
|
|
1392
|
+
}}
|
|
1393
|
+
}}
|
|
1394
|
+
|
|
1395
|
+
// Expand AWS Cloud box and canvas if dragging below current bounds
|
|
1396
|
+
const requiredBottom = newY + iconSize + 40;
|
|
1397
|
+
if (requiredBottom > currentMaxY) {{
|
|
1398
|
+
expandCanvas(requiredBottom);
|
|
1399
|
+
}}
|
|
751
1400
|
}}
|
|
752
1401
|
}}
|
|
753
1402
|
|
|
@@ -767,13 +1416,48 @@ class HTMLRenderer:
|
|
|
767
1416
|
}}
|
|
768
1417
|
}}
|
|
769
1418
|
|
|
770
|
-
function
|
|
1419
|
+
function expandCanvas(newBottom) {{
|
|
1420
|
+
const svg = document.getElementById('diagram-svg');
|
|
1421
|
+
const cloudGroup = document.querySelector('.group-aws_cloud .group-bg');
|
|
1422
|
+
|
|
1423
|
+
if (!cloudGroup || !svg) return;
|
|
1424
|
+
|
|
1425
|
+
// Get current viewBox
|
|
1426
|
+
const viewBox = svg.getAttribute('viewBox').split(' ').map(Number);
|
|
1427
|
+
const currentHeight = viewBox[3];
|
|
1428
|
+
|
|
1429
|
+
// Small margin below content (matching layout.py)
|
|
1430
|
+
const bottomMargin = 20;
|
|
1431
|
+
const padding = {icon_size} > 64 ? 45 : 30; // Approximate padding based on scale
|
|
1432
|
+
const newHeight = Math.max(currentHeight, newBottom + bottomMargin);
|
|
1433
|
+
|
|
1434
|
+
// Only expand if needed
|
|
1435
|
+
if (newHeight <= currentHeight) return;
|
|
1436
|
+
|
|
1437
|
+
// Update SVG viewBox - this automatically resizes the container
|
|
1438
|
+
svg.setAttribute('viewBox', `${{viewBox[0]}} ${{viewBox[1]}} ${{viewBox[2]}} ${{newHeight}}`);
|
|
1439
|
+
|
|
1440
|
+
// Expand AWS Cloud box to fill the entire canvas (minus margin)
|
|
1441
|
+
const minY = parseFloat(cloudGroup.dataset.minY);
|
|
1442
|
+
const newMaxY = newHeight - bottomMargin;
|
|
1443
|
+
|
|
1444
|
+
cloudGroup.dataset.maxY = newMaxY;
|
|
1445
|
+
|
|
1446
|
+
// Update the AWS Cloud rect to fill the space
|
|
1447
|
+
const awsRect = document.querySelector('.group-aws_cloud rect');
|
|
1448
|
+
if (awsRect) {{
|
|
1449
|
+
awsRect.setAttribute('height', newMaxY - minY);
|
|
1450
|
+
}}
|
|
1451
|
+
}}
|
|
1452
|
+
|
|
1453
|
+
var _baseUpdateConnectionsFor = function(serviceId) {{
|
|
771
1454
|
document.querySelectorAll('.connection').forEach(conn => {{
|
|
772
1455
|
if (conn.dataset.source === serviceId || conn.dataset.target === serviceId) {{
|
|
773
1456
|
updateConnection(conn);
|
|
774
1457
|
}}
|
|
775
1458
|
}});
|
|
776
|
-
}}
|
|
1459
|
+
}};
|
|
1460
|
+
var updateConnectionsFor = _baseUpdateConnectionsFor;
|
|
777
1461
|
|
|
778
1462
|
function updateAllConnections() {{
|
|
779
1463
|
document.querySelectorAll('.connection').forEach(updateConnection);
|
|
@@ -829,50 +1513,58 @@ class HTMLRenderer:
|
|
|
829
1513
|
if (hitareaEl) {{
|
|
830
1514
|
hitareaEl.setAttribute('d', path);
|
|
831
1515
|
}}
|
|
1516
|
+
|
|
1517
|
+
// Update multiplicity label position if present
|
|
1518
|
+
const multLabel = connEl.querySelector('.multiplicity-label');
|
|
1519
|
+
if (multLabel) {{
|
|
1520
|
+
const labelMidX = (sourcePos.x + targetPos.x) / 2 + halfSize;
|
|
1521
|
+
const labelMidY = (sourcePos.y + targetPos.y) / 2 + halfSize;
|
|
1522
|
+
multLabel.setAttribute('x', `${{labelMidX + 8}}`);
|
|
1523
|
+
multLabel.setAttribute('y', `${{labelMidY - 5}}`);
|
|
1524
|
+
}}
|
|
832
1525
|
}}
|
|
833
1526
|
|
|
834
1527
|
// ============ HIGHLIGHTING SYSTEM ============
|
|
835
1528
|
let currentHighlight = null;
|
|
836
1529
|
|
|
837
1530
|
function initHighlighting() {{
|
|
838
|
-
|
|
839
|
-
document.querySelectorAll('.service').forEach(el => {{
|
|
840
|
-
el.addEventListener('click', (e) => {{
|
|
841
|
-
// Don't highlight if dragging
|
|
842
|
-
if (el.classList.contains('dragging')) return;
|
|
843
|
-
e.stopPropagation();
|
|
1531
|
+
const svg = document.getElementById('diagram-svg');
|
|
844
1532
|
|
|
845
|
-
|
|
1533
|
+
// Use event delegation on SVG for dynamic element support
|
|
1534
|
+
svg.addEventListener('click', (e) => {{
|
|
1535
|
+
// Check if clicked on a service
|
|
1536
|
+
const serviceEl = e.target.closest('.service');
|
|
1537
|
+
if (serviceEl) {{
|
|
1538
|
+
if (serviceEl.classList.contains('dragging')) return;
|
|
1539
|
+
// Don't interfere with aggregate node popover (handled separately)
|
|
1540
|
+
if (serviceEl.classList.contains('aggregate-node')) return;
|
|
1541
|
+
e.stopPropagation();
|
|
846
1542
|
|
|
847
|
-
|
|
1543
|
+
const serviceId = serviceEl.dataset.serviceId;
|
|
848
1544
|
if (currentHighlight === serviceId) {{
|
|
849
1545
|
clearHighlights();
|
|
850
1546
|
}} else {{
|
|
851
1547
|
highlightService(serviceId);
|
|
852
1548
|
}}
|
|
853
|
-
|
|
854
|
-
|
|
1549
|
+
return;
|
|
1550
|
+
}}
|
|
855
1551
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1552
|
+
// Check if clicked on a connection
|
|
1553
|
+
const connEl = e.target.closest('.connection');
|
|
1554
|
+
if (connEl) {{
|
|
859
1555
|
e.stopPropagation();
|
|
860
|
-
|
|
861
|
-
const
|
|
862
|
-
const targetId = el.dataset.target;
|
|
1556
|
+
const sourceId = connEl.dataset.source;
|
|
1557
|
+
const targetId = connEl.dataset.target;
|
|
863
1558
|
const connKey = `conn:${{sourceId}}->${{targetId}}`;
|
|
864
|
-
|
|
865
|
-
// Toggle highlight
|
|
866
1559
|
if (currentHighlight === connKey) {{
|
|
867
1560
|
clearHighlights();
|
|
868
1561
|
}} else {{
|
|
869
|
-
highlightConnection(
|
|
1562
|
+
highlightConnection(connEl, sourceId, targetId);
|
|
870
1563
|
}}
|
|
871
|
-
|
|
872
|
-
|
|
1564
|
+
return;
|
|
1565
|
+
}}
|
|
873
1566
|
|
|
874
|
-
|
|
875
|
-
document.getElementById('diagram-svg').addEventListener('click', (e) => {{
|
|
1567
|
+
// Clicked on background
|
|
876
1568
|
if (e.target.tagName === 'svg' || e.target.classList.contains('group-bg')) {{
|
|
877
1569
|
clearHighlights();
|
|
878
1570
|
}}
|
|
@@ -883,17 +1575,17 @@ class HTMLRenderer:
|
|
|
883
1575
|
clearHighlights();
|
|
884
1576
|
currentHighlight = serviceId;
|
|
885
1577
|
|
|
886
|
-
// Find all
|
|
887
|
-
const
|
|
1578
|
+
// Find all connections involving this service
|
|
1579
|
+
const connectedServiceIds = new Set([serviceId]);
|
|
888
1580
|
const connectedConnections = [];
|
|
889
1581
|
|
|
890
|
-
document.querySelectorAll('.connection').forEach(conn => {{
|
|
891
|
-
const
|
|
892
|
-
const
|
|
1582
|
+
document.querySelectorAll('.connection:not(.conn-type-hidden)').forEach(conn => {{
|
|
1583
|
+
const srcId = conn.dataset.source;
|
|
1584
|
+
const tgtId = conn.dataset.target;
|
|
893
1585
|
|
|
894
|
-
if (
|
|
895
|
-
|
|
896
|
-
|
|
1586
|
+
if (srcId === serviceId || tgtId === serviceId) {{
|
|
1587
|
+
connectedServiceIds.add(srcId);
|
|
1588
|
+
connectedServiceIds.add(tgtId);
|
|
897
1589
|
connectedConnections.push(conn);
|
|
898
1590
|
}}
|
|
899
1591
|
}});
|
|
@@ -907,22 +1599,22 @@ class HTMLRenderer:
|
|
|
907
1599
|
}});
|
|
908
1600
|
|
|
909
1601
|
// Highlight connected services
|
|
910
|
-
|
|
911
|
-
const
|
|
912
|
-
if (
|
|
1602
|
+
document.querySelectorAll('.service').forEach(el => {{
|
|
1603
|
+
const elId = el.dataset.serviceId;
|
|
1604
|
+
if (connectedServiceIds.has(elId)) {{
|
|
913
1605
|
el.classList.remove('dimmed');
|
|
914
1606
|
el.classList.add('highlighted');
|
|
915
1607
|
}}
|
|
916
1608
|
}});
|
|
917
1609
|
|
|
918
|
-
// Highlight
|
|
1610
|
+
// Highlight connections
|
|
919
1611
|
connectedConnections.forEach(conn => {{
|
|
920
1612
|
conn.classList.remove('dimmed');
|
|
921
1613
|
conn.classList.add('highlighted');
|
|
922
1614
|
}});
|
|
923
1615
|
|
|
924
1616
|
// Show info tooltip
|
|
925
|
-
showHighlightInfo(serviceId,
|
|
1617
|
+
showHighlightInfo(serviceId, connectedServiceIds.size - 1, connectedConnections.length);
|
|
926
1618
|
}}
|
|
927
1619
|
|
|
928
1620
|
function highlightConnection(connEl, sourceId, targetId) {{
|
|
@@ -1005,28 +1697,41 @@ class HTMLRenderer:
|
|
|
1005
1697
|
|
|
1006
1698
|
function initTooltips() {{
|
|
1007
1699
|
const tooltip = document.getElementById('tooltip');
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1700
|
+
const svg = document.getElementById('diagram-svg');
|
|
1701
|
+
let tooltipTarget = null;
|
|
1702
|
+
|
|
1703
|
+
// Use event delegation on SVG for tooltip support on dynamic elements
|
|
1704
|
+
svg.addEventListener('mouseover', (e) => {{
|
|
1705
|
+
const service = e.target.closest('.service');
|
|
1706
|
+
if (service && service !== tooltipTarget) {{
|
|
1707
|
+
tooltipTarget = service;
|
|
1708
|
+
if (service.classList.contains('dragging')) return;
|
|
1709
|
+
const data = service.dataset.tooltip;
|
|
1013
1710
|
if (data) {{
|
|
1014
1711
|
tooltip.textContent = data;
|
|
1015
1712
|
tooltip.style.display = 'block';
|
|
1016
1713
|
}}
|
|
1017
|
-
}}
|
|
1018
|
-
|
|
1019
|
-
|
|
1714
|
+
}}
|
|
1715
|
+
}});
|
|
1716
|
+
svg.addEventListener('mousemove', (e) => {{
|
|
1717
|
+
if (tooltipTarget && !tooltipTarget.classList.contains('dragging')) {{
|
|
1020
1718
|
tooltip.style.left = e.clientX + 15 + 'px';
|
|
1021
1719
|
tooltip.style.top = e.clientY + 15 + 'px';
|
|
1022
|
-
}}
|
|
1023
|
-
|
|
1720
|
+
}}
|
|
1721
|
+
}});
|
|
1722
|
+
svg.addEventListener('mouseout', (e) => {{
|
|
1723
|
+
const service = e.target.closest('.service');
|
|
1724
|
+
if (service && service === tooltipTarget) {{
|
|
1725
|
+
// Check if we're leaving to a child element (not really leaving)
|
|
1726
|
+
const related = e.relatedTarget;
|
|
1727
|
+
if (related && service.contains(related)) return;
|
|
1728
|
+
tooltipTarget = null;
|
|
1024
1729
|
tooltip.style.display = 'none';
|
|
1025
|
-
}}
|
|
1730
|
+
}}
|
|
1026
1731
|
}});
|
|
1027
1732
|
}}
|
|
1028
1733
|
|
|
1029
|
-
|
|
1734
|
+
var resetPositions = function() {{
|
|
1030
1735
|
Object.keys(originalPositions).forEach(id => {{
|
|
1031
1736
|
servicePositions[id] = {{ ...originalPositions[id] }};
|
|
1032
1737
|
const el = document.querySelector(`[data-service-id="${{id}}"]`);
|
|
@@ -1035,15 +1740,15 @@ class HTMLRenderer:
|
|
|
1035
1740
|
}}
|
|
1036
1741
|
}});
|
|
1037
1742
|
updateAllConnections();
|
|
1038
|
-
}}
|
|
1743
|
+
}};
|
|
1039
1744
|
|
|
1040
|
-
|
|
1745
|
+
var savePositions = function() {{
|
|
1041
1746
|
const data = JSON.stringify(servicePositions);
|
|
1042
1747
|
localStorage.setItem('diagramPositions', data);
|
|
1043
1748
|
alert('Layout saved to browser storage!');
|
|
1044
|
-
}}
|
|
1749
|
+
}};
|
|
1045
1750
|
|
|
1046
|
-
|
|
1751
|
+
var loadPositions = function() {{
|
|
1047
1752
|
const data = localStorage.getItem('diagramPositions');
|
|
1048
1753
|
if (!data) {{
|
|
1049
1754
|
alert('No saved layout found.');
|
|
@@ -1062,50 +1767,67 @@ class HTMLRenderer:
|
|
|
1062
1767
|
}});
|
|
1063
1768
|
updateAllConnections();
|
|
1064
1769
|
alert('Layout loaded!');
|
|
1065
|
-
}}
|
|
1770
|
+
}};
|
|
1066
1771
|
|
|
1067
1772
|
function exportAs(format) {{
|
|
1068
1773
|
const svg = document.getElementById('diagram-svg');
|
|
1069
1774
|
const canvas = document.getElementById('export-canvas');
|
|
1070
1775
|
const ctx = canvas.getContext('2d');
|
|
1071
1776
|
|
|
1072
|
-
|
|
1073
|
-
const
|
|
1777
|
+
const vbW = svg.viewBox.baseVal.width;
|
|
1778
|
+
const vbH = svg.viewBox.baseVal.height;
|
|
1074
1779
|
const scale = 2; // Higher resolution
|
|
1075
|
-
canvas.width =
|
|
1076
|
-
canvas.height =
|
|
1780
|
+
canvas.width = vbW * scale;
|
|
1781
|
+
canvas.height = vbH * scale;
|
|
1782
|
+
|
|
1783
|
+
// Clone SVG so we can modify attributes for standalone rendering
|
|
1784
|
+
const svgClone = svg.cloneNode(true);
|
|
1785
|
+
// Set explicit pixel dimensions (width="100%" won't resolve in a blob image)
|
|
1786
|
+
svgClone.setAttribute('width', vbW);
|
|
1787
|
+
svgClone.setAttribute('height', vbH);
|
|
1788
|
+
svgClone.removeAttribute('style');
|
|
1789
|
+
|
|
1790
|
+
// Embed essential CSS inside the SVG for standalone rendering
|
|
1791
|
+
const styleEl = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
1792
|
+
styleEl.textContent = `
|
|
1793
|
+
.agg-hidden {{ display: none !important; }}
|
|
1794
|
+
.conn-type-hidden {{ display: none !important; }}
|
|
1795
|
+
`;
|
|
1796
|
+
svgClone.insertBefore(styleEl, svgClone.firstChild);
|
|
1077
1797
|
|
|
1078
|
-
//
|
|
1079
|
-
const svgData = new XMLSerializer().serializeToString(
|
|
1080
|
-
const
|
|
1081
|
-
const
|
|
1798
|
+
// Serialize and encode as data URI (avoids canvas taint from blob URLs)
|
|
1799
|
+
const svgData = new XMLSerializer().serializeToString(svgClone);
|
|
1800
|
+
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
|
|
1801
|
+
const dataUri = 'data:image/svg+xml;base64,' + svgBase64;
|
|
1082
1802
|
|
|
1083
1803
|
const img = new Image();
|
|
1084
1804
|
img.onload = () => {{
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
ctx.fillStyle = 'white';
|
|
1088
|
-
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
1089
|
-
}}
|
|
1805
|
+
ctx.fillStyle = 'white';
|
|
1806
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
1090
1807
|
|
|
1091
1808
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
1092
|
-
URL.revokeObjectURL(url);
|
|
1093
1809
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1810
|
+
try {{
|
|
1811
|
+
const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
|
|
1812
|
+
const quality = format === 'jpg' ? 0.95 : undefined;
|
|
1813
|
+
const dataUrl = canvas.toDataURL(mimeType, quality);
|
|
1097
1814
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
const download = document.getElementById('export-download');
|
|
1815
|
+
const preview = document.getElementById('export-preview');
|
|
1816
|
+
const download = document.getElementById('export-download');
|
|
1101
1817
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1818
|
+
preview.src = dataUrl;
|
|
1819
|
+
download.href = dataUrl;
|
|
1820
|
+
download.download = `aws-diagram.${{format}}`;
|
|
1105
1821
|
|
|
1106
|
-
|
|
1822
|
+
document.getElementById('export-modal').classList.add('active');
|
|
1823
|
+
}} catch (err) {{
|
|
1824
|
+
alert('Export failed: ' + err.message);
|
|
1825
|
+
}}
|
|
1107
1826
|
}};
|
|
1108
|
-
img.
|
|
1827
|
+
img.onerror = () => {{
|
|
1828
|
+
alert('Failed to render SVG for export.');
|
|
1829
|
+
}};
|
|
1830
|
+
img.src = dataUri;
|
|
1109
1831
|
}}
|
|
1110
1832
|
|
|
1111
1833
|
function closeExportModal() {{
|
|
@@ -1118,9 +1840,950 @@ class HTMLRenderer:
|
|
|
1118
1840
|
closeExportModal();
|
|
1119
1841
|
}}
|
|
1120
1842
|
}});
|
|
1843
|
+
|
|
1844
|
+
// ============ AGGREGATION SYSTEM ============
|
|
1845
|
+
const aggregationState = {{}}; // {{ serviceType: bool }} true=aggregated
|
|
1846
|
+
const originalConnections = []; // snapshot of all original connections
|
|
1847
|
+
const aggregateNodes = {{}}; // {{ serviceType: SVGGElement }}
|
|
1848
|
+
const aggregateConnections = {{}}; // {{ serviceType: [SVGGElement...] }}
|
|
1849
|
+
let activePopover = null;
|
|
1850
|
+
let selectedPopoverResource = null;
|
|
1851
|
+
|
|
1852
|
+
// Connection type filter state: {{ connType: bool }} true=visible
|
|
1853
|
+
const CONNECTION_TYPES = [
|
|
1854
|
+
{{ id: 'data_flow', label: 'Data Flow', color: '#3B48CC' }},
|
|
1855
|
+
{{ id: 'trigger', label: 'Event Trigger', color: '#E7157B' }},
|
|
1856
|
+
{{ id: 'encrypt', label: 'Encryption', color: '#6c757d' }},
|
|
1857
|
+
{{ id: 'network_flow', label: 'Network Flow', color: '#0d7c3f' }},
|
|
1858
|
+
{{ id: 'security_rule', label: 'Security Rule', color: '#d97706' }},
|
|
1859
|
+
{{ id: 'default', label: 'Reference', color: '#999999' }}
|
|
1860
|
+
];
|
|
1861
|
+
const connTypeFilterState = {{}};
|
|
1862
|
+
|
|
1863
|
+
function initAggregation() {{
|
|
1864
|
+
if (!AGGREGATION_CONFIG || !AGGREGATION_CONFIG.groups) return;
|
|
1865
|
+
|
|
1866
|
+
// Check if any group qualifies for aggregation
|
|
1867
|
+
const qualifyingGroups = Object.entries(AGGREGATION_CONFIG.groups)
|
|
1868
|
+
.filter(([_, g]) => g.count >= AGGREGATION_CONFIG.threshold);
|
|
1869
|
+
if (qualifyingGroups.length === 0) return;
|
|
1870
|
+
|
|
1871
|
+
// Snapshot all original connections from DOM
|
|
1872
|
+
snapshotConnections();
|
|
1873
|
+
|
|
1874
|
+
// Load saved state from localStorage or use defaults
|
|
1875
|
+
const savedState = localStorage.getItem('diagramAggregationState');
|
|
1876
|
+
let loaded = null;
|
|
1877
|
+
if (savedState) {{
|
|
1878
|
+
try {{ loaded = JSON.parse(savedState); }} catch(e) {{}}
|
|
1879
|
+
}}
|
|
1880
|
+
|
|
1881
|
+
for (const [stype, group] of Object.entries(AGGREGATION_CONFIG.groups)) {{
|
|
1882
|
+
if (group.count >= AGGREGATION_CONFIG.threshold) {{
|
|
1883
|
+
aggregationState[stype] = loaded ? !!loaded[stype] : group.defaultAggregated;
|
|
1884
|
+
}}
|
|
1885
|
+
}}
|
|
1886
|
+
|
|
1887
|
+
// Render chip panel
|
|
1888
|
+
renderChipPanel();
|
|
1889
|
+
|
|
1890
|
+
// Apply initial aggregation (skip per-group connection recalc)
|
|
1891
|
+
for (const [stype, isAgg] of Object.entries(aggregationState)) {{
|
|
1892
|
+
if (isAgg) {{
|
|
1893
|
+
aggregateGroup(stype, true);
|
|
1894
|
+
}}
|
|
1895
|
+
}}
|
|
1896
|
+
|
|
1897
|
+
// Recalculate all connections once, considering all aggregated groups
|
|
1898
|
+
recalculateAllAggregateConnections();
|
|
1899
|
+
}}
|
|
1900
|
+
|
|
1901
|
+
function snapshotConnections() {{
|
|
1902
|
+
originalConnections.length = 0;
|
|
1903
|
+
document.querySelectorAll('.connection').forEach(conn => {{
|
|
1904
|
+
originalConnections.push({{
|
|
1905
|
+
element: conn,
|
|
1906
|
+
sourceId: conn.dataset.source,
|
|
1907
|
+
targetId: conn.dataset.target,
|
|
1908
|
+
sourceType: conn.dataset.sourceType || '',
|
|
1909
|
+
targetType: conn.dataset.targetType || '',
|
|
1910
|
+
label: conn.dataset.label || '',
|
|
1911
|
+
connType: conn.dataset.connType || 'default',
|
|
1912
|
+
}});
|
|
1913
|
+
}});
|
|
1914
|
+
}}
|
|
1915
|
+
|
|
1916
|
+
function getServiceNodesForType(serviceType) {{
|
|
1917
|
+
return Array.from(document.querySelectorAll(`.service[data-service-type="${{serviceType}}"]`))
|
|
1918
|
+
.filter(el => !el.classList.contains('aggregate-node'));
|
|
1919
|
+
}}
|
|
1920
|
+
|
|
1921
|
+
function computeCentroid(serviceType) {{
|
|
1922
|
+
const group = AGGREGATION_CONFIG.groups[serviceType];
|
|
1923
|
+
if (!group) return {{ x: 0, y: 0 }};
|
|
1924
|
+
let sumX = 0, sumY = 0, count = 0;
|
|
1925
|
+
for (const sid of group.serviceIds) {{
|
|
1926
|
+
const pos = servicePositions[sid];
|
|
1927
|
+
if (pos) {{
|
|
1928
|
+
sumX += pos.x;
|
|
1929
|
+
sumY += pos.y;
|
|
1930
|
+
count++;
|
|
1931
|
+
}}
|
|
1932
|
+
}}
|
|
1933
|
+
if (count === 0) return {{ x: 100, y: 100 }};
|
|
1934
|
+
return {{ x: sumX / count, y: sumY / count }};
|
|
1935
|
+
}}
|
|
1936
|
+
|
|
1937
|
+
function aggregateGroup(serviceType, skipConnectionRecalc) {{
|
|
1938
|
+
const group = AGGREGATION_CONFIG.groups[serviceType];
|
|
1939
|
+
if (!group) return;
|
|
1940
|
+
|
|
1941
|
+
// Hide individual nodes
|
|
1942
|
+
for (const sid of group.serviceIds) {{
|
|
1943
|
+
const el = document.querySelector(`[data-service-id="${{sid}}"]`);
|
|
1944
|
+
if (el) el.classList.add('agg-hidden');
|
|
1945
|
+
}}
|
|
1946
|
+
|
|
1947
|
+
// Calculate centroid
|
|
1948
|
+
const centroid = computeCentroid(serviceType);
|
|
1949
|
+
|
|
1950
|
+
// Create aggregate node in SVG
|
|
1951
|
+
const svg = document.getElementById('diagram-svg');
|
|
1952
|
+
const servicesLayer = document.getElementById('services-layer');
|
|
1953
|
+
|
|
1954
|
+
const aggG = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
1955
|
+
aggG.classList.add('service', 'draggable', 'aggregate-node');
|
|
1956
|
+
aggG.dataset.serviceId = `__agg_${{serviceType}}`;
|
|
1957
|
+
aggG.dataset.serviceType = serviceType;
|
|
1958
|
+
aggG.dataset.tooltip = `${{group.label}} (${{group.count}} resources - click to inspect)`;
|
|
1959
|
+
// Inherit VPC status from the first service in the group
|
|
1960
|
+
const firstNode = document.querySelector(`[data-service-id="${{group.serviceIds[0]}}"]`);
|
|
1961
|
+
aggG.dataset.isVpc = (firstNode && firstNode.dataset.isVpc === 'true') ? 'true' : 'false';
|
|
1962
|
+
aggG.setAttribute('transform', `translate(${{centroid.x}}, ${{centroid.y}})`);
|
|
1963
|
+
aggG.style.cursor = 'pointer';
|
|
1964
|
+
|
|
1965
|
+
// Background rect (dashed border)
|
|
1966
|
+
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
1967
|
+
bgRect.classList.add('service-bg');
|
|
1968
|
+
bgRect.setAttribute('x', '-8');
|
|
1969
|
+
bgRect.setAttribute('y', '-8');
|
|
1970
|
+
bgRect.setAttribute('width', `${{iconSize + 16}}`);
|
|
1971
|
+
bgRect.setAttribute('height', `${{iconSize + 36}}`);
|
|
1972
|
+
bgRect.setAttribute('fill', 'white');
|
|
1973
|
+
bgRect.setAttribute('stroke', group.color || '#999');
|
|
1974
|
+
bgRect.setAttribute('stroke-width', '2');
|
|
1975
|
+
bgRect.setAttribute('stroke-dasharray', '6,3');
|
|
1976
|
+
bgRect.setAttribute('rx', '8');
|
|
1977
|
+
bgRect.setAttribute('ry', '8');
|
|
1978
|
+
bgRect.setAttribute('filter', 'url(#shadow)');
|
|
1979
|
+
aggG.appendChild(bgRect);
|
|
1980
|
+
|
|
1981
|
+
// Icon
|
|
1982
|
+
if (group.iconHtml) {{
|
|
1983
|
+
const foreignObj = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
|
1984
|
+
foreignObj.setAttribute('x', '0');
|
|
1985
|
+
foreignObj.setAttribute('y', '0');
|
|
1986
|
+
foreignObj.setAttribute('width', `${{iconSize}}`);
|
|
1987
|
+
foreignObj.setAttribute('height', `${{iconSize}}`);
|
|
1988
|
+
const div = document.createElement('div');
|
|
1989
|
+
div.innerHTML = group.iconHtml;
|
|
1990
|
+
div.style.width = `${{iconSize}}px`;
|
|
1991
|
+
div.style.height = `${{iconSize}}px`;
|
|
1992
|
+
const innerSvg = div.querySelector('svg');
|
|
1993
|
+
if (innerSvg) {{
|
|
1994
|
+
innerSvg.setAttribute('width', `${{iconSize}}`);
|
|
1995
|
+
innerSvg.setAttribute('height', `${{iconSize}}`);
|
|
1996
|
+
}}
|
|
1997
|
+
foreignObj.appendChild(div);
|
|
1998
|
+
aggG.appendChild(foreignObj);
|
|
1999
|
+
}}
|
|
2000
|
+
|
|
2001
|
+
// Label
|
|
2002
|
+
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
2003
|
+
label.classList.add('service-label');
|
|
2004
|
+
label.setAttribute('x', `${{iconSize / 2}}`);
|
|
2005
|
+
label.setAttribute('y', `${{iconSize + 16}}`);
|
|
2006
|
+
label.setAttribute('font-family', 'Arial, sans-serif');
|
|
2007
|
+
label.setAttribute('font-size', '12');
|
|
2008
|
+
label.setAttribute('fill', '#333');
|
|
2009
|
+
label.setAttribute('text-anchor', 'middle');
|
|
2010
|
+
label.setAttribute('font-weight', '500');
|
|
2011
|
+
label.textContent = `${{group.label}} (${{group.count}})`;
|
|
2012
|
+
aggG.appendChild(label);
|
|
2013
|
+
|
|
2014
|
+
// Count badge
|
|
2015
|
+
const badgeCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
2016
|
+
badgeCircle.classList.add('aggregate-badge');
|
|
2017
|
+
badgeCircle.setAttribute('cx', `${{iconSize + 8 - 8}}`);
|
|
2018
|
+
badgeCircle.setAttribute('cy', '8');
|
|
2019
|
+
badgeCircle.setAttribute('r', '12');
|
|
2020
|
+
badgeCircle.setAttribute('fill', group.color || '#ff9900');
|
|
2021
|
+
badgeCircle.setAttribute('stroke', 'white');
|
|
2022
|
+
badgeCircle.setAttribute('stroke-width', '2');
|
|
2023
|
+
aggG.appendChild(badgeCircle);
|
|
2024
|
+
|
|
2025
|
+
const badgeText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
2026
|
+
badgeText.classList.add('aggregate-badge');
|
|
2027
|
+
badgeText.setAttribute('x', `${{iconSize + 8 - 8}}`);
|
|
2028
|
+
badgeText.setAttribute('y', '12');
|
|
2029
|
+
badgeText.setAttribute('font-family', 'Arial, sans-serif');
|
|
2030
|
+
badgeText.setAttribute('font-size', '11');
|
|
2031
|
+
badgeText.setAttribute('fill', 'white');
|
|
2032
|
+
badgeText.setAttribute('text-anchor', 'middle');
|
|
2033
|
+
badgeText.setAttribute('font-weight', 'bold');
|
|
2034
|
+
badgeText.textContent = group.count;
|
|
2035
|
+
aggG.appendChild(badgeText);
|
|
2036
|
+
|
|
2037
|
+
servicesLayer.appendChild(aggG);
|
|
2038
|
+
aggregateNodes[serviceType] = aggG;
|
|
2039
|
+
|
|
2040
|
+
// Register position
|
|
2041
|
+
servicePositions[`__agg_${{serviceType}}`] = {{ x: centroid.x, y: centroid.y }};
|
|
2042
|
+
|
|
2043
|
+
// Setup drag for aggregate node
|
|
2044
|
+
aggG.addEventListener('mousedown', (e) => {{
|
|
2045
|
+
// Drag is handled by the existing drag system since we add .draggable class
|
|
2046
|
+
// But we also need click for popover — distinguish via a moved flag
|
|
2047
|
+
}});
|
|
2048
|
+
|
|
2049
|
+
// Setup click for popover (using mouseup without movement)
|
|
2050
|
+
let aggDragStartPos = null;
|
|
2051
|
+
aggG.addEventListener('mousedown', (e) => {{
|
|
2052
|
+
aggDragStartPos = {{ x: e.clientX, y: e.clientY }};
|
|
2053
|
+
}});
|
|
2054
|
+
aggG.addEventListener('mouseup', (e) => {{
|
|
2055
|
+
if (aggDragStartPos) {{
|
|
2056
|
+
const dx = Math.abs(e.clientX - aggDragStartPos.x);
|
|
2057
|
+
const dy = Math.abs(e.clientY - aggDragStartPos.y);
|
|
2058
|
+
if (dx < 5 && dy < 5) {{
|
|
2059
|
+
// This was a click, not a drag — show popover
|
|
2060
|
+
// Do NOT stopPropagation: SVG mouseup must fire to clear drag state
|
|
2061
|
+
showAggregatePopover(serviceType, e.clientX, e.clientY);
|
|
2062
|
+
}}
|
|
2063
|
+
}}
|
|
2064
|
+
aggDragStartPos = null;
|
|
2065
|
+
}});
|
|
2066
|
+
|
|
2067
|
+
// Re-route all connections (considers all aggregated groups)
|
|
2068
|
+
if (!skipConnectionRecalc) {{
|
|
2069
|
+
recalculateAllAggregateConnections();
|
|
2070
|
+
}}
|
|
2071
|
+
}}
|
|
2072
|
+
|
|
2073
|
+
function deaggregateGroup(serviceType) {{
|
|
2074
|
+
const group = AGGREGATION_CONFIG.groups[serviceType];
|
|
2075
|
+
if (!group) return;
|
|
2076
|
+
|
|
2077
|
+
// Close popover if open for this group
|
|
2078
|
+
if (activePopover) {{
|
|
2079
|
+
closeAggregatePopover();
|
|
2080
|
+
}}
|
|
2081
|
+
|
|
2082
|
+
// Show individual nodes
|
|
2083
|
+
for (const sid of group.serviceIds) {{
|
|
2084
|
+
const el = document.querySelector(`[data-service-id="${{sid}}"]`);
|
|
2085
|
+
if (el) el.classList.remove('agg-hidden');
|
|
2086
|
+
}}
|
|
2087
|
+
|
|
2088
|
+
// Remove aggregate node
|
|
2089
|
+
if (aggregateNodes[serviceType]) {{
|
|
2090
|
+
aggregateNodes[serviceType].remove();
|
|
2091
|
+
delete aggregateNodes[serviceType];
|
|
2092
|
+
delete servicePositions[`__agg_${{serviceType}}`];
|
|
2093
|
+
}}
|
|
2094
|
+
|
|
2095
|
+
// Recalculate all connections (considers remaining aggregated groups)
|
|
2096
|
+
recalculateAllAggregateConnections();
|
|
2097
|
+
}}
|
|
2098
|
+
|
|
2099
|
+
// ============ CONNECTION RE-ROUTING ============
|
|
2100
|
+
// Single function that recalculates ALL aggregate connections
|
|
2101
|
+
// considering ALL currently aggregated groups at once.
|
|
2102
|
+
// This avoids cross-group issues where group A's aggregate connections
|
|
2103
|
+
// point to individual nodes of group B that are now hidden.
|
|
2104
|
+
function recalculateAllAggregateConnections() {{
|
|
2105
|
+
// 1. Remove ALL existing aggregate connections
|
|
2106
|
+
for (const [stype, conns] of Object.entries(aggregateConnections)) {{
|
|
2107
|
+
conns.forEach(c => c.remove());
|
|
2108
|
+
}}
|
|
2109
|
+
for (const k of Object.keys(aggregateConnections)) delete aggregateConnections[k];
|
|
2110
|
+
|
|
2111
|
+
// 2. Reset hidden state on ALL original connections
|
|
2112
|
+
for (const conn of originalConnections) {{
|
|
2113
|
+
conn.element.classList.remove('agg-hidden');
|
|
2114
|
+
}}
|
|
2115
|
+
|
|
2116
|
+
// 3. Build a map: serviceId -> aggregated group type (or null)
|
|
2117
|
+
const idToAggGroup = {{}};
|
|
2118
|
+
for (const [stype, isAgg] of Object.entries(aggregationState)) {{
|
|
2119
|
+
if (!isAgg) continue;
|
|
2120
|
+
const group = AGGREGATION_CONFIG.groups[stype];
|
|
2121
|
+
if (!group) continue;
|
|
2122
|
+
for (const sid of group.serviceIds) {{
|
|
2123
|
+
idToAggGroup[sid] = stype;
|
|
2124
|
+
}}
|
|
2125
|
+
}}
|
|
2126
|
+
|
|
2127
|
+
// 4. Process each original connection: hide and build merged map
|
|
2128
|
+
// Key for merged map: "resolvedSource|resolvedTarget|connType"
|
|
2129
|
+
// where resolvedSource/Target is either the original ID or __agg_<type>
|
|
2130
|
+
const mergedMap = {{}};
|
|
2131
|
+
|
|
2132
|
+
for (const conn of originalConnections) {{
|
|
2133
|
+
const srcGroup = idToAggGroup[conn.sourceId] || null;
|
|
2134
|
+
const tgtGroup = idToAggGroup[conn.targetId] || null;
|
|
2135
|
+
|
|
2136
|
+
if (!srcGroup && !tgtGroup) {{
|
|
2137
|
+
// Neither endpoint is aggregated: leave visible
|
|
2138
|
+
continue;
|
|
2139
|
+
}}
|
|
2140
|
+
|
|
2141
|
+
// At least one endpoint is aggregated: hide original
|
|
2142
|
+
conn.element.classList.add('agg-hidden');
|
|
2143
|
+
|
|
2144
|
+
if (srcGroup && tgtGroup && srcGroup === tgtGroup) {{
|
|
2145
|
+
// Both in same group: hide entirely, no aggregate connection
|
|
2146
|
+
continue;
|
|
2147
|
+
}}
|
|
2148
|
+
|
|
2149
|
+
// Resolve endpoints: use aggregate node ID if in an aggregated group
|
|
2150
|
+
const resolvedSource = srcGroup ? `__agg_${{srcGroup}}` : conn.sourceId;
|
|
2151
|
+
const resolvedTarget = tgtGroup ? `__agg_${{tgtGroup}}` : conn.targetId;
|
|
2152
|
+
|
|
2153
|
+
const key = `${{resolvedSource}}|${{resolvedTarget}}|${{conn.connType}}`;
|
|
2154
|
+
if (!mergedMap[key]) {{
|
|
2155
|
+
mergedMap[key] = {{
|
|
2156
|
+
sourceId: resolvedSource,
|
|
2157
|
+
targetId: resolvedTarget,
|
|
2158
|
+
connType: conn.connType,
|
|
2159
|
+
label: conn.label,
|
|
2160
|
+
count: 0,
|
|
2161
|
+
}};
|
|
2162
|
+
}}
|
|
2163
|
+
mergedMap[key].count++;
|
|
2164
|
+
}}
|
|
2165
|
+
|
|
2166
|
+
// 5. Create aggregate connections from merged map
|
|
2167
|
+
const connLayer = document.getElementById('connections-layer');
|
|
2168
|
+
const styles = {{
|
|
2169
|
+
'data_flow': {{ color: '#3B48CC', dash: '', marker: 'url(#arrowhead-data)' }},
|
|
2170
|
+
'trigger': {{ color: '#E7157B', dash: '', marker: 'url(#arrowhead-trigger)' }},
|
|
2171
|
+
'encrypt': {{ color: '#6c757d', dash: '4,4', marker: 'url(#arrowhead)' }},
|
|
2172
|
+
'network_flow': {{ color: '#0d7c3f', dash: '', marker: 'url(#arrowhead-network)' }},
|
|
2173
|
+
'security_rule': {{ color: '#d97706', dash: '2,4', marker: 'url(#arrowhead-security)' }},
|
|
2174
|
+
'default': {{ color: '#999999', dash: '', marker: 'url(#arrowhead)' }},
|
|
2175
|
+
}};
|
|
2176
|
+
|
|
2177
|
+
// Group aggregate connections by which agg group they belong to (for tracking)
|
|
2178
|
+
const newAggConns = {{}};
|
|
2179
|
+
|
|
2180
|
+
for (const [key, info] of Object.entries(mergedMap)) {{
|
|
2181
|
+
const style = styles[info.connType] || styles['default'];
|
|
2182
|
+
|
|
2183
|
+
const sourcePos = servicePositions[info.sourceId];
|
|
2184
|
+
const targetPos = servicePositions[info.targetId];
|
|
2185
|
+
if (!sourcePos || !targetPos) continue;
|
|
2186
|
+
|
|
2187
|
+
const pathD = calcConnectionPath(sourcePos, targetPos);
|
|
2188
|
+
const strokeWidth = info.count > 4 ? 3.5 : info.count > 2 ? 2.5 : 1.5;
|
|
2189
|
+
|
|
2190
|
+
const connG = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
2191
|
+
connG.classList.add('connection', 'aggregate-connection');
|
|
2192
|
+
connG.dataset.source = info.sourceId;
|
|
2193
|
+
connG.dataset.target = info.targetId;
|
|
2194
|
+
connG.dataset.sourceType = getServiceTypeById(info.sourceId) || '';
|
|
2195
|
+
connG.dataset.targetType = getServiceTypeById(info.targetId) || '';
|
|
2196
|
+
connG.dataset.connType = info.connType;
|
|
2197
|
+
connG.dataset.label = info.label;
|
|
2198
|
+
connG.dataset.multiplicity = info.count;
|
|
2199
|
+
|
|
2200
|
+
const hitarea = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
2201
|
+
hitarea.classList.add('connection-hitarea');
|
|
2202
|
+
hitarea.setAttribute('d', pathD);
|
|
2203
|
+
hitarea.setAttribute('fill', 'none');
|
|
2204
|
+
hitarea.setAttribute('stroke', 'transparent');
|
|
2205
|
+
hitarea.setAttribute('stroke-width', '15');
|
|
2206
|
+
connG.appendChild(hitarea);
|
|
2207
|
+
|
|
2208
|
+
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
2209
|
+
pathEl.classList.add('connection-path');
|
|
2210
|
+
pathEl.setAttribute('d', pathD);
|
|
2211
|
+
pathEl.setAttribute('fill', 'none');
|
|
2212
|
+
pathEl.setAttribute('stroke', style.color);
|
|
2213
|
+
pathEl.setAttribute('stroke-width', `${{strokeWidth}}`);
|
|
2214
|
+
if (style.dash) pathEl.setAttribute('stroke-dasharray', style.dash);
|
|
2215
|
+
pathEl.setAttribute('marker-end', style.marker);
|
|
2216
|
+
pathEl.setAttribute('opacity', '0.7');
|
|
2217
|
+
connG.appendChild(pathEl);
|
|
2218
|
+
|
|
2219
|
+
if (info.count > 1) {{
|
|
2220
|
+
const midX = (sourcePos.x + targetPos.x) / 2 + iconSize / 2;
|
|
2221
|
+
const midY = (sourcePos.y + targetPos.y) / 2 + iconSize / 2;
|
|
2222
|
+
const multLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
2223
|
+
multLabel.classList.add('multiplicity-label');
|
|
2224
|
+
multLabel.setAttribute('x', `${{midX + 8}}`);
|
|
2225
|
+
multLabel.setAttribute('y', `${{midY - 5}}`);
|
|
2226
|
+
multLabel.setAttribute('font-family', 'Arial, sans-serif');
|
|
2227
|
+
multLabel.setAttribute('font-size', '10');
|
|
2228
|
+
multLabel.setAttribute('font-weight', 'bold');
|
|
2229
|
+
multLabel.setAttribute('fill', style.color);
|
|
2230
|
+
multLabel.textContent = `x${{info.count}}`;
|
|
2231
|
+
connG.appendChild(multLabel);
|
|
2232
|
+
}}
|
|
2233
|
+
|
|
2234
|
+
connLayer.appendChild(connG);
|
|
2235
|
+
|
|
2236
|
+
// Track by involved agg groups for cleanup
|
|
2237
|
+
const involvedGroups = new Set();
|
|
2238
|
+
if (info.sourceId.startsWith('__agg_')) involvedGroups.add(info.sourceId.replace('__agg_', ''));
|
|
2239
|
+
if (info.targetId.startsWith('__agg_')) involvedGroups.add(info.targetId.replace('__agg_', ''));
|
|
2240
|
+
for (const g of involvedGroups) {{
|
|
2241
|
+
if (!newAggConns[g]) newAggConns[g] = [];
|
|
2242
|
+
newAggConns[g].push(connG);
|
|
2243
|
+
}}
|
|
2244
|
+
}}
|
|
2245
|
+
|
|
2246
|
+
// Update the global tracking object
|
|
2247
|
+
for (const [g, conns] of Object.entries(newAggConns)) {{
|
|
2248
|
+
aggregateConnections[g] = conns;
|
|
2249
|
+
}}
|
|
2250
|
+
|
|
2251
|
+
// Re-apply connection type filter to new aggregate connections
|
|
2252
|
+
applyConnTypeFilter();
|
|
2253
|
+
}}
|
|
2254
|
+
|
|
2255
|
+
function calcConnectionPath(sourcePos, targetPos) {{
|
|
2256
|
+
const halfSize = iconSize / 2;
|
|
2257
|
+
let sx = sourcePos.x + halfSize;
|
|
2258
|
+
let sy = sourcePos.y + halfSize;
|
|
2259
|
+
let tx = targetPos.x + halfSize;
|
|
2260
|
+
let ty = targetPos.y + halfSize;
|
|
2261
|
+
|
|
2262
|
+
if (Math.abs(ty - sy) > Math.abs(tx - sx)) {{
|
|
2263
|
+
if (ty > sy) {{
|
|
2264
|
+
sy = sourcePos.y + iconSize + 8;
|
|
2265
|
+
ty = targetPos.y - 8;
|
|
2266
|
+
}} else {{
|
|
2267
|
+
sy = sourcePos.y - 8;
|
|
2268
|
+
ty = targetPos.y + iconSize + 8;
|
|
2269
|
+
}}
|
|
2270
|
+
}} else {{
|
|
2271
|
+
if (tx > sx) {{
|
|
2272
|
+
sx = sourcePos.x + iconSize + 8;
|
|
2273
|
+
tx = targetPos.x - 8;
|
|
2274
|
+
}} else {{
|
|
2275
|
+
sx = sourcePos.x - 8;
|
|
2276
|
+
tx = targetPos.x + iconSize + 8;
|
|
2277
|
+
}}
|
|
2278
|
+
}}
|
|
2279
|
+
|
|
2280
|
+
const midX = (sx + tx) / 2;
|
|
2281
|
+
const midY = (sy + ty) / 2;
|
|
2282
|
+
return `M ${{sx}} ${{sy}} Q ${{midX}} ${{sy}}, ${{midX}} ${{midY}} T ${{tx}} ${{ty}}`;
|
|
2283
|
+
}}
|
|
2284
|
+
|
|
2285
|
+
function getServiceTypeById(serviceId) {{
|
|
2286
|
+
const el = document.querySelector(`[data-service-id="${{serviceId}}"]`);
|
|
2287
|
+
return el ? (el.dataset.serviceType || '') : '';
|
|
2288
|
+
}}
|
|
2289
|
+
|
|
2290
|
+
// ============ CHIP PANEL ============
|
|
2291
|
+
function renderChipPanel() {{
|
|
2292
|
+
const panel = document.getElementById('aggregation-panel');
|
|
2293
|
+
const chipsContainer = document.getElementById('aggregation-chips');
|
|
2294
|
+
if (!panel || !chipsContainer) return;
|
|
2295
|
+
|
|
2296
|
+
// Get qualifying groups sorted by count desc
|
|
2297
|
+
const groups = Object.entries(AGGREGATION_CONFIG.groups)
|
|
2298
|
+
.filter(([_, g]) => g.count >= AGGREGATION_CONFIG.threshold)
|
|
2299
|
+
.sort((a, b) => b[1].count - a[1].count);
|
|
2300
|
+
|
|
2301
|
+
if (groups.length === 0) return;
|
|
2302
|
+
|
|
2303
|
+
panel.style.display = 'flex';
|
|
2304
|
+
chipsContainer.innerHTML = '';
|
|
2305
|
+
|
|
2306
|
+
for (const [stype, group] of groups) {{
|
|
2307
|
+
const chip = document.createElement('div');
|
|
2308
|
+
chip.classList.add('aggregation-chip');
|
|
2309
|
+
chip.dataset.serviceType = stype;
|
|
2310
|
+
const isActive = !!aggregationState[stype];
|
|
2311
|
+
chip.classList.add(isActive ? 'active' : 'inactive');
|
|
2312
|
+
const color = group.color || '#666';
|
|
2313
|
+
chip.style.borderColor = color;
|
|
2314
|
+
chip.style.backgroundColor = isActive ? color : 'transparent';
|
|
2315
|
+
chip.style.color = isActive ? 'white' : color;
|
|
2316
|
+
|
|
2317
|
+
chip.innerHTML = `<span class="chip-check">${{isActive ? '✓' : ''}}</span>${{group.label}} (${{group.count}})`;
|
|
2318
|
+
|
|
2319
|
+
chip.addEventListener('click', () => toggleAggregation(stype));
|
|
2320
|
+
chipsContainer.appendChild(chip);
|
|
2321
|
+
}}
|
|
2322
|
+
}}
|
|
2323
|
+
|
|
2324
|
+
function toggleAggregation(serviceType) {{
|
|
2325
|
+
const wasAggregated = aggregationState[serviceType];
|
|
2326
|
+
aggregationState[serviceType] = !wasAggregated;
|
|
2327
|
+
|
|
2328
|
+
if (aggregationState[serviceType]) {{
|
|
2329
|
+
aggregateGroup(serviceType);
|
|
2330
|
+
}} else {{
|
|
2331
|
+
deaggregateGroup(serviceType);
|
|
2332
|
+
}}
|
|
2333
|
+
|
|
2334
|
+
// Update chip visual
|
|
2335
|
+
renderChipPanel();
|
|
2336
|
+
|
|
2337
|
+
// Save state
|
|
2338
|
+
localStorage.setItem('diagramAggregationState', JSON.stringify(aggregationState));
|
|
2339
|
+
}}
|
|
2340
|
+
|
|
2341
|
+
// ============ CONNECTION TYPE FILTER ============
|
|
2342
|
+
function initConnectionTypeFilter() {{
|
|
2343
|
+
// Load saved state or default all visible
|
|
2344
|
+
const saved = localStorage.getItem('diagramConnTypeFilter');
|
|
2345
|
+
let loaded = null;
|
|
2346
|
+
if (saved) {{
|
|
2347
|
+
try {{ loaded = JSON.parse(saved); }} catch(e) {{}}
|
|
2348
|
+
}}
|
|
2349
|
+
for (const ct of CONNECTION_TYPES) {{
|
|
2350
|
+
connTypeFilterState[ct.id] = loaded ? (loaded[ct.id] !== false) : true;
|
|
2351
|
+
}}
|
|
2352
|
+
renderConnFilterPanel();
|
|
2353
|
+
applyConnTypeFilter();
|
|
2354
|
+
}}
|
|
2355
|
+
|
|
2356
|
+
function renderConnFilterPanel() {{
|
|
2357
|
+
const container = document.getElementById('conn-filter-chips');
|
|
2358
|
+
if (!container) return;
|
|
2359
|
+
container.innerHTML = '';
|
|
2360
|
+
|
|
2361
|
+
for (const ct of CONNECTION_TYPES) {{
|
|
2362
|
+
const chip = document.createElement('div');
|
|
2363
|
+
chip.classList.add('conn-filter-chip');
|
|
2364
|
+
const isActive = connTypeFilterState[ct.id] !== false;
|
|
2365
|
+
chip.classList.add(isActive ? 'active' : 'inactive');
|
|
2366
|
+
chip.style.borderColor = ct.color;
|
|
2367
|
+
chip.style.backgroundColor = isActive ? ct.color : 'transparent';
|
|
2368
|
+
chip.style.color = isActive ? 'white' : ct.color;
|
|
2369
|
+
chip.innerHTML = `<span class="chip-check">${{isActive ? '✓' : ''}}</span>${{ct.label}}`;
|
|
2370
|
+
chip.addEventListener('click', () => toggleConnTypeFilter(ct.id));
|
|
2371
|
+
container.appendChild(chip);
|
|
2372
|
+
}}
|
|
2373
|
+
}}
|
|
2374
|
+
|
|
2375
|
+
function toggleConnTypeFilter(connType) {{
|
|
2376
|
+
connTypeFilterState[connType] = connTypeFilterState[connType] === false;
|
|
2377
|
+
applyConnTypeFilter();
|
|
2378
|
+
renderConnFilterPanel();
|
|
2379
|
+
localStorage.setItem('diagramConnTypeFilter', JSON.stringify(connTypeFilterState));
|
|
2380
|
+
}}
|
|
2381
|
+
|
|
2382
|
+
function applyConnTypeFilter() {{
|
|
2383
|
+
// Apply to original connections
|
|
2384
|
+
document.querySelectorAll('.connection').forEach(conn => {{
|
|
2385
|
+
const ct = conn.dataset.connType || 'default';
|
|
2386
|
+
if (connTypeFilterState[ct] === false) {{
|
|
2387
|
+
conn.classList.add('conn-type-hidden');
|
|
2388
|
+
}} else {{
|
|
2389
|
+
conn.classList.remove('conn-type-hidden');
|
|
2390
|
+
}}
|
|
2391
|
+
}});
|
|
2392
|
+
// Apply to aggregate connections
|
|
2393
|
+
for (const conns of Object.values(aggregateConnections)) {{
|
|
2394
|
+
conns.forEach(conn => {{
|
|
2395
|
+
const ct = conn.dataset.connType || 'default';
|
|
2396
|
+
if (connTypeFilterState[ct] === false) {{
|
|
2397
|
+
conn.classList.add('conn-type-hidden');
|
|
2398
|
+
}} else {{
|
|
2399
|
+
conn.classList.remove('conn-type-hidden');
|
|
2400
|
+
}}
|
|
2401
|
+
}});
|
|
2402
|
+
}}
|
|
2403
|
+
}}
|
|
2404
|
+
|
|
2405
|
+
// ============ POPOVER ============
|
|
2406
|
+
function showAggregatePopover(serviceType, clientX, clientY) {{
|
|
2407
|
+
closeAggregatePopover();
|
|
2408
|
+
clearHighlights();
|
|
2409
|
+
|
|
2410
|
+
const group = AGGREGATION_CONFIG.groups[serviceType];
|
|
2411
|
+
if (!group) return;
|
|
2412
|
+
|
|
2413
|
+
const popover = document.createElement('div');
|
|
2414
|
+
popover.classList.add('aggregate-popover');
|
|
2415
|
+
popover.id = 'aggregate-popover';
|
|
2416
|
+
|
|
2417
|
+
const header = document.createElement('div');
|
|
2418
|
+
header.classList.add('aggregate-popover-header');
|
|
2419
|
+
header.textContent = `${{group.label}} (${{group.count}})`;
|
|
2420
|
+
popover.appendChild(header);
|
|
2421
|
+
|
|
2422
|
+
group.serviceIds.forEach((sid, idx) => {{
|
|
2423
|
+
const item = document.createElement('div');
|
|
2424
|
+
item.classList.add('aggregate-popover-item');
|
|
2425
|
+
item.dataset.resourceId = sid;
|
|
2426
|
+
|
|
2427
|
+
const iconDiv = document.createElement('div');
|
|
2428
|
+
iconDiv.innerHTML = group.iconHtml || '';
|
|
2429
|
+
const innerSvg = iconDiv.querySelector('svg');
|
|
2430
|
+
if (innerSvg) {{
|
|
2431
|
+
innerSvg.setAttribute('width', '24');
|
|
2432
|
+
innerSvg.setAttribute('height', '24');
|
|
2433
|
+
}}
|
|
2434
|
+
item.appendChild(innerSvg || iconDiv);
|
|
2435
|
+
|
|
2436
|
+
const nameSpan = document.createElement('span');
|
|
2437
|
+
nameSpan.textContent = group.serviceNames[idx] || sid;
|
|
2438
|
+
item.appendChild(nameSpan);
|
|
2439
|
+
|
|
2440
|
+
item.addEventListener('click', (e) => {{
|
|
2441
|
+
e.stopPropagation();
|
|
2442
|
+
selectResourceInPopover(sid, serviceType, item);
|
|
2443
|
+
}});
|
|
2444
|
+
|
|
2445
|
+
popover.appendChild(item);
|
|
2446
|
+
}});
|
|
2447
|
+
|
|
2448
|
+
// Position popover near click
|
|
2449
|
+
popover.style.left = `${{clientX + 10}}px`;
|
|
2450
|
+
popover.style.top = `${{clientY + 10}}px`;
|
|
2451
|
+
|
|
2452
|
+
document.body.appendChild(popover);
|
|
2453
|
+
activePopover = popover;
|
|
2454
|
+
|
|
2455
|
+
// Adjust if off-screen
|
|
2456
|
+
const rect = popover.getBoundingClientRect();
|
|
2457
|
+
if (rect.right > window.innerWidth) {{
|
|
2458
|
+
popover.style.left = `${{clientX - rect.width - 10}}px`;
|
|
2459
|
+
}}
|
|
2460
|
+
if (rect.bottom > window.innerHeight) {{
|
|
2461
|
+
popover.style.top = `${{clientY - rect.height - 10}}px`;
|
|
2462
|
+
}}
|
|
2463
|
+
|
|
2464
|
+
// Close on click outside (delayed to avoid immediate close)
|
|
2465
|
+
setTimeout(() => {{
|
|
2466
|
+
document.addEventListener('click', closePopoverOnOutsideClick);
|
|
2467
|
+
}}, 10);
|
|
2468
|
+
}}
|
|
2469
|
+
|
|
2470
|
+
function closePopoverOnOutsideClick(e) {{
|
|
2471
|
+
if (activePopover && !activePopover.contains(e.target)) {{
|
|
2472
|
+
closeAggregatePopover();
|
|
2473
|
+
}}
|
|
2474
|
+
}}
|
|
2475
|
+
|
|
2476
|
+
function closeAggregatePopover() {{
|
|
2477
|
+
if (activePopover) {{
|
|
2478
|
+
activePopover.remove();
|
|
2479
|
+
activePopover = null;
|
|
2480
|
+
selectedPopoverResource = null;
|
|
2481
|
+
document.removeEventListener('click', closePopoverOnOutsideClick);
|
|
2482
|
+
|
|
2483
|
+
// Restore aggregate connections opacity
|
|
2484
|
+
document.querySelectorAll('.aggregate-connection').forEach(c => {{
|
|
2485
|
+
c.style.opacity = '';
|
|
2486
|
+
}});
|
|
2487
|
+
// Remove any temporary highlight connections
|
|
2488
|
+
document.querySelectorAll('.popover-highlight-conn').forEach(c => c.remove());
|
|
2489
|
+
}}
|
|
2490
|
+
}}
|
|
2491
|
+
|
|
2492
|
+
function selectResourceInPopover(resourceId, serviceType, itemEl) {{
|
|
2493
|
+
const group = AGGREGATION_CONFIG.groups[serviceType];
|
|
2494
|
+
if (!group) return;
|
|
2495
|
+
const groupIds = new Set(group.serviceIds);
|
|
2496
|
+
|
|
2497
|
+
// Toggle selection
|
|
2498
|
+
if (selectedPopoverResource === resourceId) {{
|
|
2499
|
+
// Deselect
|
|
2500
|
+
selectedPopoverResource = null;
|
|
2501
|
+
itemEl.classList.remove('selected');
|
|
2502
|
+
// Restore ALL aggregate connections
|
|
2503
|
+
for (const conns of Object.values(aggregateConnections)) {{
|
|
2504
|
+
conns.forEach(c => c.style.opacity = '');
|
|
2505
|
+
}}
|
|
2506
|
+
document.querySelectorAll('.popover-highlight-conn').forEach(c => c.remove());
|
|
2507
|
+
return;
|
|
2508
|
+
}}
|
|
2509
|
+
|
|
2510
|
+
// Clear previous selection
|
|
2511
|
+
if (activePopover) {{
|
|
2512
|
+
activePopover.querySelectorAll('.aggregate-popover-item').forEach(i => i.classList.remove('selected'));
|
|
2513
|
+
}}
|
|
2514
|
+
selectedPopoverResource = resourceId;
|
|
2515
|
+
itemEl.classList.add('selected');
|
|
2516
|
+
|
|
2517
|
+
// Dim ALL aggregate connections (not just this group's)
|
|
2518
|
+
for (const conns of Object.values(aggregateConnections)) {{
|
|
2519
|
+
conns.forEach(c => c.style.opacity = '0.15');
|
|
2520
|
+
}}
|
|
2521
|
+
|
|
2522
|
+
// Remove previous highlight connections
|
|
2523
|
+
document.querySelectorAll('.popover-highlight-conn').forEach(c => c.remove());
|
|
2524
|
+
|
|
2525
|
+
// Find original connections for this specific resource and draw them from aggregate node
|
|
2526
|
+
const aggNodeId = `__agg_${{serviceType}}`;
|
|
2527
|
+
const aggPos = servicePositions[aggNodeId];
|
|
2528
|
+
if (!aggPos) return;
|
|
2529
|
+
|
|
2530
|
+
const connLayer = document.getElementById('connections-layer');
|
|
2531
|
+
const styles = {{
|
|
2532
|
+
'data_flow': {{ color: '#3B48CC', dash: '', marker: 'url(#arrowhead-data)' }},
|
|
2533
|
+
'trigger': {{ color: '#E7157B', dash: '', marker: 'url(#arrowhead-trigger)' }},
|
|
2534
|
+
'encrypt': {{ color: '#6c757d', dash: '4,4', marker: 'url(#arrowhead)' }},
|
|
2535
|
+
'network_flow': {{ color: '#0d7c3f', dash: '', marker: 'url(#arrowhead-network)' }},
|
|
2536
|
+
'security_rule': {{ color: '#d97706', dash: '2,4', marker: 'url(#arrowhead-security)' }},
|
|
2537
|
+
'default': {{ color: '#999999', dash: '', marker: 'url(#arrowhead)' }},
|
|
2538
|
+
}};
|
|
2539
|
+
|
|
2540
|
+
// Build map of which IDs are in aggregated groups (for resolving targets)
|
|
2541
|
+
const idToAggGroup = {{}};
|
|
2542
|
+
for (const [stype, isAgg] of Object.entries(aggregationState)) {{
|
|
2543
|
+
if (!isAgg || stype === serviceType) continue;
|
|
2544
|
+
const g = AGGREGATION_CONFIG.groups[stype];
|
|
2545
|
+
if (!g) continue;
|
|
2546
|
+
for (const sid of g.serviceIds) {{
|
|
2547
|
+
idToAggGroup[sid] = stype;
|
|
2548
|
+
}}
|
|
2549
|
+
}}
|
|
2550
|
+
|
|
2551
|
+
// Collect and deduplicate highlight connections
|
|
2552
|
+
const hlMerged = {{}};
|
|
2553
|
+
for (const conn of originalConnections) {{
|
|
2554
|
+
let externalId = null;
|
|
2555
|
+
let direction = null;
|
|
2556
|
+
|
|
2557
|
+
if (conn.sourceId === resourceId && !groupIds.has(conn.targetId)) {{
|
|
2558
|
+
externalId = conn.targetId;
|
|
2559
|
+
direction = 'out';
|
|
2560
|
+
}} else if (conn.targetId === resourceId && !groupIds.has(conn.sourceId)) {{
|
|
2561
|
+
externalId = conn.sourceId;
|
|
2562
|
+
direction = 'in';
|
|
2563
|
+
}}
|
|
2564
|
+
|
|
2565
|
+
if (!externalId) continue;
|
|
2566
|
+
|
|
2567
|
+
// Resolve external ID to aggregate node if the target is in another aggregated group
|
|
2568
|
+
const resolvedId = idToAggGroup[externalId] ? `__agg_${{idToAggGroup[externalId]}}` : externalId;
|
|
2569
|
+
const hlSource = direction === 'out' ? aggNodeId : resolvedId;
|
|
2570
|
+
const hlTarget = direction === 'out' ? resolvedId : aggNodeId;
|
|
2571
|
+
const key = `${{hlSource}}|${{hlTarget}}|${{conn.connType || 'default'}}`;
|
|
2572
|
+
|
|
2573
|
+
if (!hlMerged[key]) {{
|
|
2574
|
+
hlMerged[key] = {{ source: hlSource, target: hlTarget, connType: conn.connType || 'default', count: 0 }};
|
|
2575
|
+
}}
|
|
2576
|
+
hlMerged[key].count++;
|
|
2577
|
+
}}
|
|
2578
|
+
|
|
2579
|
+
// Draw deduplicated highlight connections
|
|
2580
|
+
for (const [key, info] of Object.entries(hlMerged)) {{
|
|
2581
|
+
const sourcePos = servicePositions[info.source];
|
|
2582
|
+
const targetPos = servicePositions[info.target];
|
|
2583
|
+
if (!sourcePos || !targetPos) continue;
|
|
2584
|
+
|
|
2585
|
+
const pathD = calcConnectionPath(sourcePos, targetPos);
|
|
2586
|
+
const style = styles[info.connType] || styles['default'];
|
|
2587
|
+
const strokeWidth = info.count > 4 ? 3.5 : info.count > 2 ? 2.5 : 2;
|
|
2588
|
+
|
|
2589
|
+
const connG = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
2590
|
+
connG.classList.add('connection', 'popover-highlight-conn');
|
|
2591
|
+
connG.dataset.connType = info.connType;
|
|
2592
|
+
if (connTypeFilterState[info.connType] === false) {{
|
|
2593
|
+
connG.classList.add('conn-type-hidden');
|
|
2594
|
+
}}
|
|
2595
|
+
connG.dataset.source = info.source;
|
|
2596
|
+
connG.dataset.target = info.target;
|
|
2597
|
+
|
|
2598
|
+
const hitarea = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
2599
|
+
hitarea.classList.add('connection-hitarea');
|
|
2600
|
+
hitarea.setAttribute('d', pathD);
|
|
2601
|
+
hitarea.setAttribute('fill', 'none');
|
|
2602
|
+
hitarea.setAttribute('stroke', 'transparent');
|
|
2603
|
+
hitarea.setAttribute('stroke-width', '15');
|
|
2604
|
+
connG.appendChild(hitarea);
|
|
2605
|
+
|
|
2606
|
+
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
2607
|
+
pathEl.classList.add('connection-path');
|
|
2608
|
+
pathEl.setAttribute('d', pathD);
|
|
2609
|
+
pathEl.setAttribute('fill', 'none');
|
|
2610
|
+
pathEl.setAttribute('stroke', style.color);
|
|
2611
|
+
pathEl.setAttribute('stroke-width', `${{strokeWidth}}`);
|
|
2612
|
+
if (style.dash) pathEl.setAttribute('stroke-dasharray', style.dash);
|
|
2613
|
+
pathEl.setAttribute('marker-end', style.marker);
|
|
2614
|
+
pathEl.setAttribute('opacity', '1');
|
|
2615
|
+
connG.appendChild(pathEl);
|
|
2616
|
+
|
|
2617
|
+
if (info.count > 1) {{
|
|
2618
|
+
const midX = (sourcePos.x + targetPos.x) / 2 + iconSize / 2;
|
|
2619
|
+
const midY = (sourcePos.y + targetPos.y) / 2 + iconSize / 2;
|
|
2620
|
+
const multLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
2621
|
+
multLabel.classList.add('multiplicity-label');
|
|
2622
|
+
multLabel.setAttribute('x', `${{midX + 8}}`);
|
|
2623
|
+
multLabel.setAttribute('y', `${{midY - 5}}`);
|
|
2624
|
+
multLabel.setAttribute('font-family', 'Arial, sans-serif');
|
|
2625
|
+
multLabel.setAttribute('font-size', '10');
|
|
2626
|
+
multLabel.setAttribute('font-weight', 'bold');
|
|
2627
|
+
multLabel.setAttribute('fill', style.color);
|
|
2628
|
+
multLabel.textContent = `x${{info.count}}`;
|
|
2629
|
+
connG.appendChild(multLabel);
|
|
2630
|
+
}}
|
|
2631
|
+
|
|
2632
|
+
connLayer.appendChild(connG);
|
|
2633
|
+
}}
|
|
2634
|
+
}}
|
|
2635
|
+
|
|
2636
|
+
// ============ AGGREGATION-AWARE DRAG ============
|
|
2637
|
+
// Override updateConnectionsFor to also handle aggregate connections
|
|
2638
|
+
updateConnectionsFor = function(serviceId) {{
|
|
2639
|
+
// Update original connections
|
|
2640
|
+
_baseUpdateConnectionsFor(serviceId);
|
|
2641
|
+
|
|
2642
|
+
// Update aggregate connections involving this serviceId
|
|
2643
|
+
document.querySelectorAll(`.aggregate-connection[data-source="${{serviceId}}"], .aggregate-connection[data-target="${{serviceId}}"]`).forEach(conn => {{
|
|
2644
|
+
updateConnection(conn);
|
|
2645
|
+
}});
|
|
2646
|
+
|
|
2647
|
+
// Also update popover highlight connections
|
|
2648
|
+
document.querySelectorAll(`.popover-highlight-conn`).forEach(conn => {{
|
|
2649
|
+
const sId = conn.dataset.source;
|
|
2650
|
+
const tId = conn.dataset.target;
|
|
2651
|
+
if (sId === serviceId || tId === serviceId) {{
|
|
2652
|
+
const sPos = servicePositions[sId];
|
|
2653
|
+
const tPos = servicePositions[tId];
|
|
2654
|
+
if (sPos && tPos) {{
|
|
2655
|
+
const pathD = calcConnectionPath(sPos, tPos);
|
|
2656
|
+
const pathEl = conn.querySelector('.connection-path');
|
|
2657
|
+
const hitEl = conn.querySelector('.connection-hitarea');
|
|
2658
|
+
if (pathEl) pathEl.setAttribute('d', pathD);
|
|
2659
|
+
if (hitEl) hitEl.setAttribute('d', pathD);
|
|
2660
|
+
// Update multiplicity label position
|
|
2661
|
+
const multLabel = conn.querySelector('.multiplicity-label');
|
|
2662
|
+
if (multLabel) {{
|
|
2663
|
+
const midX = (sPos.x + tPos.x) / 2 + iconSize / 2;
|
|
2664
|
+
const midY = (sPos.y + tPos.y) / 2 + iconSize / 2;
|
|
2665
|
+
multLabel.setAttribute('x', `${{midX + 8}}`);
|
|
2666
|
+
multLabel.setAttribute('y', `${{midY - 5}}`);
|
|
2667
|
+
}}
|
|
2668
|
+
}}
|
|
2669
|
+
}}
|
|
2670
|
+
}});
|
|
2671
|
+
}};
|
|
2672
|
+
|
|
2673
|
+
// ============ PERSISTENCE INTEGRATION ============
|
|
2674
|
+
// Override save/load/reset to include aggregation state
|
|
2675
|
+
|
|
2676
|
+
savePositions = function() {{
|
|
2677
|
+
// Save positions (including aggregate node positions)
|
|
2678
|
+
const data = JSON.stringify(servicePositions);
|
|
2679
|
+
localStorage.setItem('diagramPositions', data);
|
|
2680
|
+
// Save aggregation state
|
|
2681
|
+
localStorage.setItem('diagramAggregationState', JSON.stringify(aggregationState));
|
|
2682
|
+
// Save connection type filter state
|
|
2683
|
+
localStorage.setItem('diagramConnTypeFilter', JSON.stringify(connTypeFilterState));
|
|
2684
|
+
alert('Layout and aggregation state saved!');
|
|
2685
|
+
}};
|
|
2686
|
+
|
|
2687
|
+
loadPositions = function() {{
|
|
2688
|
+
const data = localStorage.getItem('diagramPositions');
|
|
2689
|
+
if (!data) {{
|
|
2690
|
+
alert('No saved layout found.');
|
|
2691
|
+
return;
|
|
2692
|
+
}}
|
|
2693
|
+
|
|
2694
|
+
const saved = JSON.parse(data);
|
|
2695
|
+
Object.keys(saved).forEach(id => {{
|
|
2696
|
+
if (servicePositions[id] !== undefined) {{
|
|
2697
|
+
servicePositions[id] = saved[id];
|
|
2698
|
+
const el = document.querySelector(`[data-service-id="${{id}}"]`);
|
|
2699
|
+
if (el) {{
|
|
2700
|
+
el.setAttribute('transform', `translate(${{saved[id].x}}, ${{saved[id].y}})`);
|
|
2701
|
+
}}
|
|
2702
|
+
}}
|
|
2703
|
+
}});
|
|
2704
|
+
|
|
2705
|
+
// Load aggregation state
|
|
2706
|
+
const aggData = localStorage.getItem('diagramAggregationState');
|
|
2707
|
+
if (aggData) {{
|
|
2708
|
+
try {{
|
|
2709
|
+
const savedAgg = JSON.parse(aggData);
|
|
2710
|
+
for (const [stype, isAgg] of Object.entries(savedAgg)) {{
|
|
2711
|
+
if (aggregationState[stype] !== undefined && aggregationState[stype] !== isAgg) {{
|
|
2712
|
+
toggleAggregation(stype);
|
|
2713
|
+
}}
|
|
2714
|
+
}}
|
|
2715
|
+
}} catch(e) {{}}
|
|
2716
|
+
}}
|
|
2717
|
+
|
|
2718
|
+
// Load connection type filter state
|
|
2719
|
+
const ctData = localStorage.getItem('diagramConnTypeFilter');
|
|
2720
|
+
if (ctData) {{
|
|
2721
|
+
try {{
|
|
2722
|
+
const savedCt = JSON.parse(ctData);
|
|
2723
|
+
for (const ct of CONNECTION_TYPES) {{
|
|
2724
|
+
if (savedCt[ct.id] !== undefined) {{
|
|
2725
|
+
connTypeFilterState[ct.id] = savedCt[ct.id];
|
|
2726
|
+
}}
|
|
2727
|
+
}}
|
|
2728
|
+
renderConnFilterPanel();
|
|
2729
|
+
applyConnTypeFilter();
|
|
2730
|
+
}} catch(e) {{}}
|
|
2731
|
+
}}
|
|
2732
|
+
|
|
2733
|
+
updateAllConnections();
|
|
2734
|
+
alert('Layout loaded!');
|
|
2735
|
+
}};
|
|
2736
|
+
|
|
2737
|
+
resetPositions = function() {{
|
|
2738
|
+
// Reset individual node positions
|
|
2739
|
+
Object.keys(originalPositions).forEach(id => {{
|
|
2740
|
+
servicePositions[id] = {{ ...originalPositions[id] }};
|
|
2741
|
+
const el = document.querySelector(`[data-service-id="${{id}}"]`);
|
|
2742
|
+
if (el) {{
|
|
2743
|
+
el.setAttribute('transform', `translate(${{originalPositions[id].x}}, ${{originalPositions[id].y}})`);
|
|
2744
|
+
}}
|
|
2745
|
+
}});
|
|
2746
|
+
|
|
2747
|
+
// Reset aggregation to defaults
|
|
2748
|
+
for (const [stype, group] of Object.entries(AGGREGATION_CONFIG.groups)) {{
|
|
2749
|
+
if (group.count >= AGGREGATION_CONFIG.threshold) {{
|
|
2750
|
+
const shouldAgg = group.defaultAggregated;
|
|
2751
|
+
if (aggregationState[stype] !== shouldAgg) {{
|
|
2752
|
+
aggregationState[stype] = shouldAgg;
|
|
2753
|
+
if (shouldAgg) {{
|
|
2754
|
+
aggregateGroup(stype);
|
|
2755
|
+
}} else {{
|
|
2756
|
+
deaggregateGroup(stype);
|
|
2757
|
+
}}
|
|
2758
|
+
}} else if (shouldAgg && aggregateNodes[stype]) {{
|
|
2759
|
+
// Recalculate centroid with reset positions
|
|
2760
|
+
const centroid = computeCentroid(stype);
|
|
2761
|
+
servicePositions[`__agg_${{stype}}`] = centroid;
|
|
2762
|
+
aggregateNodes[stype].setAttribute('transform', `translate(${{centroid.x}}, ${{centroid.y}})`);
|
|
2763
|
+
}}
|
|
2764
|
+
}}
|
|
2765
|
+
}}
|
|
2766
|
+
|
|
2767
|
+
renderChipPanel();
|
|
2768
|
+
updateAllConnections();
|
|
2769
|
+
// Also update aggregate connections
|
|
2770
|
+
for (const [stype, conns] of Object.entries(aggregateConnections)) {{
|
|
2771
|
+
conns.forEach(c => updateConnection(c));
|
|
2772
|
+
}}
|
|
2773
|
+
|
|
2774
|
+
localStorage.removeItem('diagramAggregationState');
|
|
2775
|
+
|
|
2776
|
+
// Reset connection type filter to all visible
|
|
2777
|
+
for (const ct of CONNECTION_TYPES) {{
|
|
2778
|
+
connTypeFilterState[ct.id] = true;
|
|
2779
|
+
}}
|
|
2780
|
+
renderConnFilterPanel();
|
|
2781
|
+
applyConnTypeFilter();
|
|
2782
|
+
localStorage.removeItem('diagramConnTypeFilter');
|
|
2783
|
+
}};
|
|
1121
2784
|
</script>
|
|
1122
2785
|
</body>
|
|
1123
|
-
</html>
|
|
2786
|
+
</html>"""
|
|
1124
2787
|
|
|
1125
2788
|
def __init__(self, svg_renderer: SVGRenderer):
|
|
1126
2789
|
self.svg_renderer = svg_renderer
|
|
@@ -1130,25 +2793,59 @@ class HTMLRenderer:
|
|
|
1130
2793
|
aggregated: AggregatedResult,
|
|
1131
2794
|
positions: Dict[str, Position],
|
|
1132
2795
|
groups: List[ServiceGroup],
|
|
1133
|
-
environment: str =
|
|
2796
|
+
environment: str = "dev",
|
|
2797
|
+
actual_height: Optional[int] = None,
|
|
1134
2798
|
) -> str:
|
|
1135
2799
|
"""Generate complete HTML page with interactive diagram."""
|
|
1136
2800
|
svg_content = self.svg_renderer.render_svg(
|
|
1137
2801
|
aggregated.services,
|
|
1138
2802
|
positions,
|
|
1139
2803
|
aggregated.connections,
|
|
1140
|
-
groups
|
|
2804
|
+
groups,
|
|
2805
|
+
vpc_structure=aggregated.vpc_structure,
|
|
2806
|
+
actual_height=actual_height,
|
|
1141
2807
|
)
|
|
1142
2808
|
|
|
1143
2809
|
total_resources = sum(len(s.resources) for s in aggregated.services)
|
|
1144
2810
|
|
|
2811
|
+
# Build aggregation config for client-side JS
|
|
2812
|
+
agg_metadata = ResourceAggregator.get_aggregation_metadata(aggregated)
|
|
2813
|
+
# Add icon SVG HTML for each service type so JS can render aggregate nodes
|
|
2814
|
+
agg_config: Dict[str, Any] = {"threshold": 3, "groups": {}}
|
|
2815
|
+
for stype, info in agg_metadata.items():
|
|
2816
|
+
icon_svg = self.svg_renderer.icon_mapper.get_icon_svg(
|
|
2817
|
+
info["icon_resource_type"], 48
|
|
2818
|
+
)
|
|
2819
|
+
icon_html = ""
|
|
2820
|
+
if icon_svg:
|
|
2821
|
+
icon_content = self.svg_renderer._extract_svg_content(icon_svg)
|
|
2822
|
+
icon_viewbox = self.svg_renderer._extract_svg_viewbox(icon_svg)
|
|
2823
|
+
if icon_content:
|
|
2824
|
+
icon_html = (
|
|
2825
|
+
f'<svg width="48" height="48" viewBox="{icon_viewbox}">'
|
|
2826
|
+
f"{icon_content}</svg>"
|
|
2827
|
+
)
|
|
2828
|
+
color = self.svg_renderer.icon_mapper.get_category_color(
|
|
2829
|
+
info["icon_resource_type"]
|
|
2830
|
+
)
|
|
2831
|
+
agg_config["groups"][stype] = {
|
|
2832
|
+
"count": info["count"],
|
|
2833
|
+
"label": info["label"],
|
|
2834
|
+
"defaultAggregated": info["defaultAggregated"],
|
|
2835
|
+
"iconHtml": icon_html,
|
|
2836
|
+
"color": color,
|
|
2837
|
+
"serviceIds": info["service_ids"],
|
|
2838
|
+
"serviceNames": info["service_names"],
|
|
2839
|
+
}
|
|
2840
|
+
|
|
1145
2841
|
html_content = self.HTML_TEMPLATE.format(
|
|
1146
2842
|
svg_content=svg_content,
|
|
1147
2843
|
service_count=len(aggregated.services),
|
|
1148
2844
|
resource_count=total_resources,
|
|
1149
2845
|
connection_count=len(aggregated.connections),
|
|
1150
2846
|
environment=environment,
|
|
1151
|
-
icon_size=self.svg_renderer.config.icon_size
|
|
2847
|
+
icon_size=self.svg_renderer.config.icon_size,
|
|
2848
|
+
aggregation_config_json=json.dumps(agg_config),
|
|
1152
2849
|
)
|
|
1153
2850
|
|
|
1154
2851
|
return html_content
|