From 9d1845517b2d4cfb23cb9657cb6b0aa4036cde6c Mon Sep 17 00:00:00 2001 From: barry Date: Wed, 25 Feb 2026 13:16:06 +0800 Subject: [PATCH 1/7] chore: remove dixrender/renderer.go file --- dixrender/renderer.go | 144 ------------------------------------------ 1 file changed, 144 deletions(-) delete mode 100644 dixrender/renderer.go diff --git a/dixrender/renderer.go b/dixrender/renderer.go deleted file mode 100644 index 2fbcfcc..0000000 --- a/dixrender/renderer.go +++ /dev/null @@ -1,144 +0,0 @@ -package dixrender - -import ( - "bytes" - "fmt" - "sort" - "strings" -) - -// DotRenderer implements DOT format graph rendering -type DotRenderer struct { - Buf *bytes.Buffer // Exported for testing - indent string - cache map[string]string -} - -func NewDotRenderer() *DotRenderer { - return &DotRenderer{ - Buf: &bytes.Buffer{}, - indent: "", - cache: make(map[string]string), - } -} - -// Writef writes a formatted string to the renderer buffer -func (d *DotRenderer) Writef(format string, args ...any) { - _, _ = fmt.Fprintf(d.Buf, d.indent+format+"\n", args...) -} - -func (d *DotRenderer) writef(format string, args ...any) { - d.Writef(format, args...) -} - -// escapeDotString escapes special characters for DOT format -func escapeDotString(s string) string { - s = strings.ReplaceAll(s, "\\", "\\\\") - s = strings.ReplaceAll(s, "\"", "\\\"") - s = strings.ReplaceAll(s, "\n", "\\n") - s = strings.ReplaceAll(s, "\r", "\\r") - s = strings.ReplaceAll(s, "\t", "\\t") - return s -} - -func (d *DotRenderer) RenderNode(name string, attrs map[string]string) { - escapedName := escapeDotString(name) - d.writef(`"%s" [label="%s"%s]`, escapedName, escapedName, d.formatAttrs(attrs)) -} - -func (d *DotRenderer) RenderEdge(from, to string, attrs map[string]string) { - d.writef(`"%s" -> "%s" %s`, escapeDotString(from), escapeDotString(to), d.formatAttrs(attrs)) -} - -func (d *DotRenderer) BeginSubgraph(name, label string) { - d.writef("subgraph %s {", escapeDotString(name)) - d.indent += "\t" - d.writef("label=\"%s\"", escapeDotString(label)) -} - -func (d *DotRenderer) EndSubgraph() { - d.indent = d.indent[:len(d.indent)-1] - d.writef("}") -} - -func (d *DotRenderer) String() string { - return d.Buf.String() -} - -// FormatAttrs formats attributes map into DOT format string -func (d *DotRenderer) FormatAttrs(attrs map[string]string) string { - return d.formatAttrs(attrs) -} - -func (d *DotRenderer) formatAttrs(attrs map[string]string) string { - if len(attrs) == 0 { - return "" - } - - // Sort keys to ensure consistent ordering - keys := make([]string, 0, len(attrs)) - for k := range attrs { - keys = append(keys, k) - } - sort.Strings(keys) - - var result bytes.Buffer - result.WriteString(" [") - first := true - for _, k := range keys { - if !first { - result.WriteString(",") - } - first = false - v := attrs[k] - fmt.Fprintf(&result, "%s=\"%s\"", k, v) - } - result.WriteString("]") - return result.String() -} - -// Graph represents dependency graphs in DOT format -type Graph struct { - Objects string `json:"objects"` - Providers string `json:"providers"` - ProviderTypes string `json:"provider_types"` -} - -// GraphOptions holds configuration options for graph rendering -type GraphOptions struct { - // MaxDepth limits the depth of dependencies to show (0 = unlimited) - MaxDepth int - - // GroupByPackage enables grouping nodes by package - GroupByPackage bool - - // ShowStructFields controls whether to show struct field dependencies - ShowStructFields bool - - // FilterPackages allows filtering by specific packages - FilterPackages []string -} - -// NewGraphOptions creates GraphOptions with sensible defaults -func NewGraphOptions() *GraphOptions { - return &GraphOptions{ - MaxDepth: 0, // Unlimited by default - GroupByPackage: true, - ShowStructFields: false, - FilterPackages: []string{}, - } -} - -// ShouldIncludeType checks if a type should be included in the graph based on filters -func (opts *GraphOptions) ShouldIncludeType(typ string) bool { - if len(opts.FilterPackages) == 0 { - return true - } - - for _, pkg := range opts.FilterPackages { - if strings.Contains(typ, pkg) { - return true - } - } - return false -} From 481965cefe189a4af95f9c5b4faed2a9b52201d9 Mon Sep 17 00:00:00 2001 From: barry Date: Wed, 25 Feb 2026 13:18:17 +0800 Subject: [PATCH 2/7] build: update go.mod and go.sum dependencies --- example/go.mod | 8 ++------ example/go.sum | 4 ---- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/example/go.mod b/example/go.mod index f8099a0..e8e20ac 100644 --- a/example/go.mod +++ b/example/go.mod @@ -4,10 +4,6 @@ go 1.24.0 replace github.com/pubgo/dix/v2 => ../ -require github.com/pubgo/dix/v2 v2.0.0-alpha.3 +require github.com/pubgo/dix/v2 v2.0.0-00010101000000-000000000000 -require ( - github.com/lmittmann/tint v1.1.2 // indirect - github.com/samber/lo v1.52.0 // indirect - golang.org/x/text v0.31.0 // indirect -) +require github.com/lmittmann/tint v1.1.2 // indirect diff --git a/example/go.sum b/example/go.sum index 802974a..ca8ef76 100644 --- a/example/go.sum +++ b/example/go.sum @@ -1,6 +1,2 @@ github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= -github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= From 00c15cc00e5eaad19c8af77ffe99749b0ea5da79 Mon Sep 17 00:00:00 2001 From: barry Date: Wed, 25 Feb 2026 13:49:39 +0800 Subject: [PATCH 3/7] docs: add base path support for server routes --- dixhttp/README.md | 12 ++++++++ dixhttp/README_zh.md | 12 ++++++++ dixhttp/server.go | 68 +++++++++++++++++++++++++++++++++++++------ dixhttp/template.html | 11 ++++--- 4 files changed, 90 insertions(+), 13 deletions(-) diff --git a/dixhttp/README.md b/dixhttp/README.md index adf7cd9..9d28f1e 100644 --- a/dixhttp/README.md +++ b/dixhttp/README.md @@ -54,6 +54,18 @@ func main() { Open browser and visit `http://localhost:8080` to view the dependency graph. +### Base Path / Prefix + +If you need to mount the UI and API under a path prefix (e.g. behind a gateway), use `WithBasePath`: + +```go +server := dixhttp.NewServerWithOptions( + (*dixinternal.Dix)(di), + dixhttp.WithBasePath("/dix"), +) +// Visit http://localhost:8080/dix/ +``` + ## UI Layout ``` diff --git a/dixhttp/README_zh.md b/dixhttp/README_zh.md index e3e709b..b0f0c60 100644 --- a/dixhttp/README_zh.md +++ b/dixhttp/README_zh.md @@ -54,6 +54,18 @@ func main() { 打开浏览器访问 `http://localhost:8080` 即可查看依赖关系图。 +### 配置访问前缀 + +如果需要将页面和 API 挂载到一个前缀路径(例如网关转发),可以使用 `WithBasePath`: + +```go +server := dixhttp.NewServerWithOptions( + (*dixinternal.Dix)(di), + dixhttp.WithBasePath("/dix"), +) +// 访问 http://localhost:8080/dix/ +``` + ## 界面布局 ``` diff --git a/dixhttp/server.go b/dixhttp/server.go index 5ef068e..c0846ac 100644 --- a/dixhttp/server.go +++ b/dixhttp/server.go @@ -18,13 +18,36 @@ var htmlTemplate string type Server struct { dix *dixinternal.Dix mux *http.ServeMux + // basePath is an optional URL prefix (no trailing slash). Example: "/dix" + basePath string } // NewServer creates a new HTTP server for dependency visualization func NewServer(dix *dixinternal.Dix) *Server { + return NewServerWithOptions(dix) +} + +// ServerOption customizes the HTTP server behavior. +type ServerOption func(*Server) + +// WithBasePath sets an optional URL prefix for all routes. Example: "/dix". +func WithBasePath(basePath string) ServerOption { + return func(s *Server) { + s.basePath = normalizeBasePath(basePath) + } +} + +// NewServerWithOptions creates a new HTTP server with options. +func NewServerWithOptions(dix *dixinternal.Dix, opts ...ServerOption) *Server { s := &Server{ - dix: dix, - mux: http.NewServeMux(), + dix: dix, + mux: http.NewServeMux(), + basePath: "", + } + for _, opt := range opts { + if opt != nil { + opt(s) + } } s.setupRoutes() return s @@ -32,12 +55,26 @@ func NewServer(dix *dixinternal.Dix) *Server { // setupRoutes configures all HTTP routes func (s *Server) setupRoutes() { - s.mux.HandleFunc("/", s.HandleIndex) - s.mux.HandleFunc("/api/dependencies", s.HandleDependencies) - s.mux.HandleFunc("/api/stats", s.HandleStats) - s.mux.HandleFunc("/api/packages", s.HandlePackages) - s.mux.HandleFunc("/api/package/", s.HandlePackageDetails) - s.mux.HandleFunc("/api/type/", s.HandleTypeDetails) + base := s.basePath + indexPath := "/" + if base != "" { + indexPath = base + "/" + // Redirect /base -> /base/ + s.mux.HandleFunc(base, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == base { + http.Redirect(w, r, indexPath, http.StatusMovedPermanently) + return + } + http.NotFound(w, r) + }) + } + + s.mux.HandleFunc(indexPath, s.HandleIndex) + s.mux.HandleFunc(base+"/api/dependencies", s.HandleDependencies) + s.mux.HandleFunc(base+"/api/stats", s.HandleStats) + s.mux.HandleFunc(base+"/api/packages", s.HandlePackages) + s.mux.HandleFunc(base+"/api/package/", s.HandlePackageDetails) + s.mux.HandleFunc(base+"/api/type/", s.HandleTypeDetails) } // ServeHTTP implements http.Handler interface @@ -54,7 +91,8 @@ func (s *Server) ListenAndServe(addr string) error { func (s *Server) HandleIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) - fmt.Fprint(w, htmlTemplate) + html := strings.ReplaceAll(htmlTemplate, "__DIX_BASE_PATH__", s.basePath) + fmt.Fprint(w, html) } // HandleStats returns summary statistics @@ -501,3 +539,15 @@ func writeJSON(w http.ResponseWriter, data any) { http.Error(w, fmt.Sprintf("Failed to encode JSON: %v", err), http.StatusInternalServerError) } } + +func normalizeBasePath(basePath string) string { + base := strings.TrimSpace(basePath) + if base == "" || base == "/" { + return "" + } + if !strings.HasPrefix(base, "/") { + base = "/" + base + } + base = strings.TrimRight(base, "/") + return base +} diff --git a/dixhttp/template.html b/dixhttp/template.html index fc4efcb..2fb07a9 100644 --- a/dixhttp/template.html +++ b/dixhttp/template.html @@ -350,6 +350,9 @@

