@@ -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
262262type 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
278287type 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
401551func parseDOTEdges (dot string ) []PlanEdge {
402552 edgeRegex := regexp .MustCompile (`"([^"]+)"\s*->\s*"([^"]+)"` )
0 commit comments