terraformgraph 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- terraformgraph/__init__.py +19 -0
- terraformgraph/__main__.py +6 -0
- terraformgraph/aggregator.py +396 -0
- terraformgraph/config/aggregation_rules.yaml +132 -0
- terraformgraph/config/logical_connections.yaml +183 -0
- terraformgraph/config_loader.py +55 -0
- terraformgraph/icons.py +795 -0
- terraformgraph/layout.py +239 -0
- terraformgraph/main.py +194 -0
- terraformgraph/parser.py +341 -0
- terraformgraph/renderer.py +1134 -0
- terraformgraph-1.0.1.dist-info/METADATA +161 -0
- terraformgraph-1.0.1.dist-info/RECORD +17 -0
- terraformgraph-1.0.1.dist-info/WHEEL +5 -0
- terraformgraph-1.0.1.dist-info/entry_points.txt +2 -0
- terraformgraph-1.0.1.dist-info/licenses/LICENSE +21 -0
- terraformgraph-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SVG/HTML Renderer
|
|
3
|
+
|
|
4
|
+
Generates interactive HTML diagrams with:
|
|
5
|
+
- Drag-and-drop for repositioning services
|
|
6
|
+
- Connections that follow moved elements
|
|
7
|
+
- Export to PNG/JPG
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import re
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from .aggregator import AggregatedResult, LogicalConnection, LogicalService
|
|
15
|
+
from .icons import IconMapper
|
|
16
|
+
from .layout import LayoutConfig, Position, ServiceGroup
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SVGRenderer:
|
|
20
|
+
"""Renders infrastructure diagrams as SVG."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, icon_mapper: IconMapper, config: Optional[LayoutConfig] = None):
|
|
23
|
+
self.icon_mapper = icon_mapper
|
|
24
|
+
self.config = config or LayoutConfig()
|
|
25
|
+
|
|
26
|
+
def render_svg(
|
|
27
|
+
self,
|
|
28
|
+
services: List[LogicalService],
|
|
29
|
+
positions: Dict[str, Position],
|
|
30
|
+
connections: List[LogicalConnection],
|
|
31
|
+
groups: List[ServiceGroup]
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Generate SVG content for the diagram."""
|
|
34
|
+
svg_parts = []
|
|
35
|
+
|
|
36
|
+
# SVG header with ID for export
|
|
37
|
+
svg_parts.append(f'''<svg id="diagram-svg" xmlns="http://www.w3.org/2000/svg"
|
|
38
|
+
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
39
|
+
viewBox="0 0 {self.config.canvas_width} {self.config.canvas_height}"
|
|
40
|
+
width="{self.config.canvas_width}" height="{self.config.canvas_height}">''')
|
|
41
|
+
|
|
42
|
+
# Defs for arrows and filters
|
|
43
|
+
svg_parts.append(self._render_defs())
|
|
44
|
+
|
|
45
|
+
# Background
|
|
46
|
+
svg_parts.append('''<rect width="100%" height="100%" fill="#f8f9fa"/>''')
|
|
47
|
+
|
|
48
|
+
# Render groups (AWS Cloud, VPC)
|
|
49
|
+
for group in groups:
|
|
50
|
+
svg_parts.append(self._render_group(group))
|
|
51
|
+
|
|
52
|
+
# Connections container (will be updated dynamically)
|
|
53
|
+
svg_parts.append('<g id="connections-layer">')
|
|
54
|
+
for conn in connections:
|
|
55
|
+
if conn.source_id in positions and conn.target_id in positions:
|
|
56
|
+
svg_parts.append(self._render_connection(
|
|
57
|
+
positions[conn.source_id],
|
|
58
|
+
positions[conn.target_id],
|
|
59
|
+
conn
|
|
60
|
+
))
|
|
61
|
+
svg_parts.append('</g>')
|
|
62
|
+
|
|
63
|
+
# Services layer
|
|
64
|
+
svg_parts.append('<g id="services-layer">')
|
|
65
|
+
for service in services:
|
|
66
|
+
if service.id in positions:
|
|
67
|
+
svg_parts.append(self._render_service(service, positions[service.id]))
|
|
68
|
+
svg_parts.append('</g>')
|
|
69
|
+
|
|
70
|
+
svg_parts.append('</svg>')
|
|
71
|
+
|
|
72
|
+
return '\n'.join(svg_parts)
|
|
73
|
+
|
|
74
|
+
def _render_defs(self) -> str:
|
|
75
|
+
"""Render SVG definitions (markers, filters)."""
|
|
76
|
+
return '''
|
|
77
|
+
<defs>
|
|
78
|
+
<marker id="arrowhead" markerWidth="10" markerHeight="7"
|
|
79
|
+
refX="9" refY="3.5" orient="auto">
|
|
80
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#999"/>
|
|
81
|
+
</marker>
|
|
82
|
+
<marker id="arrowhead-data" markerWidth="10" markerHeight="7"
|
|
83
|
+
refX="9" refY="3.5" orient="auto">
|
|
84
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#3B48CC"/>
|
|
85
|
+
</marker>
|
|
86
|
+
<marker id="arrowhead-trigger" markerWidth="10" markerHeight="7"
|
|
87
|
+
refX="9" refY="3.5" orient="auto">
|
|
88
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#E7157B"/>
|
|
89
|
+
</marker>
|
|
90
|
+
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
91
|
+
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
|
|
92
|
+
</filter>
|
|
93
|
+
</defs>
|
|
94
|
+
'''
|
|
95
|
+
|
|
96
|
+
def _render_group(self, group: ServiceGroup) -> str:
|
|
97
|
+
"""Render a group container (AWS Cloud, VPC)."""
|
|
98
|
+
if not group.position:
|
|
99
|
+
return ''
|
|
100
|
+
|
|
101
|
+
pos = group.position
|
|
102
|
+
|
|
103
|
+
colors = {
|
|
104
|
+
'aws_cloud': ('#232f3e', '#ffffff', '#232f3e'),
|
|
105
|
+
'vpc': ('#8c4fff', '#faf8ff', '#8c4fff'),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
border_color, bg_color, text_color = colors.get(group.group_type, ('#666', '#fff', '#666'))
|
|
109
|
+
|
|
110
|
+
return f'''
|
|
111
|
+
<g class="group group-{group.group_type}" data-group-type="{group.group_type}">
|
|
112
|
+
<rect class="group-bg" x="{pos.x}" y="{pos.y}" width="{pos.width}" height="{pos.height}"
|
|
113
|
+
fill="{bg_color}" stroke="{border_color}" stroke-width="2"
|
|
114
|
+
stroke-dasharray="8,4" rx="12" ry="12"
|
|
115
|
+
data-min-x="{pos.x}" data-min-y="{pos.y}"
|
|
116
|
+
data-max-x="{pos.x + pos.width}" data-max-y="{pos.y + pos.height}"/>
|
|
117
|
+
<text x="{pos.x + 15}" y="{pos.y + 22}"
|
|
118
|
+
font-family="Arial, sans-serif" font-size="14" font-weight="bold"
|
|
119
|
+
fill="{text_color}">{html.escape(group.name)}</text>
|
|
120
|
+
</g>
|
|
121
|
+
'''
|
|
122
|
+
|
|
123
|
+
def _render_service(self, service: LogicalService, pos: Position) -> str:
|
|
124
|
+
"""Render a draggable logical service with its icon."""
|
|
125
|
+
icon_svg = self.icon_mapper.get_icon_svg(service.icon_resource_type, 48)
|
|
126
|
+
color = self.icon_mapper.get_category_color(service.icon_resource_type)
|
|
127
|
+
|
|
128
|
+
# Count badge
|
|
129
|
+
count_badge = ''
|
|
130
|
+
if service.count > 1:
|
|
131
|
+
count_badge = f'''
|
|
132
|
+
<circle class="count-badge" cx="{pos.width - 8}" cy="8" r="12"
|
|
133
|
+
fill="{color}" stroke="white" stroke-width="2"/>
|
|
134
|
+
<text class="count-text" x="{pos.width - 8}" y="12"
|
|
135
|
+
font-family="Arial, sans-serif" font-size="11" fill="white"
|
|
136
|
+
text-anchor="middle" font-weight="bold">{service.count}</text>
|
|
137
|
+
'''
|
|
138
|
+
|
|
139
|
+
resource_count = len(service.resources)
|
|
140
|
+
tooltip = f"{service.name} ({resource_count} resources)"
|
|
141
|
+
|
|
142
|
+
# Determine if this is a VPC service
|
|
143
|
+
is_vpc_service = 'true' if service.is_vpc_resource else 'false'
|
|
144
|
+
|
|
145
|
+
if icon_svg:
|
|
146
|
+
icon_content = self._extract_svg_content(icon_svg)
|
|
147
|
+
|
|
148
|
+
svg = f'''
|
|
149
|
+
<g class="service draggable" data-service-id="{html.escape(service.id)}"
|
|
150
|
+
data-tooltip="{html.escape(tooltip)}" data-is-vpc="{is_vpc_service}"
|
|
151
|
+
transform="translate({pos.x}, {pos.y})" style="cursor: grab;">
|
|
152
|
+
<rect class="service-bg" x="-8" y="-8"
|
|
153
|
+
width="{pos.width + 16}" height="{pos.height + 36}"
|
|
154
|
+
fill="white" stroke="#e0e0e0" stroke-width="1" rx="8" ry="8"
|
|
155
|
+
filter="url(#shadow)"/>
|
|
156
|
+
<svg class="service-icon" width="{pos.width}" height="{pos.height}" viewBox="0 0 64 64">
|
|
157
|
+
{icon_content}
|
|
158
|
+
</svg>
|
|
159
|
+
<text class="service-label" x="{pos.width/2}" y="{pos.height + 16}"
|
|
160
|
+
font-family="Arial, sans-serif" font-size="12" fill="#333"
|
|
161
|
+
text-anchor="middle" font-weight="500">
|
|
162
|
+
{html.escape(service.name)}
|
|
163
|
+
</text>
|
|
164
|
+
{count_badge}
|
|
165
|
+
</g>
|
|
166
|
+
'''
|
|
167
|
+
else:
|
|
168
|
+
svg = f'''
|
|
169
|
+
<g class="service draggable" data-service-id="{html.escape(service.id)}"
|
|
170
|
+
data-tooltip="{html.escape(tooltip)}" data-is-vpc="{is_vpc_service}"
|
|
171
|
+
transform="translate({pos.x}, {pos.y})" style="cursor: grab;">
|
|
172
|
+
<rect class="service-bg" x="-8" y="-8"
|
|
173
|
+
width="{pos.width + 16}" height="{pos.height + 36}"
|
|
174
|
+
fill="white" stroke="#e0e0e0" stroke-width="1" rx="8" ry="8"
|
|
175
|
+
filter="url(#shadow)"/>
|
|
176
|
+
<rect x="0" y="0" width="{pos.width}" height="{pos.height}"
|
|
177
|
+
fill="{color}" rx="8" ry="8"/>
|
|
178
|
+
<text x="{pos.width/2}" y="{pos.height/2 + 5}"
|
|
179
|
+
font-family="Arial, sans-serif" font-size="11" fill="white"
|
|
180
|
+
text-anchor="middle">{html.escape(service.service_type[:8])}</text>
|
|
181
|
+
<text class="service-label" x="{pos.width/2}" y="{pos.height + 16}"
|
|
182
|
+
font-family="Arial, sans-serif" font-size="12" fill="#333"
|
|
183
|
+
text-anchor="middle" font-weight="500">
|
|
184
|
+
{html.escape(service.name)}
|
|
185
|
+
</text>
|
|
186
|
+
{count_badge}
|
|
187
|
+
</g>
|
|
188
|
+
'''
|
|
189
|
+
|
|
190
|
+
return svg
|
|
191
|
+
|
|
192
|
+
def _extract_svg_content(self, svg_string: str) -> str:
|
|
193
|
+
"""Extract the inner content of an SVG, removing outer tags."""
|
|
194
|
+
svg_string = re.sub(r'<\?xml[^?]*\?>\s*', '', svg_string)
|
|
195
|
+
match = re.search(r'<svg[^>]*>(.*)</svg>', svg_string, re.DOTALL)
|
|
196
|
+
if match:
|
|
197
|
+
return match.group(1)
|
|
198
|
+
return ''
|
|
199
|
+
|
|
200
|
+
def _render_connection(
|
|
201
|
+
self,
|
|
202
|
+
source_pos: Position,
|
|
203
|
+
target_pos: Position,
|
|
204
|
+
connection: LogicalConnection
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Render a connection line between services."""
|
|
207
|
+
styles = {
|
|
208
|
+
'data_flow': ('#3B48CC', '', 'url(#arrowhead-data)'),
|
|
209
|
+
'trigger': ('#E7157B', '', 'url(#arrowhead-trigger)'),
|
|
210
|
+
'encrypt': ('#6c757d', '4,4', 'url(#arrowhead)'),
|
|
211
|
+
'default': ('#999999', '', 'url(#arrowhead)'),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
stroke_color, stroke_dash, marker = styles.get(connection.connection_type, styles['default'])
|
|
215
|
+
dash_attr = f'stroke-dasharray="{stroke_dash}"' if stroke_dash else ''
|
|
216
|
+
|
|
217
|
+
# Calculate initial path
|
|
218
|
+
half_size = self.config.icon_size / 2
|
|
219
|
+
sx = source_pos.x + half_size
|
|
220
|
+
sy = source_pos.y + half_size
|
|
221
|
+
tx = target_pos.x + half_size
|
|
222
|
+
ty = target_pos.y + half_size
|
|
223
|
+
|
|
224
|
+
# Adjust to connect from edges
|
|
225
|
+
if abs(ty - sy) > abs(tx - sx):
|
|
226
|
+
# Mostly vertical
|
|
227
|
+
if ty > sy:
|
|
228
|
+
sy = source_pos.y + self.config.icon_size + 8
|
|
229
|
+
ty = target_pos.y - 8
|
|
230
|
+
else:
|
|
231
|
+
sy = source_pos.y - 8
|
|
232
|
+
ty = target_pos.y + self.config.icon_size + 8
|
|
233
|
+
else:
|
|
234
|
+
# Mostly horizontal
|
|
235
|
+
if tx > sx:
|
|
236
|
+
sx = source_pos.x + self.config.icon_size + 8
|
|
237
|
+
tx = target_pos.x - 8
|
|
238
|
+
else:
|
|
239
|
+
sx = source_pos.x - 8
|
|
240
|
+
tx = target_pos.x + self.config.icon_size + 8
|
|
241
|
+
|
|
242
|
+
# Simple quadratic curve path (better for export)
|
|
243
|
+
mid_x = (sx + tx) / 2
|
|
244
|
+
mid_y = (sy + ty) / 2
|
|
245
|
+
path = f"M {sx} {sy} Q {mid_x} {sy}, {mid_x} {mid_y} T {tx} {ty}"
|
|
246
|
+
|
|
247
|
+
label = connection.label or ''
|
|
248
|
+
return f'''
|
|
249
|
+
<g class="connection" data-source="{html.escape(connection.source_id)}"
|
|
250
|
+
data-target="{html.escape(connection.target_id)}"
|
|
251
|
+
data-conn-type="{connection.connection_type}"
|
|
252
|
+
data-label="{html.escape(label)}">
|
|
253
|
+
<path class="connection-hitarea" d="{path}"/>
|
|
254
|
+
<path class="connection-path" d="{path}" fill="none" stroke="{stroke_color}"
|
|
255
|
+
stroke-width="1.5" {dash_attr} marker-end="{marker}" opacity="0.7"/>
|
|
256
|
+
</g>
|
|
257
|
+
'''
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class HTMLRenderer:
|
|
261
|
+
"""Wraps SVG in interactive HTML with drag-and-drop and export."""
|
|
262
|
+
|
|
263
|
+
HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
264
|
+
<html lang="en">
|
|
265
|
+
<head>
|
|
266
|
+
<meta charset="UTF-8">
|
|
267
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
268
|
+
<title>AWS Infrastructure Diagram</title>
|
|
269
|
+
<style>
|
|
270
|
+
* {{ box-sizing: border-box; }}
|
|
271
|
+
body {{
|
|
272
|
+
margin: 0;
|
|
273
|
+
padding: 20px;
|
|
274
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
275
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
276
|
+
min-height: 100vh;
|
|
277
|
+
}}
|
|
278
|
+
.container {{
|
|
279
|
+
max-width: 1500px;
|
|
280
|
+
margin: 0 auto;
|
|
281
|
+
}}
|
|
282
|
+
.header {{
|
|
283
|
+
display: flex;
|
|
284
|
+
justify-content: space-between;
|
|
285
|
+
align-items: center;
|
|
286
|
+
margin-bottom: 20px;
|
|
287
|
+
padding: 20px 25px;
|
|
288
|
+
background: white;
|
|
289
|
+
border-radius: 12px;
|
|
290
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
291
|
+
}}
|
|
292
|
+
.header h1 {{
|
|
293
|
+
margin: 0;
|
|
294
|
+
font-size: 24px;
|
|
295
|
+
color: #232f3e;
|
|
296
|
+
}}
|
|
297
|
+
.header .subtitle {{
|
|
298
|
+
margin: 4px 0 0 0;
|
|
299
|
+
font-size: 14px;
|
|
300
|
+
color: #666;
|
|
301
|
+
}}
|
|
302
|
+
.header-right {{
|
|
303
|
+
display: flex;
|
|
304
|
+
align-items: center;
|
|
305
|
+
gap: 20px;
|
|
306
|
+
}}
|
|
307
|
+
.stats {{
|
|
308
|
+
display: flex;
|
|
309
|
+
gap: 30px;
|
|
310
|
+
}}
|
|
311
|
+
.stat {{
|
|
312
|
+
text-align: center;
|
|
313
|
+
}}
|
|
314
|
+
.stat-value {{
|
|
315
|
+
font-size: 28px;
|
|
316
|
+
font-weight: bold;
|
|
317
|
+
color: #8c4fff;
|
|
318
|
+
}}
|
|
319
|
+
.stat-label {{
|
|
320
|
+
font-size: 12px;
|
|
321
|
+
color: #666;
|
|
322
|
+
text-transform: uppercase;
|
|
323
|
+
}}
|
|
324
|
+
.export-buttons {{
|
|
325
|
+
display: flex;
|
|
326
|
+
gap: 10px;
|
|
327
|
+
}}
|
|
328
|
+
.export-btn {{
|
|
329
|
+
padding: 10px 16px;
|
|
330
|
+
border: none;
|
|
331
|
+
border-radius: 8px;
|
|
332
|
+
font-size: 13px;
|
|
333
|
+
font-weight: 500;
|
|
334
|
+
cursor: pointer;
|
|
335
|
+
transition: all 0.2s;
|
|
336
|
+
}}
|
|
337
|
+
.export-btn-primary {{
|
|
338
|
+
background: #8c4fff;
|
|
339
|
+
color: white;
|
|
340
|
+
}}
|
|
341
|
+
.export-btn-primary:hover {{
|
|
342
|
+
background: #7a3de8;
|
|
343
|
+
}}
|
|
344
|
+
.export-btn-secondary {{
|
|
345
|
+
background: #e9ecef;
|
|
346
|
+
color: #333;
|
|
347
|
+
}}
|
|
348
|
+
.export-btn-secondary:hover {{
|
|
349
|
+
background: #dee2e6;
|
|
350
|
+
}}
|
|
351
|
+
.diagram-container {{
|
|
352
|
+
background: white;
|
|
353
|
+
border-radius: 12px;
|
|
354
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
355
|
+
overflow: hidden;
|
|
356
|
+
position: relative;
|
|
357
|
+
}}
|
|
358
|
+
.toolbar {{
|
|
359
|
+
display: flex;
|
|
360
|
+
justify-content: space-between;
|
|
361
|
+
align-items: center;
|
|
362
|
+
padding: 12px 20px;
|
|
363
|
+
background: #f8f9fa;
|
|
364
|
+
border-bottom: 1px solid #e9ecef;
|
|
365
|
+
}}
|
|
366
|
+
.toolbar-info {{
|
|
367
|
+
font-size: 13px;
|
|
368
|
+
color: #666;
|
|
369
|
+
}}
|
|
370
|
+
.toolbar-actions {{
|
|
371
|
+
display: flex;
|
|
372
|
+
gap: 10px;
|
|
373
|
+
}}
|
|
374
|
+
.toolbar-btn {{
|
|
375
|
+
padding: 6px 12px;
|
|
376
|
+
border: 1px solid #ddd;
|
|
377
|
+
background: white;
|
|
378
|
+
border-radius: 6px;
|
|
379
|
+
font-size: 12px;
|
|
380
|
+
cursor: pointer;
|
|
381
|
+
transition: all 0.2s;
|
|
382
|
+
}}
|
|
383
|
+
.toolbar-btn:hover {{
|
|
384
|
+
background: #f0f0f0;
|
|
385
|
+
border-color: #ccc;
|
|
386
|
+
}}
|
|
387
|
+
.diagram-wrapper {{
|
|
388
|
+
padding: 20px;
|
|
389
|
+
overflow: auto;
|
|
390
|
+
max-height: 70vh;
|
|
391
|
+
}}
|
|
392
|
+
.diagram-wrapper svg {{
|
|
393
|
+
display: block;
|
|
394
|
+
margin: 0 auto;
|
|
395
|
+
}}
|
|
396
|
+
.service.dragging {{
|
|
397
|
+
opacity: 0.8;
|
|
398
|
+
cursor: grabbing !important;
|
|
399
|
+
}}
|
|
400
|
+
.service:hover .service-bg {{
|
|
401
|
+
stroke: #8c4fff;
|
|
402
|
+
stroke-width: 2;
|
|
403
|
+
}}
|
|
404
|
+
/* Highlighting states */
|
|
405
|
+
.service.highlighted .service-bg {{
|
|
406
|
+
stroke: #8c4fff;
|
|
407
|
+
stroke-width: 3;
|
|
408
|
+
filter: url(#shadow) drop-shadow(0 0 8px rgba(140, 79, 255, 0.5));
|
|
409
|
+
}}
|
|
410
|
+
.service.dimmed {{
|
|
411
|
+
opacity: 0.3;
|
|
412
|
+
}}
|
|
413
|
+
.connection.highlighted .connection-path {{
|
|
414
|
+
stroke-width: 3 !important;
|
|
415
|
+
opacity: 1 !important;
|
|
416
|
+
}}
|
|
417
|
+
.connection.dimmed {{
|
|
418
|
+
opacity: 0.1 !important;
|
|
419
|
+
}}
|
|
420
|
+
.connection {{
|
|
421
|
+
cursor: pointer;
|
|
422
|
+
}}
|
|
423
|
+
.connection:hover .connection-path {{
|
|
424
|
+
stroke-width: 3;
|
|
425
|
+
opacity: 1;
|
|
426
|
+
}}
|
|
427
|
+
.connection-hitarea {{
|
|
428
|
+
stroke: transparent;
|
|
429
|
+
stroke-width: 15;
|
|
430
|
+
fill: none;
|
|
431
|
+
cursor: pointer;
|
|
432
|
+
}}
|
|
433
|
+
.legend {{
|
|
434
|
+
margin-top: 20px;
|
|
435
|
+
padding: 20px 25px;
|
|
436
|
+
background: white;
|
|
437
|
+
border-radius: 12px;
|
|
438
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
439
|
+
}}
|
|
440
|
+
.legend h3 {{
|
|
441
|
+
margin: 0 0 15px 0;
|
|
442
|
+
font-size: 16px;
|
|
443
|
+
color: #232f3e;
|
|
444
|
+
}}
|
|
445
|
+
.legend-grid {{
|
|
446
|
+
display: grid;
|
|
447
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
448
|
+
gap: 20px;
|
|
449
|
+
}}
|
|
450
|
+
.legend-section h4 {{
|
|
451
|
+
margin: 0 0 10px 0;
|
|
452
|
+
font-size: 13px;
|
|
453
|
+
color: #666;
|
|
454
|
+
text-transform: uppercase;
|
|
455
|
+
}}
|
|
456
|
+
.legend-items {{
|
|
457
|
+
display: flex;
|
|
458
|
+
flex-direction: column;
|
|
459
|
+
gap: 8px;
|
|
460
|
+
}}
|
|
461
|
+
.legend-item {{
|
|
462
|
+
display: flex;
|
|
463
|
+
align-items: center;
|
|
464
|
+
gap: 10px;
|
|
465
|
+
font-size: 13px;
|
|
466
|
+
}}
|
|
467
|
+
.legend-line {{
|
|
468
|
+
width: 30px;
|
|
469
|
+
height: 3px;
|
|
470
|
+
border-radius: 2px;
|
|
471
|
+
}}
|
|
472
|
+
.tooltip {{
|
|
473
|
+
position: fixed;
|
|
474
|
+
padding: 10px 14px;
|
|
475
|
+
background: #232f3e;
|
|
476
|
+
color: white;
|
|
477
|
+
border-radius: 6px;
|
|
478
|
+
font-size: 13px;
|
|
479
|
+
pointer-events: none;
|
|
480
|
+
z-index: 1000;
|
|
481
|
+
display: none;
|
|
482
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
483
|
+
}}
|
|
484
|
+
.export-modal {{
|
|
485
|
+
display: none;
|
|
486
|
+
position: fixed;
|
|
487
|
+
top: 0;
|
|
488
|
+
left: 0;
|
|
489
|
+
width: 100%;
|
|
490
|
+
height: 100%;
|
|
491
|
+
background: rgba(0,0,0,0.5);
|
|
492
|
+
z-index: 2000;
|
|
493
|
+
justify-content: center;
|
|
494
|
+
align-items: center;
|
|
495
|
+
}}
|
|
496
|
+
.export-modal.active {{
|
|
497
|
+
display: flex;
|
|
498
|
+
}}
|
|
499
|
+
.export-modal-content {{
|
|
500
|
+
background: white;
|
|
501
|
+
padding: 30px;
|
|
502
|
+
border-radius: 12px;
|
|
503
|
+
text-align: center;
|
|
504
|
+
max-width: 400px;
|
|
505
|
+
}}
|
|
506
|
+
.export-modal h3 {{
|
|
507
|
+
margin: 0 0 20px 0;
|
|
508
|
+
}}
|
|
509
|
+
.export-preview {{
|
|
510
|
+
max-width: 100%;
|
|
511
|
+
border: 1px solid #ddd;
|
|
512
|
+
border-radius: 8px;
|
|
513
|
+
margin-bottom: 20px;
|
|
514
|
+
}}
|
|
515
|
+
.export-modal-actions {{
|
|
516
|
+
display: flex;
|
|
517
|
+
gap: 10px;
|
|
518
|
+
justify-content: center;
|
|
519
|
+
}}
|
|
520
|
+
.highlight-info {{
|
|
521
|
+
position: fixed;
|
|
522
|
+
bottom: 20px;
|
|
523
|
+
right: 20px;
|
|
524
|
+
padding: 15px 20px;
|
|
525
|
+
background: #232f3e;
|
|
526
|
+
color: white;
|
|
527
|
+
border-radius: 10px;
|
|
528
|
+
font-size: 14px;
|
|
529
|
+
line-height: 1.6;
|
|
530
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
|
531
|
+
z-index: 1000;
|
|
532
|
+
display: none;
|
|
533
|
+
max-width: 280px;
|
|
534
|
+
}}
|
|
535
|
+
.highlight-info strong {{
|
|
536
|
+
color: #8c4fff;
|
|
537
|
+
}}
|
|
538
|
+
.highlight-info small {{
|
|
539
|
+
color: #999;
|
|
540
|
+
display: block;
|
|
541
|
+
margin-top: 8px;
|
|
542
|
+
font-size: 11px;
|
|
543
|
+
}}
|
|
544
|
+
</style>
|
|
545
|
+
</head>
|
|
546
|
+
<body>
|
|
547
|
+
<div class="container">
|
|
548
|
+
<div class="header">
|
|
549
|
+
<div>
|
|
550
|
+
<h1>AWS Infrastructure Diagram</h1>
|
|
551
|
+
<p class="subtitle">Environment: {environment} | Drag icons to reposition</p>
|
|
552
|
+
</div>
|
|
553
|
+
<div class="header-right">
|
|
554
|
+
<div class="stats">
|
|
555
|
+
<div class="stat">
|
|
556
|
+
<div class="stat-value">{service_count}</div>
|
|
557
|
+
<div class="stat-label">Services</div>
|
|
558
|
+
</div>
|
|
559
|
+
<div class="stat">
|
|
560
|
+
<div class="stat-value">{resource_count}</div>
|
|
561
|
+
<div class="stat-label">Resources</div>
|
|
562
|
+
</div>
|
|
563
|
+
<div class="stat">
|
|
564
|
+
<div class="stat-value">{connection_count}</div>
|
|
565
|
+
<div class="stat-label">Connections</div>
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
<div class="export-buttons">
|
|
569
|
+
<button class="export-btn export-btn-secondary" onclick="exportAs('png')">Export PNG</button>
|
|
570
|
+
<button class="export-btn export-btn-primary" onclick="exportAs('jpg')">Export JPG</button>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
<div class="diagram-container">
|
|
575
|
+
<div class="toolbar">
|
|
576
|
+
<div class="toolbar-info">Click and drag services to reposition. Connections update automatically.</div>
|
|
577
|
+
<div class="toolbar-actions">
|
|
578
|
+
<button class="toolbar-btn" onclick="resetPositions()">Reset Layout</button>
|
|
579
|
+
<button class="toolbar-btn" onclick="savePositions()">Save Layout</button>
|
|
580
|
+
<button class="toolbar-btn" onclick="loadPositions()">Load Layout</button>
|
|
581
|
+
</div>
|
|
582
|
+
</div>
|
|
583
|
+
<div class="diagram-wrapper" id="diagram-wrapper">
|
|
584
|
+
{svg_content}
|
|
585
|
+
</div>
|
|
586
|
+
</div>
|
|
587
|
+
<div class="legend">
|
|
588
|
+
<h3>Legend</h3>
|
|
589
|
+
<div class="legend-grid">
|
|
590
|
+
<div class="legend-section">
|
|
591
|
+
<h4>Connection Types</h4>
|
|
592
|
+
<div class="legend-items">
|
|
593
|
+
<div class="legend-item">
|
|
594
|
+
<div class="legend-line" style="background: #3B48CC;"></div>
|
|
595
|
+
<span>Data Flow</span>
|
|
596
|
+
</div>
|
|
597
|
+
<div class="legend-item">
|
|
598
|
+
<div class="legend-line" style="background: #E7157B;"></div>
|
|
599
|
+
<span>Event Trigger</span>
|
|
600
|
+
</div>
|
|
601
|
+
<div class="legend-item">
|
|
602
|
+
<div class="legend-line" style="background: #6c757d;"></div>
|
|
603
|
+
<span>Encryption</span>
|
|
604
|
+
</div>
|
|
605
|
+
<div class="legend-item">
|
|
606
|
+
<div class="legend-line" style="background: #999;"></div>
|
|
607
|
+
<span>Reference</span>
|
|
608
|
+
</div>
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
<div class="legend-section">
|
|
612
|
+
<h4>Instructions</h4>
|
|
613
|
+
<div class="legend-items">
|
|
614
|
+
<div class="legend-item">Drag icons to reposition</div>
|
|
615
|
+
<div class="legend-item">VPC services stay within VPC bounds</div>
|
|
616
|
+
<div class="legend-item">Use Save/Load to persist layout</div>
|
|
617
|
+
<div class="legend-item">Export as PNG or JPG for sharing</div>
|
|
618
|
+
</div>
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
</div>
|
|
623
|
+
<div class="tooltip" id="tooltip"></div>
|
|
624
|
+
<div class="highlight-info" id="highlight-info"></div>
|
|
625
|
+
<div class="export-modal" id="export-modal">
|
|
626
|
+
<div class="export-modal-content">
|
|
627
|
+
<h3>Export Diagram</h3>
|
|
628
|
+
<canvas id="export-canvas" style="display:none;"></canvas>
|
|
629
|
+
<img id="export-preview" class="export-preview" alt="Preview"/>
|
|
630
|
+
<div class="export-modal-actions">
|
|
631
|
+
<button class="export-btn export-btn-secondary" onclick="closeExportModal()">Cancel</button>
|
|
632
|
+
<a id="export-download" class="export-btn export-btn-primary" download="diagram.png">Download</a>
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
|
|
637
|
+
<script>
|
|
638
|
+
// Service positions storage
|
|
639
|
+
const servicePositions = {{}};
|
|
640
|
+
const iconSize = {icon_size};
|
|
641
|
+
let originalPositions = {{}};
|
|
642
|
+
|
|
643
|
+
// Initialize
|
|
644
|
+
document.addEventListener('DOMContentLoaded', () => {{
|
|
645
|
+
initDragAndDrop();
|
|
646
|
+
initTooltips();
|
|
647
|
+
initHighlighting();
|
|
648
|
+
updateAllConnections();
|
|
649
|
+
saveOriginalPositions();
|
|
650
|
+
}});
|
|
651
|
+
|
|
652
|
+
function saveOriginalPositions() {{
|
|
653
|
+
document.querySelectorAll('.service').forEach(el => {{
|
|
654
|
+
const id = el.dataset.serviceId;
|
|
655
|
+
const transform = el.getAttribute('transform');
|
|
656
|
+
const match = transform.match(/translate\\(([^,]+),\\s*([^)]+)\\)/);
|
|
657
|
+
if (match) {{
|
|
658
|
+
originalPositions[id] = {{ x: parseFloat(match[1]), y: parseFloat(match[2]) }};
|
|
659
|
+
servicePositions[id] = {{ ...originalPositions[id] }};
|
|
660
|
+
}}
|
|
661
|
+
}});
|
|
662
|
+
}}
|
|
663
|
+
|
|
664
|
+
function initDragAndDrop() {{
|
|
665
|
+
const svg = document.getElementById('diagram-svg');
|
|
666
|
+
let dragging = null;
|
|
667
|
+
let offset = {{ x: 0, y: 0 }};
|
|
668
|
+
|
|
669
|
+
document.querySelectorAll('.service.draggable').forEach(el => {{
|
|
670
|
+
el.addEventListener('mousedown', startDrag);
|
|
671
|
+
}});
|
|
672
|
+
|
|
673
|
+
svg.addEventListener('mousemove', drag);
|
|
674
|
+
svg.addEventListener('mouseup', endDrag);
|
|
675
|
+
svg.addEventListener('mouseleave', endDrag);
|
|
676
|
+
|
|
677
|
+
function startDrag(e) {{
|
|
678
|
+
e.preventDefault();
|
|
679
|
+
dragging = e.currentTarget;
|
|
680
|
+
dragging.classList.add('dragging');
|
|
681
|
+
dragging.style.cursor = 'grabbing';
|
|
682
|
+
|
|
683
|
+
const pt = svg.createSVGPoint();
|
|
684
|
+
pt.x = e.clientX;
|
|
685
|
+
pt.y = e.clientY;
|
|
686
|
+
const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
|
|
687
|
+
|
|
688
|
+
const id = dragging.dataset.serviceId;
|
|
689
|
+
const pos = servicePositions[id] || {{ x: 0, y: 0 }};
|
|
690
|
+
offset.x = svgP.x - pos.x;
|
|
691
|
+
offset.y = svgP.y - pos.y;
|
|
692
|
+
|
|
693
|
+
// Hide tooltip while dragging
|
|
694
|
+
document.getElementById('tooltip').style.display = 'none';
|
|
695
|
+
}}
|
|
696
|
+
|
|
697
|
+
function drag(e) {{
|
|
698
|
+
if (!dragging) return;
|
|
699
|
+
|
|
700
|
+
const pt = svg.createSVGPoint();
|
|
701
|
+
pt.x = e.clientX;
|
|
702
|
+
pt.y = e.clientY;
|
|
703
|
+
const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
|
|
704
|
+
|
|
705
|
+
let newX = svgP.x - offset.x;
|
|
706
|
+
let newY = svgP.y - offset.y;
|
|
707
|
+
|
|
708
|
+
// Constrain to VPC if it's a VPC service
|
|
709
|
+
if (dragging.dataset.isVpc === 'true') {{
|
|
710
|
+
const vpcGroup = document.querySelector('.group-vpc .group-bg');
|
|
711
|
+
if (vpcGroup) {{
|
|
712
|
+
const minX = parseFloat(vpcGroup.dataset.minX) + 20;
|
|
713
|
+
const minY = parseFloat(vpcGroup.dataset.minY) + 40;
|
|
714
|
+
const maxX = parseFloat(vpcGroup.dataset.maxX) - iconSize - 20;
|
|
715
|
+
const maxY = parseFloat(vpcGroup.dataset.maxY) - iconSize - 40;
|
|
716
|
+
|
|
717
|
+
newX = Math.max(minX, Math.min(maxX, newX));
|
|
718
|
+
newY = Math.max(minY, Math.min(maxY, newY));
|
|
719
|
+
}}
|
|
720
|
+
}} else {{
|
|
721
|
+
// Constrain to AWS Cloud bounds
|
|
722
|
+
const cloudGroup = document.querySelector('.group-aws_cloud .group-bg');
|
|
723
|
+
if (cloudGroup) {{
|
|
724
|
+
const minX = parseFloat(cloudGroup.dataset.minX) + 20;
|
|
725
|
+
const minY = parseFloat(cloudGroup.dataset.minY) + 40;
|
|
726
|
+
const maxX = parseFloat(cloudGroup.dataset.maxX) - iconSize - 20;
|
|
727
|
+
const maxY = parseFloat(cloudGroup.dataset.maxY) - iconSize - 40;
|
|
728
|
+
|
|
729
|
+
newX = Math.max(minX, Math.min(maxX, newX));
|
|
730
|
+
newY = Math.max(minY, Math.min(maxY, newY));
|
|
731
|
+
}}
|
|
732
|
+
}}
|
|
733
|
+
|
|
734
|
+
const id = dragging.dataset.serviceId;
|
|
735
|
+
servicePositions[id] = {{ x: newX, y: newY }};
|
|
736
|
+
|
|
737
|
+
dragging.setAttribute('transform', `translate(${{newX}}, ${{newY}})`);
|
|
738
|
+
updateConnectionsFor(id);
|
|
739
|
+
}}
|
|
740
|
+
|
|
741
|
+
function endDrag() {{
|
|
742
|
+
if (dragging) {{
|
|
743
|
+
dragging.classList.remove('dragging');
|
|
744
|
+
dragging.style.cursor = 'grab';
|
|
745
|
+
dragging = null;
|
|
746
|
+
}}
|
|
747
|
+
}}
|
|
748
|
+
}}
|
|
749
|
+
|
|
750
|
+
function updateConnectionsFor(serviceId) {{
|
|
751
|
+
document.querySelectorAll('.connection').forEach(conn => {{
|
|
752
|
+
if (conn.dataset.source === serviceId || conn.dataset.target === serviceId) {{
|
|
753
|
+
updateConnection(conn);
|
|
754
|
+
}}
|
|
755
|
+
}});
|
|
756
|
+
}}
|
|
757
|
+
|
|
758
|
+
function updateAllConnections() {{
|
|
759
|
+
document.querySelectorAll('.connection').forEach(updateConnection);
|
|
760
|
+
}}
|
|
761
|
+
|
|
762
|
+
function updateConnection(connEl) {{
|
|
763
|
+
const sourceId = connEl.dataset.source;
|
|
764
|
+
const targetId = connEl.dataset.target;
|
|
765
|
+
|
|
766
|
+
const sourcePos = servicePositions[sourceId];
|
|
767
|
+
const targetPos = servicePositions[targetId];
|
|
768
|
+
|
|
769
|
+
if (!sourcePos || !targetPos) return;
|
|
770
|
+
|
|
771
|
+
// Calculate center points
|
|
772
|
+
const halfSize = iconSize / 2;
|
|
773
|
+
let sx = sourcePos.x + halfSize;
|
|
774
|
+
let sy = sourcePos.y + halfSize;
|
|
775
|
+
let tx = targetPos.x + halfSize;
|
|
776
|
+
let ty = targetPos.y + halfSize;
|
|
777
|
+
|
|
778
|
+
// Adjust to connect from edges
|
|
779
|
+
if (Math.abs(ty - sy) > Math.abs(tx - sx)) {{
|
|
780
|
+
// Mostly vertical
|
|
781
|
+
if (ty > sy) {{
|
|
782
|
+
sy = sourcePos.y + iconSize + 8;
|
|
783
|
+
ty = targetPos.y - 8;
|
|
784
|
+
}} else {{
|
|
785
|
+
sy = sourcePos.y - 8;
|
|
786
|
+
ty = targetPos.y + iconSize + 8;
|
|
787
|
+
}}
|
|
788
|
+
}} else {{
|
|
789
|
+
// Mostly horizontal
|
|
790
|
+
if (tx > sx) {{
|
|
791
|
+
sx = sourcePos.x + iconSize + 8;
|
|
792
|
+
tx = targetPos.x - 8;
|
|
793
|
+
}} else {{
|
|
794
|
+
sx = sourcePos.x - 8;
|
|
795
|
+
tx = targetPos.x + iconSize + 8;
|
|
796
|
+
}}
|
|
797
|
+
}}
|
|
798
|
+
|
|
799
|
+
// Quadratic curve path (matches server-side rendering)
|
|
800
|
+
const midX = (sx + tx) / 2;
|
|
801
|
+
const midY = (sy + ty) / 2;
|
|
802
|
+
const path = `M ${{sx}} ${{sy}} Q ${{midX}} ${{sy}}, ${{midX}} ${{midY}} T ${{tx}} ${{ty}}`;
|
|
803
|
+
|
|
804
|
+
const pathEl = connEl.querySelector('.connection-path');
|
|
805
|
+
const hitareaEl = connEl.querySelector('.connection-hitarea');
|
|
806
|
+
if (pathEl) {{
|
|
807
|
+
pathEl.setAttribute('d', path);
|
|
808
|
+
}}
|
|
809
|
+
if (hitareaEl) {{
|
|
810
|
+
hitareaEl.setAttribute('d', path);
|
|
811
|
+
}}
|
|
812
|
+
}}
|
|
813
|
+
|
|
814
|
+
// ============ HIGHLIGHTING SYSTEM ============
|
|
815
|
+
let currentHighlight = null;
|
|
816
|
+
|
|
817
|
+
function initHighlighting() {{
|
|
818
|
+
// Click on service to highlight connections
|
|
819
|
+
document.querySelectorAll('.service').forEach(el => {{
|
|
820
|
+
el.addEventListener('click', (e) => {{
|
|
821
|
+
// Don't highlight if dragging
|
|
822
|
+
if (el.classList.contains('dragging')) return;
|
|
823
|
+
e.stopPropagation();
|
|
824
|
+
|
|
825
|
+
const serviceId = el.dataset.serviceId;
|
|
826
|
+
|
|
827
|
+
// Toggle highlight
|
|
828
|
+
if (currentHighlight === serviceId) {{
|
|
829
|
+
clearHighlights();
|
|
830
|
+
}} else {{
|
|
831
|
+
highlightService(serviceId);
|
|
832
|
+
}}
|
|
833
|
+
}});
|
|
834
|
+
}});
|
|
835
|
+
|
|
836
|
+
// Click on connection to highlight
|
|
837
|
+
document.querySelectorAll('.connection').forEach(el => {{
|
|
838
|
+
el.addEventListener('click', (e) => {{
|
|
839
|
+
e.stopPropagation();
|
|
840
|
+
|
|
841
|
+
const sourceId = el.dataset.source;
|
|
842
|
+
const targetId = el.dataset.target;
|
|
843
|
+
const connKey = `conn:${{sourceId}}->${{targetId}}`;
|
|
844
|
+
|
|
845
|
+
// Toggle highlight
|
|
846
|
+
if (currentHighlight === connKey) {{
|
|
847
|
+
clearHighlights();
|
|
848
|
+
}} else {{
|
|
849
|
+
highlightConnection(el, sourceId, targetId);
|
|
850
|
+
}}
|
|
851
|
+
}});
|
|
852
|
+
}});
|
|
853
|
+
|
|
854
|
+
// Click on background to clear highlights
|
|
855
|
+
document.getElementById('diagram-svg').addEventListener('click', (e) => {{
|
|
856
|
+
if (e.target.tagName === 'svg' || e.target.classList.contains('group-bg')) {{
|
|
857
|
+
clearHighlights();
|
|
858
|
+
}}
|
|
859
|
+
}});
|
|
860
|
+
}}
|
|
861
|
+
|
|
862
|
+
function highlightService(serviceId) {{
|
|
863
|
+
clearHighlights();
|
|
864
|
+
currentHighlight = serviceId;
|
|
865
|
+
|
|
866
|
+
// Find all connected services
|
|
867
|
+
const connectedServices = new Set([serviceId]);
|
|
868
|
+
const connectedConnections = [];
|
|
869
|
+
|
|
870
|
+
document.querySelectorAll('.connection').forEach(conn => {{
|
|
871
|
+
const source = conn.dataset.source;
|
|
872
|
+
const target = conn.dataset.target;
|
|
873
|
+
|
|
874
|
+
if (source === serviceId || target === serviceId) {{
|
|
875
|
+
connectedServices.add(source);
|
|
876
|
+
connectedServices.add(target);
|
|
877
|
+
connectedConnections.push(conn);
|
|
878
|
+
}}
|
|
879
|
+
}});
|
|
880
|
+
|
|
881
|
+
// Dim all services and connections
|
|
882
|
+
document.querySelectorAll('.service').forEach(el => {{
|
|
883
|
+
el.classList.add('dimmed');
|
|
884
|
+
}});
|
|
885
|
+
document.querySelectorAll('.connection').forEach(el => {{
|
|
886
|
+
el.classList.add('dimmed');
|
|
887
|
+
}});
|
|
888
|
+
|
|
889
|
+
// Highlight connected services
|
|
890
|
+
connectedServices.forEach(id => {{
|
|
891
|
+
const el = document.querySelector(`[data-service-id="${{id}}"]`);
|
|
892
|
+
if (el) {{
|
|
893
|
+
el.classList.remove('dimmed');
|
|
894
|
+
el.classList.add('highlighted');
|
|
895
|
+
}}
|
|
896
|
+
}});
|
|
897
|
+
|
|
898
|
+
// Highlight connected connections
|
|
899
|
+
connectedConnections.forEach(conn => {{
|
|
900
|
+
conn.classList.remove('dimmed');
|
|
901
|
+
conn.classList.add('highlighted');
|
|
902
|
+
}});
|
|
903
|
+
|
|
904
|
+
// Show info tooltip
|
|
905
|
+
showHighlightInfo(serviceId, connectedServices.size - 1, connectedConnections.length);
|
|
906
|
+
}}
|
|
907
|
+
|
|
908
|
+
function highlightConnection(connEl, sourceId, targetId) {{
|
|
909
|
+
clearHighlights();
|
|
910
|
+
currentHighlight = `conn:${{sourceId}}->${{targetId}}`;
|
|
911
|
+
|
|
912
|
+
// Dim all
|
|
913
|
+
document.querySelectorAll('.service').forEach(el => {{
|
|
914
|
+
el.classList.add('dimmed');
|
|
915
|
+
}});
|
|
916
|
+
document.querySelectorAll('.connection').forEach(el => {{
|
|
917
|
+
el.classList.add('dimmed');
|
|
918
|
+
}});
|
|
919
|
+
|
|
920
|
+
// Highlight the connection
|
|
921
|
+
connEl.classList.remove('dimmed');
|
|
922
|
+
connEl.classList.add('highlighted');
|
|
923
|
+
|
|
924
|
+
// Highlight source and target services
|
|
925
|
+
const sourceEl = document.querySelector(`[data-service-id="${{sourceId}}"]`);
|
|
926
|
+
const targetEl = document.querySelector(`[data-service-id="${{targetId}}"]`);
|
|
927
|
+
|
|
928
|
+
if (sourceEl) {{
|
|
929
|
+
sourceEl.classList.remove('dimmed');
|
|
930
|
+
sourceEl.classList.add('highlighted');
|
|
931
|
+
}}
|
|
932
|
+
if (targetEl) {{
|
|
933
|
+
targetEl.classList.remove('dimmed');
|
|
934
|
+
targetEl.classList.add('highlighted');
|
|
935
|
+
}}
|
|
936
|
+
|
|
937
|
+
// Show connection info
|
|
938
|
+
const label = connEl.dataset.label || connEl.dataset.connType;
|
|
939
|
+
const sourceName = sourceEl ? sourceEl.dataset.tooltip.split(' (')[0] : sourceId;
|
|
940
|
+
const targetName = targetEl ? targetEl.dataset.tooltip.split(' (')[0] : targetId;
|
|
941
|
+
showConnectionInfo(sourceName, targetName, label);
|
|
942
|
+
}}
|
|
943
|
+
|
|
944
|
+
function clearHighlights() {{
|
|
945
|
+
currentHighlight = null;
|
|
946
|
+
|
|
947
|
+
document.querySelectorAll('.service').forEach(el => {{
|
|
948
|
+
el.classList.remove('dimmed', 'highlighted');
|
|
949
|
+
}});
|
|
950
|
+
document.querySelectorAll('.connection').forEach(el => {{
|
|
951
|
+
el.classList.remove('dimmed', 'highlighted');
|
|
952
|
+
}});
|
|
953
|
+
|
|
954
|
+
hideHighlightInfo();
|
|
955
|
+
}}
|
|
956
|
+
|
|
957
|
+
function showHighlightInfo(serviceId, connectedCount, connectionCount) {{
|
|
958
|
+
const el = document.querySelector(`[data-service-id="${{serviceId}}"]`);
|
|
959
|
+
const name = el ? el.dataset.tooltip.split(' (')[0] : serviceId;
|
|
960
|
+
|
|
961
|
+
const infoEl = document.getElementById('highlight-info');
|
|
962
|
+
infoEl.innerHTML = `
|
|
963
|
+
<strong>${{name}}</strong><br>
|
|
964
|
+
Connected to ${{connectedCount}} service${{connectedCount !== 1 ? 's' : ''}}<br>
|
|
965
|
+
${{connectionCount}} connection${{connectionCount !== 1 ? 's' : ''}}
|
|
966
|
+
<br><small>Click elsewhere to clear</small>
|
|
967
|
+
`;
|
|
968
|
+
infoEl.style.display = 'block';
|
|
969
|
+
}}
|
|
970
|
+
|
|
971
|
+
function showConnectionInfo(sourceName, targetName, label) {{
|
|
972
|
+
const infoEl = document.getElementById('highlight-info');
|
|
973
|
+
infoEl.innerHTML = `
|
|
974
|
+
<strong>${{sourceName}}</strong><br>
|
|
975
|
+
↓ ${{label}}<br>
|
|
976
|
+
<strong>${{targetName}}</strong>
|
|
977
|
+
<br><small>Click elsewhere to clear</small>
|
|
978
|
+
`;
|
|
979
|
+
infoEl.style.display = 'block';
|
|
980
|
+
}}
|
|
981
|
+
|
|
982
|
+
function hideHighlightInfo() {{
|
|
983
|
+
document.getElementById('highlight-info').style.display = 'none';
|
|
984
|
+
}}
|
|
985
|
+
|
|
986
|
+
function initTooltips() {{
|
|
987
|
+
const tooltip = document.getElementById('tooltip');
|
|
988
|
+
|
|
989
|
+
document.querySelectorAll('.service').forEach(el => {{
|
|
990
|
+
el.addEventListener('mouseenter', (e) => {{
|
|
991
|
+
if (el.classList.contains('dragging')) return;
|
|
992
|
+
const data = el.dataset.tooltip;
|
|
993
|
+
if (data) {{
|
|
994
|
+
tooltip.textContent = data;
|
|
995
|
+
tooltip.style.display = 'block';
|
|
996
|
+
}}
|
|
997
|
+
}});
|
|
998
|
+
el.addEventListener('mousemove', (e) => {{
|
|
999
|
+
if (el.classList.contains('dragging')) return;
|
|
1000
|
+
tooltip.style.left = e.clientX + 15 + 'px';
|
|
1001
|
+
tooltip.style.top = e.clientY + 15 + 'px';
|
|
1002
|
+
}});
|
|
1003
|
+
el.addEventListener('mouseleave', () => {{
|
|
1004
|
+
tooltip.style.display = 'none';
|
|
1005
|
+
}});
|
|
1006
|
+
}});
|
|
1007
|
+
}}
|
|
1008
|
+
|
|
1009
|
+
function resetPositions() {{
|
|
1010
|
+
Object.keys(originalPositions).forEach(id => {{
|
|
1011
|
+
servicePositions[id] = {{ ...originalPositions[id] }};
|
|
1012
|
+
const el = document.querySelector(`[data-service-id="${{id}}"]`);
|
|
1013
|
+
if (el) {{
|
|
1014
|
+
el.setAttribute('transform', `translate(${{originalPositions[id].x}}, ${{originalPositions[id].y}})`);
|
|
1015
|
+
}}
|
|
1016
|
+
}});
|
|
1017
|
+
updateAllConnections();
|
|
1018
|
+
}}
|
|
1019
|
+
|
|
1020
|
+
function savePositions() {{
|
|
1021
|
+
const data = JSON.stringify(servicePositions);
|
|
1022
|
+
localStorage.setItem('diagramPositions', data);
|
|
1023
|
+
alert('Layout saved to browser storage!');
|
|
1024
|
+
}}
|
|
1025
|
+
|
|
1026
|
+
function loadPositions() {{
|
|
1027
|
+
const data = localStorage.getItem('diagramPositions');
|
|
1028
|
+
if (!data) {{
|
|
1029
|
+
alert('No saved layout found.');
|
|
1030
|
+
return;
|
|
1031
|
+
}}
|
|
1032
|
+
|
|
1033
|
+
const saved = JSON.parse(data);
|
|
1034
|
+
Object.keys(saved).forEach(id => {{
|
|
1035
|
+
if (servicePositions[id]) {{
|
|
1036
|
+
servicePositions[id] = saved[id];
|
|
1037
|
+
const el = document.querySelector(`[data-service-id="${{id}}"]`);
|
|
1038
|
+
if (el) {{
|
|
1039
|
+
el.setAttribute('transform', `translate(${{saved[id].x}}, ${{saved[id].y}})`);
|
|
1040
|
+
}}
|
|
1041
|
+
}}
|
|
1042
|
+
}});
|
|
1043
|
+
updateAllConnections();
|
|
1044
|
+
alert('Layout loaded!');
|
|
1045
|
+
}}
|
|
1046
|
+
|
|
1047
|
+
function exportAs(format) {{
|
|
1048
|
+
const svg = document.getElementById('diagram-svg');
|
|
1049
|
+
const canvas = document.getElementById('export-canvas');
|
|
1050
|
+
const ctx = canvas.getContext('2d');
|
|
1051
|
+
|
|
1052
|
+
// Set canvas size
|
|
1053
|
+
const svgRect = svg.getBoundingClientRect();
|
|
1054
|
+
const scale = 2; // Higher resolution
|
|
1055
|
+
canvas.width = svg.viewBox.baseVal.width * scale;
|
|
1056
|
+
canvas.height = svg.viewBox.baseVal.height * scale;
|
|
1057
|
+
|
|
1058
|
+
// Create image from SVG
|
|
1059
|
+
const svgData = new XMLSerializer().serializeToString(svg);
|
|
1060
|
+
const svgBlob = new Blob([svgData], {{ type: 'image/svg+xml;charset=utf-8' }});
|
|
1061
|
+
const url = URL.createObjectURL(svgBlob);
|
|
1062
|
+
|
|
1063
|
+
const img = new Image();
|
|
1064
|
+
img.onload = () => {{
|
|
1065
|
+
// White background for JPG
|
|
1066
|
+
if (format === 'jpg') {{
|
|
1067
|
+
ctx.fillStyle = 'white';
|
|
1068
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
1069
|
+
}}
|
|
1070
|
+
|
|
1071
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
1072
|
+
URL.revokeObjectURL(url);
|
|
1073
|
+
|
|
1074
|
+
const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
|
|
1075
|
+
const quality = format === 'jpg' ? 0.95 : undefined;
|
|
1076
|
+
const dataUrl = canvas.toDataURL(mimeType, quality);
|
|
1077
|
+
|
|
1078
|
+
// Show modal with preview
|
|
1079
|
+
const preview = document.getElementById('export-preview');
|
|
1080
|
+
const download = document.getElementById('export-download');
|
|
1081
|
+
|
|
1082
|
+
preview.src = dataUrl;
|
|
1083
|
+
download.href = dataUrl;
|
|
1084
|
+
download.download = `aws-diagram.${{format}}`;
|
|
1085
|
+
|
|
1086
|
+
document.getElementById('export-modal').classList.add('active');
|
|
1087
|
+
}};
|
|
1088
|
+
img.src = url;
|
|
1089
|
+
}}
|
|
1090
|
+
|
|
1091
|
+
function closeExportModal() {{
|
|
1092
|
+
document.getElementById('export-modal').classList.remove('active');
|
|
1093
|
+
}}
|
|
1094
|
+
|
|
1095
|
+
// Close modal on background click
|
|
1096
|
+
document.getElementById('export-modal').addEventListener('click', (e) => {{
|
|
1097
|
+
if (e.target.id === 'export-modal') {{
|
|
1098
|
+
closeExportModal();
|
|
1099
|
+
}}
|
|
1100
|
+
}});
|
|
1101
|
+
</script>
|
|
1102
|
+
</body>
|
|
1103
|
+
</html>'''
|
|
1104
|
+
|
|
1105
|
+
def __init__(self, svg_renderer: SVGRenderer):
|
|
1106
|
+
self.svg_renderer = svg_renderer
|
|
1107
|
+
|
|
1108
|
+
def render_html(
|
|
1109
|
+
self,
|
|
1110
|
+
aggregated: AggregatedResult,
|
|
1111
|
+
positions: Dict[str, Position],
|
|
1112
|
+
groups: List[ServiceGroup],
|
|
1113
|
+
environment: str = 'dev'
|
|
1114
|
+
) -> str:
|
|
1115
|
+
"""Generate complete HTML page with interactive diagram."""
|
|
1116
|
+
svg_content = self.svg_renderer.render_svg(
|
|
1117
|
+
aggregated.services,
|
|
1118
|
+
positions,
|
|
1119
|
+
aggregated.connections,
|
|
1120
|
+
groups
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
total_resources = sum(len(s.resources) for s in aggregated.services)
|
|
1124
|
+
|
|
1125
|
+
html_content = self.HTML_TEMPLATE.format(
|
|
1126
|
+
svg_content=svg_content,
|
|
1127
|
+
service_count=len(aggregated.services),
|
|
1128
|
+
resource_count=total_resources,
|
|
1129
|
+
connection_count=len(aggregated.connections),
|
|
1130
|
+
environment=environment,
|
|
1131
|
+
icon_size=self.svg_renderer.config.icon_size
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
return html_content
|