图例

diff --git a/dixinternal/api.go b/dixinternal/api.go index 083e216..08b5fa9 100644 --- a/dixinternal/api.go +++ b/dixinternal/api.go @@ -3,6 +3,7 @@ package dixinternal import ( "errors" "reflect" + "strings" ) // New Dix new @@ -95,8 +96,11 @@ func (dix *Dix) GetObjects() map[reflect.Type]map[string][]reflect.Value { // ProviderDetails contains detailed information about a provider type ProviderDetails struct { OutputType string + OutputPkg string FunctionName string + FunctionPkg string InputTypes []string + InputPkgs []string } // GetProviderDetails returns detailed information about all providers @@ -106,6 +110,7 @@ func (dix *Dix) GetProviderDetails() []ProviderDetails { for _, providerFn := range providerList { fnName := GetFnName(providerFn.fn) var inputTypes []string + var inputPkgs []string seen := make(map[string]bool) for _, input := range providerFn.inputList { if input.isStruct || input.typ.Kind() == reflect.Struct { @@ -116,6 +121,7 @@ func (dix *Dix) GetProviderDetails() []ProviderDetails { } seen[name] = true inputTypes = append(inputTypes, name) + inputPkgs = append(inputPkgs, resolveTypePkgPath(in.typ)) } continue } @@ -126,13 +132,44 @@ func (dix *Dix) GetProviderDetails() []ProviderDetails { } seen[name] = true inputTypes = append(inputTypes, name) + inputPkgs = append(inputPkgs, resolveTypePkgPath(input.typ)) } details = append(details, ProviderDetails{ OutputType: outputType.String(), + OutputPkg: resolveTypePkgPath(outputType), FunctionName: fnName, + FunctionPkg: resolveFuncPkgPath(fnName), InputTypes: inputTypes, + InputPkgs: inputPkgs, }) } } return details } + +func resolveTypePkgPath(typ reflect.Type) string { + if typ == nil { + return "" + } + for typ.Kind() == reflect.Ptr || typ.Kind() == reflect.Slice || typ.Kind() == reflect.Array { + typ = typ.Elem() + if typ == nil { + return "" + } + } + if typ.Kind() == reflect.Map { + return resolveTypePkgPath(typ.Elem()) + } + return typ.PkgPath() +} + +func resolveFuncPkgPath(fnName string) string { + name := strings.TrimSpace(fnName) + if name == "" { + return "" + } + if idx := strings.LastIndex(name, "."); idx > 0 { + return name[:idx] + } + return "" +} diff --git a/example/cycle/main.go b/example/cycle/main.go index dfe6973..9c9b9a5 100644 --- a/example/cycle/main.go +++ b/example/cycle/main.go @@ -12,9 +12,6 @@ func main() { fmt.Printf("panic: %v\n", r) } }() - defer func() { - fmt.Println(dixglobal.Graph()) - }() type ( A struct{} diff --git a/example/handler/main.go b/example/handler/main.go index 9a004ac..b1a3eab 100644 --- a/example/handler/main.go +++ b/example/handler/main.go @@ -24,10 +24,6 @@ func main() { } }() - defer func() { - fmt.Println(dixglobal.Graph()) - }() - dixglobal.Provide(func() *log.Logger { return log.New(os.Stderr, "example: ", log.LstdFlags|log.Lshortfile) }) @@ -47,8 +43,6 @@ func main() { } }) - fmt.Println(dixglobal.Graph()) - dixglobal.Inject(func(r *Redis, l *log.Logger, rr map[string]*Redis) { l.Println("invoke redis") fmt.Println("invoke:", r.name) diff --git a/example/struct-out/main.go b/example/struct-out/main.go index ed7b3ed..c11015d 100644 --- a/example/struct-out/main.go +++ b/example/struct-out/main.go @@ -50,7 +50,6 @@ func main() { err = fmt.Errorf("panic: %v", r) } fmt.Printf("panic: %v\n", err) - fmt.Println(dixglobal.Graph()) // Original behavior from recovery.Exit's func } }() @@ -97,6 +96,4 @@ func main() { fmt.Println(dm) fmt.Println(d5) }) - - fmt.Println(dixglobal.Graph()) } From 88c4608a87973e22cb42c98f327325cf21cdfe45 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 26 Feb 2026 11:11:51 +0800 Subject: [PATCH 6/7] feat: add SVG export button for network graph --- dixhttp/template.html | 132 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/dixhttp/template.html b/dixhttp/template.html index 12739c8..f5b91f7 100644 --- a/dixhttp/template.html +++ b/dixhttp/template.html @@ -316,6 +316,13 @@

