Skip to content

Commit 16f753b

Browse files
committed
feat(资源拓扑): 增加资源类型汇总表和节点详情展示
在资源拓扑预览中新增资源类型汇总表格,展示每种资源类型的数量和关键信息 为拓扑节点添加详情展示,包括实例类型、CIDR等配置信息 调整节点布局以适应详情展示,优化国际化文本
1 parent 6d02217 commit 16f753b

File tree

5 files changed

+306
-25
lines changed

5 files changed

+306
-25
lines changed

app_scene.go

Lines changed: 165 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,13 @@ func (a *App) DeployCase(templateName string, name string, vars map[string]strin
260260

261261
// PlanResourceChange represents a single resource change in the plan
262262
type PlanResourceChange struct {
263-
Address string `json:"address"`
264-
Type string `json:"type"`
265-
Name string `json:"name"`
266-
ProviderName string `json:"providerName"`
267-
Actions []string `json:"actions"`
268-
IsData bool `json:"isData"`
263+
Address string `json:"address"`
264+
Type string `json:"type"`
265+
Name string `json:"name"`
266+
ProviderName string `json:"providerName"`
267+
Actions []string `json:"actions"`
268+
IsData bool `json:"isData"`
269+
Detail map[string]string `json:"detail,omitempty"`
269270
}
270271

271272
// PlanEdge represents a dependency edge between two resources
@@ -274,15 +275,24 @@ type PlanEdge struct {
274275
To string `json:"to"`
275276
}
276277

278+
// PlanTypeSummary summarizes resource count by type
279+
type PlanTypeSummary struct {
280+
Type string `json:"type"`
281+
Label string `json:"label"`
282+
Count int `json:"count"`
283+
Actions string `json:"actions"`
284+
}
285+
277286
// PlanPreview contains the full plan preview data for topology visualization
278287
type PlanPreview struct {
279-
HasChanges bool `json:"hasChanges"`
280-
ToCreate int `json:"toCreate"`
281-
ToUpdate int `json:"toUpdate"`
282-
ToDelete int `json:"toDelete"`
283-
ToRecreate int `json:"toRecreate"`
284-
Resources []PlanResourceChange `json:"resources"`
285-
Edges []PlanEdge `json:"edges"`
288+
HasChanges bool `json:"hasChanges"`
289+
ToCreate int `json:"toCreate"`
290+
ToUpdate int `json:"toUpdate"`
291+
ToDelete int `json:"toDelete"`
292+
ToRecreate int `json:"toRecreate"`
293+
Resources []PlanResourceChange `json:"resources"`
294+
Edges []PlanEdge `json:"edges"`
295+
TypeSummary []PlanTypeSummary `json:"typeSummary"`
286296
}
287297

288298
// GetCasePlanPreview returns structured plan preview data for a case
@@ -340,8 +350,9 @@ func buildPlanPreview(workDir string) (*PlanPreview, error) {
340350
defer cancel()
341351

342352
preview := &PlanPreview{
343-
Resources: []PlanResourceChange{},
344-
Edges: []PlanEdge{},
353+
Resources: []PlanResourceChange{},
354+
Edges: []PlanEdge{},
355+
TypeSummary: []PlanTypeSummary{},
345356
}
346357

347358
resourceChanges, err := te.GetPlanResourceChanges(ctx)
@@ -353,6 +364,11 @@ func buildPlanPreview(workDir string) (*PlanPreview, error) {
353364
return preview, nil
354365
}
355366

367+
// For type summary
368+
typeCount := make(map[string]int)
369+
typeAction := make(map[string]string)
370+
typeOrder := []string{}
371+
356372
preview.HasChanges = true
357373
for _, rc := range resourceChanges {
358374
actions := make([]string, len(rc.Change.Actions))
@@ -372,6 +388,7 @@ func buildPlanPreview(workDir string) (*PlanPreview, error) {
372388
ProviderName: rc.ProviderName,
373389
Actions: actions,
374390
IsData: isData,
391+
Detail: extractResourceDetail(rc.Type, rc.Change.After),
375392
}
376393
preview.Resources = append(preview.Resources, prc)
377394

@@ -387,6 +404,25 @@ func buildPlanPreview(workDir string) (*PlanPreview, error) {
387404
} else if len(actions) == 2 && actions[0] == "delete" && actions[1] == "create" {
388405
preview.ToRecreate++
389406
}
407+
408+
// Type summary
409+
if _, exists := typeCount[rc.Type]; !exists {
410+
typeOrder = append(typeOrder, rc.Type)
411+
}
412+
typeCount[rc.Type]++
413+
if _, exists := typeAction[rc.Type]; !exists {
414+
typeAction[rc.Type] = actions[0]
415+
}
416+
}
417+
418+
// Build type summary in order of first appearance
419+
for _, t := range typeOrder {
420+
preview.TypeSummary = append(preview.TypeSummary, PlanTypeSummary{
421+
Type: t,
422+
Label: humanizeResourceType(t),
423+
Count: typeCount[t],
424+
Actions: typeAction[t],
425+
})
390426
}
391427

392428
dot, err := te.GetGraph(ctx)
@@ -397,6 +433,120 @@ func buildPlanPreview(workDir string) (*PlanPreview, error) {
397433
return preview, nil
398434
}
399435

436+
// extractResourceDetail extracts key configuration values from plan after-values
437+
func extractResourceDetail(resType string, after interface{}) map[string]string {
438+
m, ok := after.(map[string]interface{})
439+
if !ok || m == nil {
440+
return nil
441+
}
442+
443+
detail := make(map[string]string)
444+
getString := func(key string) string {
445+
if v, ok := m[key]; ok && v != nil {
446+
return fmt.Sprintf("%v", v)
447+
}
448+
return ""
449+
}
450+
451+
// Instance types
452+
if v := getString("instance_type"); v != "" {
453+
detail["instance_type"] = v
454+
}
455+
if v := getString("image_id"); v != "" {
456+
detail["image"] = v
457+
}
458+
if v := getString("ami"); v != "" {
459+
detail["image"] = v
460+
}
461+
462+
// VPC / Subnet
463+
if v := getString("cidr_block"); v != "" {
464+
detail["cidr"] = v
465+
}
466+
467+
// Security group rules (alicloud style)
468+
if v := getString("port_range"); v != "" {
469+
proto := getString("ip_protocol")
470+
policy := getString("policy")
471+
cidr := getString("cidr_ip")
472+
detail["rule"] = fmt.Sprintf("%s %s %s → %s", proto, v, policy, cidr)
473+
}
474+
475+
// Security group (AWS style - embedded ingress/egress)
476+
if ingress, ok := m["ingress"]; ok && ingress != nil {
477+
detail["ingress"] = formatSGRules(ingress)
478+
}
479+
if egress, ok := m["egress"]; ok && egress != nil {
480+
detail["egress"] = formatSGRules(egress)
481+
}
482+
483+
// Tencent cloud SG rule
484+
if v := getString("policy_index"); v != "" {
485+
proto := getString("ip_protocol")
486+
if proto == "" {
487+
proto = "all"
488+
}
489+
cidr := getString("cidr_ip")
490+
policy := getString("policy")
491+
ruleType := getString("type")
492+
detail["rule"] = fmt.Sprintf("%s %s %s %s → %s", ruleType, proto, policy, cidr, v)
493+
}
494+
495+
if len(detail) == 0 {
496+
return nil
497+
}
498+
return detail
499+
}
500+
501+
// formatSGRules formats AWS-style security group ingress/egress rules
502+
func formatSGRules(rules interface{}) string {
503+
ruleList, ok := rules.([]interface{})
504+
if !ok || len(ruleList) == 0 {
505+
return ""
506+
}
507+
var parts []string
508+
for _, r := range ruleList {
509+
rm, ok := r.(map[string]interface{})
510+
if !ok {
511+
continue
512+
}
513+
proto := fmt.Sprintf("%v", rm["protocol"])
514+
fromPort := fmt.Sprintf("%v", rm["from_port"])
515+
toPort := fmt.Sprintf("%v", rm["to_port"])
516+
var cidrs []string
517+
if cb, ok := rm["cidr_blocks"].([]interface{}); ok {
518+
for _, c := range cb {
519+
cidrs = append(cidrs, fmt.Sprintf("%v", c))
520+
}
521+
}
522+
cidr := strings.Join(cidrs, ",")
523+
if cidr == "" {
524+
cidr = "*"
525+
}
526+
if proto == "-1" {
527+
parts = append(parts, fmt.Sprintf("all → %s", cidr))
528+
} else {
529+
parts = append(parts, fmt.Sprintf("%s:%s-%s → %s", proto, fromPort, toPort, cidr))
530+
}
531+
}
532+
return strings.Join(parts, "; ")
533+
}
534+
535+
// humanizeResourceType converts terraform type to human-readable label
536+
func humanizeResourceType(resType string) string {
537+
parts := strings.Split(resType, "_")
538+
if len(parts) <= 1 {
539+
return resType
540+
}
541+
var words []string
542+
for _, p := range parts[1:] {
543+
if len(p) > 0 {
544+
words = append(words, strings.ToUpper(p[:1])+p[1:])
545+
}
546+
}
547+
return strings.Join(words, " ")
548+
}
549+
400550
// parseDOTEdges extracts edges from DOT format string
401551
func parseDOTEdges(dot string) []PlanEdge {
402552
edgeRegex := regexp.MustCompile(`"([^"]+)"\s*->\s*"([^"]+)"`)

frontend/src/components/Cases/Cases.svelte

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -458,19 +458,30 @@ let { t, onTabChange = () => {} } = $props();
458458
459459
function getNodeLabel(resource) {
460460
const parts = resource.type.split('_');
461-
// e.g. aws_instance → Instance, volcengine_ecs_instance → ECS Instance
462461
if (parts.length <= 1) return resource.type;
463-
const provider = parts[0];
464462
const rest = parts.slice(1).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
465463
return rest;
466464
}
467465
466+
function getNodeDetail(resource) {
467+
const d = resource?.detail;
468+
if (!d) return '';
469+
const parts = [];
470+
if (d.instance_type) parts.push(d.instance_type);
471+
if (d.cidr) parts.push(d.cidr);
472+
if (d.rule) parts.push(d.rule);
473+
if (d.ingress) parts.push('in: ' + d.ingress);
474+
if (d.egress && !d.ingress) parts.push('out: ' + d.egress);
475+
return parts.join(' | ').substring(0, 60);
476+
}
477+
468478
async function layoutTopology(data) {
469479
try {
470480
const elk = new ELK();
471481
472-
const NODE_W = 180;
473-
const NODE_H = 48;
482+
const NODE_W = 200;
483+
const hasDetail = data.resources.some(r => r.detail && Object.keys(r.detail).length > 0);
484+
const NODE_H = hasDetail ? 58 : 48;
474485
475486
// Build address set for filtering edges
476487
const addrSet = new Set(data.resources.map(r => r.address));
@@ -519,6 +530,7 @@ let { t, onTabChange = () => {} } = $props();
519530
resource: resMap[n.id],
520531
color: getActionColor(resMap[n.id]?.actions),
521532
label: getNodeLabel(resMap[n.id] || {}),
533+
detailText: getNodeDetail(resMap[n.id] || {}),
522534
}));
523535
524536
const newEdges = (layout.edges || []).map(e => {
@@ -1752,6 +1764,41 @@ let { t, onTabChange = () => {} } = $props();
17521764
</div>
17531765
</div>
17541766
1767+
<!-- Type Summary Table -->
1768+
{#if planPreviewModal.data.typeSummary && planPreviewModal.data.typeSummary.length > 0}
1769+
<div class="mb-4 bg-white rounded-lg border border-gray-200 overflow-hidden">
1770+
<table class="w-full text-[12px]">
1771+
<thead>
1772+
<tr class="bg-gray-50 border-b border-gray-100">
1773+
<th class="text-left px-3 py-1.5 font-medium text-gray-500">{t.resourceType || '资源类型'}</th>
1774+
<th class="text-center px-3 py-1.5 font-medium text-gray-500 w-16">{t.count || '数量'}</th>
1775+
<th class="text-left px-3 py-1.5 font-medium text-gray-500">{t.keyInfo || '关键信息'}</th>
1776+
</tr>
1777+
</thead>
1778+
<tbody>
1779+
{#each planPreviewModal.data.typeSummary as ts}
1780+
{@const firstRes = planPreviewModal.data.resources.find(r => r.type === ts.type)}
1781+
{@const detailStr = firstRes?.detail ? Object.values(firstRes.detail).join(' · ') : ''}
1782+
<tr class="border-b border-gray-50 last:border-0">
1783+
<td class="px-3 py-1.5">
1784+
<span class="font-medium text-gray-700">{ts.label}</span>
1785+
<span class="text-gray-400 ml-1 text-[10px]">{ts.type}</span>
1786+
</td>
1787+
<td class="text-center px-3 py-1.5">
1788+
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-[11px] font-bold {ts.count > 1 ? 'bg-blue-50 text-blue-600' : 'bg-gray-50 text-gray-500'}">
1789+
{ts.count}
1790+
</span>
1791+
</td>
1792+
<td class="px-3 py-1.5 text-gray-500 text-[11px] truncate max-w-[300px]" title={detailStr}>
1793+
{detailStr || '-'}
1794+
</td>
1795+
</tr>
1796+
{/each}
1797+
</tbody>
1798+
</table>
1799+
</div>
1800+
{/if}
1801+
17551802
<!-- Topology SVG -->
17561803
{#if elkNodes.length > 0}
17571804
<div class="relative">
@@ -1786,13 +1833,18 @@ let { t, onTabChange = () => {} } = $props();
17861833
<g transform="translate({node.x}, {node.y})">
17871834
<rect width={node.w} height={node.h} rx="8" ry="8"
17881835
fill="white" stroke={node.color.border} stroke-width="2" class="drop-shadow-sm" />
1789-
<circle cx="14" cy={node.h / 2} r="4" fill={node.color.border} />
1836+
<circle cx="14" cy="20" r="4" fill={node.color.border} />
17901837
<text x="26" y="18" font-size="11" font-weight="600" fill="#374151" font-family="system-ui, sans-serif">
17911838
{node.label}
17921839
</text>
1793-
<text x="26" y="34" font-size="9" fill="#9ca3af" font-family="system-ui, sans-serif">
1840+
<text x="26" y="32" font-size="9" fill="#9ca3af" font-family="system-ui, sans-serif">
17941841
{node.resource?.name || ''}
17951842
</text>
1843+
{#if node.detailText}
1844+
<text x="26" y="46" font-size="8" fill="#6b7280" font-family="system-ui, sans-serif" font-style="italic">
1845+
{node.detailText}
1846+
</text>
1847+
{/if}
17961848
<text x={node.w - 10} y="18" font-size="11" font-weight="700" fill={node.color.text} text-anchor="end" font-family="system-ui, sans-serif">
17971849
{node.color.label}
17981850
</text>

0 commit comments

Comments
 (0)