> 🔄 重置 +
@@ -1642,6 +1649,131 @@

图例

return Array.from(set).sort(); }, + async exportSvg() { + try { + if (!this.network || !this.network.body) return; + const nodesData = this.network.body.data.nodes.get(); + const edgesData = this.network.body.data.edges.get(); + if (!nodesData || nodesData.length === 0) return; + + const positions = this.network.getPositions(nodesData.map(n => n.id)); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + nodesData.forEach(n => { + const id = n.id; + let box; + try { + box = this.network.getBoundingBox(id); + } catch (e) { + box = null; + } + const pos = positions[id] || { x: 0, y: 0 }; + const left = box ? box.left : pos.x - 50; + const right = box ? box.right : pos.x + 50; + const top = box ? box.top : pos.y - 25; + const bottom = box ? box.bottom : pos.y + 25; + minX = Math.min(minX, left); + minY = Math.min(minY, top); + maxX = Math.max(maxX, right); + maxY = Math.max(maxY, bottom); + }); + + if (!isFinite(minX) || !isFinite(minY) || !isFinite(maxX) || !isFinite(maxY)) return; + + const padding = 20; + const width = Math.max(1, Math.round(maxX - minX + padding * 2)); + const height = Math.max(1, Math.round(maxY - minY + padding * 2)); + + const mapX = (x) => x - minX + padding; + const mapY = (y) => y - minY + padding; + + const escapeXml = (s) => String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const parts = []; + parts.push(``); + parts.push(``); + parts.push(``); + parts.push(``); + parts.push(``); + parts.push(``); + parts.push(``); + + edgesData.forEach(e => { + const from = positions[e.from]; + const to = positions[e.to]; + if (!from || !to) return; + const x1 = mapX(from.x); + const y1 = mapY(from.y); + const x2 = mapX(to.x); + const y2 = mapY(to.y); + const color = (e.color && e.color.color) ? e.color.color : '#9ca3af'; + const dash = e.dashes ? ' stroke-dasharray="6 4"' : ''; + parts.push(``); + }); + + nodesData.forEach(n => { + const id = n.id; + const pos = positions[id] || { x: 0, y: 0 }; + let box; + try { + box = this.network.getBoundingBox(id); + } catch (e) { + box = null; + } + const left = box ? box.left : pos.x - 50; + const right = box ? box.right : pos.x + 50; + const top = box ? box.top : pos.y - 25; + const bottom = box ? box.bottom : pos.y + 25; + const w = Math.max(10, right - left); + const h = Math.max(10, bottom - top); + const x = mapX(left); + const y = mapY(top); + const cx = mapX(pos.x); + const cy = mapY(pos.y); + + const bg = n.color && n.color.background ? n.color.background : '#bfdbfe'; + const border = n.color && n.color.border ? n.color.border : '#3b82f6'; + const fontSize = (n.font && n.font.size) ? n.font.size : 12; + const fontColor = (n.font && n.font.color) ? n.font.color : '#111827'; + + const nodeType = n.data && n.data.type ? n.data.type : ''; + const isBox = n.shape === 'box' || nodeType === 'provider' || nodeType === 'group'; + + if (isBox) { + const rx = nodeType === 'group' ? 8 : 4; + parts.push(``); + } else { + parts.push(``); + } + + const label = escapeXml(n.label || ''); + if (label) { + parts.push(`${label}`); + } + }); + + parts.push(``); + + const svg = parts.join('\n'); + const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `dix-graph-${Date.now()}.svg`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { + console.warn('导出 SVG 失败:', e); + } + }, + filterByPrefix(nodes, edges, protectedIds = new Set()) { From 347a92ac1b0b9777745b8a96aba3bdbfdb23d795 Mon Sep 17 00:00:00 2001 From: barry Date: Sat, 28 Feb 2026 18:05:21 +0800 Subject: [PATCH 7/7] chore: quick update fix/rm_render at 2026-02-28 18:05:20 --- dixhttp/README.md | 10 +++ dixhttp/README_zh.md | 10 +++ dixhttp/template.html | 200 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) diff --git a/dixhttp/README.md b/dixhttp/README.md index 7fab872..3e679f7 100644 --- a/dixhttp/README.md +++ b/dixhttp/README.md @@ -16,6 +16,7 @@ This module provides an HTTP server to visualize dependency relationships in the - 🔎 **Prefix Filter** - Show only nodes/providers matching a prefix - 🧭 **Group Subgraph** - View a group's internal + upstream/downstream dependencies - 📡 **RESTful API** - Provide JSON format dependency data +- 🧩 **Mermaid Export/Preview** - Generate Mermaid flowcharts for current graph (respects grouping/filtering) ## Quick Start @@ -222,6 +223,15 @@ Left panel features: | **Scroll Zoom** | Zoom in/out graph | | **Click Type in Details** | Jump to view that type's dependencies | +## Mermaid Support + +The toolbar includes a **Mermaid** button. It generates a Mermaid `flowchart` from the **current graph view** (including grouping, depth, and prefix filters), opens a preview modal, and lets you copy the Mermaid source. + +**Typical usage**: +1. Adjust view / grouping / filters. +2. Click **Mermaid**. +3. Copy the generated Mermaid text or use the preview. + ## API Endpoints ### GET `/` diff --git a/dixhttp/README_zh.md b/dixhttp/README_zh.md index f69bcb4..228133f 100644 --- a/dixhttp/README_zh.md +++ b/dixhttp/README_zh.md @@ -16,6 +16,7 @@ - 🔎 **前缀过滤** - 只显示匹配前缀的节点/Provider - 🧭 **组内子图** - 查看组内及上下游依赖 - 📡 **RESTful API** - 提供 JSON 格式的依赖关系数据 +- 🧩 **Mermaid 预览/导出** - 将当前图生成 Mermaid 流程图(支持分组/过滤) ## 快速开始 @@ -221,6 +222,15 @@ dixhttp.RegisterGroupRules( | **滚轮缩放** | 放大/缩小图形 | | **点击详情中的类型** | 跳转查看该类型的依赖 | +## Mermaid 支持 + +工具栏新增 **Mermaid** 按钮,会基于**当前视图**生成 Mermaid `flowchart`,并弹出预览窗口,支持一键复制源码。 + +**典型用法**: +1. 调整视图/分组/过滤条件。 +2. 点击 **Mermaid**。 +3. 复制生成的 Mermaid 文本或直接预览。 + ## API 端点 ### GET `/` diff --git a/dixhttp/template.html b/dixhttp/template.html index f5b91f7..6a43b10 100644 --- a/dixhttp/template.html +++ b/dixhttp/template.html @@ -7,6 +7,7 @@ +