diff --git a/.gitignore b/.gitignore index 28330a0..fdee7c8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ *.test # Dependency directories (remove the comment below to include it) -vendor +/vendor .env .vscode/ .local/ diff --git a/.version/changelog/Unreleased.md b/.version/changelog/Unreleased.md index 28fc11a..febec9b 100644 --- a/.version/changelog/Unreleased.md +++ b/.version/changelog/Unreleased.md @@ -9,7 +9,7 @@ - 运行时依赖图 Graph(P1):Provide 增量维护声明边/执行计数,环检测改读增量图;`/api/search`、`/api/modules`、`/api/ego` 服务端检索与分层查询;dixhttp 依赖数据按图版本快照缓存(热请求零反射) - trace 容器化(P2):事件携带随机 container_id 实现多容器隔离,TraceID 改为随机 32 hex;`WithTraceBuffer(n)` 私有 trace 缓冲;`Dix.TraceTree` 与 `/api/trace-tree` 嵌套调用树查询,`/api/trace` 支持 container_id 过滤 - 大规模演示容器 example/http:10 个域模块 + 120 个泛型插件/工作器,约 190 provider / 190+ 对象 / 12 模块 -- 五视图实验版 UI(`/next`):概览/依赖图/检索/调用链/诊断,本地静态资源零 CDN +- legacy `/` 架构可视化:模块地图、短标签、密度横幅/耦合枢纽、可读布局;共享 `graph_state.mjs` helpers ## 修复 @@ -21,7 +21,7 @@ ## 变更 - DI 点事件统一走 tracer 事件流(console `di_trace` 与 diag file 成为订阅者,输出契约不变);直接移除独立 LLM 诊断通道(`DIX_LLM_DIAG_MODE`、stderr `DIX_LLM_DIAG` 行、diag `kind:llm`),`error_type`/`root_cause`/`hint` 结构化字段在全部出口保留 -- 默认 Web UI 恢复 v2.0.2 交互版本(完整依赖图、双视图、节点详情、Mermaid/SVG 导出、Trace 诊断),五视图新版 UI 移至 `/next` 实验入口 +- 默认 Web UI 为 legacy `/`(完整依赖图、模块地图、分组聚合、Mermaid/SVG、Trace 诊断);已移除 `/next` 五视图实验壳 - example/http 升级为大规模演示容器;任务 `task web-demo` 改为整包构建 ## 文档 diff --git a/dixhttp/README.md b/dixhttp/README.md index 49bbbbf..c563f1a 100644 --- a/dixhttp/README.md +++ b/dixhttp/README.md @@ -4,22 +4,35 @@ This module provides an HTTP server to visualize dependency relationships in the [中文文档](./README_zh.md) -## UI 信息架构(五视图) +## UI 信息架构 -前端为无构建的本地静态资源(`static/`:原生 JS + 自绘 CSS + 本地 vendored vis-network,**零 CDN 依赖**),按任务组织为五个视图: +主界面为 legacy `/`(Alpine + vis-network + 本地静态资源)。面向 DI **架构审查**:包/模块边界、Providers、类型依赖、业务分组聚合。 -| 视图(hash 路由) | 主任务 | 数据源 | -| --- | --- | --- | -| 概览 `#/overview` | 全局状态一览 | `/api/stats`、`/api/errors` | -| 依赖图 `#/graph` | 模块列表 + 任意类型为中心的邻域子图 | `/api/modules`、`/api/search`、`/api/ego` | -| 检索 `#/search` | 服务端检索 + 状态过滤,一键跳转依赖图 | `/api/search` | -| 调用链 `#/trace` | trace 列表(错误优先)+ 嵌套调用树 | `/api/trace`、`/api/trace-tree` | -| 诊断 `#/diag` | 最近注入错误 + provider 启动耗时 | `/api/errors`、`/api/runtime-stats` | +| 能力 | 说明 | +| --- | --- | +| Providers / 类型依赖 | 现有全局构图;默认仍可进 Providers | +| 模块地图 | `/api/modules` 跨模块边界总览;双击下钻到包前缀过滤的 Providers | +| 包范围侧栏 | 选择包以缩小架构切片(前缀过滤) | +| 按分组聚合 | `dix.groupRules.v1` / `/api/group-rules` | +| 规模提示 | 超可读预算时横幅建议换粒度,列出耦合枢纽,最后才 Top-K 裁点 | +| Trace / 诊断 | 页内弹层;排障主路径仍是调用链 | +Object(已创建实例)默认不作为架构画布节点,只在统计/详情中展示。 +## Scale-first graph workflow + +```text +Module map / group aggregate -> scoped Providers or Types -> detail / Trace +``` + +- The default graph mode remains Providers (legacy habit); crowding is handled by semantic prompts, hubs, short labels, readable layout, then truncation. +- Module map is bounded by module count; Providers/Types use a soft on-canvas readability cap (~40 nodes) with hub listing. +- Objects are shown as state in details and counts; they are not rendered as architecture nodes by default. +- `/api/dependencies` remains the full-data endpoint for Providers/Types views. +- Visualization UI entry is `/` only (`/next` removed). ## Features -- 📊 **Interactive Visualization** - Modern UI built with vis.js + Tailwind CSS + Alpine.js +- 📊 **Interactive Visualization** - Modern no-build UI built with vanilla JS, local CSS, and vis-network - 🔍 **Global Fuzzy Search** - Quickly search for type names or function names to view dependencies - 📦 **Package Grouping** - Collapsible left panel to browse by package - 🔄 **Bidirectional Dependency Tracking** - Show both upstream (dependencies) and downstream (dependents) @@ -425,7 +438,7 @@ Returns package list ``` ### GET `/api/dependencies?package=xxx&limit=100` -Returns dependency data, supports package filtering +Returns full dependency data for global/legacy views and package filtering. New scale-first module and ego views use their own bounded endpoints first. ```json { @@ -476,11 +489,8 @@ Returns backend-registered group rules (used as UI defaults) ## Tech Stack - **Backend**: Go standard library `net/http` -- **Frontend**: - - [Tailwind CSS](https://tailwindcss.com/) - Styling - - [Alpine.js](https://alpinejs.dev/) - Reactive interactions - - [vis-network](https://visjs.github.io/vis-network/) - Graph rendering -- **Template**: Go embed single-file HTML +- **Frontend**: vanilla JS, local CSS, and [vis-network](https://visjs.github.io/vis-network/) for graph rendering +- **Assets**: Go-embedded local files; no build step and no CDN dependency ## Use Cases diff --git a/dixhttp/README_zh.md b/dixhttp/README_zh.md index 7182f09..e565b3d 100644 --- a/dixhttp/README_zh.md +++ b/dixhttp/README_zh.md @@ -4,9 +4,23 @@ [English](./README.md) +## 规模优先工作流 + +主界面为 legacy `/`。面向 DI 架构审查: + +```text +模块地图 / 分组聚合 -> 范围内的 Providers 或类型依赖 -> 详情 / Trace +``` + +- 默认仍可进全局 Providers;超规模时提示换粒度、标耦合枢纽、短标签与可读布局,最后才裁点。 +- 模块地图看跨边界依赖;包侧栏用于缩小架构切片。 +- Object 默认不上架构画布,只在统计/详情中展示。 +- `/api/dependencies` 仍是 Providers/类型全量数据接口。 +- 可视化入口仅为 `/`(已移除 `/next`)。 + ## 功能特性 -- 📊 **交互式可视化** - 使用 vis.js + Tailwind CSS + Alpine.js 构建现代化界面 +- 📊 **交互式可视化** - 使用原生 JS、本地 CSS 和 vis-network 构建无构建现代化界面 - 🔍 **全局模糊搜索** - 快速搜索类型名或函数名,直接查看依赖关系 - 📦 **按包分组** - 左侧可折叠面板,按包过滤查看依赖 - 🔄 **双向依赖追踪** - 同时展示依赖(上游)和被依赖(下游)关系 @@ -305,7 +319,7 @@ dixhttp.RegisterGroupRules( ``` ### GET `/api/dependencies?package=xxx&limit=100` -返回依赖关系数据,支持按包过滤 +返回全局/旧视图使用的全量依赖数据,支持包过滤。规模优先的模块图和邻域图优先使用各自的有界接口。 ```json { @@ -443,11 +457,8 @@ dixhttp.RegisterGroupRules( ## 技术栈 - **后端**: Go 标准库 `net/http` -- **前端框架**: - - [Tailwind CSS](https://tailwindcss.com/) - 样式 - - [Alpine.js](https://alpinejs.dev/) - 响应式交互 - - [vis-network](https://visjs.github.io/vis-network/) - 图形渲染 -- **模板**: Go embed 嵌入单文件 HTML +- **前端**: 原生 JS、本地 CSS 和 [vis-network](https://visjs.github.io/vis-network/) 图形渲染 +- **静态资源**: Go embed 本地文件;无构建步骤,无 CDN 依赖 ## 适用场景 diff --git a/dixhttp/http_scale_e2e_test.go b/dixhttp/http_scale_e2e_test.go new file mode 100644 index 0000000..02c59f4 --- /dev/null +++ b/dixhttp/http_scale_e2e_test.go @@ -0,0 +1,80 @@ +package dixhttp + +import ( + "net/http/httptest" + "strings" + "testing" + + dix "github.com/pubgo/dix/v2" +) + +func TestLegacyGraphEmbedsSharedHelpers(t *testing.T) { + container := dix.New() + server := NewServer(container) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("GET", "/", nil)) + + if recorder.Code != 200 { + t.Fatalf("status = %d", recorder.Code) + } + body := recorder.Body.String() + for _, asset := range []string{"static/js/graph_state.mjs", "static/js/legacy/app.js", "模块地图"} { + if !strings.Contains(body, asset) { + t.Fatalf("missing %s", asset) + } + } + if strings.Contains(body, `value="panorama"`) || strings.Contains(body, "全景") { + t.Fatalf("panorama layout must be removed from legacy UI") + } + if strings.Contains(body, "static/js/views/graph.js") { + t.Fatalf("legacy index must not reference next graph.js") + } +} + +func TestInventoryHelpersPresentInBundle(t *testing.T) { + container := dix.New() + server := NewServer(container) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("GET", "/static/js/graph_state.mjs", nil)) + if recorder.Code != 200 { + t.Fatalf("status = %d", recorder.Code) + } + body := recorder.Body.String() + for _, sym := range []string{ + "buildProviderInventory", + "buildProvidersPyramidView", + "buildProviderDependencyGraph", + "assignProviderPyramidLevels", + "buildTypesPyramidView", + "buildTypeDependencyGraph", + "providerDisplayLabel", + "HIERARCHICAL_NODE_BUDGET", + "labelLodVisibleIds", + "buildModuleMapGraph", + } { + if !strings.Contains(body, sym) { + t.Fatalf("graph_state.mjs missing %s", sym) + } + } + for _, gone := range []string{ + "layoutPanoramaPositions", + "buildPanoramaProviderGraph", + "buildPanoramaStructureGraph", + "resolvePanoramaCameraScale", + "labelLodByBand", + } { + if strings.Contains(body, gone) { + t.Fatalf("graph_state.mjs still contains removed panorama helper %s", gone) + } + } +} + +func TestNextRouteRemoved(t *testing.T) { + container := dix.New() + server := NewServer(container) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("GET", "/next", nil)) + if recorder.Code != 404 { + t.Fatalf("/next status = %d, want 404", recorder.Code) + } +} diff --git a/dixhttp/scripts/graph_layout_e2e.mjs b/dixhttp/scripts/graph_layout_e2e.mjs new file mode 100644 index 0000000..1b6a3af --- /dev/null +++ b/dixhttp/scripts/graph_layout_e2e.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Layout E2E against a live dixhttp demo (no browser dependency). + * Fetches /api/modules, builds the same star positions the UI uses, and asserts readability. + * + * DIX_HTTP_ADDR=127.0.0.1:18099 go run -C example ./http + * node dixhttp/scripts/graph_layout_e2e.mjs + */ +import { layoutStarPositions, assessLayoutMetrics, shortGraphLabel, resolveCameraStrategy } from "../static/js/graph_state.mjs"; + +const BASE = process.env.DIX_E2E_BASE || "http://127.0.0.1:18099"; + +async function main() { + const health = await fetch(`${BASE}/`); + if (!health.ok) throw new Error(`demo server not reachable at ${BASE}/ (${health.status})`); + + const html = await health.text(); + if (!html.includes("graph_state.mjs")) throw new Error("/ HTML missing graph_state.mjs"); + if (!html.includes("legacy/app.js")) throw new Error("/ HTML missing legacy/app.js"); + if (!html.includes("模块地图")) throw new Error("/ HTML missing module map control"); + + const modules = await (await fetch(`${BASE}/api/modules`)).json(); + if (!Array.isArray(modules) || modules.length < 3) { + throw new Error(`expected modules list, got ${JSON.stringify(modules).slice(0, 200)}`); + } + + const nodes = modules.map((m) => ({ + id: m.name, + label: shortGraphLabel(m.name) + "\n(" + m.provider_count + "p/" + m.object_count + "o)", + })); + const edges = []; + for (const m of modules) { + for (const dep of m.depends_on || []) { + edges.push({ from: m.name, to: dep }); + } + } + + for (const n of nodes) { + if (n.label.includes("github.com") || n.label.split("/").length > 3) { + throw new Error(`label not shortened enough: ${n.label}`); + } + } + + const camera = resolveCameraStrategy("modules", nodes.length); + if (camera !== "fit") throw new Error(`expected fit camera, got ${camera}`); + + const positions = layoutStarPositions(nodes, edges); + const assessment = assessLayoutMetrics(positions); + if (!assessment.ok) { + throw new Error(`module map layout not readable: ${JSON.stringify(assessment)}`); + } + + // Overlap check: pairwise distance for non-hub nodes should stay healthy. + const ids = Object.keys(positions); + const hub = positions[ids.find((id) => positions[id].x === 0 && positions[id].y === 0)]; + let minPeer = Infinity; + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const a = positions[ids[i]], b = positions[ids[j]]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d > 0) minPeer = Math.min(minPeer, d); + } + } + if (!Number.isFinite(minPeer) || minPeer < 80) { + throw new Error(`nodes too close: minPeer=${minPeer}`); + } + + console.log(JSON.stringify({ + ok: true, + moduleCount: modules.length, + camera, + assessment, + minPeer: Math.round(minPeer), + sampleLabels: nodes.slice(0, 5).map((n) => n.label.replace("\n", " ")), + hubAtOrigin: !!hub, + }, null, 2)); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/dixhttp/server.go b/dixhttp/server.go index 7533fe8..9c63d01 100644 --- a/dixhttp/server.go +++ b/dixhttp/server.go @@ -8,9 +8,11 @@ import ( "io/fs" "net/http" "reflect" + "sort" "strconv" "strings" "sync" + "time" "github.com/pubgo/dix/v2" "github.com/pubgo/dix/v2/dixinternal" @@ -131,13 +133,13 @@ func NewServerWithOptions(di *dix.Dix, opts ...ServerOption) *Server { // setupRoutes configures all HTTP routes func (s *Server) setupRoutes() { base := s.basePath - indexPath := "/" + indexPath := "/{$}" if base != "" { - indexPath = 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) + http.Redirect(w, r, base+"/", http.StatusMovedPermanently) return } http.NotFound(w, r) @@ -149,6 +151,7 @@ func (s *Server) setupRoutes() { s.mux.HandleFunc(base+"/api/stats", s.HandleStats) s.mux.HandleFunc(base+"/api/runtime-stats", s.HandleRuntimeStats) s.mux.HandleFunc(base+"/api/errors", s.HandleErrors) + s.mux.HandleFunc(base+"/api/issues", s.HandleIssues) s.mux.HandleFunc(base+"/api/diagnostics", s.HandleDiagnostics) s.mux.HandleFunc(base+"/api/trace", s.HandleTrace) s.mux.HandleFunc(base+"/api/trace-tree", s.HandleTraceTree) @@ -156,9 +159,9 @@ func (s *Server) setupRoutes() { if err == nil { s.mux.Handle(base+"/static/", http.StripPrefix(base+"/static/", http.FileServer(http.FS(staticRoot)))) } - s.mux.HandleFunc(base+"/next", s.HandleNextIndex) s.mux.HandleFunc(base+"/api/search", s.HandleSearch) s.mux.HandleFunc(base+"/api/modules", s.HandleModules) + s.mux.HandleFunc(base+"/api/module", s.HandleModule) s.mux.HandleFunc(base+"/api/ego", s.HandleEgo) s.mux.HandleFunc(base+"/api/packages", s.HandlePackages) s.mux.HandleFunc(base+"/api/package/", s.HandlePackageDetails) @@ -290,6 +293,24 @@ func (s *Server) HandleModules(w http.ResponseWriter, r *http.Request) { writeJSON(w, s.dix.ModuleGraph()) } +// HandleModule returns a bounded provider/type topology for one module. +// Query params: +// - name: exact module/package path (required) +// - limit: max nodes, default 100, max 500 +// - edge_limit: max edges, default 300, max 1000 +func (s *Server) HandleModule(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.URL.Query().Get("name")) + if name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + writeJSON(w, s.dix.ModuleView( + name, + atoiOr(r.URL.Query().Get("limit"), 100), + atoiOr(r.URL.Query().Get("edge_limit"), 300), + )) +} + // HandleEgo 返回以 center 为中心的 N 跳邻域子图。 // Query params: // - center: 类型 label(必填) @@ -311,18 +332,6 @@ func atoiOr(s string, def int) int { return def } -// HandleNextIndex 服务五视图实验版 UI(/next)。 -func (s *Server) HandleNextIndex(w http.ResponseWriter, r *http.Request) { - index, err := fs.ReadFile(staticFS, "static/index.html") - if err != nil { - http.Error(w, "index not found", http.StatusInternalServerError) - return - } - html := strings.ReplaceAll(string(index), "__DIX_BASE_PATH__", s.basePath) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, html) -} - // ServeHTTP implements http.Handler interface func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) @@ -391,6 +400,161 @@ func (s *Server) HandleStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, stats) } +// IssueInfo is one actionable diagnostic item with a stable link back to the graph/trace. +type IssueInfo struct { + Severity string `json:"severity"` + Kind string `json:"kind"` + ProviderID string `json:"provider_id,omitempty"` + Provider string `json:"provider,omitempty"` + OutputType string `json:"output_type,omitempty"` + Module string `json:"module,omitempty"` + Title string `json:"title"` + RootCause string `json:"root_cause,omitempty"` + Hint string `json:"hint,omitempty"` + TraceID string `json:"trace_id,omitempty"` + OccurredAtUnixNano int64 `json:"occurred_at_unix_nano,omitempty"` +} + +// HandleIssues merges injection failures, provider failures, and slow providers +// into one issue-first diagnostic feed. +func (s *Server) HandleIssues(w http.ResponseWriter, r *http.Request) { + details, _ := s.cachedGraphInputs() + issues := buildIssues( + details, + s.dix.GetRecentErrors(atoiOr(r.URL.Query().Get("error_limit"), 100)), + s.dix.GetProviderRuntimeStats(), + s.dix.Option().SlowProviderThreshold, + atoiOr(r.URL.Query().Get("limit"), 100), + ) + writeJSON(w, issues) +} + +// buildIssues projects raw diagnostic records into a bounded, stable issue list. +func buildIssues(details []dixinternal.ProviderDetails, recent []dixinternal.RecentError, stats []dixinternal.ProviderRuntimeStats, slowThreshold time.Duration, limit int) []IssueInfo { + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + + providerByID := make(map[string]dixinternal.ProviderDetails, len(details)) + providerByFnOutput := make(map[string]dixinternal.ProviderDetails, len(details)) + for _, detail := range details { + if detail.ProviderID != "" { + providerByID[detail.ProviderID] = detail + } + providerByFnOutput[detail.FunctionName+"\x00"+detail.OutputType] = detail + } + + lookup := func(provider, output string) (dixinternal.ProviderDetails, bool) { + if provider != "" { + if detail, ok := providerByFnOutput[provider+"\x00"+output]; ok { + return detail, true + } + } + if output != "" { + for _, detail := range details { + if detail.OutputType == output && (provider == "" || detail.FunctionName == provider) { + return detail, true + } + } + } + return dixinternal.ProviderDetails{}, false + } + + issues := make([]IssueInfo, 0, len(recent)+len(stats)) + seenError := make(map[string]bool, len(recent)) + for _, item := range recent { + key := item.ErrorType + "\x00" + item.Component + "\x00" + item.Stage + "\x00" + + item.ProviderFunction + "\x00" + item.OutputType + "\x00" + item.RootCause + "\x00" + item.Message + if seenError[key] { + continue + } + seenError[key] = true + + issue := IssueInfo{ + Severity: "error", + Kind: "inject_error", + Provider: item.ProviderFunction, + OutputType: item.OutputType, + Title: item.ErrorType, + RootCause: item.RootCause, + Hint: item.Hint, + TraceID: item.TraceID, + OccurredAtUnixNano: item.OccurredAtUnixNano, + } + if issue.Title == "" { + issue.Title = "inject error" + } + if detail, ok := lookup(item.ProviderFunction, item.OutputType); ok { + issue.ProviderID = detail.ProviderID + issue.Module = detail.OutputPkg + } + issues = append(issues, issue) + } + + seenProvider := make(map[string]bool, len(stats)) + for _, stat := range stats { + identity := stat.ProviderID + if identity == "" { + identity = stat.FunctionName + "\x00" + stat.OutputType + } + if seenProvider[identity] { + continue + } + + isError := stat.LastError != "" + isSlow := slowThreshold > 0 && + (stat.LastDuration > slowThreshold || (stat.CallCount > 0 && stat.AverageDuration > slowThreshold)) + if !isError && !isSlow { + continue + } + seenProvider[identity] = true + + detail, ok := providerByID[stat.ProviderID] + if !ok { + detail, _ = lookup(stat.FunctionName, stat.OutputType) + } + issue := IssueInfo{ + Severity: "slow", + Kind: "provider_slow", + ProviderID: stat.ProviderID, + Provider: stat.FunctionName, + OutputType: stat.OutputType, + Module: detail.OutputPkg, + Title: "slow provider", + RootCause: stat.LastError, + OccurredAtUnixNano: stat.LastRunAtUnixNano, + } + if isError { + issue.Severity = "error" + issue.Kind = "provider_error" + issue.Title = "provider error" + issue.RootCause = stat.LastError + } + issues = append(issues, issue) + } + + severityRank := map[string]int{"error": 0, "slow": 1} + sort.Slice(issues, func(i, j int) bool { + if severityRank[issues[i].Severity] != severityRank[issues[j].Severity] { + return severityRank[issues[i].Severity] < severityRank[issues[j].Severity] + } + if issues[i].OccurredAtUnixNano != issues[j].OccurredAtUnixNano { + return issues[i].OccurredAtUnixNano > issues[j].OccurredAtUnixNano + } + if issues[i].Kind != issues[j].Kind { + return issues[i].Kind < issues[j].Kind + } + return issues[i].Title < issues[j].Title + }) + if len(issues) > limit { + issues = issues[:limit] + } + return issues +} + // HandleRuntimeStats returns provider runtime stats for startup/perf diagnosis. // Query params: // - limit: optional positive integer to limit returned rows. @@ -413,45 +577,44 @@ func (s *Server) HandleRuntimeStats(w http.ResponseWriter, r *http.Request) { // HandlePackages returns list of packages for navigation func (s *Server) HandlePackages(w http.ResponseWriter, r *http.Request) { providerDetails, _ := s.cachedGraphInputs() + packages := buildPackageInfos(providerDetails) + writeJSON(w, packages) +} - // Group by package +// buildPackageInfos groups provider outputs by their resolved package path. +func buildPackageInfos(details []dixinternal.ProviderDetails) []PackageInfo { packageMap := make(map[string]*PackageInfo) - for _, detail := range providerDetails { - pkg := extractPackage(detail.OutputType) + for _, detail := range details { + pkg := detail.OutputPkg if pkg == "" { pkg = "(anonymous)" } - if _, exists := packageMap[pkg]; !exists { - packageMap[pkg] = &PackageInfo{ - Name: pkg, - ProviderCount: 0, - Types: make([]string, 0), - } + packageInfo, exists := packageMap[pkg] + if !exists { + packageInfo = &PackageInfo{Name: pkg, Types: make([]string, 0)} + packageMap[pkg] = packageInfo } - packageMap[pkg].ProviderCount++ - - // Track unique types + packageInfo.ProviderCount++ found := false - for _, t := range packageMap[pkg].Types { - if t == detail.OutputType { + for _, typ := range packageInfo.Types { + if typ == detail.OutputType { found = true break } } if !found { - packageMap[pkg].Types = append(packageMap[pkg].Types, detail.OutputType) + packageInfo.Types = append(packageInfo.Types, detail.OutputType) } } - // Convert to slice packages := make([]PackageInfo, 0, len(packageMap)) for _, pkg := range packageMap { packages = append(packages, *pkg) } - - writeJSON(w, packages) + sort.Slice(packages, func(i, j int) bool { return packages[i].Name < packages[j].Name }) + return packages } // HandlePackageDetails returns details for a specific package @@ -663,30 +826,16 @@ func buildDependencyData(details []dixinternal.ProviderDetails, objects map[refl Edges: []EdgeInfo{}, } - data.Providers = aggregateProviderInfos(details, pkgFilter, limit) - - for _, provider := range data.Providers { - outputTypes := provider.OutputTypes - if len(outputTypes) == 0 && provider.OutputType != "" { - outputTypes = []string{provider.OutputType} - } - for _, outputType := range outputTypes { - for _, inputTypeStr := range provider.InputTypes { - data.Edges = append(data.Edges, EdgeInfo{ - From: inputTypeStr, - To: outputType, - Type: "provider", - }) - } - } - } + providerEdges, providers := aggregateProviderInfos(details, pkgFilter, limit) + data.Providers = providers + data.Edges = append(data.Edges, providerEdges...) // Extract object information using the cached objects table for outputType, groupsMap := range objects { - // Apply package filter if specified + // Apply package filter if specified (align with sidebar OutputPkg names) if pkgFilter != "" { pkg := extractPackage(outputType.String()) - if pkg != pkgFilter { + if !packagePathMatchesFilter(pkg, pkgFilter) { continue } } @@ -717,22 +866,21 @@ func buildDependencyData(details []dixinternal.ProviderDetails, objects map[refl return data } -func aggregateProviderInfos(details []dixinternal.ProviderDetails, pkgFilter string, limit int) []ProviderInfo { +func aggregateProviderInfos(details []dixinternal.ProviderDetails, pkgFilter string, limit int) ([]EdgeInfo, []ProviderInfo) { type providerBucket struct { - provider ProviderInfo - outputSeen map[string]bool - inputSeen map[string]bool + provider ProviderInfo + details []dixinternal.ProviderDetails + outputSeen map[string]bool + inputSeen map[string]bool + providerIDSeen map[string]bool } buckets := make(map[string]*providerBucket) order := make([]string, 0, len(details)) for _, detail := range details { - if pkgFilter != "" { - pkg := extractPackage(detail.OutputType) - if pkg != pkgFilter { - continue - } + if pkgFilter != "" && !providerMatchesPackageFilter(detail, pkgFilter) { + continue } key := providerAggregateKey(detail) @@ -740,19 +888,22 @@ func aggregateProviderInfos(details []dixinternal.ProviderDetails, pkgFilter str if !exists { bucket = &providerBucket{ provider: ProviderInfo{ - ID: "provider_" + key, - OutputType: detail.OutputType, - OutputPkg: detail.OutputPkg, - FunctionName: detail.FunctionName, - FunctionPkg: detail.FunctionPkg, - FunctionFile: detail.FunctionFile, - FunctionLine: detail.FunctionLine, - OutputTypes: make([]string, 0, 4), - InputTypes: make([]string, 0, 8), - InputPkgs: make([]string, 0, 8), + ID: "provider_registration_" + key, + RegistrationID: detail.RegistrationID, + ProviderIDs: make([]string, 0, 4), + OutputType: detail.OutputType, + OutputPkg: detail.OutputPkg, + FunctionName: detail.FunctionName, + FunctionPkg: detail.FunctionPkg, + FunctionFile: detail.FunctionFile, + FunctionLine: detail.FunctionLine, + OutputTypes: make([]string, 0, 4), + InputTypes: make([]string, 0, 8), + InputPkgs: make([]string, 0, 8), }, - outputSeen: make(map[string]bool), - inputSeen: make(map[string]bool), + outputSeen: make(map[string]bool), + inputSeen: make(map[string]bool), + providerIDSeen: make(map[string]bool), } buckets[key] = bucket order = append(order, key) @@ -766,35 +917,73 @@ func aggregateProviderInfos(details []dixinternal.ProviderDetails, pkgFilter str } } + if detail.ProviderID != "" && !bucket.providerIDSeen[detail.ProviderID] { + bucket.providerIDSeen[detail.ProviderID] = true + bucket.provider.ProviderIDs = append(bucket.provider.ProviderIDs, detail.ProviderID) + } + bucket.details = append(bucket.details, detail) + for i, in := range detail.InputTypes { in = strings.TrimSpace(in) - if in == "" || bucket.inputSeen[in] { + if in == "" { continue } - bucket.inputSeen[in] = true - bucket.provider.InputTypes = append(bucket.provider.InputTypes, in) - pkg := "" if i < len(detail.InputPkgs) { pkg = strings.TrimSpace(detail.InputPkgs[i]) } + // Same type.String() can come from different packages (*/handler.Handler). + seenKey := pkg + "\x00" + in + if bucket.inputSeen[seenKey] { + continue + } + bucket.inputSeen[seenKey] = true + bucket.provider.InputTypes = append(bucket.provider.InputTypes, in) bucket.provider.InputPkgs = append(bucket.provider.InputPkgs, pkg) } } providers := make([]ProviderInfo, 0, len(order)) + edges := make([]EdgeInfo, 0, len(details)*2) + edgeSeen := make(map[string]bool, len(details)*2) for _, key := range order { - providers = append(providers, buckets[key].provider) + bucket := buckets[key] + providers = append(providers, bucket.provider) + + for _, detail := range bucket.details { + var outputTypes []string + if len(outputTypes) == 0 && detail.OutputType != "" { + outputTypes = []string{detail.OutputType} + } + for _, outputType := range outputTypes { + for _, inputType := range detail.InputTypes { + inputType = strings.TrimSpace(inputType) + outputType = strings.TrimSpace(outputType) + if inputType == "" || outputType == "" { + continue + } + edgeKey := inputType + "\x00" + outputType + if edgeSeen[edgeKey] { + continue + } + edgeSeen[edgeKey] = true + edges = append(edges, EdgeInfo{From: inputType, To: outputType, Type: "provider"}) + } + } + } } if limit > 0 && len(providers) > limit { providers = providers[:limit] } - return providers + return edges, providers } func providerAggregateKey(detail dixinternal.ProviderDetails) string { + if detail.RegistrationID != 0 { + return fmt.Sprintf("registration_%d", detail.RegistrationID) + } if detail.FunctionFile != "" && detail.FunctionLine > 0 { return fmt.Sprintf("%s:%d", detail.FunctionFile, detail.FunctionLine) } @@ -868,16 +1057,18 @@ type DependencyData struct { // ProviderInfo contains information about a provider type ProviderInfo struct { - ID string `json:"id"` - OutputType string `json:"output_type"` - OutputTypes []string `json:"output_types,omitempty"` - OutputPkg string `json:"output_pkg"` - FunctionName string `json:"function_name"` - FunctionPkg string `json:"function_pkg"` - FunctionFile string `json:"function_file"` - FunctionLine int `json:"function_line"` - InputTypes []string `json:"input_types"` - InputPkgs []string `json:"input_pkgs"` + ID string `json:"id"` + RegistrationID uint64 `json:"registration_id"` + ProviderIDs []string `json:"provider_ids"` + OutputType string `json:"output_type"` + OutputTypes []string `json:"output_types,omitempty"` + OutputPkg string `json:"output_pkg"` + FunctionName string `json:"function_name"` + FunctionPkg string `json:"function_pkg"` + FunctionFile string `json:"function_file"` + FunctionLine int `json:"function_line"` + InputTypes []string `json:"input_types"` + InputPkgs []string `json:"input_pkgs"` } // ObjectInfo contains information about an object instance @@ -897,6 +1088,42 @@ type EdgeInfo struct { // Helper functions +// providerMatchesPackageFilter aligns /api/packages sidebar names (OutputPkg) +// with /api/dependencies?package= filtering. extractPackage(OutputType) alone is +// wrong for module paths like github.com/.../domain/analytics vs type "analytics.Client". +func providerMatchesPackageFilter(detail dixinternal.ProviderDetails, pkgFilter string) bool { + if pkgFilter == "" { + return true + } + if detail.OutputPkg != "" && packagePathMatchesFilter(detail.OutputPkg, pkgFilter) { + return true + } + if detail.FunctionPkg != "" && packagePathMatchesFilter(detail.FunctionPkg, pkgFilter) { + return true + } + return packagePathMatchesFilter(extractPackage(detail.OutputType), pkgFilter) +} + +func packagePathMatchesFilter(pkg, pkgFilter string) bool { + if pkgFilter == "" { + return true + } + if pkg == "" { + return false + } + if pkg == pkgFilter { + return true + } + // Sidebar uses full OutputPkg paths; type extract may be a short name ("analytics"). + if !strings.Contains(pkg, "/") && strings.HasSuffix(pkgFilter, "/"+pkg) { + return true + } + if !strings.Contains(pkgFilter, "/") && strings.HasSuffix(pkg, "/"+pkgFilter) { + return true + } + return false +} + func extractPackage(typeName string) string { // Handle pointer types typeName = strings.TrimPrefix(typeName, "*") diff --git a/dixhttp/server_api_test.go b/dixhttp/server_api_test.go index 3faaeec..089d263 100644 --- a/dixhttp/server_api_test.go +++ b/dixhttp/server_api_test.go @@ -311,6 +311,29 @@ func TestHandleSearchModulesEgo(t *testing.T) { } } +func TestHandleModuleReturnsBoundedTopology(t *testing.T) { + di := dixinternal.New() + server := NewServer(di) + + req := httptest.NewRequest(http.MethodGet, "/api/module?name=github.com%2Fpubgo%2Fdix%2Fv2%2Fdixinternal&limit=20&edge_limit=20", nil) + rec := httptest.NewRecorder() + server.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var view dixinternal.ModuleDetailView + if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { + t.Fatal(err) + } + if view.Name != "github.com/pubgo/dix/v2/dixinternal" { + t.Fatalf("view = %+v", view) + } + if len(view.Nodes) == 0 || len(view.Edges) == 0 { + t.Fatalf("empty topology: %+v", view) + } +} + // 回归:stats/packages 先行"预热"输入缓存后,/api/dependencies 仍必须 // 返回完整数据(此前 snapFull 未随缓存刷新,返回了字面量 null)。 func TestHandleDependenciesAfterStatsWarmup(t *testing.T) { diff --git a/dixhttp/server_issues_test.go b/dixhttp/server_issues_test.go new file mode 100644 index 0000000..1cf306d --- /dev/null +++ b/dixhttp/server_issues_test.go @@ -0,0 +1,132 @@ +package dixhttp + +import ( + "encoding/json" + "errors" + "net/http/httptest" + "strconv" + "testing" + "time" + + dix "github.com/pubgo/dix/v2" + "github.com/pubgo/dix/v2/dixinternal" +) + +type issueTarget struct{} + +func TestHandleIssuesReturnsActionableDiagnosticFeed(t *testing.T) { + container := dix.New() + dix.Provide(container, func() (*issueTarget, error) { + return nil, errors.New("database unavailable") + }) + if err := container.TryInject(func(*issueTarget) {}); err == nil { + t.Fatal("expected injection failure") + } + + server := NewServer(container) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("GET", "/api/issues", nil)) + if recorder.Code != 200 { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + + var issues []IssueInfo + if err := json.Unmarshal(recorder.Body.Bytes(), &issues); err != nil { + t.Fatal(err) + } + if len(issues) == 0 { + t.Fatal("expected at least one issue") + } + if issues[0].Severity != "error" { + t.Fatalf("first issue = %+v", issues[0]) + } +} + +func TestBuildIssuesMergesErrorsAndSlowProviders(t *testing.T) { + details := []dixinternal.ProviderDetails{ + { + OutputType: "*app.SlowService", + OutputPkg: "app/slow", + FunctionName: "app.NewSlowService", + RegistrationID: 7, + ProviderID: "provider_7_*app.SlowService", + }, + } + recent := []dixinternal.RecentError{ + { + ErrorType: "dependency_cycle", + Component: "func(*app.Cycle)", + Message: "circular dependency", + RootCause: "cycle A -> B -> A", + Hint: "split the cycle", + OccurredAtUnixNano: 30, + }, + } + stats := []dixinternal.ProviderRuntimeStats{ + { + FunctionName: "app.NewSlowService", + OutputType: "*app.SlowService", + RegistrationID: 7, + ProviderID: "provider_7_*app.SlowService", + CallCount: 3, + AverageDuration: 20 * time.Millisecond, + LastDuration: 30 * time.Millisecond, + LastRunAtUnixNano: 20, + }, + } + + issues := buildIssues(details, recent, stats, 10*time.Millisecond, 10) + if len(issues) != 2 { + t.Fatalf("issues = %+v", issues) + } + if issues[0].Severity != "error" || issues[0].Kind != "inject_error" { + t.Fatalf("error issue should sort first: %+v", issues[0]) + } + if issues[1].Severity != "slow" || issues[1].Kind != "provider_slow" { + t.Fatalf("slow issue mismatch: %+v", issues[1]) + } + if issues[1].ProviderID != details[0].ProviderID { + t.Fatalf("slow issue provider mismatch: %+v", issues[1]) + } + if issues[1].Module != "app/slow" { + t.Fatalf("module = %q", issues[1].Module) + } +} + +func TestBuildIssuesPropagatesTraceID(t *testing.T) { + recent := []dixinternal.RecentError{{ + ErrorType: "provider_error", + Message: "boom", + TraceID: "abc123", + OutputType: "*app.Service", + ProviderFunction: "app.NewService", + OccurredAtUnixNano: 1, + }} + issues := buildIssues(nil, recent, nil, 0, 10) + if len(issues) != 1 { + t.Fatalf("issues = %+v", issues) + } + if issues[0].TraceID != "abc123" { + t.Fatalf("trace id = %q", issues[0].TraceID) + } +} + +func TestBuildIssuesLimitsAndSortsStably(t *testing.T) { + base := time.Now().UnixNano() + recent := make([]dixinternal.RecentError, 0, 4) + for i := 0; i < 4; i++ { + recent = append(recent, dixinternal.RecentError{ + ErrorType: "inject_failed", + Component: "component-" + strconv.Itoa(i), + Message: "message-" + strconv.Itoa(i), + OccurredAtUnixNano: base - int64(i), + }) + } + issues := buildIssues(nil, recent, nil, time.Second, 2) + if len(issues) != 2 { + t.Fatalf("issues = %d, want 2", len(issues)) + } + if issues[0].OccurredAtUnixNano < issues[1].OccurredAtUnixNano { + t.Fatalf("issues are not latest first: %+v", issues) + } +} diff --git a/dixhttp/server_packages_test.go b/dixhttp/server_packages_test.go new file mode 100644 index 0000000..407cad4 --- /dev/null +++ b/dixhttp/server_packages_test.go @@ -0,0 +1,86 @@ +package dixhttp + +import ( + "testing" + + "github.com/pubgo/dix/v2/dixinternal" +) + +func TestPackageInfoUsesResolvedOutputPackage(t *testing.T) { + details := []dixinternal.ProviderDetails{ + {OutputType: "*main.Plugin[main.RoleReader]", OutputPkg: "main"}, + {OutputType: "*billing.Client", OutputPkg: "example/billing"}, + } + packages := buildPackageInfos(details) + if len(packages) != 2 { + t.Fatalf("packages = %+v", packages) + } + if packages[0].Name != "example/billing" || packages[1].Name != "main" { + t.Fatalf("malformed generic package path retained or rows not sorted: %+v", packages) + } +} + +func TestAggregateProviderInfosKeepsCollidingInputPkgs(t *testing.T) { + details := []dixinternal.ProviderDetails{ + { + RegistrationID: 1, + OutputType: "*app.Application", + OutputPkg: "example/http/app", + FunctionName: "app.Provide", + InputTypes: []string{ + "*handler.Handler", + "*handler.Handler", + "*plugins.Platform", + }, + InputPkgs: []string{ + "example/http/domain/billing/handler", + "example/http/domain/inventory/handler", + "example/http/plugins", + }, + }, + } + _, providers := aggregateProviderInfos(details, "", 0) + if len(providers) != 1 { + t.Fatalf("providers = %d, want 1", len(providers)) + } + p := providers[0] + if len(p.InputTypes) != 3 || len(p.InputPkgs) != 3 { + t.Fatalf("inputs collapsed: types=%v pkgs=%v", p.InputTypes, p.InputPkgs) + } + if p.InputPkgs[0] != "example/http/domain/billing/handler" || p.InputPkgs[1] != "example/http/domain/inventory/handler" { + t.Fatalf("input pkgs = %v", p.InputPkgs) + } +} + +func TestDependencyPackageFilterUsesOutputPkg(t *testing.T) { + details := []dixinternal.ProviderDetails{ + { + FunctionName: "analytics.NewClient", + OutputType: "*analytics.Client", + OutputPkg: "github.com/pubgo/dix/example/http/domain/analytics/infra", + FunctionPkg: "github.com/pubgo/dix/example/http/domain/analytics/infra", + }, + { + FunctionName: "billing.NewClient", + OutputType: "*billing.Client", + OutputPkg: "github.com/pubgo/dix/example/http/domain/billing/infra", + FunctionPkg: "github.com/pubgo/dix/example/http/domain/billing/infra", + }, + } + data := buildDependencyData(details, nil, "github.com/pubgo/dix/example/http/domain/analytics/infra", 0) + if len(data.Providers) != 1 { + t.Fatalf("providers = %+v, want 1 analytics provider", data.Providers) + } + if data.Providers[0].OutputType != "*analytics.Client" { + t.Fatalf("got provider %+v", data.Providers[0]) + } + // extractPackage("*analytics.Client") == "analytics" should match path suffix + byShort := buildDependencyData(details, nil, "analytics", 0) + if len(byShort.Providers) != 1 { + t.Fatalf("short name analytics should match OutputPkg path suffix, got %+v", byShort.Providers) + } + byMain := buildDependencyData(details, nil, "main", 0) + if len(byMain.Providers) != 0 { + t.Fatalf("filter main must not match domain/.../analytics via substring, got %+v", byMain.Providers) + } +} diff --git a/dixhttp/server_provider_identity_test.go b/dixhttp/server_provider_identity_test.go new file mode 100644 index 0000000..9ce7936 --- /dev/null +++ b/dixhttp/server_provider_identity_test.go @@ -0,0 +1,89 @@ +package dixhttp + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + dix "github.com/pubgo/dix/v2" +) + +type projectionOutputA struct{} +type projectionOutputB struct{} +type projectionAggregate struct { + A *projectionOutputA + B *projectionOutputB +} + +func TestDependenciesPreserveRegistrationAndOutputIdentity(t *testing.T) { + container := dix.New() + dix.Provide(container, func() projectionAggregate { + return projectionAggregate{A: &projectionOutputA{}, B: &projectionOutputB{}} + }) + + server := NewServer(container) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("GET", "/api/dependencies", nil)) + if recorder.Code != 200 { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + + var response DependencyData + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + + registrationIDs := map[uint64]bool{} + providerIDs := map[string]bool{} + for _, provider := range response.Providers { + if provider.OutputType != "*dixhttp.projectionOutputA" && provider.OutputType != "*dixhttp.projectionOutputB" { + continue + } + if provider.RegistrationID == 0 || len(provider.ProviderIDs) == 0 { + t.Fatalf("provider identities must not be empty: %+v", provider) + } + registrationIDs[provider.RegistrationID] = true + for _, providerID := range provider.ProviderIDs { + providerIDs[providerID] = true + } + } + if len(registrationIDs) != 1 { + t.Fatalf("one struct registration should produce one registration_id, got %v", registrationIDs) + } + if len(providerIDs) != 2 { + t.Fatalf("two outputs should produce two provider IDs, got %v", providerIDs) + } +} + +func TestDistinctSameLineRegistrationsDoNotCartesianProductEdges(t *testing.T) { + container := dix.New() + for i := 0; i < 2; i++ { + dix.Provide(container, func() *projectionOutputA { return &projectionOutputA{} }) + } + + server := NewServer(container) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("GET", "/api/dependencies", nil)) + + var response DependencyData + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + targetCount := 0 + for _, provider := range response.Providers { + if provider.OutputType == "*dixhttp.projectionOutputA" { + targetCount++ + } + } + if targetCount != 2 { + t.Fatalf("expected distinct provider nodes, got %d", targetCount) + } + for _, provider := range response.Providers { + if provider.OutputType != "*dixhttp.projectionOutputA" { + continue + } + if len(provider.InputTypes)*len(provider.OutputTypes) > 1 { + t.Fatalf("unrelated input/output pairs created a Cartesian product: %+v", provider) + } + } +} diff --git a/dixhttp/static/css/app.css b/dixhttp/static/css/app.css deleted file mode 100644 index dc16109..0000000 --- a/dixhttp/static/css/app.css +++ /dev/null @@ -1,74 +0,0 @@ -/* dixhttp 五视图 UI:轻量自绘样式(零 CDN 依赖) */ -* { box-sizing: border-box; } -:root { - --bg: #f5f6fa; --card: #fff; --line: #e3e5ec; --text: #1c2333; --muted: #6b7280; - --brand: #4f5ce5; --brand-soft: #eef0ff; --ok: #16a34a; --err: #dc2626; --warn: #d97706; -} -html, body { margin: 0; height: 100%; } -body { - font: 14px/1.55 -apple-system, "PingFang SC", "Segoe UI", "Microsoft YaHei", sans-serif; - background: var(--bg); color: var(--text); display: flex; flex-direction: column; -} -.topbar { - display: flex; align-items: center; gap: 16px; padding: 10px 20px; - background: var(--card); border-bottom: 1px solid var(--line); flex-wrap: wrap; -} -.brand { font-size: 15px; white-space: nowrap; } -.gsearch { flex: 1; min-width: 200px; max-width: 460px; } -.gsearch input { - width: 100%; padding: 7px 12px; border: 1px solid var(--line); border-radius: 8px; font-size: 13px; -} -.tabs { display: flex; gap: 4px; } -.tabs a { - padding: 7px 14px; border-radius: 8px; color: var(--muted); text-decoration: none; font-weight: 500; -} -.tabs a.active { background: var(--brand-soft); color: var(--brand); } -.view { flex: 1; overflow: auto; padding: 18px 20px; } -.loading { color: var(--muted); padding: 30px; text-align: center; } - -.grid { display: grid; gap: 14px; } -.cards { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); margin-bottom: 16px; } -.cols-2 { grid-template-columns: 1fr 1fr; } -.card { - background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; -} -.card h3 { margin: 0 0 10px; font-size: 13px; color: var(--muted); font-weight: 600; } -.stat .num { font-size: 26px; font-weight: 700; } -.stat .cap { color: var(--muted); font-size: 12px; } - -table.tbl { width: 100%; border-collapse: collapse; font-size: 13px; } -.tbl th, .tbl td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--line); } -.tbl th { color: var(--muted); font-weight: 600; } -.tbl tr:hover td { background: var(--brand-soft); } -.tbl td.num, .tbl th.num { text-align: right; } - -.badge { - display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; - background: var(--brand-soft); color: var(--brand); -} -.badge.ok { background: #e8f7ee; color: var(--ok); } -.badge.err { background: #fdeaea; color: var(--err); } -.badge.warn { background: #fdf3e2; color: var(--warn); } -.muted { color: var(--muted); } -.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } -.click { cursor: pointer; } - -form.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; } -form.filters input, form.filters select { - padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; font-size: 13px; background: var(--card); -} -form.filters input[type="search"], form.filters input[type="text"] { min-width: 220px; } -button.btn { - padding: 7px 14px; border: 1px solid var(--brand); background: var(--brand); color: #fff; - border-radius: 8px; font-size: 13px; cursor: pointer; -} -button.btn.ghost { background: var(--card); color: var(--brand); } - -#graph-canvas { height: 560px; background: var(--card); border: 1px solid var(--line); border-radius: 10px; } -.tree, .tree ul { list-style: none; padding-left: 18px; } -.tree > li { padding-left: 0; } -.tree .node-line { display: flex; gap: 8px; align-items: baseline; padding: 4px 6px; border-radius: 6px; } -.tree .node-line:hover { background: var(--brand-soft); } -.node-line .dur { color: var(--muted); font-size: 12px; } -.chip { display: inline-block; margin: 2px 4px 2px 0; padding: 2px 9px; background: var(--card); border: 1px solid var(--line); border-radius: 999px; font-size: 12px; } -.err-text { color: var(--err); } diff --git a/dixhttp/static/index.html b/dixhttp/static/index.html deleted file mode 100644 index 8f85a58..0000000 --- a/dixhttp/static/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - -Dix 依赖可视化 - - - -
-
🔗 Dix 依赖可视化
- - -
-
加载中…
- - - - - - - - - - - diff --git a/dixhttp/static/js/api.js b/dixhttp/static/js/api.js deleted file mode 100644 index 1ec1290..0000000 --- a/dixhttp/static/js/api.js +++ /dev/null @@ -1,43 +0,0 @@ -// API 客户端:所有请求经 DIX_BASE 前缀(basePath 部署兼容)。 -window.DIX = window.DIX || {}; -(function () { - const base = window.DIX_BASE || ""; - - DIX.get = async function (path, params) { - let url = base + path; - if (params) { - const qs = new URLSearchParams(); - for (const [k, v] of Object.entries(params)) { - if (v !== undefined && v !== null && v !== "") qs.set(k, v); - } - const q = qs.toString(); - if (q) url += "?" + q; - } - const resp = await fetch(url); - if (!resp.ok) { - const text = await resp.text(); - throw new Error(resp.status + " " + text.slice(0, 200)); - } - return resp.json(); - }; - - DIX.renderError = function (el, err) { - el.innerHTML = '

加载失败

'; - el.querySelector(".err-text").textContent = String(err); - }; - - DIX.esc = function (s) { - return String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); - }; - - // 全局搜索:Enter 跳检索视图 - document.addEventListener("DOMContentLoaded", () => { - const form = document.getElementById("global-search"); - if (!form) return; - form.addEventListener("submit", ev => { - ev.preventDefault(); - const q = document.getElementById("gsearch-input").value.trim(); - location.hash = "#/search" + (q ? "?q=" + encodeURIComponent(q) : ""); - }); - }); -})(); diff --git a/dixhttp/static/js/graph_state.mjs b/dixhttp/static/js/graph_state.mjs new file mode 100644 index 0000000..d5341e8 --- /dev/null +++ b/dixhttp/static/js/graph_state.mjs @@ -0,0 +1,1553 @@ +export function resolveGraphMode(query) { + const mode = (query.get("mode") || "").trim(); + return ["modules", "module", "ego", "providers", "types"].includes(mode) ? mode : "modules"; +} + +/** Soft cap for on-canvas readability; denser data stays in hubs/table warnings. */ +export const READABLE_NODE_CAP = 40; + +/** Soft budget when user explicitly chooses hierarchical/force. */ +export const HIERARCHICAL_NODE_BUDGET = 150; +export const HIERARCHICAL_EDGE_BUDGET = 400; + +/** + * Shorten package paths and type names for readable node labels. + * Keep a package/type qualifier (e.g. analytics.Service) so many "Service" + * nodes do not become indistinguishable when zoomed out. + * Generic types like Worker[pkg.RoleX] keep the type args readable. + */ +export function shortGraphLabel(name) { + const s = String(name || "").replace(/^\*/, ""); + if (!s) return ""; + const generic = s.match(/^([^\[\]]+)\[(.+)\]$/); + if (generic) { + const base = shortGraphLabel(generic[1]); + const inner = shortGraphLabel(generic[2]); + const baseLeaf = base.includes(".") ? base.split(".").pop() : base; + return `${baseLeaf}[${inner}]`; + } + if (s.includes("/")) { + const parts = s.split("/").filter(Boolean); + const last = parts[parts.length - 1] || s; + // Import path ending in pkg.Type → keep type qualifier, drop long module path. + if (last.includes(".")) { + return shortGraphLabel(last); + } + return parts.slice(-2).join("/") || s; + } + const dotted = s.split(".").filter(Boolean); + if (dotted.length >= 2) { + return dotted.slice(-2).join("."); + } + return dotted[dotted.length - 1] || s; +} + +/** Shared layer leaf names that collide across domains (models/handler/…). */ +const LAYER_PKG_LEAVES = new Set([ + "models", "infra", "logic", "service", "handler", "router", "plugins", +]); + +/** + * Qualify a type label with output_pkg parent when many packages share + * the same Go package name (e.g. domain/billing/handler → handler.Handler). + */ +export function labelTypeWithPackage(typeName, pkgPath = "") { + const short = shortGraphLabel(typeName); + const parts = String(pkgPath || "").split("/").filter(Boolean); + if (!short || parts.length < 2) return short; + const leaf = parts[parts.length - 1]; + const parent = parts[parts.length - 2]; + const typeLeaf = short.includes(".") ? short.split(".").pop() : short; + const typePkg = short.includes(".") ? short.slice(0, short.lastIndexOf(".")) : ""; + if (typePkg === leaf || LAYER_PKG_LEAVES.has(leaf)) { + return `${parent}/${leaf}.${typeLeaf}`; + } + return short; +} + +/** + * Provider node label for the Providers canvas. + * Prefer the Provide symbol (vault.Provide); arrow suffix only disambiguates + * which Dix map/interface namespace that provider registers into — not a Go type + * living under the plugin package. + */ +export function providerDisplayLabel(provider = {}) { + const outs = provider.output_types && provider.output_types.length + ? provider.output_types.filter(Boolean) + : (provider.output_type ? [provider.output_type] : []); + const pkg = provider.output_pkg || provider.function_pkg || ""; + const symbol = providerSymbolLabel(provider); + const nsLeaf = outs.length === 1 ? dixNamespaceLeaf(outs[0]) : ""; + + // Plugin package providers: always show as providers, not fake types. + if (symbol && /\/plugins\/[^/]+/.test(String(provider.function_pkg || provider.function_name || ""))) { + return nsLeaf ? `${symbol} → ${nsLeaf}` : symbol; + } + + if (outs.length === 1) { + return labelTypeWithPackage(outs[0], pkg); + } + if (outs.length > 1) { + return outs.map((t) => labelTypeWithPackage(t, pkg)).join(", "); + } + return symbol || shortGraphLabel(provider.function_name || provider.id || ""); +} + +/** `vault.Provide.func2` → `vault.Provide` */ +export function providerSymbolLabel(provider = {}) { + const fn = String(provider.function_name || "").trim(); + if (!fn) return ""; + const leaf = fn.includes("/") ? fn.slice(fn.lastIndexOf("/") + 1) : fn; + const cleaned = leaf.replace(/\.func\d+$/i, ""); + if (cleaned && cleaned.includes(".")) return cleaned; + const pkg = String(provider.function_pkg || "").trim(); + if (pkg) { + const parts = pkg.split("/").filter(Boolean); + const last = parts[parts.length - 1] || ""; + if (last && cleaned) return `${last}.${cleaned}`; + if (last) return last; + } + return cleaned || leaf; +} + +/** Map/interface namespace leaf: map[string]…plugins.Worker → Worker */ +export function dixNamespaceLeaf(typeName = "") { + const raw = String(typeName || "").replace(/^\*/, ""); + const mapMatch = raw.match(/^map\[([^\]]+)\](.+)$/); + const inner = mapMatch ? mapMatch[2] : raw; + const short = shortGraphLabel(inner); + return short.includes(".") ? short.split(".").pop() : short; +} + +/** Pick which node ids keep visible labels at a given zoom scale. */ +export function labelLodVisibleIds(nodes = [], edges = [], scale = 1, opts = {}) { + const hubLimit = opts.hubLimit ?? 12; + const hideBelow = opts.hideBelow ?? 0.62; + const ids = nodes.map((n) => n.id); + if (scale >= hideBelow || ids.length <= hubLimit) { + return new Set(ids); + } + const hubs = rankHubNodes(nodes, edges, hubLimit).map((h) => h.id); + return new Set(hubs); +} + +/** Full or scoped provider inventory for the completeness channel (sidebar). */ +export function buildProviderInventory(allData = {}, pkgName = "") { + const pkg = String(pkgName || "").trim(); + if (pkg) return buildPackageSummary(allData, pkg); + const list = allData.providers || []; + const types = []; + const seen = new Set(); + for (const p of list) { + const pkgPath = p.output_pkg || p.function_pkg || ""; + for (const t of (p.output_types && p.output_types.length ? p.output_types : [p.output_type])) { + if (!t) continue; + const id = typeNodeIdentity(t, pkgPath); + if (!id || seen.has(id)) continue; + seen.add(id); + types.push({ id, fullType: t, packagePath: pkgPath, label: labelTypeWithPackage(t, pkgPath) }); + } + } + return { + providers: list.map((p) => ({ + id: p.id, + function_name: p.function_name, + output_type: p.output_type, + output_pkg: p.output_pkg || p.function_pkg || "", + label: providerDisplayLabel(p), + })), + types, + }; +} + +function providerOutputTypesOf(p = {}) { + if (p.output_types && p.output_types.length) return p.output_types.filter(Boolean); + return p.output_type ? [p.output_type] : []; +} + +function providerMatchesPkg(p = {}, pkgName = "") { + const pkg = String(pkgName || "").trim(); + if (!pkg) return true; + const out = String(p.output_pkg || "").trim(); + const fn = String(p.function_pkg || "").trim(); + if (out === pkg || fn === pkg) return true; + if (!pkg.includes("/") && (out.endsWith("/" + pkg) || fn.endsWith("/" + pkg))) return true; + return false; +} + +/** + * Provider-only dependency graph. + * Edge A→B means A depends on B (A consumes an output type produced by B). + * Entries (pyramid tip) = providers with indegree 0 (not consumed by anyone). + * + * When many packages share a Go package name (handler.Handler in billing vs + * inventory), OutputType strings collide. Prefer matching producers whose + * output_pkg equals the consumer's input_pkgs[i] when that is present. + */ +export function buildProviderDependencyGraph(providers = []) { + const list = Array.isArray(providers) ? providers : []; + const nodes = list.map((p) => { + const id = p.id || p.provider_id || p.function_name || p.output_type; + const label = providerDisplayLabel(p); + return { + id, + label, + title: (p.output_type || label) + + (p.function_name ? "\n" + p.function_name : ""), + shape: "box", + font: { size: 11 }, + data: { + ...p, + type: "provider", + packagePath: p.output_pkg || p.function_pkg || "", + displayLabel: label, + }, + }; + }); + const byId = new Map(nodes.map((n) => [n.id, n])); + const typeProducers = new Map(); + for (const p of list) { + const id = p.id || p.provider_id || p.function_name || p.output_type; + if (!byId.has(id)) continue; + for (const t of providerOutputTypesOf(p)) { + if (!typeProducers.has(t)) typeProducers.set(t, new Set()); + typeProducers.get(t).add(id); + } + } + const edgeKeys = new Set(); + const edges = []; + for (const p of list) { + const from = p.id || p.provider_id || p.function_name || p.output_type; + if (!byId.has(from)) continue; + const inputs = p.input_types || []; + const inputPkgs = p.input_pkgs || []; + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i]; + const wantPkg = String(inputPkgs[i] || "").trim(); + const producers = typeProducers.get(input); + if (!producers) continue; + const producerList = [...producers]; + for (const to of producerList) { + if (!to || to === from) continue; + if (wantPkg) { + const prod = byId.get(to); + const prodPkg = String( + (prod && prod.data && (prod.data.output_pkg || prod.data.packagePath)) || "" + ).trim(); + if (prodPkg && prodPkg !== wantPkg) continue; + } + const key = from + "->" + to; + if (edgeKeys.has(key)) continue; + edgeKeys.add(key); + edges.push({ + from, + to, + arrows: "to", + color: { color: "#9ca3af" }, + }); + } + } + } + return { nodes, edges }; +} + +/** Level 1 = unconsumed entries; deeper levels follow depends-on edges. */ +export function assignProviderPyramidLevels(nodes = [], edges = []) { + const ids = nodes.map((n) => n.id); + const idSet = new Set(ids); + const outgoing = new Map(ids.map((id) => [id, []])); + const indeg = new Map(ids.map((id) => [id, 0])); + for (const e of edges) { + if (!idSet.has(e.from) || !idSet.has(e.to)) continue; + outgoing.get(e.from).push(e.to); + indeg.set(e.to, (indeg.get(e.to) || 0) + 1); + } + const levels = new Map(); + const queue = []; + for (const id of ids) { + if ((indeg.get(id) || 0) === 0) { + levels.set(id, 1); + queue.push(id); + } + } + // Isolated / cycle leftovers: treat remaining as entries if never reached. + while (queue.length) { + const id = queue.shift(); + const level = levels.get(id); + for (const to of outgoing.get(id) || []) { + if (levels.has(to)) continue; + levels.set(to, level + 1); + queue.push(to); + } + } + for (const id of ids) { + if (!levels.has(id)) levels.set(id, 1); + } + return levels; +} + +export function truncateProviderPyramid(nodes = [], edges = [], levels = new Map(), maxLevel = 0) { + const max = Number(maxLevel) || 0; + if (max <= 0) { + return { + nodes: nodes.map((n) => ({ ...n, level: levels.get(n.id) || n.level || 1 })), + edges: [...edges], + }; + } + const keep = new Set( + nodes.filter((n) => (levels.get(n.id) || 1) <= max).map((n) => n.id) + ); + return { + nodes: nodes + .filter((n) => keep.has(n.id)) + .map((n) => ({ ...n, level: levels.get(n.id) || 1 })), + edges: edges.filter((e) => keep.has(e.from) && keep.has(e.to)), + }; +} + +/** + * Collect seed node ids plus all descendants following edge from→to + * (provider/types: consumer→dependency; module map: parent→child containment). + */ +export function collectDownstreamNodeIds(nodes = [], edges = [], seedIds = []) { + const seeds = [...new Set((seedIds || []).map(String).filter(Boolean))]; + if (!seeds.length) return new Set(); + const idSet = new Set(nodes.map((n) => n.id)); + const down = new Map([...idSet].map((id) => [id, []])); + for (const e of edges) { + if (!idSet.has(e.from) || !idSet.has(e.to)) continue; + down.get(e.from).push(e.to); + } + const hide = new Set(); + const queue = []; + for (const id of seeds) { + hide.add(id); + if (idSet.has(id)) queue.push(id); + } + while (queue.length) { + const id = queue.shift(); + for (const to of down.get(id) || []) { + if (hide.has(to)) continue; + hide.add(to); + queue.push(to); + } + } + return hide; +} + +/** + * Expand hide seeds (node ids and/or packagePrefix) to concrete node ids + * present on the current canvas. Skips `exact` seeds (those hide one id only). + */ +export function expandHiddenSeedsToNodeIds(nodes = [], seeds = []) { + const ids = []; + for (const s of seeds || []) { + if (s == null) continue; + if (typeof s === "string" || typeof s === "number") { + ids.push(String(s)); + continue; + } + if (s.exact) continue; + const prefix = String(s.packagePrefix || "").trim(); + if (prefix) { + for (const n of nodes) { + const pkg = String( + (n.data && (n.data.packagePath || n.data.output_pkg || n.data.function_pkg)) || "" + ); + const nid = String(n.id || ""); + if ( + pkg === prefix + || pkg.startsWith(prefix + "/") + || nid === prefix + || nid.startsWith(prefix + "/") + ) { + ids.push(nid); + } + } + continue; + } + if (s.id) ids.push(String(s.id)); + } + return [...new Set(ids)]; +} + +/** Compact hide seeds for URL `hide=` (pipe-separated; pkg: / exact: prefixes). */ +export function encodeHiddenSeedsForUrl(seeds = []) { + return (seeds || []) + .map((s) => { + if (s == null) return ""; + if (typeof s === "string" || typeof s === "number") return String(s); + const prefix = String(s.packagePrefix || "").trim(); + if (prefix) return "pkg:" + prefix; + if (s.exact && s.id) return "exact:" + String(s.id); + return String(s.id || "").trim(); + }) + .filter(Boolean) + .join("|"); +} + +/** Parse URL `hide=` back into seed objects (labels are best-effort). */ +export function decodeHiddenSeedsFromUrl(param) { + if (param == null) return []; + const raw = String(param).trim(); + if (!raw) return []; + return raw.split("|").map((part) => { + const token = String(part || "").trim(); + if (!token) return null; + if (token.startsWith("pkg:")) { + const pref = token.slice(4).trim(); + if (!pref) return null; + return { + id: "pkg:" + pref, + label: pref.split("/").filter(Boolean).slice(-2).join("/") || pref, + packagePrefix: pref, + }; + } + if (token.startsWith("exact:")) { + const id = token.slice(6).trim(); + if (!id) return null; + const short = id.includes("\x00") ? id.split("\x00").pop() : id; + return { id, label: short, exact: true }; + } + return { id: token, label: token }; + }).filter(Boolean); +} + +/** Remove hidden seeds (ids or package prefixes) and their downstream. + * Seeds marked `exact: true` hide only that node (invert / keep-neighborhood). + */ +export function applyHiddenNodeSeeds(nodes = [], edges = [], seedIds = []) { + const raw = seedIds || []; + if (!raw.length) { + return { nodes: [...nodes], edges: [...edges], hiddenIds: new Set() }; + } + const exactIds = []; + const soft = []; + for (const s of raw) { + if (s != null && typeof s === "object" && s.exact && s.id) { + exactIds.push(String(s.id)); + } else { + soft.push(s); + } + } + const expanded = soft.length && typeof soft[0] === "object" + ? expandHiddenSeedsToNodeIds(nodes, soft) + : soft.map(String).filter(Boolean); + const hiddenIds = new Set(exactIds); + if (expanded.length) { + for (const id of collectDownstreamNodeIds(nodes, edges, expanded)) { + hiddenIds.add(id); + } + } + if (!hiddenIds.size) { + return { nodes: [...nodes], edges: [...edges], hiddenIds }; + } + return { + nodes: nodes.filter((n) => !hiddenIds.has(n.id)), + edges: edges.filter((e) => !hiddenIds.has(e.from) && !hiddenIds.has(e.to)), + hiddenIds, + }; +} + +/** Seeds + all ancestors (upstream consumers) + descendants (downstream deps). */ +export function providerRelatedSubgraph(nodes = [], edges = [], seedIds = []) { + const seeds = new Set((seedIds || []).filter(Boolean).map(String)); + if (!seeds.size) return { nodes: [...nodes], edges: [...edges] }; + const idSet = new Set(nodes.map((n) => n.id)); + const down = new Map(nodes.map((n) => [n.id, []])); + const up = new Map(nodes.map((n) => [n.id, []])); + for (const e of edges) { + if (!idSet.has(e.from) || !idSet.has(e.to)) continue; + down.get(e.from).push(e.to); + up.get(e.to).push(e.from); + } + const keep = new Set(); + const walk = (startIds, adj) => { + const q = [...startIds]; + for (const id of q) keep.add(id); + while (q.length) { + const id = q.shift(); + for (const n of adj.get(id) || []) { + if (keep.has(n)) continue; + keep.add(n); + q.push(n); + } + } + }; + const presentSeeds = [...seeds].filter((id) => idSet.has(id)); + walk(presentSeeds, down); + walk(presentSeeds, up); + return { + nodes: nodes.filter((n) => keep.has(n.id)), + edges: edges.filter((e) => keep.has(e.from) && keep.has(e.to)), + }; +} + +/** + * Role of each node relative to focus seeds. + * Edge direction is depends-on (from consumer → dependency): + * - dependency = downstream (what the seed depends on) + * - dependent = upstream (who depends on the seed) + */ +export function classifyRelatedRoles(nodes = [], edges = [], seedIds = []) { + const seeds = new Set((seedIds || []).filter(Boolean).map(String)); + const roles = new Map(); + if (!seeds.size) return roles; + const idSet = new Set(nodes.map((n) => n.id)); + const down = new Map(nodes.map((n) => [n.id, []])); + const up = new Map(nodes.map((n) => [n.id, []])); + for (const e of edges) { + if (!idSet.has(e.from) || !idSet.has(e.to)) continue; + down.get(e.from).push(e.to); + up.get(e.to).push(e.from); + } + const walkReachable = (adj) => { + const reached = new Set(); + const q = [...seeds].filter((id) => idSet.has(id)); + while (q.length) { + const id = q.shift(); + for (const n of adj.get(id) || []) { + if (seeds.has(n) || reached.has(n)) continue; + reached.add(n); + q.push(n); + } + } + return reached; + }; + const dependencies = walkReachable(down); + const dependents = walkReachable(up); + for (const id of seeds) { + if (idSet.has(id)) roles.set(id, "seed"); + } + for (const id of dependencies) { + roles.set(id, dependents.has(id) ? "both" : "dependency"); + } + for (const id of dependents) { + if (roles.has(id) && roles.get(id) !== "seed") { + if (roles.get(id) === "dependency") roles.set(id, "both"); + } else if (!roles.has(id)) { + roles.set(id, "dependent"); + } + } + return roles; +} + +/** + * Providers canvas pipeline: provider-only pyramid, optional package/provider seeds, + * then depth truncation by pyramid level (depth<=0 keeps all levels). + */ +export function buildProvidersPyramidView(providers = [], opts = {}) { + const depth = Number(opts.depth) || 0; + const packageName = String(opts.packageName || "").trim(); + const seedProviderIds = (opts.seedProviderIds || []).map(String).filter(Boolean); + let { nodes, edges } = buildProviderDependencyGraph(providers); + + const seeds = new Set(seedProviderIds); + if (packageName) { + for (const p of providers) { + if (providerMatchesPkg(p, packageName)) { + seeds.add(String(p.id || p.function_name || p.output_type)); + } + } + } + if (seeds.size) { + const sub = providerRelatedSubgraph(nodes, edges, [...seeds]); + nodes = sub.nodes; + edges = sub.edges; + } + + const levels = assignProviderPyramidLevels(nodes, edges); + const cut = truncateProviderPyramid(nodes, edges, levels, depth); + const entries = cut.nodes.filter((n) => (n.level || levels.get(n.id) || 1) === 1).map((n) => n.id); + return { + nodes: cut.nodes, + edges: cut.edges, + levels, + entries, + depth, + }; +} + +function typeMatchesPkg(typeName = "", pkgName = "") { + const pkg = String(pkgName || "").trim(); + if (!pkg) return true; + const t = String(typeName || ""); + if (!t) return false; + if (t.includes(pkg)) return true; + const short = pkg.includes("/") ? pkg.split("/").pop() : pkg; + return !!(short && (t.includes("/" + short + ".") || t.includes("." + short + ".") || t.endsWith("." + short))); +} + +/** + * Stable type-node identity: Go typ.String() collides across packages + * (*handler.Handler in billing vs inventory). Prefer pkg\x00type. + */ +export function typeNodeIdentity(typeName = "", pkgPath = "") { + const t = String(typeName || "").trim(); + if (!t) return ""; + if (t.includes("\x00")) return t; + const pkg = String(pkgPath || "").trim(); + return pkg ? `${pkg}\x00${t}` : t; +} + +/** + * Type-only dependency graph. + * Edge A→B means type A depends on type B (A's provider consumes B). + * Nodes are keyed by package + type so same short names stay distinct. + */ +export function buildTypeDependencyGraph(allData = {}) { + const providers = allData.providers || []; + const nodeMap = new Map(); + const ensure = (typeName, pkgHint = "") => { + const id = typeNodeIdentity(typeName, pkgHint); + if (!id) return ""; + if (nodeMap.has(id)) { + const existing = nodeMap.get(id); + if (pkgHint && !(existing.data && existing.data.packagePath)) { + existing.data.packagePath = pkgHint; + existing.label = labelTypeWithPackage(typeName, pkgHint); + existing.data.displayLabel = existing.label; + existing.title = pkgHint + "\n" + typeName; + } + return id; + } + const label = labelTypeWithPackage(typeName, pkgHint); + nodeMap.set(id, { + id, + label, + title: (pkgHint ? pkgHint + "\n" : "") + typeName, + shape: "ellipse", + font: { size: 11 }, + color: { background: "#bfdbfe", border: "#3b82f6" }, + data: { + type: "type", + fullType: typeName, + packagePath: pkgHint || "", + displayLabel: label, + }, + }); + return id; + }; + const edgeKeys = new Set(); + const edges = []; + const addEdge = (from, to) => { + if (!from || !to || from === to) return; + const key = from + "->" + to; + if (edgeKeys.has(key)) return; + edgeKeys.add(key); + edges.push({ from, to, arrows: "to", color: { color: "#9ca3af" } }); + }; + + // First pass: register all produced types (authoritative package paths). + const producersByType = new Map(); + for (const p of providers) { + const outs = providerOutputTypesOf(p); + const pkg = String(p.output_pkg || p.function_pkg || "").trim(); + for (const out of outs) { + const id = ensure(out, pkg); + if (!id) continue; + if (!producersByType.has(out)) producersByType.set(out, []); + producersByType.get(out).push({ id, pkg }); + } + } + + const resolveInputId = (typeName, hintPkg) => { + const hint = String(hintPkg || "").trim(); + if (hint) return ensure(typeName, hint); + const producers = producersByType.get(typeName) || []; + if (producers.length === 1) return ensure(typeName, producers[0].pkg); + // Ambiguous or external: keep type-only id (last resort). + return ensure(typeName, ""); + }; + + for (const p of providers) { + const outs = providerOutputTypesOf(p); + const pkg = String(p.output_pkg || p.function_pkg || "").trim(); + const outIds = outs.map((out) => ensure(out, pkg)).filter(Boolean); + const inputs = p.input_types || []; + const inputPkgs = p.input_pkgs || []; + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i]; + const inId = resolveInputId(input, inputPkgs[i]); + for (const outId of outIds) addEdge(outId, inId); + } + } + + return { nodes: [...nodeMap.values()], edges }; +} + +/** + * Types canvas pyramid: same depth/package/seed semantics as providers. + */ +export function buildTypesPyramidView(allData = {}, opts = {}) { + const depth = Number(opts.depth) || 0; + const packageName = String(opts.packageName || "").trim(); + const seedTypeIds = (opts.seedTypeIds || []).map(String).filter(Boolean); + let { nodes, edges } = buildTypeDependencyGraph(allData); + + const seeds = new Set(seedTypeIds); + // Allow unqualified type strings to match all package-qualified nodes. + if (seeds.size) { + for (const seed of [...seeds]) { + if (String(seed).includes("\x00")) continue; + for (const n of nodes) { + if (n.id === seed || (n.data && n.data.fullType === seed)) { + seeds.add(n.id); + } + } + } + } + if (packageName) { + for (const n of nodes) { + const pkgPath = (n.data && n.data.packagePath) || ""; + if (providerMatchesPkg({ output_pkg: pkgPath }, packageName) || typeMatchesPkg(n.id, packageName)) { + seeds.add(n.id); + } + } + } + if (seeds.size) { + const sub = providerRelatedSubgraph(nodes, edges, [...seeds]); + nodes = sub.nodes; + edges = sub.edges; + } + + const levels = assignProviderPyramidLevels(nodes, edges); + const cut = truncateProviderPyramid(nodes, edges, levels, depth); + const entries = cut.nodes.filter((n) => (n.level || levels.get(n.id) || 1) === 1).map((n) => n.id); + return { + nodes: cut.nodes, + edges: cut.edges, + levels, + entries, + depth, + }; +} + +export function applyGraphBudget(nodes, edges, budget = { nodes: 100, edges: 300 }) { + if (nodes.length <= budget.nodes && edges.length <= budget.edges) { + return { nodes, edges, degraded: false }; + } + + const degree = new Map(); + for (const edge of edges) { + degree.set(edge.from, (degree.get(edge.from) || 0) + 1); + degree.set(edge.to, (degree.get(edge.to) || 0) + 1); + } + + const ranked = [...nodes].sort((a, b) => + (degree.get(b.id) || 0) - (degree.get(a.id) || 0) || + String(a.id).localeCompare(String(b.id)) + ).slice(0, budget.nodes); + const keptNodes = new Set(ranked.map(node => node.id)); + const keptEdges = edges.filter(edge => + keptNodes.has(edge.from) && keptNodes.has(edge.to) + ).slice(0, budget.edges); + + return { + nodes: nodes.filter(node => keptNodes.has(node.id)), + edges: keptEdges, + degraded: true, + }; +} + +export function resolveEffectiveLayout(preferred, nodeCount, opts = {}) { + const choice = (preferred || "auto").trim() || "auto"; + // Explicit user choice always wins — crowding is handled by scope/budget, not layout hijack. + if (choice === "physics") return "physics"; + if (choice === "hierarchical") return "hierarchical"; + // auto only: small graphs get hierarchy, denser graphs get physics + void opts; + return nodeCount > READABLE_NODE_CAP ? "physics" : "hierarchical"; +} + +/** Minimum vis scale after fit before we focus a hub instead of showing a microscopic strip. */ +export const MIN_READABLE_FIT_SCALE = 0.55; + +/** After fit(), decide whether to keep overview or focus a hub for readable labels. */ +export function resolvePostFitCamera(scale, opts = {}) { + const floor = opts.floor ?? MIN_READABLE_FIT_SCALE; + return Number(scale) < floor ? "focus" : "fit"; +} + +/** + * Estimate whether hierarchical UD will sprawl into a thin horizontal strip. + * Used for density tips (shrink scope), never to silently swap the user's layout. + */ +export function estimateHierarchicalStripRisk(nodes = [], edges = [], opts = {}) { + const list = Array.isArray(nodes) ? nodes : []; + const edgeList = Array.isArray(edges) ? edges : []; + const minRoots = opts.minRoots ?? 6; + if (list.length < 8) { + return { wide: false, roots: list.length, components: list.length }; + } + const ids = new Set(list.map((n) => n.id)); + const indeg = new Map([...ids].map((id) => [id, 0])); + const parent = new Map([...ids].map((id) => [id, id])); + const find = (x) => { + let r = x; + while (parent.get(r) !== r) r = parent.get(r); + return r; + }; + const unite = (a, b) => { + const ra = find(a); + const rb = find(b); + if (ra !== rb) parent.set(ra, rb); + }; + for (const e of edgeList) { + if (!ids.has(e.from) || !ids.has(e.to)) continue; + indeg.set(e.to, (indeg.get(e.to) || 0) + 1); + unite(e.from, e.to); + } + let roots = 0; + for (const id of ids) { + if ((indeg.get(id) || 0) === 0) roots += 1; + } + const comps = new Set([...ids].map((id) => find(id))).size; + const wide = roots >= minRoots + || comps >= minRoots + || (list.length >= 12 && roots >= Math.ceil(list.length * 0.35)); + return { wide, roots, components: comps }; +} + +/** Architecture views prefer fit so the whole slice is visible; label LOD keeps hubs readable when zoomed out. */ +export function resolveCameraStrategy(mode, nodeCount) { + if (mode === "modules") return "fit"; + if (nodeCount <= 20) return "fit"; + return "fit"; +} + +/** + * Place a hub at origin and remaining nodes on a circle — readable for module maps. + * Returns { [id]: { x, y } }. + */ +export function layoutStarPositions(nodes = [], edges = [], opts = {}) { + const radius = opts.radius ?? Math.max(260, 40 * Math.max(nodes.length - 1, 1)); + const hubId = pickFocusNodeId(nodes, edges); + const others = nodes.filter((n) => n.id !== hubId); + const positions = {}; + if (hubId) positions[hubId] = { x: 0, y: 0 }; + const n = Math.max(others.length, 1); + others.forEach((node, i) => { + const angle = (2 * Math.PI * i) / n - Math.PI / 2; + positions[node.id] = { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + }; + }); + return positions; +} + +/** Longest common path-prefix length among module names (by segment). */ +function commonPathPrefixLen(paths = []) { + if (!paths.length) return 0; + const split = paths.map((p) => String(p).split("/").filter(Boolean)); + const first = split[0] || []; + let n = first.length; + for (const parts of split.slice(1)) { + let i = 0; + while (i < n && i < parts.length && parts[i] === first[i]) i += 1; + n = i; + } + // Keep at least one shared root segment visible when possible, but never eat the whole path. + return Math.max(0, Math.min(n - 1, n)); +} + +/** Coarse bucket under a fat Go package — keep module map readable. */ +export function providerPackageBucket(provider = {}) { + const out = String(provider.output_type || ""); + if (/Worker\[/.test(out)) return "workers"; + if (/Plugin\[/.test(out)) return "plugins"; + if (/\.(Application|PluginPlatform)\b/.test(out) || /Application|PluginPlatform/.test(out)) return "app"; + if (/Timeout|SlowRemote|Startup|Logger|ConsoleLogger/.test(out)) return "diag"; + return "other"; +} + +/** Type-based path suffix used only when explicitly requesting fine split. */ +export function providerPackageSuffix(provider = {}) { + return providerPackageBucket(provider); +} + +/** + * Spread fat leaf modules into a few coarse child buckets (app/plugins/workers/…). + * Avoids exploding the module map into hundreds of per-type micro-leaves. + */ +export function disperseModulesByProviders(modules = [], providers = [], opts = {}) { + const fatThreshold = opts.fatThreshold ?? 12; + const list = Array.isArray(modules) ? modules : []; + if (!providers.length) return list; + + const byPkg = new Map(); + for (const p of providers) { + const pkg = String(p.output_pkg || p.function_pkg || "").trim(); + if (!pkg) continue; + if (!byPkg.has(pkg)) byPkg.set(pkg, []); + byPkg.get(pkg).push(p); + } + + const out = []; + for (const m of list) { + const name = String(m.name || ""); + const bucket = byPkg.get(name) || []; + const shouldSplit = bucket.length >= fatThreshold; + if (!shouldSplit) { + out.push({ ...m }); + continue; + } + const groups = new Map(); + for (const p of bucket) { + const suffix = providerPackageBucket(p); + const child = `${name}/${suffix}`; + if (!groups.has(child)) { + groups.set(child, { + name: child, + provider_count: 0, + object_count: 0, + type_count: 0, + depends_on: [], + parent: name, + scopePkg: name, + virtual: true, + }); + } + const g = groups.get(child); + g.provider_count += 1; + g.type_count += 1; + } + // Keep cross-package depends once on the package root via a synthetic rollup + // node edge elsewhere — do NOT copy depends_on onto every bucket (edge bomb). + for (const g of groups.values()) out.push(g); + } + return out; +} + +/** + * Expand module paths into a parent/child package tree and assign pyramid levels. + * Cross-package depends_on edges are kept between leaves; containment edges link parents→children. + */ +export function expandPackageHierarchy(modules = [], opts = {}) { + const minDepth = opts.minDepth ?? 5; + const list = Array.isArray(modules) ? modules.map((m) => ({ ...m })) : []; + const names = list.map((m) => m.name).filter(Boolean); + const strip = opts.stripSegments ?? commonPathPrefixLen(names); + + const norm = (name) => { + const parts = String(name).split("/").filter(Boolean); + const sliced = parts.slice(strip); + return sliced.join("/") || String(name); + }; + + const nodes = new Map(); + const ensure = (path, seed = {}) => { + if (!path) return null; + if (!nodes.has(path)) { + const level = path.split("/").filter(Boolean).length; + nodes.set(path, { + name: path, + provider_count: 0, + object_count: 0, + type_count: 0, + depends_on: [], + leaf: false, + level, + ...seed, + }); + } + return nodes.get(path); + }; + + const leafMap = new Map(); // original/full name → normalized leaf path + for (const m of list) { + const path = norm(m.name); + if (!path) continue; + leafMap.set(m.name, path); + const segs = path.split("/").filter(Boolean); + for (let i = 1; i <= segs.length; i++) { + ensure(segs.slice(0, i).join("/")); + } + const leaf = ensure(path); + leaf.leaf = true; + leaf.provider_count += m.provider_count || 0; + leaf.object_count += m.object_count || 0; + leaf.type_count += m.type_count || 0; + leaf.fullName = m.name; + leaf.scopePkg = m.scopePkg || m.parent || m.name; + leaf.virtual = !!m.virtual; + } + + // Roll up counts once from each leaf to its ancestors (ancestors are never leaves). + for (const leaf of [...nodes.values()].filter((n) => n.leaf)) { + const segs = leaf.name.split("/").filter(Boolean); + for (let i = segs.length - 1; i >= 1; i--) { + const parent = segs.slice(0, i).join("/"); + const p = nodes.get(parent); + if (!p || p.leaf) continue; + p.provider_count += leaf.provider_count; + p.object_count += leaf.object_count; + p.type_count += leaf.type_count; + } + } + + // Containment edges parent → child + const containment = []; + for (const path of nodes.keys()) { + const segs = path.split("/").filter(Boolean); + if (segs.length < 2) continue; + const parent = segs.slice(0, -1).join("/"); + containment.push({ from: parent, to: path }); + } + + // Map depends_on onto normalized leaf paths + const depEdges = []; + for (const m of list) { + const from = leafMap.get(m.name); + for (const dep of m.depends_on || []) { + const to = leafMap.get(dep) || norm(dep); + if (!from || !to || from === to) continue; + ensure(to); + depEdges.push({ from, to, kind: "depends" }); + } + } + + void minDepth; + return { + modules: [...nodes.values()].sort((a, b) => a.name.localeCompare(b.name)), + containment, + depEdges, + strip, + }; +} + +/** Build package/module architecture graph; object counts are label text only. */ +export function buildModuleMapGraph(modules = [], opts = {}) { + const hierarchy = opts.hierarchy !== false; + if (!hierarchy) { + const nodes = modules.map((m) => ({ + id: m.name, + label: `${shortGraphLabel(m.name)}\n(${m.provider_count || 0}p/${m.object_count || 0}o)`, + title: m.name, + shape: "box", + font: { size: 12 }, + color: { background: "#eef0ff", border: "#4f5ce5" }, + data: { type: "module", module: m, packagePath: m.name }, + })); + const edges = []; + for (const m of modules) { + for (const dep of m.depends_on || []) { + edges.push({ from: m.name, to: dep, arrows: "to", color: { color: "#9ca3af" } }); + } + } + return { nodes, edges }; + } + + const dispersed = disperseModulesByProviders(modules, opts.providers || [], { + fatThreshold: opts.fatThreshold ?? 12, + }); + const expanded = expandPackageHierarchy(dispersed, { + minDepth: opts.minDepth ?? 5, + stripSegments: opts.stripSegments, + }); + + const nodes = expanded.modules.map((m) => { + const labelName = m.name.split("/").filter(Boolean).slice(-2).join("/") || m.name; + return { + id: m.name, + label: `${labelName}\n(${m.provider_count || 0}p/${m.object_count || 0}o)`, + title: (m.fullName || m.name) + (m.virtual ? " (bucket)" : ""), + shape: "box", + font: { size: m.leaf ? 12 : 11 }, + level: m.level || m.name.split("/").filter(Boolean).length, + color: m.leaf + ? { background: "#eef0ff", border: "#4f5ce5" } + : { background: "#f8fafc", border: "#94a3b8" }, + data: { + type: "module", + module: { ...m, name: m.scopePkg || m.fullName || m.name }, + packagePath: m.scopePkg || m.fullName || m.name, + leaf: !!m.leaf, + provider_count: m.provider_count || 0, + displayLabel: labelName, + }, + }; + }); + + // Module map layout must stay a package TREE. Only containment edges — + // cross-package depends_on fan-out turns the canvas into an unreadable clump. + const edges = []; + const seen = new Set(); + for (const e of expanded.containment) { + const key = e.from + "=>" + e.to; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + from: e.from, + to: e.to, + arrows: "to", + color: { color: "#94a3b8" }, + dashes: true, + data: { kind: "containment" }, + }); + } + return { nodes, edges }; +} + +/** + * Judge whether node positions are a readable 2D spread (not a hairline). + * positions: { [id]: { x, y } } + */ +export function assessLayoutMetrics(positions, opts = {}) { + const maxAspect = opts.maxAspect ?? 6; + const minSpan = opts.minSpan ?? 80; + const ids = Object.keys(positions || {}); + if (ids.length === 0) { + return { ok: false, reason: "empty", nodeCount: 0, aspect: 0, spanX: 0, spanY: 0, thin: false }; + } + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const id of ids) { + const p = positions[id]; + if (!p) continue; + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x); + minY = Math.min(minY, p.y); + maxY = Math.max(maxY, p.y); + } + const spanX = Math.max(0, maxX - minX); + const spanY = Math.max(0, maxY - minY); + const major = Math.max(spanX, spanY, 1); + const minor = Math.max(Math.min(spanX, spanY), 1); + const aspect = major / minor; + const thin = ids.length >= 5 && aspect > maxAspect; + const tooSmall = ids.length >= 5 && Math.min(spanX, spanY) < minSpan && aspect > 3; + const ok = !thin && !tooSmall; + return { + ok, + reason: ok ? "" : (thin ? "thin-strip" : "too-small-spread"), + nodeCount: ids.length, + aspect, + spanX, + spanY, + thin, + }; +} + +export function createLoadGuard() { + let current = 0; + return { + begin() { + current += 1; + return { seq: current }; + }, + isCurrent(seq) { + return seq === current; + }, + }; +} + +export function rankHubNodes(nodes, edges, limit = 10) { + const degree = new Map(); + for (const node of nodes) degree.set(node.id, 0); + for (const edge of edges) { + degree.set(edge.from, (degree.get(edge.from) || 0) + 1); + degree.set(edge.to, (degree.get(edge.to) || 0) + 1); + } + return [...nodes] + .map((node) => ({ id: node.id, degree: degree.get(node.id) || 0 })) + .sort((a, b) => b.degree - a.degree || String(a.id).localeCompare(String(b.id))) + .slice(0, limit); +} + +export function pickFocusNodeId(nodes = [], edges = [], preferredId = "") { + if (preferredId && nodes.some(node => node.id === preferredId)) return preferredId; + const hubs = rankHubNodes(nodes, edges, 1); + if (hubs.length) return hubs[0].id; + return nodes[0] ? nodes[0].id : null; +} + + +export function issueGraphHash(issue = {}) { + const params = new URLSearchParams(); + if (issue.output_type) { + params.set("mode", "ego"); + params.set("center", issue.output_type); + } else if (issue.module) { + params.set("mode", "module"); + params.set("module", issue.module); + } else if (issue.provider) { + params.set("mode", "providers"); + params.set("prefix", issue.provider); + } else { + params.set("mode", "modules"); + } + return "#/graph?" + params.toString(); +} + +export function issueTraceHash(issue = {}) { + const params = new URLSearchParams(); + if (issue.trace_id) params.set("trace_id", issue.trace_id); + if (issue.provider) params.set("provider", issue.provider); + if (issue.output_type) params.set("output_type", issue.output_type); + params.set("status", issue.severity === "slow" ? "slow" : "error"); + return "#/trace?" + params.toString(); +} + +export function matchTraceRecord(rec = {}, filter = {}) { + if (filter.trace_id && rec.trace_id !== filter.trace_id) return false; + const provider = rec.provider_function || rec.provider || ""; + if (filter.provider && provider !== filter.provider) return false; + if (filter.output_type && rec.output_type !== filter.output_type) return false; + if (filter.status === "error" && rec.status !== "error") return false; + if (filter.status === "slow") { + return true; + } + return true; +} + +export function filterTraceRecords(records = [], filter = {}) { + return records.filter((rec) => matchTraceRecord(rec, filter)); +} + +/** Package path used for cross-boundary edge scoring. */ +export function nodePackageKey(node = {}) { + const d = node.data || {}; + const raw = d.packagePath || d.pkg || d.group || ""; + if (raw) return String(raw); + const id = String(node.id || ""); + if (id.includes("/")) { + const lastDot = id.lastIndexOf("."); + const lastSlash = id.lastIndexOf("/"); + if (lastDot > lastSlash) return id.slice(0, lastDot).replace(/^\*/, ""); + } + const short = shortGraphLabel(id); + const dot = short.lastIndexOf("."); + return dot > 0 ? short.slice(0, dot) : ""; +} + +/** Coarse architecture bucket from a Go package path (domain/plugins/infra/…). */ +export function architecturePathBucket(pkgPath = "") { + const parts = String(pkgPath || "").split("/").filter(Boolean); + for (const key of ["domain", "plugins", "infra", "app", "bootstrap", "router"]) { + if (parts.includes(key)) return key; + } + return "other"; +} + +/** Domain name under …/domain//… ; empty if not a domain path. */ +export function architectureDomainName(pkgPath = "") { + const parts = String(pkgPath || "").split("/").filter(Boolean); + const i = parts.indexOf("domain"); + if (i < 0 || i + 1 >= parts.length) return ""; + return parts[i + 1]; +} + +export const FINDINGS_HUB_DEGREE = 12; +export const FINDINGS_ENTRY_FANOUT = 8; +export const FINDINGS_FAT_PACKAGE = 12; +export const FINDINGS_MAX = 30; + +const SEVERITY_RANK = { error: 0, warn: 1, info: 2 }; + +/** + * Static architecture findings from provider graph (no runtime /api/issues). + * Returns actionable items with view/package/focus hints for the UI. + */ +export function buildArchitectureFindings(allData = {}, opts = {}) { + const providers = (allData && allData.providers) || []; + if (!providers.length) return []; + + const hubDegree = opts.hubDegree ?? FINDINGS_HUB_DEGREE; + const entryFanout = opts.entryFanout ?? FINDINGS_ENTRY_FANOUT; + const fatPackage = opts.fatPackage ?? FINDINGS_FAT_PACKAGE; + const maxFindings = opts.max ?? FINDINGS_MAX; + + const { nodes, edges } = buildProviderDependencyGraph(providers); + const byId = new Map(nodes.map((n) => [n.id, n])); + const findings = []; + + // --- cross_bucket (aggregate by smell key) --- + const crossGroups = new Map(); + for (const e of edges) { + const from = byId.get(e.from); + const to = byId.get(e.to); + if (!from || !to) continue; + const fromPkg = nodePackageKey(from); + const toPkg = nodePackageKey(to); + const fb = architecturePathBucket(fromPkg); + const tb = architecturePathBucket(toPkg); + + let smellKey = ""; + let title = ""; + let summary = ""; + if ((fb === "plugins" || fb === "infra") && tb === "domain") { + smellKey = `cross:${fb}->domain`; + title = `${fb} 依赖 domain 实现`; + summary = `存在 ${fb} → domain 的依赖边,插件/基础设施可能耦合了业务实现。`; + } else { + const fd = architectureDomainName(fromPkg); + const td = architectureDomainName(toPkg); + if (fb === "domain" && tb === "domain" && fd && td && fd !== td) { + smellKey = `cross:domain:${fd}->${td}`; + title = `跨域依赖 ${fd} → ${td}`; + summary = `domain/${fd} 直接依赖 domain/${td},边界可能需要通过接口或共享内核解耦。`; + } + } + if (!smellKey) continue; + + let g = crossGroups.get(smellKey); + if (!g) { + g = { + id: smellKey, + kind: "cross_bucket", + severity: "warn", + title, + summary, + packages: new Set(), + providerIds: [], + edgeCount: 0, + seedId: e.from, + seedPkg: fromPkg, + }; + crossGroups.set(smellKey, g); + } + g.edgeCount += 1; + if (fromPkg) g.packages.add(fromPkg); + if (toPkg) g.packages.add(toPkg); + if (e.from && !g.providerIds.includes(e.from)) g.providerIds.push(e.from); + if (e.to && !g.providerIds.includes(e.to)) g.providerIds.push(e.to); + } + for (const g of crossGroups.values()) { + const pkgs = [...g.packages]; + findings.push({ + id: g.id, + kind: g.kind, + severity: g.severity, + title: g.title, + summary: g.summary + `(${g.edgeCount} 边)`, + evidence: { + packages: pkgs, + providerIds: g.providerIds, + typeIds: [], + edgeCount: g.edgeCount, + }, + action: { + view: "providers", + package: "", + focusProviderId: g.seedId, + keepNeighborhood: true, + }, + }); + } + + // --- super_hub --- + for (const h of rankHubNodes(nodes, edges, 25)) { + if (h.degree < hubDegree) continue; + const node = byId.get(h.id); + if (!node) continue; + const pkg = nodePackageKey(node); + const label = (node.data && node.data.displayLabel) || node.label || h.id; + findings.push({ + id: "hub:" + h.id, + kind: "super_hub", + severity: "warn", + title: `超级枢纽 · ${label}`, + summary: `该节点度数 ${h.degree}(阈值 ${hubDegree}),上下游过密,建议拆分或引入门面。`, + evidence: { + packages: pkg ? [pkg] : [], + providerIds: [h.id], + typeIds: [], + edgeCount: h.degree, + }, + action: { + view: "providers", + package: "", + focusProviderId: h.id, + keepNeighborhood: true, + }, + }); + } + + // --- entry_fanout --- + const idSet = new Set(nodes.map((n) => n.id)); + const indeg = new Map([...idSet].map((id) => [id, 0])); + for (const e of edges) { + if (!idSet.has(e.from) || !idSet.has(e.to)) continue; + indeg.set(e.to, (indeg.get(e.to) || 0) + 1); + } + const entries = nodes.filter((n) => (indeg.get(n.id) || 0) === 0); + if (entries.length >= entryFanout) { + findings.push({ + id: "entry_fanout", + kind: "entry_fanout", + severity: "info", + title: `入口过多 · ${entries.length} 个`, + summary: `未被子依赖消费的入口 provider 有 ${entries.length} 个(阈值 ${entryFanout})。可收敛为业务入口或改用模块地图。`, + evidence: { + packages: [], + providerIds: entries.slice(0, 20).map((n) => n.id), + typeIds: [], + edgeCount: 0, + }, + action: { + view: "modules", + keepNeighborhood: false, + }, + }); + } + + // --- fat_package --- + const pkgCounts = new Map(); + for (const p of providers) { + const pkg = String(p.output_pkg || p.function_pkg || "").trim(); + if (!pkg) continue; + if (!pkgCounts.has(pkg)) pkgCounts.set(pkg, []); + const id = p.id || p.provider_id || p.function_name || p.output_type; + pkgCounts.get(pkg).push(id); + } + for (const [pkg, ids] of pkgCounts.entries()) { + if (ids.length < fatPackage) continue; + const leaf = pkg.split("/").filter(Boolean).slice(-2).join("/") || pkg; + findings.push({ + id: "fat:" + pkg, + kind: "fat_package", + severity: "info", + title: `包过肥 · ${leaf}`, + summary: `${pkg} 含 ${ids.length} 个 provider(阈值 ${fatPackage}),建议按职责拆分子包。`, + evidence: { + packages: [pkg], + providerIds: ids.slice(0, 30), + typeIds: [], + edgeCount: 0, + }, + action: { + view: "providers", + package: pkg, + keepNeighborhood: false, + }, + }); + } + + findings.sort((a, b) => { + const sa = SEVERITY_RANK[a.severity] ?? 9; + const sb = SEVERITY_RANK[b.severity] ?? 9; + if (sa !== sb) return sa - sb; + const ea = (a.evidence && a.evidence.edgeCount) || (a.evidence && a.evidence.providerIds && a.evidence.providerIds.length) || 0; + const eb = (b.evidence && b.evidence.edgeCount) || (b.evidence && b.evidence.providerIds && b.evidence.providerIds.length) || 0; + if (eb !== ea) return eb - ea; + return String(a.id).localeCompare(String(b.id)); + }); + + return findings.slice(0, maxFindings); +} + +/** + * Prefer cross-package and hub-touching edges when the canvas is too dense. + * Same-package peripheral edges are dropped first. + * Never leave a previously-connected node with degree 0 (no orphan isolates). + */ +export function declutterEdges(nodes = [], edges = [], opts = {}) { + const maxEdges = opts.maxEdges ?? 80; + if (!edges.length || edges.length <= maxEdges) { + return { edges, decluttered: false, dropped: 0 }; + } + const byId = new Map(nodes.map((n) => [n.id, n])); + const hubs = new Set(rankHubNodes(nodes, edges, opts.hubLimit ?? 12).map((h) => h.id)); + const scored = edges.map((e) => { + const from = byId.get(e.from); + const to = byId.get(e.to); + const fp = nodePackageKey(from); + const tp = nodePackageKey(to); + const cross = !!(fp && tp && fp !== tp); + const hubTouch = hubs.has(e.from) || hubs.has(e.to); + let score = 0; + if (cross) score += 4; + if (hubTouch) score += 3; + if (e.dashes) score += 1; + return { e, score }; + }); + scored.sort((a, b) => b.score - a.score || String(a.e.from).localeCompare(String(b.e.from))); + const kept = []; + const keptKeys = new Set(); + const degree = new Map(nodes.map((n) => [n.id, 0])); + const pushEdge = (e) => { + const key = e.from + "->" + e.to; + if (keptKeys.has(key)) return; + keptKeys.add(key); + kept.push(e); + degree.set(e.from, (degree.get(e.from) || 0) + 1); + degree.set(e.to, (degree.get(e.to) || 0) + 1); + }; + for (const s of scored) { + if (kept.length >= maxEdges) break; + pushEdge(s.e); + } + // Restore one edge for any node that had connectivity but became isolated. + const originallyConnected = new Set(); + for (const e of edges) { + originallyConnected.add(e.from); + originallyConnected.add(e.to); + } + for (const n of nodes) { + if (!originallyConnected.has(n.id)) continue; + if ((degree.get(n.id) || 0) > 0) continue; + const candidate = scored.find((s) => s.e.from === n.id || s.e.to === n.id); + if (candidate) pushEdge(candidate.e); + } + return { + edges: kept, + decluttered: true, + dropped: Math.max(0, edges.length - kept.length), + }; +} + +/** Build a package-scoped inventory for the summary card (not canvas nodes). */ +export function buildPackageSummary(allData = {}, pkgName = "") { + const pkg = String(pkgName || "").trim(); + if (!pkg || !allData) return { providers: [], types: [] }; + const list = (allData.providers || []).filter((p) => { + const out = p.output_pkg || ""; + const fn = p.function_pkg || ""; + if (out === pkg || fn === pkg) return true; + if (!pkg.includes("/") && (out.endsWith("/" + pkg) || fn.endsWith("/" + pkg))) return true; + return false; + }); + const types = []; + const seen = new Set(); + for (const p of list) { + const pkgPath = p.output_pkg || p.function_pkg || ""; + for (const t of (p.output_types && p.output_types.length ? p.output_types : [p.output_type])) { + if (!t) continue; + const id = typeNodeIdentity(t, pkgPath); + if (!id || seen.has(id)) continue; + seen.add(id); + types.push({ id, fullType: t, packagePath: pkgPath, label: labelTypeWithPackage(t, pkgPath) }); + } + } + return { + providers: list.map((p) => ({ + id: p.id, + function_name: p.function_name, + output_type: p.output_type, + output_pkg: p.output_pkg || p.function_pkg || "", + label: providerDisplayLabel(p), + })), + types, + }; +} + +if (typeof window !== "undefined") { + window.DIXGraphState = { + resolveGraphMode, applyGraphBudget, READABLE_NODE_CAP, shortGraphLabel, labelTypeWithPackage, providerDisplayLabel, labelLodVisibleIds, + providerSymbolLabel, dixNamespaceLeaf, + HIERARCHICAL_NODE_BUDGET, HIERARCHICAL_EDGE_BUDGET, + resolveEffectiveLayout, resolveCameraStrategy, layoutStarPositions, assessLayoutMetrics, pickFocusNodeId, + estimateHierarchicalStripRisk, resolvePostFitCamera, MIN_READABLE_FIT_SCALE, + buildProviderInventory, buildProviderDependencyGraph, assignProviderPyramidLevels, + truncateProviderPyramid, providerRelatedSubgraph, classifyRelatedRoles, buildProvidersPyramidView, + buildTypeDependencyGraph, buildTypesPyramidView, + collectDownstreamNodeIds, expandHiddenSeedsToNodeIds, applyHiddenNodeSeeds, + encodeHiddenSeedsForUrl, decodeHiddenSeedsFromUrl, + typeNodeIdentity, createLoadGuard, rankHubNodes, buildModuleMapGraph, disperseModulesByProviders, + expandPackageHierarchy, providerPackageSuffix, providerPackageBucket, nodePackageKey, declutterEdges, buildPackageSummary, + architecturePathBucket, architectureDomainName, buildArchitectureFindings, + FINDINGS_HUB_DEGREE, FINDINGS_ENTRY_FANOUT, FINDINGS_FAT_PACKAGE, FINDINGS_MAX, + issueGraphHash, issueTraceHash, matchTraceRecord, filterTraceRecords, + }; +} diff --git a/dixhttp/static/js/graph_view.test.mjs b/dixhttp/static/js/graph_view.test.mjs new file mode 100644 index 0000000..fa9607b --- /dev/null +++ b/dixhttp/static/js/graph_view.test.mjs @@ -0,0 +1,811 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +test("graph defaults to module map instead of empty ego", async () => { + const { resolveGraphMode } = await import("./graph_state.mjs"); + assert.equal(resolveGraphMode(new URLSearchParams()), "modules"); + assert.equal(resolveGraphMode(new URLSearchParams("mode=ego")), "ego"); +}); + +test("graphs degrade to table metadata above explicit budgets", async () => { + const { applyGraphBudget } = await import("./graph_state.mjs"); + const nodes = Array.from({ length: 101 }, (_, i) => ({ id: String(i) })); + const edges = Array.from({ length: 301 }, (_, i) => ({ from: String(i % 100), to: String((i + 1) % 100) })); + const result = applyGraphBudget(nodes, edges, { nodes: 100, edges: 300 }); + assert.equal(result.nodes.length, 100); + assert.equal(result.edges.length, 300); + assert.equal(result.degraded, true); +}); + +test("stale graph loads are rejected by sequence", async () => { + const { createLoadGuard } = await import("./graph_state.mjs"); + const guard = createLoadGuard(); + const first = guard.begin(); + const second = guard.begin(); + assert.equal(guard.isCurrent(first.seq), false); + assert.equal(guard.isCurrent(second.seq), true); +}); + +test("issues have deterministic graph and trace links", async () => { + const { issueGraphHash, issueTraceHash } = await import("./graph_state.mjs"); + const issue = { + output_type: "*app.Service", + provider: "app.NewService", + module: "app/service", + }; + assert.equal(issueGraphHash(issue), "#/graph?mode=ego¢er=*app.Service"); + assert.equal(issueGraphHash({ ...issue, output_type: "" }), "#/graph?mode=module&module=app%2Fservice"); + assert.equal(issueTraceHash(issue), "#/trace?provider=app.NewService&output_type=*app.Service&status=error"); + assert.equal( + issueTraceHash({ ...issue, severity: "slow" }), + "#/trace?provider=app.NewService&output_type=*app.Service&status=slow" + ); + assert.equal( + issueTraceHash({ ...issue, trace_id: "abc123" }), + "#/trace?trace_id=abc123&provider=app.NewService&output_type=*app.Service&status=error" + ); +}); + +test("resolveGraphMode accepts module drilldown", async () => { + const { resolveGraphMode } = await import("./graph_state.mjs"); + assert.equal(resolveGraphMode(new URLSearchParams("mode=module")), "module"); +}); + +test("rankHubNodes returns highest-degree nodes first", async () => { + const { rankHubNodes } = await import("./graph_state.mjs"); + const nodes = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const edges = [ + { from: "a", to: "b" }, + { from: "a", to: "c" }, + { from: "b", to: "c" }, + ]; + assert.deepEqual(rankHubNodes(nodes, edges, 2).map((h) => h.id), ["a", "b"]); +}); + +test("filterTraceRecords keeps matching provider and status", async () => { + const { filterTraceRecords } = await import("./graph_state.mjs"); + const records = [ + { trace_id: "aa", provider_function: "app.NewA", output_type: "*A", status: "error" }, + { trace_id: "bb", provider_function: "app.NewB", output_type: "*B", status: "ok" }, + { trace_id: "cc", provider_function: "app.NewA", output_type: "*A", status: "ok" }, + ]; + const filtered = filterTraceRecords(records, { + provider: "app.NewA", + output_type: "*A", + status: "error", + }); + assert.deepEqual(filtered.map((r) => r.trace_id), ["aa"]); +}); + +test("provider drawer can build trace hash from node identity", async () => { + const { issueTraceHash } = await import("./graph_state.mjs"); + assert.equal( + issueTraceHash({ provider: "app.NewService", output_type: "*app.Service", severity: "error" }), + "#/trace?provider=app.NewService&output_type=*app.Service&status=error" + ); +}); + +test("explicit hierarchical layout is never auto-swapped", async () => { + const { resolveEffectiveLayout, READABLE_NODE_CAP } = await import("./graph_state.mjs"); + assert.equal(resolveEffectiveLayout("hierarchical", READABLE_NODE_CAP), "hierarchical"); + assert.equal(resolveEffectiveLayout("hierarchical", READABLE_NODE_CAP + 50), "hierarchical"); + assert.equal(resolveEffectiveLayout("hierarchical", 20, { stripRisk: { wide: true } }), "hierarchical"); + assert.equal(resolveEffectiveLayout("physics", 5), "physics"); + assert.equal(resolveEffectiveLayout("auto", 10), "hierarchical"); + assert.equal(resolveEffectiveLayout("auto", READABLE_NODE_CAP + 5), "physics"); +}); +test("estimateHierarchicalStripRisk flags many roots", async () => { + const { estimateHierarchicalStripRisk } = await import("./graph_state.mjs"); + const nodes = Array.from({ length: 10 }, (_, i) => ({ id: "n" + i })); + const edges = []; // all roots / islands + const risk = estimateHierarchicalStripRisk(nodes, edges); + assert.equal(risk.wide, true); + assert.ok(risk.roots >= 6); + + const chainNodes = [ + { id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }, + { id: "e" }, { id: "f" }, { id: "g" }, { id: "h" }, + ]; + const chainEdges = [ + { from: "a", to: "b" }, { from: "b", to: "c" }, { from: "c", to: "d" }, + { from: "d", to: "e" }, { from: "e", to: "f" }, { from: "f", to: "g" }, + { from: "g", to: "h" }, + ]; + assert.equal(estimateHierarchicalStripRisk(chainNodes, chainEdges).wide, false); +}); + +test("hierarchical soft budget constants are larger than display soft-cap", async () => { + const { READABLE_NODE_CAP, HIERARCHICAL_NODE_BUDGET, HIERARCHICAL_EDGE_BUDGET } = await import("./graph_state.mjs"); + assert.ok(HIERARCHICAL_NODE_BUDGET > READABLE_NODE_CAP); + assert.ok(HIERARCHICAL_EDGE_BUDGET >= HIERARCHICAL_NODE_BUDGET); +}); +test("display soft-cap keeps graphs readable", async () => { + const { applyGraphBudget, READABLE_NODE_CAP } = await import("./graph_state.mjs"); + const nodes = Array.from({ length: 80 }, (_, i) => ({ id: String(i) })); + const edges = Array.from({ length: 120 }, (_, i) => ({ from: String(i % 80), to: String((i + 1) % 80) })); + const result = applyGraphBudget(nodes, edges, { nodes: READABLE_NODE_CAP, edges: READABLE_NODE_CAP * 3 }); + assert.ok(result.nodes.length <= READABLE_NODE_CAP); + assert.equal(result.degraded, true); +}); + +test("pickFocusNodeId prefers explicit center then hub", async () => { + const { pickFocusNodeId } = await import("./graph_state.mjs"); + const nodes = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const edges = [ + { from: "a", to: "b" }, + { from: "a", to: "c" }, + ]; + assert.equal(pickFocusNodeId(nodes, edges, "c"), "c"); + assert.equal(pickFocusNodeId(nodes, edges, ""), "a"); +}); + +test("shortGraphLabel shortens package paths and types", async () => { + const { shortGraphLabel } = await import("./graph_state.mjs"); + assert.equal(shortGraphLabel("github.com/pubgo/dix/example/http/domain/billing"), "domain/billing"); + assert.equal(shortGraphLabel("*billing.Config"), "billing.Config"); + assert.equal(shortGraphLabel("main"), "main"); + assert.equal( + shortGraphLabel("*main.Worker[github.com/pubgo/dix/example/http.RoleAuthReader]"), + "Worker[http.RoleAuthReader]" + ); + assert.equal( + shortGraphLabel("*github.com/pubgo/dix/example/http/domain/analytics.Handler"), + "analytics.Handler" + ); +}); + +test("providerDisplayLabel prefers output type over anonymous func names", async () => { + const { providerDisplayLabel } = await import("./graph_state.mjs"); + assert.equal( + providerDisplayLabel({ + function_name: "github.com/pubgo/dix/example/http/domain/analytics.Providers.func6", + output_type: "*github.com/pubgo/dix/example/http/domain/analytics.Handler", + output_pkg: "github.com/pubgo/dix/example/http/domain/analytics", + }), + "domain/analytics.Handler" + ); + assert.equal( + providerDisplayLabel({ + function_name: "main.registerWorker[...].func1", + output_type: "*main.Worker[github.com/pubgo/dix/example/http.RoleAuthReader]", + }), + "Worker[http.RoleAuthReader]" + ); + assert.equal( + providerDisplayLabel({ function_name: "main.buildContainer.func4", output_type: "*main.Application" }), + "main.Application" + ); +}); + +test("providerDisplayLabel disambiguates shared layer package names", async () => { + const { providerDisplayLabel } = await import("./graph_state.mjs"); + assert.equal( + providerDisplayLabel({ + function_name: "handler.Provide.func1", + output_type: "*handler.Handler", + output_pkg: "github.com/pubgo/dix/example/http/domain/billing/handler", + }), + "billing/handler.Handler" + ); + assert.equal( + providerDisplayLabel({ + function_name: "handler.Provide.func1", + output_type: "*handler.Handler", + output_pkg: "github.com/pubgo/dix/example/http/domain/inventory/handler", + }), + "inventory/handler.Handler" + ); + assert.equal( + providerDisplayLabel({ + function_name: "service.Provide.func1", + output_type: "*service.Service", + output_pkg: "github.com/pubgo/dix/example/http/domain/billing/service", + }), + "billing/service.Service" + ); +}); + +test("labelLodVisibleIds keeps hubs when zoomed out", async () => { + const { labelLodVisibleIds } = await import("./graph_state.mjs"); + const nodes = [{ id: "hub" }, { id: "a" }, { id: "b" }, { id: "c" }]; + const edges = [ + { from: "hub", to: "a" }, + { from: "hub", to: "b" }, + { from: "hub", to: "c" }, + ]; + const zoomedOut = labelLodVisibleIds(nodes, edges, 0.4, { hubLimit: 2 }); + assert.equal(zoomedOut.has("hub"), true); + assert.equal(zoomedOut.size <= 2, true); + const zoomedIn = labelLodVisibleIds(nodes, edges, 1.0, { hubLimit: 2 }); + assert.equal(zoomedIn.size, 4); +}); + +test("module maps prefer fit camera; dense graphs prefer focus", async () => { + const { resolveCameraStrategy } = await import("./graph_state.mjs"); + assert.equal(resolveCameraStrategy("modules", 11), "fit"); + assert.equal(resolveCameraStrategy("ego", 40), "fit"); + assert.equal(resolveCameraStrategy("providers", 8), "fit"); +}); + +test("assessLayoutMetrics rejects thin-strip layouts", async () => { + const { assessLayoutMetrics } = await import("./graph_state.mjs"); + const thin = {}; + for (let i = 0; i < 10; i++) thin["n" + i] = { x: i * 40, y: 0 }; + assert.equal(assessLayoutMetrics(thin).ok, false); + assert.equal(assessLayoutMetrics(thin).thin, true); + + const spread = { + a: { x: 0, y: 0 }, + b: { x: 200, y: 0 }, + c: { x: 0, y: 200 }, + d: { x: 200, y: 200 }, + e: { x: 100, y: 100 }, + }; + assert.equal(assessLayoutMetrics(spread).ok, true); +}); + +test("buildModuleMapGraph uses modules not objects as nodes", async () => { + const { buildModuleMapGraph } = await import("./graph_state.mjs"); + const { nodes, edges } = buildModuleMapGraph([ + { name: "app/a", provider_count: 2, object_count: 5, depends_on: ["app/b"] }, + { name: "app/b", provider_count: 1, object_count: 1, depends_on: [] }, + ], { hierarchy: false }); + assert.equal(nodes.length, 2); + assert.equal(edges.length, 1); + assert.equal(nodes[0].data.type, "module"); + assert.match(nodes[0].label, /2p\/5o/); +}); + +test("buildModuleMapGraph expands package hierarchy and disperses fat packages", async () => { + const { buildModuleMapGraph } = await import("./graph_state.mjs"); + const modules = [ + { + name: "github.com/pubgo/dix/example/http", + provider_count: 30, + object_count: 30, + depends_on: ["github.com/pubgo/dix/example/http/domain/analytics"], + }, + { + name: "github.com/pubgo/dix/example/http/domain/analytics", + provider_count: 7, + object_count: 8, + depends_on: [], + }, + ]; + const providers = [ + { output_type: "*main.Application", output_pkg: "github.com/pubgo/dix/example/http" }, + { output_type: "*main.PluginPlatform", output_pkg: "github.com/pubgo/dix/example/http" }, + ...Array.from({ length: 20 }, (_, i) => ({ + output_type: `*main.Worker[github.com/pubgo/dix/example/http.Role${i}]`, + output_pkg: "github.com/pubgo/dix/example/http", + })), + ...Array.from({ length: 8 }, (_, i) => ({ + output_type: `*main.Plugin[github.com/pubgo/dix/example/http.Role${i}]`, + output_pkg: "github.com/pubgo/dix/example/http", + })), + { + output_type: "*github.com/pubgo/dix/example/http/domain/analytics.Handler", + output_pkg: "github.com/pubgo/dix/example/http/domain/analytics", + }, + ]; + const g = buildModuleMapGraph(modules, { providers, minDepth: 5, fatThreshold: 12 }); + const maxLevel = Math.max(...g.nodes.map((n) => n.level || 1)); + assert.ok(maxLevel >= 3, `maxLevel=${maxLevel}`); + // Must stay readable: not hundreds of micro-leaves / thousand depends edges. + assert.ok(g.nodes.length < 40, `nodes=${g.nodes.length}`); + assert.ok(g.edges.length < 80, `edges=${g.edges.length}`); + assert.equal(g.edges.every((e) => e.data && e.data.kind === "containment"), true); + assert.equal(g.nodes.every((n) => n.data.type === "module"), true); + // Fat main should be split into coarse buckets, not one blob. + const buckets = g.nodes.filter((n) => n.data.leaf && /\/(app|plugins|workers|diag|other)$/.test(n.id)); + assert.ok(buckets.length >= 2, `bucket leaves=${buckets.map((n) => n.id)}`); +}); + +test("layoutStarPositions spreads modules around a hub", async () => { + const { layoutStarPositions, assessLayoutMetrics } = await import("./graph_state.mjs"); + const nodes = [ + { id: "main" }, + { id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }, { id: "e" }, + ]; + const edges = [ + { from: "main", to: "a" }, + { from: "main", to: "b" }, + { from: "main", to: "c" }, + { from: "main", to: "d" }, + { from: "main", to: "e" }, + ]; + const positions = layoutStarPositions(nodes, edges); + assert.deepEqual(positions.main, { x: 0, y: 0 }); + assert.equal(Object.keys(positions).length, 6); + assert.equal(assessLayoutMetrics(positions).ok, true); +}); + +test("aggregateByGroups collapses matching providers", async () => { + const { aggregateByGroups, matchGroup, filterByPrefix, buildMermaidSource } = await import("./graph_workbench.mjs"); + const rules = [{ name: "billing", prefixes: ["billing"] }]; + assert.equal(matchGroup("billing.Service", "example/billing", rules), "billing"); + const nodes = [ + { id: "p1", data: { kind: "provider", packagePath: "example/billing" } }, + { id: "p2", data: { kind: "provider", packagePath: "example/billing" } }, + { id: "t1", data: { kind: "type", packagePath: "example/other" } }, + ]; + const edges = [ + { from: "p1", to: "t1" }, + { from: "p2", to: "t1" }, + ]; + const agg = aggregateByGroups(nodes, edges, { enabled: true, groupRules: rules }); + assert.equal(agg.nodes.some((n) => n.id === "group:billing"), true); + assert.equal(agg.nodes.some((n) => n.id === "p1"), false); + const filtered = filterByPrefix(nodes, edges, "billing"); + assert.deepEqual(filtered.nodes.map((n) => n.id).sort(), ["p1", "p2"]); + assert.match(buildMermaidSource(agg.nodes, agg.edges), /flowchart TD/); +}); + +test("declutterEdges keeps cross-package and hub edges first", async () => { + const { declutterEdges } = await import("./graph_state.mjs"); + const nodes = [ + { id: "a.X", data: { packagePath: "pkg/a" } }, + { id: "b.Y", data: { packagePath: "pkg/b" } }, + { id: "a.Z", data: { packagePath: "pkg/a" } }, + { id: "a.W", data: { packagePath: "pkg/a" } }, + ]; + const edges = [ + { from: "a.X", to: "b.Y" }, // cross + { from: "a.X", to: "a.Z" }, + { from: "a.Z", to: "a.W" }, + { from: "a.W", to: "a.X" }, + { from: "a.Z", to: "a.X" }, + ]; + const result = declutterEdges(nodes, edges, { maxEdges: 2, hubLimit: 2 }); + assert.equal(result.decluttered, true); + assert.equal(result.edges.some((e) => e.from === "a.X" && e.to === "b.Y"), true); + // May keep > maxEdges to avoid orphaning nodes that were previously connected. + assert.equal(result.edges.length >= 2, true); + for (const id of ["a.X", "b.Y", "a.Z", "a.W"]) { + assert.equal(result.edges.some((e) => e.from === id || e.to === id), true); + } +}); + +test("buildPackageSummary lists providers for OutputPkg", async () => { + const { buildPackageSummary, typeNodeIdentity } = await import("./graph_state.mjs"); + const summary = buildPackageSummary({ + providers: [ + { id: "p1", function_name: "analytics.New", output_type: "*analytics.Client", output_pkg: "domain/analytics" }, + { id: "p2", function_name: "billing.New", output_type: "*billing.Client", output_pkg: "domain/billing" }, + ], + }, "domain/analytics"); + assert.equal(summary.providers.length, 1); + assert.equal(summary.types[0].id, typeNodeIdentity("*analytics.Client", "domain/analytics")); + assert.equal(summary.types[0].fullType, "*analytics.Client"); +}); + +test("buildProviderInventory lists all providers when unscoped", async () => { + const { buildProviderInventory } = await import("./graph_state.mjs"); + const inv = buildProviderInventory({ + providers: [ + { id: "p1", function_name: "a.New", output_type: "*a.T", output_pkg: "pkg/a" }, + { id: "p2", function_name: "b.New", output_type: "*b.T", output_pkg: "pkg/b" }, + ], + }); + assert.equal(inv.providers.length, 2); + assert.ok(inv.types.length >= 2); +}); + +test("buildProviderDependencyGraph is provider-only with depends-on edges", async () => { + const { buildProviderDependencyGraph } = await import("./graph_state.mjs"); + const g = buildProviderDependencyGraph([ + { id: "leaf", function_name: "leaf.New", output_type: "*leaf.T", output_pkg: "pkg/leaf", input_types: [] }, + { + id: "mid", function_name: "mid.New", output_type: "*mid.T", output_pkg: "pkg/mid", + input_types: ["*leaf.T"], + }, + { + id: "app", function_name: "app.New", output_type: "*app.T", output_pkg: "pkg/app", + input_types: ["*mid.T"], + }, + ]); + assert.deepEqual(g.nodes.map((n) => n.id).sort(), ["app", "leaf", "mid"]); + assert.equal(g.nodes.every((n) => n.data.type === "provider"), true); + assert.equal(g.edges.some((e) => e.from === "app" && e.to === "mid"), true); + assert.equal(g.edges.some((e) => e.from === "mid" && e.to === "leaf"), true); + assert.equal(g.edges.some((e) => e.from === "leaf"), false); +}); + +test("assignProviderPyramidLevels puts unconsumed entries at level 1", async () => { + const { buildProviderDependencyGraph, assignProviderPyramidLevels } = await import("./graph_state.mjs"); + const g = buildProviderDependencyGraph([ + { id: "leaf", output_type: "*leaf.T", input_types: [] }, + { id: "mid", output_type: "*mid.T", input_types: ["*leaf.T"] }, + { id: "app", output_type: "*app.T", input_types: ["*mid.T"] }, + ]); + const levels = assignProviderPyramidLevels(g.nodes, g.edges); + assert.equal(levels.get("app"), 1); + assert.equal(levels.get("mid"), 2); + assert.equal(levels.get("leaf"), 3); +}); + +test("truncateProviderPyramid keeps only levels up to depth", async () => { + const { buildProviderDependencyGraph, assignProviderPyramidLevels, truncateProviderPyramid } = await import("./graph_state.mjs"); + const g = buildProviderDependencyGraph([ + { id: "leaf", output_type: "*leaf.T", input_types: [] }, + { id: "mid", output_type: "*mid.T", input_types: ["*leaf.T"] }, + { id: "app", output_type: "*app.T", input_types: ["*mid.T"] }, + ]); + const levels = assignProviderPyramidLevels(g.nodes, g.edges); + const cut = truncateProviderPyramid(g.nodes, g.edges, levels, 2); + assert.deepEqual(cut.nodes.map((n) => n.id).sort(), ["app", "mid"]); + assert.equal(cut.edges.length, 1); + assert.equal(cut.edges[0].from, "app"); +}); + +test("providerRelatedSubgraph keeps upstream and downstream of seeds", async () => { + const { buildProviderDependencyGraph, providerRelatedSubgraph } = await import("./graph_state.mjs"); + const g = buildProviderDependencyGraph([ + { id: "leaf", output_type: "*leaf.T", output_pkg: "pkg/leaf", input_types: [] }, + { id: "mid", output_type: "*mid.T", output_pkg: "pkg/mid", input_types: ["*leaf.T"] }, + { id: "app", output_type: "*app.T", output_pkg: "pkg/app", input_types: ["*mid.T"] }, + { id: "other", output_type: "*other.T", output_pkg: "pkg/other", input_types: [] }, + ]); + const sub = providerRelatedSubgraph(g.nodes, g.edges, ["mid"]); + assert.deepEqual(sub.nodes.map((n) => n.id).sort(), ["app", "leaf", "mid"]); +}); + +test("buildProvidersPyramidView scopes package and applies depth", async () => { + const { buildProvidersPyramidView } = await import("./graph_state.mjs"); + const providers = [ + { id: "leaf", function_name: "leaf.New", output_type: "*leaf.T", output_pkg: "pkg/leaf", input_types: [] }, + { + id: "mid", function_name: "mid.New", output_type: "*mid.T", output_pkg: "pkg/mid", + input_types: ["*leaf.T"], + }, + { + id: "app", function_name: "app.New", output_type: "*app.T", output_pkg: "pkg/app", + input_types: ["*mid.T"], + }, + { + id: "orphan", function_name: "orphan.New", output_type: "*orphan.T", output_pkg: "pkg/other", + input_types: [], + }, + ]; + const full = buildProvidersPyramidView(providers, { depth: 2 }); + assert.equal(full.nodes.some((n) => n.id === "leaf"), false); + assert.equal(full.nodes.some((n) => n.id === "app"), true); + assert.equal(full.entries.length >= 1, true); + assert.equal(full.nodes.find((n) => n.id === "app").level, 1); + + const scoped = buildProvidersPyramidView(providers, { depth: 0, packageName: "pkg/mid" }); + assert.deepEqual(scoped.nodes.map((n) => n.id).sort(), ["app", "leaf", "mid"]); +}); + +test("buildProviderDependencyGraph matches by input_pkgs when type names collide", async () => { + const { buildProviderDependencyGraph } = await import("./graph_state.mjs"); + const g = buildProviderDependencyGraph([ + { + id: "bh", output_type: "*handler.Handler", output_pkg: "domain/billing/handler", + input_types: ["*service.Service"], input_pkgs: ["domain/billing/service"], + }, + { + id: "ih", output_type: "*handler.Handler", output_pkg: "domain/inventory/handler", + input_types: ["*service.Service"], input_pkgs: ["domain/inventory/service"], + }, + { id: "bs", output_type: "*service.Service", output_pkg: "domain/billing/service", input_types: [] }, + { id: "is", output_type: "*service.Service", output_pkg: "domain/inventory/service", input_types: [] }, + ]); + assert.equal(g.edges.some((e) => e.from === "bh" && e.to === "bs"), true); + assert.equal(g.edges.some((e) => e.from === "ih" && e.to === "is"), true); + assert.equal(g.edges.some((e) => e.from === "bh" && e.to === "is"), false); + assert.equal(g.edges.some((e) => e.from === "ih" && e.to === "bs"), false); +}); + +test("declutterEdges does not orphan previously connected nodes", async () => { + const { declutterEdges } = await import("./graph_state.mjs"); + const nodes = [ + { id: "hub", data: { packagePath: "pkg/hub" } }, + { id: "a", data: { packagePath: "pkg/a" } }, + { id: "b", data: { packagePath: "pkg/b" } }, + { id: "w1", data: { packagePath: "pkg/plugins" } }, + { id: "w2", data: { packagePath: "pkg/plugins" } }, + ]; + const edges = [ + { from: "hub", to: "a" }, + { from: "hub", to: "b" }, + { from: "hub", to: "w1" }, + { from: "hub", to: "w2" }, + { from: "a", to: "b" }, + ]; + const cleaned = declutterEdges(nodes, edges, { maxEdges: 2, hubLimit: 2 }); + const degree = (id) => cleaned.edges.filter((e) => e.from === id || e.to === id).length; + assert.equal(degree("w1") > 0 || degree("w2") > 0 || cleaned.edges.length >= 2, true); + for (const id of ["hub", "a", "b", "w1", "w2"]) { + const had = edges.some((e) => e.from === id || e.to === id); + if (had) assert.equal(degree(id) > 0, true, `${id} should not be orphaned`); + } +}); + +test("applyHiddenNodeSeeds removes seed and downstream only", async () => { + const { applyHiddenNodeSeeds } = await import("./graph_state.mjs"); + const nodes = [{ id: "app" }, { id: "platform" }, { id: "worker" }, { id: "plugin" }, { id: "handler" }]; + const edges = [ + { from: "app", to: "platform" }, + { from: "app", to: "handler" }, + { from: "platform", to: "worker" }, + { from: "worker", to: "plugin" }, + ]; + const cut = applyHiddenNodeSeeds(nodes, edges, ["platform"]); + assert.deepEqual(cut.nodes.map((n) => n.id).sort(), ["app", "handler"]); + assert.equal(cut.edges.some((e) => e.from === "app" && e.to === "handler"), true); + assert.equal(cut.edges.some((e) => e.to === "platform" || e.from === "platform"), false); + assert.equal(cut.hiddenIds.has("plugin"), true); +}); + +test("applyHiddenNodeSeeds supports packagePrefix seeds", async () => { + const { applyHiddenNodeSeeds, expandHiddenSeedsToNodeIds } = await import("./graph_state.mjs"); + const nodes = [ + { id: "p1", data: { packagePath: "example/http/plugins/auth" } }, + { id: "p2", data: { packagePath: "example/http/plugins/cache" } }, + { id: "h1", data: { packagePath: "example/http/domain/billing/handler" } }, + { id: "app", data: { packagePath: "example/http/app" } }, + ]; + const edges = [ + { from: "app", to: "p1" }, + { from: "app", to: "h1" }, + { from: "p1", to: "p2" }, + ]; + const ids = expandHiddenSeedsToNodeIds(nodes, [{ packagePrefix: "example/http/plugins" }]); + assert.deepEqual(ids.sort(), ["p1", "p2"]); + const cut = applyHiddenNodeSeeds(nodes, edges, [{ packagePrefix: "example/http/plugins", id: "pkg:example/http/plugins" }]); + assert.deepEqual(cut.nodes.map((n) => n.id).sort(), ["app", "h1"]); +}); + +test("applyHiddenNodeSeeds exact seeds hide only that node", async () => { + const { applyHiddenNodeSeeds } = await import("./graph_state.mjs"); + const nodes = [{ id: "app" }, { id: "svc" }, { id: "noise" }]; + const edges = [ + { from: "app", to: "svc" }, + { from: "noise", to: "svc" }, + ]; + const cut = applyHiddenNodeSeeds(nodes, edges, [{ id: "noise", exact: true }]); + assert.deepEqual(cut.nodes.map((n) => n.id).sort(), ["app", "svc"]); + assert.equal(cut.hiddenIds.has("noise"), true); + assert.equal(cut.hiddenIds.has("svc"), false); +}); + +test("encode/decode exact hide seeds for URL", async () => { + const { encodeHiddenSeedsForUrl, decodeHiddenSeedsFromUrl } = await import("./graph_state.mjs"); + const encoded = encodeHiddenSeedsForUrl([{ id: "n1", exact: true, label: "n1" }]); + assert.equal(encoded, "exact:n1"); + const decoded = decodeHiddenSeedsFromUrl(encoded); + assert.equal(decoded[0].exact, true); + assert.equal(decoded[0].id, "n1"); +}); + +test("encode/decode hidden seeds for URL round-trip", async () => { + const { encodeHiddenSeedsForUrl, decodeHiddenSeedsFromUrl } = await import("./graph_state.mjs"); + const seeds = [ + { id: "prov-1", label: "App" }, + { id: "pkg:example/http/plugins", label: "…/plugins", packagePrefix: "example/http/plugins" }, + ]; + const encoded = encodeHiddenSeedsForUrl(seeds); + assert.equal(encoded, "prov-1|pkg:example/http/plugins"); + const decoded = decodeHiddenSeedsFromUrl(encoded); + assert.equal(decoded.length, 2); + assert.equal(decoded[0].id, "prov-1"); + assert.equal(decoded[1].packagePrefix, "example/http/plugins"); + assert.equal(decoded[1].id, "pkg:example/http/plugins"); + assert.deepEqual(decodeHiddenSeedsFromUrl(""), []); + assert.deepEqual(decodeHiddenSeedsFromUrl(null), []); +}); + +test("providerDisplayLabel uses plugin Provide symbol for shared Worker type", async () => { + const { providerDisplayLabel } = await import("./graph_state.mjs"); + assert.equal( + providerDisplayLabel({ + output_type: "plugins.Worker", + output_pkg: "github.com/pubgo/dix/example/http/plugins", + function_pkg: "github.com/pubgo/dix/example/http/plugins/auth", + function_name: "github.com/pubgo/dix/example/http/plugins/auth.Provide.func2", + }), + "auth.Provide → Worker" + ); +}); + +test("providerDisplayLabel prefers Provide symbol for plugin map providers", async () => { + const { providerDisplayLabel } = await import("./graph_state.mjs"); + assert.equal( + providerDisplayLabel({ + output_type: "map[string]github.com/pubgo/dix/example/http/plugins.Worker", + output_pkg: "github.com/pubgo/dix/example/http/plugins", + function_pkg: "github.com/pubgo/dix/example/http/plugins/vault", + function_name: "github.com/pubgo/dix/example/http/plugins/vault.Provide.func2", + }), + "vault.Provide → Worker" + ); + assert.equal( + providerDisplayLabel({ + output_type: "map[string]github.com/pubgo/dix/example/http/plugins.Plugin", + output_pkg: "github.com/pubgo/dix/example/http/plugins", + function_pkg: "github.com/pubgo/dix/example/http/plugins/login", + function_name: "github.com/pubgo/dix/example/http/plugins/login.Provide.func1", + }), + "login.Provide → Plugin" + ); +}); + +test("classifyRelatedRoles marks seed dependency and dependent", async () => { + const { classifyRelatedRoles } = await import("./graph_state.mjs"); + const nodes = [{ id: "app" }, { id: "svc" }, { id: "repo" }, { id: "other" }]; + const edges = [ + { from: "app", to: "svc" }, + { from: "svc", to: "repo" }, + { from: "other", to: "svc" }, + ]; + const roles = classifyRelatedRoles(nodes, edges, ["svc"]); + assert.equal(roles.get("svc"), "seed"); + assert.equal(roles.get("repo"), "dependency"); + assert.equal(roles.get("app"), "dependent"); + assert.equal(roles.get("other"), "dependent"); +}); + +test("buildTypesPyramidView is type-only with entry types at level 1", async () => { + const { buildTypesPyramidView, typeNodeIdentity } = await import("./graph_state.mjs"); + const appId = typeNodeIdentity("*app.T", "pkg/app"); + const midId = typeNodeIdentity("*mid.T", "pkg/mid"); + const view = buildTypesPyramidView({ + providers: [ + { output_type: "*leaf.T", output_pkg: "pkg/leaf", input_types: [] }, + { + output_type: "*mid.T", + output_pkg: "pkg/mid", + input_types: ["*leaf.T"], + input_pkgs: ["pkg/leaf"], + }, + { + output_type: "*app.T", + output_pkg: "pkg/app", + input_types: ["*mid.T"], + input_pkgs: ["pkg/mid"], + }, + ], + }, { depth: 2 }); + assert.equal(view.nodes.every((n) => n.data.type === "type"), true); + assert.equal(view.nodes.find((n) => n.id === appId).level, 1); + assert.equal(view.nodes.find((n) => n.id === midId).level, 2); + assert.equal(view.nodes.some((n) => n.data.fullType === "*leaf.T"), false); + assert.equal(view.edges.some((e) => e.from === appId && e.to === midId), true); +}); + +test("buildTypeDependencyGraph keeps same short type from different packages", async () => { + const { buildTypeDependencyGraph } = await import("./graph_state.mjs"); + const { nodes, edges } = buildTypeDependencyGraph({ + providers: [ + { + output_type: "*handler.Handler", + output_pkg: "example/http/domain/billing/handler", + input_types: ["*service.Service"], + input_pkgs: ["example/http/domain/billing/service"], + }, + { + output_type: "*service.Service", + output_pkg: "example/http/domain/billing/service", + input_types: [], + input_pkgs: [], + }, + { + output_type: "*handler.Handler", + output_pkg: "example/http/domain/inventory/handler", + input_types: ["*service.Service"], + input_pkgs: ["example/http/domain/inventory/service"], + }, + { + output_type: "*service.Service", + output_pkg: "example/http/domain/inventory/service", + input_types: [], + input_pkgs: [], + }, + { + output_type: "*app.Application", + output_pkg: "example/http/app", + input_types: ["*handler.Handler", "*handler.Handler"], + input_pkgs: [ + "example/http/domain/billing/handler", + "example/http/domain/inventory/handler", + ], + }, + ], + }); + const handlers = nodes.filter((n) => n.data.fullType === "*handler.Handler"); + assert.equal(handlers.length, 2); + assert.equal(handlers.some((n) => n.label.includes("billing")), true); + assert.equal(handlers.some((n) => n.label.includes("inventory")), true); + const services = nodes.filter((n) => n.data.fullType === "*service.Service"); + assert.equal(services.length, 2); + assert.ok(nodes.length >= 5); + assert.ok(edges.length >= 4); +}); + +test("buildArchitectureFindings detects cross-bucket domain edges and hubs", async () => { + const { buildArchitectureFindings } = await import("./graph_state.mjs"); + const findings = buildArchitectureFindings({ + providers: [ + { + id: "p_plugin", + function_name: "plugins/auth.Provide", + output_type: "*auth.Plugin", + output_pkg: "example/http/plugins/auth", + input_types: ["*billing.Service"], + input_pkgs: ["example/http/domain/billing/service"], + }, + { + id: "p_billing", + function_name: "billing/service.Provide", + output_type: "*billing.Service", + output_pkg: "example/http/domain/billing/service", + input_types: [], + input_pkgs: [], + }, + { + id: "p_inv", + function_name: "inventory/service.Provide", + output_type: "*inventory.Service", + output_pkg: "example/http/domain/inventory/service", + input_types: ["*billing.Service"], + input_pkgs: ["example/http/domain/billing/service"], + }, + ], + }); + const kinds = findings.map((f) => f.kind); + assert.ok(kinds.includes("cross_bucket"), "expected cross_bucket for plugins→domain or domain→domain"); + for (const f of findings) { + assert.ok(f.id && f.title && f.summary && f.action && f.action.view); + assert.ok(["error", "warn", "info"].includes(f.severity)); + } +}); + +test("buildArchitectureFindings flags super hub entry fanout and fat package", async () => { + const { buildArchitectureFindings } = await import("./graph_state.mjs"); + const providers = []; + // One hub consumed by many + providers.push({ + id: "hub", + function_name: "infra.ProvideHub", + output_type: "*infra.DB", + output_pkg: "example/http/infra/db", + input_types: [], + input_pkgs: [], + }); + for (let i = 0; i < 12; i++) { + providers.push({ + id: "c" + i, + function_name: "domain/foo.P" + i, + output_type: "*foo.T" + i, + output_pkg: "example/http/domain/foo", + input_types: ["*infra.DB"], + input_pkgs: ["example/http/infra/db"], + }); + } + // Extra entries (indegree 0) beyond the consumers — hub is also entry + for (let i = 0; i < 8; i++) { + providers.push({ + id: "e" + i, + function_name: "app.Entry" + i, + output_type: "*app.E" + i, + output_pkg: "example/http/app", + input_types: [], + input_pkgs: [], + }); + } + const findings = buildArchitectureFindings( + { providers }, + { hubDegree: 12, entryFanout: 8, fatPackage: 12 } + ); + assert.ok(findings.some((f) => f.kind === "super_hub")); + assert.ok(findings.some((f) => f.kind === "entry_fanout")); + assert.ok(findings.some((f) => f.kind === "fat_package")); + assert.equal(findings[0].severity === "warn" || findings[0].severity === "error", true); +}); + +test("buildArchitectureFindings empty providers returns empty list", async () => { + const { buildArchitectureFindings } = await import("./graph_state.mjs"); + assert.deepEqual(buildArchitectureFindings({}), []); + assert.deepEqual(buildArchitectureFindings({ providers: [] }), []); +}); diff --git a/dixhttp/static/js/graph_workbench.mjs b/dixhttp/static/js/graph_workbench.mjs new file mode 100644 index 0000000..040d31e --- /dev/null +++ b/dixhttp/static/js/graph_workbench.mjs @@ -0,0 +1,440 @@ +// Shared helpers for legacy architecture visualization (labels, budget, groups, export). + +export const GROUP_RULES_STORAGE_KEY = "dix.groupRules.v1"; + +export const ERROR_TYPE_GUIDE = [ + { + code: "provider_registration_invalid", + summary: "Provider 注册参数非法(如 nil、非函数、返回值不合法)。", + action: "检查 Provide/TryProvide 的入参签名与返回值数量。", + }, + { + code: "inject_dependency_missing", + summary: "注入阶段缺失依赖,当前类型没有可用 provider。", + action: "确认依赖已注册、类型精确匹配,并检查导入顺序。", + }, + { + code: "provider_input_unresolved", + summary: "Provider 的输入参数无法解析(上游依赖缺失或不匹配)。", + action: "根据 input_type/input_types 逐级回溯缺失节点。", + }, + { + code: "provider_return_error", + summary: "Provider 正常执行但返回了业务错误。", + action: "优先排查外部资源(DB/缓存/HTTP)与配置有效性。", + }, + { + code: "provider_panic", + summary: "Provider 执行中发生 panic。", + action: "建议 provider 内捕获并返回 error,必要时开启 debug 栈。", + }, + { + code: "provider_timeout", + summary: "Provider 执行超时。", + action: "优化初始化路径,或临时调大 WithProviderTimeout。", + }, + { + code: "inject_callback_error", + summary: "Inject/TryInject 的回调函数主动返回了 error。", + action: "检查回调里的参数校验和业务逻辑分支。", + }, + { + code: "dependency_cycle", + summary: "检测到循环依赖。", + action: "拆分互相引用组件,改为接口或延迟注入解环。", + }, + { + code: "inject_failed", + summary: "注入失败兜底分类(需要结合 stage/message 细看)。", + action: "结合 root_cause + hint + provider_function 做链路定位。", + }, +]; + +export function normalizeTypeForMatch(typeName) { + let t = String(typeName || "").trim(); + t = t.replace(/^\*/, "").replace(/^\[\]/, ""); + if (t.startsWith("map[")) { + const idx = t.indexOf("]"); + if (idx > -1 && idx < t.length - 1) { + t = t.slice(idx + 1).replace(/^\*/, ""); + } + } + return t; +} + +export function extractPackagePath(typeName) { + const t = String(typeName || "").trim(); + if (!t) return ""; + const lastSlash = t.lastIndexOf("/"); + const lastDot = t.lastIndexOf("."); + if (lastDot > -1 && lastDot > lastSlash) return t.slice(0, lastDot); + return ""; +} + +export function isPathLikePrefix(prefix) { + return ( + prefix.includes("/") || + prefix.startsWith("github.com/") || + prefix.startsWith("gitee.com/") || + prefix.startsWith("gitlab.com/") + ); +} + +export function nodeKind(node) { + const d = (node && node.data) || {}; + return d.kind || d.type || ""; +} + +export function nodePackagePath(node) { + const d = (node && node.data) || {}; + return d.packagePath || d.pkg || ""; +} + +export function matchGroup(typeName, pkgPathOverride, groupRules) { + if (!typeName) return null; + const normalized = normalizeTypeForMatch(typeName); + const pkgPath = pkgPathOverride || extractPackagePath(normalized); + for (const grp of groupRules || []) { + for (const prefix of grp.prefixes || []) { + const p = String(prefix || "").trim(); + if (!p) continue; + const target = pkgPath || normalized; + if (isPathLikePrefix(p)) { + if (target.startsWith(p) || target.includes(p)) return grp.name; + continue; + } + if (target.includes(p)) return grp.name; + } + } + return null; +} + +export function filterByPrefix(nodes, edges, prefix, protectedIds = new Set()) { + const p = String(prefix || "").trim(); + if (!p) return { nodes, edges }; + const matches = (node) => { + if (!node || !node.data) return false; + const pkg = String(nodePackagePath(node) || ""); + const id = String(node.id || ""); + const fullType = String(node.data.fullType || node.data.label || ""); + const fn = String((node.data.provider && node.data.provider.function_name) || node.data.function_name || ""); + return pkg.includes(p) || id.includes(p) || fullType.includes(p) || fn.includes(p); + }; + const keep = new Set(); + nodes.forEach((n) => { + if (protectedIds.has(n.id) || matches(n)) keep.add(n.id); + }); + return { + nodes: nodes.filter((n) => keep.has(n.id)), + edges: edges.filter((e) => keep.has(e.from) && keep.has(e.to)), + }; +} + +export function aggregateByGroups(nodes, edges, options = {}) { + const { + enabled = false, + groupRules = [], + expandedGroups = new Set(), + protectedIds = new Set(), + debug = false, + } = options; + + if (!enabled || !groupRules.length) { + return { nodes, edges, groupMembers: {}, debugStats: null }; + } + + const mapping = new Map(); + const groupNodes = new Map(); + const debugStats = new Map(); + const members = {}; + + const makeGroup = (name) => ({ + id: `group:${name}`, + label: name, + shape: "box", + font: { size: 13 }, + color: { background: "#e5e7eb", border: "#6b7280" }, + data: { kind: "group", type: "group", group: name }, + }); + + nodes.forEach((n) => { + if (protectedIds.has(n.id)) return; + const kind = nodeKind(n); + if (kind !== "type" && kind !== "provider") return; + const pkg = nodePackagePath(n); + const groupName = matchGroup(n.id, pkg, groupRules); + if (!groupName) return; + if (expandedGroups.has(groupName)) return; + mapping.set(n.id, `group:${groupName}`); + if (!groupNodes.has(groupName)) groupNodes.set(groupName, makeGroup(groupName)); + if (!members[groupName]) members[groupName] = []; + if (members[groupName].length < 200) { + members[groupName].push({ id: n.id, packagePath: pkg, nodeType: kind }); + } + if (debug) { + if (!debugStats.has(groupName)) debugStats.set(groupName, []); + if (debugStats.get(groupName).length < 50) { + debugStats.get(groupName).push({ id: n.id, packagePath: pkg }); + } + } + }); + + if (mapping.size === 0) { + return { nodes, edges, groupMembers: members, debugStats: debug ? debugStats : null }; + } + + const mappedNodes = nodes.filter((n) => !mapping.has(n.id)); + groupNodes.forEach((n) => mappedNodes.push(n)); + + const edgeMap = new Map(); + edges.forEach((e) => { + const from = mapping.get(e.from) || e.from; + const to = mapping.get(e.to) || e.to; + if (from === to) return; + const key = `${from}=>${to}`; + if (edgeMap.has(key)) return; + edgeMap.set(key, { ...e, from, to }); + }); + + return { + nodes: mappedNodes, + edges: Array.from(edgeMap.values()), + groupMembers: members, + debugStats: debug ? debugStats : null, + }; +} + +export function loadGroupRulesState(storage = globalThis.localStorage) { + const empty = { aggregateGroups: false, groupRules: [] }; + try { + const raw = storage && storage.getItem(GROUP_RULES_STORAGE_KEY); + if (!raw) return empty; + const data = JSON.parse(raw); + return { + aggregateGroups: typeof data.aggregateGroups === "boolean" ? data.aggregateGroups : false, + groupRules: Array.isArray(data.groupRules) + ? data.groupRules.map((g) => ({ + name: g.name, + prefixes: Array.isArray(g.prefixes) ? g.prefixes : [], + })) + : [], + }; + } catch { + return empty; + } +} + +export function saveGroupRulesState(state, storage = globalThis.localStorage) { + try { + const payload = { + aggregateGroups: !!state.aggregateGroups, + groupRules: (state.groupRules || []).map((g) => ({ + name: g.name, + prefixes: g.prefixes || [], + })), + }; + storage.setItem(GROUP_RULES_STORAGE_KEY, JSON.stringify(payload)); + } catch { + /* ignore quota / private mode */ + } +} + +export function mergeServerGroupRules(localRules, serverRules) { + if (localRules && localRules.length) return localRules; + if (!Array.isArray(serverRules) || !serverRules.length) return localRules || []; + return serverRules.map((g) => ({ + name: g.name, + prefixes: Array.isArray(g.prefixes) ? g.prefixes : [], + })); +} + +export function filterPackages(packages, query) { + const q = String(query || "").toLowerCase().trim(); + const list = Array.isArray(packages) ? [...packages] : []; + list.sort((a, b) => (b.provider_count || 0) - (a.provider_count || 0)); + if (!q) return list; + return list.filter((p) => String(p.name || "").toLowerCase().includes(q)); +} + +export function escapeMermaidLabel(label) { + return String(label || "") + .replace(/"/g, "'") + .replace(/\n/g, " ") + .replace(/\r/g, " "); +} + +export function buildMermaidSource(nodes, edges) { + if (!nodes || !nodes.length) return "flowchart TD\n A[No data]"; + const idMap = new Map(); + const lines = ["flowchart TD"]; + nodes.forEach((n, idx) => { + const safeId = `N${idx}`; + idMap.set(n.id, safeId); + lines.push(` ${safeId}["${escapeMermaidLabel(n.label || n.id)}"]`); + }); + (edges || []).forEach((e) => { + const from = idMap.get(e.from); + const to = idMap.get(e.to); + if (!from || !to) return; + lines.push(` ${from} --> ${to}`); + }); + return lines.join("\n"); +} + +function escapeXml(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function buildSvgFromNetwork(network) { + if (!network || !network.body) return ""; + const nodesData = network.body.data.nodes.get(); + const edgesData = network.body.data.edges.get(); + if (!nodesData || !nodesData.length) return ""; + + const positions = network.getPositions(nodesData.map((n) => n.id)); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + nodesData.forEach((n) => { + let box; + try { + box = network.getBoundingBox(n.id); + } catch { + box = null; + } + const pos = positions[n.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 parts = []; + 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 color = (e.color && e.color.color) || "#9ca3af"; + const dash = e.dashes ? ' stroke-dasharray="6 4"' : ""; + parts.push( + ``, + ); + }); + + nodesData.forEach((n) => { + const pos = positions[n.id] || { x: 0, y: 0 }; + let box; + try { + box = network.getBoundingBox(n.id); + } catch { + 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) || "#bfdbfe"; + const border = (n.color && n.color.border) || "#3b82f6"; + const fontSize = (n.font && n.font.size) || 12; + const fontColor = (n.font && n.font.color) || "#111827"; + const kind = nodeKind(n); + const isBox = n.shape === "box" || kind === "provider" || kind === "group"; + if (isBox) { + const rx = kind === "group" ? 8 : 4; + parts.push(``); + } else { + parts.push(``); + } + const label = escapeXml(String(n.label || n.id || "").split("\n")[0]); + parts.push(`${label}`); + }); + + parts.push(``); + return parts.join(""); +} + +export function downloadTextFile(filename, content, mime = "text/plain;charset=utf-8") { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export function filterErrorGuide(items, query) { + const q = String(query || "").toLowerCase().trim(); + if (!q) return items || []; + return (items || []).filter((item) => { + const hay = `${item.code} ${item.summary} ${item.action}`.toLowerCase(); + return hay.includes(q); + }); +} + +export function filterRuntimeStats(stats, options = {}) { + const q = String(options.query || "").toLowerCase().trim(); + const onlyExecuted = !!options.onlyExecuted; + return (stats || []).filter((s) => { + if (onlyExecuted && !(s.call_count > 0)) return false; + if (!q) return true; + const hay = `${s.function_name || ""} ${s.output_type || ""} ${s.last_error || ""}`.toLowerCase(); + return hay.includes(q); + }); +} + +if (typeof window !== "undefined") { + window.DIXGraphWorkbench = { + GROUP_RULES_STORAGE_KEY, + ERROR_TYPE_GUIDE, + normalizeTypeForMatch, + extractPackagePath, + isPathLikePrefix, + nodeKind, + nodePackagePath, + matchGroup, + filterByPrefix, + aggregateByGroups, + loadGroupRulesState, + saveGroupRulesState, + mergeServerGroupRules, + filterPackages, + escapeMermaidLabel, + buildMermaidSource, + buildSvgFromNetwork, + downloadTextFile, + filterErrorGuide, + filterRuntimeStats, + }; +} diff --git a/dixhttp/static/js/legacy/app.js b/dixhttp/static/js/legacy/app.js index 479658e..1dd04d4 100644 --- a/dixhttp/static/js/legacy/app.js +++ b/dixhttp/static/js/legacy/app.js @@ -6,22 +6,64 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 // State loading: true, sidebarCollapsed: false, + /** Right inspector: inventory vs node detail. */ + rightPanelCollapsed: false, + rightPanelTab: 'inventory', + toolbarMoreOpen: false, + packageGroupOpen: {}, + /** Compact pyramid status for toolbar (not a floating tip). */ + pyramidStatus: '', + /** Dismissible first-run / dense-graph canvas guide. */ + canvasGuideDismissed: false, + canvasGuideKey: 'dix.canvasGuide.v1', + showCanvasGuide: false, + /** Canvas chrome: maximize graph (collapse sidebars + optional fullscreen). */ + graphMaximized: false, + _preMaximize: null, + graphScaleLabel: '', + /** Keyboard shortcuts overlay (?). */ + shortcutsHelpOpen: false, + /** Opt-in: also persist hide seeds in localStorage across sessions. */ + persistHideSeeds: false, + hiddenSeedsPersistKey: 'dix.hiddenNodeSeeds.persist.v1', stats: { provider_count: 0, object_count: 0, package_count: 0, edge_count: 0 }, packages: [], - uiVersion: 'trace-entry-v5-20260323', + uiVersion: 'arch-layout-v1', packageSearch: '', globalSearch: '', searchResults: [], currentPackage: null, - currentView: 'providers', + currentView: 'findings', currentLayout: 'hierarchical', + architectureFindings: [], + activeFindingId: null, + findingsRulesOpen: false, currentDepth: '2', + /** Max pyramid level observed for current architecture view (depth UI). */ + pyramidMaxLevel: 4, allData: null, + modulesData: null, selectedNode: null, network: null, focusedType: null, focusedGraph: null, focusedGroup: null, + /** Provider id seed for pyramid neighborhood (upstream/downstream). */ + pyramidFocusProviderId: null, + /** Type id seed for types pyramid neighborhood. */ + pyramidFocusTypeId: null, + /** Hidden canvas seeds shared across architecture views (session + URL). */ + hiddenSeeds: [], + hiddenSeedsKey: 'dix.hiddenNodeSeeds.v1', + /** Snapshots of hiddenSeeds before each hide/clear (newest last); cap 20. */ + hideHistory: [], + hideHistoryMax: 20, + /** Right-click menu on graph nodes. */ + graphContextMenu: null, + /** Transient undo toast after hide. */ + hideToast: null, + _hideToastTimer: null, + hiddenListOpen: false, aggregateGroups: true, debugGroupMatching: false, groupRules: [], @@ -35,6 +77,20 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 mermaidSvg: '', mermaidError: '', lastGraphData: null, + packageSummary: { providers: [], types: [] }, + inventorySearch: '', + edgeDeclutter: true, + densityWarning: { + show: false, + message: '', + hubs: [], + suggestModules: false, + suggestAggregate: false, + suggestKeepBusiness: false, + edgesDropped: 0, + expanded: false, + minimized: false, + }, expandedGroups: [], runtimeStats: [], runtimeStatsLoading: false, @@ -126,6 +182,33 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 .sort((a, b) => b.provider_count - a.provider_count); }, + get packageGroups() { + const order = ['app', 'bootstrap', 'router', 'domain', 'infra', 'plugins', 'other']; + const labels = { + app: '应用', + bootstrap: '装配', + router: '路由', + domain: '领域', + infra: '基础设施', + plugins: '插件', + other: '其它', + }; + const buckets = Object.fromEntries(order.map((k) => [k, []])); + for (const p of this.filteredPackages) { + const key = this.packageBucket(p.name); + if (!buckets[key]) buckets[key] = []; + buckets[key].push(p); + } + return order + .filter((k) => (buckets[k] || []).length) + .map((k) => ({ + key: k, + label: labels[k] || k, + packages: buckets[k], + count: buckets[k].reduce((sum, p) => sum + (p.provider_count || 0), 0), + })); + }, + get totalProviders() { return this.packages.reduce((sum, p) => sum + p.provider_count, 0); }, @@ -518,19 +601,325 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 }, // Methods + graphHelpers() { + return window.DIXGraphState || null; + }, + async init() { this.loadLocalState(); + this.ensureFindingsHomeOnce(); + this.loadHiddenSeeds(); + this.loadCanvasGuideState(); + this.readArchitectureUrlState(); await this.loadGroupRules(); await this.loadStats(); await this.loadPackages(); await this.loadDependencies(); + if (this.currentView === 'modules') { + await this.loadModulesData(); + this.renderGraph(); + } await this.loadRuntimeStats(); await this.loadRecentErrors(); await this.loadTraceRecords(); + this.syncArchitectureUrl(); if (window.mermaid && window.mermaid.initialize) { window.mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' }); } document.addEventListener('fullscreenchange', () => this.handleFullscreenChange()); + document.addEventListener('fullscreenchange', () => this.handleGraphFullscreenChange()); + this._onArchKeydown = (e) => this.handleArchitectureHotkeys(e); + document.addEventListener('keydown', this._onArchKeydown); + if (typeof this.$watch === 'function') { + this.$watch('selectedNode', (n) => { + if (n && (n.id || (n.data && n.data.function_name))) { + this.rightPanelTab = 'detail'; + this.rightPanelCollapsed = false; + } + }); + this.$watch('sidebarCollapsed', () => this.saveLocalState()); + this.$watch('rightPanelCollapsed', () => this.saveLocalState()); + this.$watch('rightPanelTab', () => this.saveLocalState()); + } + }, + + /** One-time migrate: existing localStorage views still land on 体检 once. */ + ensureFindingsHomeOnce() { + try { + const key = 'dix.findingsHome.v1'; + if (localStorage.getItem(key) === '1') return; + this.currentView = 'findings'; + localStorage.setItem(key, '1'); + this.saveLocalState(); + } catch (e) { + this.currentView = 'findings'; + } + }, + + loadCanvasGuideState() { + try { + this.canvasGuideDismissed = sessionStorage.getItem(this.canvasGuideKey) === '1'; + } catch (e) { + this.canvasGuideDismissed = false; + } + }, + + dismissCanvasGuide() { + this.canvasGuideDismissed = true; + this.showCanvasGuide = false; + try { + sessionStorage.setItem(this.canvasGuideKey, '1'); + } catch (e) { + // ignore + } + }, + + openPackageSidebar() { + this.sidebarCollapsed = false; + this.dismissCanvasGuide(); + }, + + handleArchitectureHotkeys(e) { + const tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : ''; + if (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable)) { + return; + } + if (e.key === '?' || (e.key === '/' && e.shiftKey)) { + e.preventDefault(); + this.shortcutsHelpOpen = !this.shortcutsHelpOpen; + return; + } + if (e.key === 'Escape') { + if (this.shortcutsHelpOpen) { + e.preventDefault(); + this.shortcutsHelpOpen = false; + return; + } + if (this.graphMaximized) { + e.preventDefault(); + this.exitGraphMaximize(); + return; + } + this.clearHidePreview(); + this.graphContextMenu = null; + return; + } + if (this.shortcutsHelpOpen) return; + if (e.key === '+' || e.key === '=' || e.code === 'NumpadAdd') { + e.preventDefault(); + this.zoomGraphIn(); + return; + } + if (e.key === '-' || e.key === '_' || e.code === 'NumpadSubtract') { + e.preventDefault(); + this.zoomGraphOut(); + return; + } + if (e.key === '0' || e.code === 'Numpad0') { + e.preventDefault(); + this.fitGraph(); + return; + } + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') { + if (!this.network) return; + e.preventDefault(); + this.panGraphByKey(e.key); + return; + } + if (e.key === 'm' || e.key === 'M') { + e.preventDefault(); + this.toggleGraphMaximize(); + return; + } + if (e.key === 'f' || e.key === 'F') { + if (!e.metaKey && !e.ctrlKey) { + e.preventDefault(); + this.fitGraph(); + } + return; + } + if (e.key === 'h' || e.key === 'H') { + if (this.selectedNode && this.selectedNode.id) { + e.preventDefault(); + this.hideSelectedNodeAndDownstream(); + } + return; + } + if (e.key === 'u' || e.key === 'U') { + if (this.hideHistory.length) { + e.preventDefault(); + this.undoLastHide(); + } + return; + } + }, + + panGraphByKey(key) { + if (!this.network) return; + let scale = 1; + let position = { x: 0, y: 0 }; + try { + scale = this.network.getScale() || 1; + position = this.network.getViewPosition(); + } catch (err) { + return; + } + const step = 80 / Math.max(0.2, scale); + const next = { x: position.x, y: position.y }; + if (key === 'ArrowLeft') next.x -= step; + if (key === 'ArrowRight') next.x += step; + if (key === 'ArrowUp') next.y -= step; + if (key === 'ArrowDown') next.y += step; + try { + this.network.moveTo({ + position: next, + scale, + animation: { duration: 90, easingFunction: 'easeInOutQuad' }, + }); + } catch (err) { + // ignore + } + }, + + zoomGraphBy(factor) { + if (!this.network) return; + let scale = 1; + let position = { x: 0, y: 0 }; + try { + scale = this.network.getScale(); + position = this.network.getViewPosition(); + } catch (e) { + return; + } + const next = Math.min(4, Math.max(0.12, scale * factor)); + try { + this.network.moveTo({ + scale: next, + position, + animation: { duration: 140, easingFunction: 'easeInOutQuad' }, + }); + } catch (e) { + return; + } + this.updateGraphScaleLabel(next); + setTimeout(() => this.applyLabelLod(), 160); + }, + + zoomGraphIn() { + this.zoomGraphBy(1.28); + }, + + zoomGraphOut() { + this.zoomGraphBy(1 / 1.28); + }, + + updateGraphScaleLabel(scale) { + const s = Number(scale); + if (!Number.isFinite(s)) { + this.graphScaleLabel = ''; + return; + } + this.graphScaleLabel = Math.round(s * 100) + '%'; + }, + + refreshGraphScaleLabel() { + if (!this.network) { + this.graphScaleLabel = ''; + return; + } + try { + this.updateGraphScaleLabel(this.network.getScale()); + } catch (e) { + this.graphScaleLabel = ''; + } + }, + + focusSelectedOrHub() { + if (this.selectedNode && this.selectedNode.id && this.network && this.lastGraphData + && this.lastGraphData.nodes.get(this.selectedNode.id)) { + try { + this.network.focus(this.selectedNode.id, { + scale: Math.max(1.05, (this.network.getScale() || 1)), + animation: { duration: 220, easingFunction: 'easeInOutQuad' }, + }); + setTimeout(() => { + this.applyLabelLod(); + this.refreshGraphScaleLabel(); + }, 240); + return; + } catch (e) { + // fall through + } + } + this.focusHubNode(); + }, + + toggleGraphMaximize() { + if (this.graphMaximized) this.exitGraphMaximize(); + else this.enterGraphMaximize(); + }, + + enterGraphMaximize() { + this._preMaximize = { + sidebarCollapsed: this.sidebarCollapsed, + rightPanelCollapsed: this.rightPanelCollapsed, + }; + this.sidebarCollapsed = true; + this.rightPanelCollapsed = true; + this.graphMaximized = true; + this.toolbarMoreOpen = false; + this.shortcutsHelpOpen = false; + this.graphContextMenu = null; + const stage = document.getElementById('graph-stage'); + if (stage && stage.requestFullscreen) { + stage.requestFullscreen().catch(() => { }); + } + setTimeout(() => { + if (this.network) { + try { this.network.redraw(); } catch (e) { /* ignore */ } + this.fitGraph(); + } + }, 120); + }, + + exitGraphMaximize() { + const prev = this._preMaximize; + this.graphMaximized = false; + this._preMaximize = null; + if (prev) { + this.sidebarCollapsed = !!prev.sidebarCollapsed; + this.rightPanelCollapsed = !!prev.rightPanelCollapsed; + } + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => { }); + } + setTimeout(() => { + if (this.network) { + try { this.network.redraw(); } catch (e) { /* ignore */ } + this.fitGraph(); + } + }, 120); + }, + + handleGraphFullscreenChange() { + const stage = document.getElementById('graph-stage'); + const active = !!(stage && document.fullscreenElement === stage); + if (!active && this.graphMaximized) { + // User exited OS fullscreen (Esc) — restore chrome. + const prev = this._preMaximize; + this.graphMaximized = false; + this._preMaximize = null; + if (prev) { + this.sidebarCollapsed = !!prev.sidebarCollapsed; + this.rightPanelCollapsed = !!prev.rightPanelCollapsed; + } + setTimeout(() => { + if (this.network) { + try { this.network.redraw(); } catch (e) { /* ignore */ } + this.fitGraph(); + } + }, 80); + } }, async loadGroupRules() { @@ -1063,18 +1452,26 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 async selectPackage(pkgName) { this.currentPackage = pkgName; + this.pyramidFocusProviderId = null; + this.pyramidFocusTypeId = null; + this.filterPrefix = ''; + if (pkgName) this.sidebarCollapsed = false; + if (this.currentView === 'modules' && pkgName) { + this.currentView = 'providers'; + } + this.syncArchitectureUrl(); await this.loadDependencies(); }, async loadDependencies() { this.loading = true; try { - let url = apiUrl('/api/dependencies'); - if (this.currentPackage) { - url += '?package=' + encodeURIComponent(this.currentPackage); - } + // Always fetch the full provider set so package/provider scope can + // include related upstream/downstream outside the selected package. + const url = apiUrl('/api/dependencies'); const res = await fetch(url); this.allData = await res.json(); + this.refreshArchitectureFindings(); this.renderGraph(); } catch (e) { console.error('加载依赖失败:', e); @@ -1083,15 +1480,138 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 } }, - switchView(view) { + refreshArchitectureFindings() { + const helpers = this.graphHelpers(); + if (!helpers || !helpers.buildArchitectureFindings || !this.allData) { + this.architectureFindings = []; + return; + } + this.architectureFindings = helpers.buildArchitectureFindings(this.allData) || []; + }, + + findingsSummary() { + const list = this.architectureFindings || []; + let warn = 0; + let info = 0; + let error = 0; + for (const f of list) { + if (f.severity === 'error') error += 1; + else if (f.severity === 'warn') warn += 1; + else info += 1; + } + return { total: list.length, error, warn, info }; + }, + + findingSeverityClass(sev) { + if (sev === 'error') return 'bg-rose-100 text-rose-800 border-rose-200'; + if (sev === 'warn') return 'bg-amber-100 text-amber-900 border-amber-200'; + return 'bg-stone-100 text-stone-600 border-stone-200'; + }, + + activeFindingTitle() { + if (!this.activeFindingId) return ''; + const f = (this.architectureFindings || []).find((x) => x.id === this.activeFindingId); + return f ? f.title : this.activeFindingId; + }, + + async applyFinding(finding) { + if (!finding || !finding.action) return; + this.activeFindingId = finding.id; + const a = finding.action; + this.pyramidFocusProviderId = null; + this.pyramidFocusTypeId = null; + this.filterPrefix = ''; + this.graphContextMenu = null; + if (typeof this.clearHidePreview === 'function') { + this.clearHidePreview(); + } + + if (a.keepNeighborhood && a.focusProviderId) { + this.currentPackage = null; + this.pyramidFocusProviderId = a.focusProviderId; + } else if (a.keepNeighborhood && a.focusTypeId) { + this.currentPackage = null; + this.pyramidFocusTypeId = a.focusTypeId; + } else if (a.package) { + this.currentPackage = a.package; + } else { + this.currentPackage = null; + } + + this.currentView = a.view || 'providers'; + this.saveLocalState(); + this.syncArchitectureUrl(); + + if (this.currentView === 'modules') { + await this.loadModulesData(); + } + this.renderGraph(); + this.fitGraphCameraSoon(); + if (a.focusProviderId) { + this.focusGraphNodeSoon(a.focusProviderId); + } else if (a.focusTypeId) { + this.focusGraphNodeSoon(a.focusTypeId); + } + }, + + backToFindings() { + this.currentView = 'findings'; + this.pyramidFocusProviderId = null; + this.pyramidFocusTypeId = null; + this.saveLocalState(); + this.renderGraph(); + }, + + openProvidersGraph() { + this.activeFindingId = null; + this.currentView = 'providers'; + this.pyramidFocusProviderId = null; + this.pyramidFocusTypeId = null; + this.currentPackage = null; + this.saveLocalState(); + this.renderGraph(); + }, + + async switchView(view) { this.currentView = view; this.focusedType = null; this.focusedGraph = null; + this.filterPrefix = ''; + if (view === 'findings') { + this.pyramidFocusProviderId = null; + this.pyramidFocusTypeId = null; + this.refreshArchitectureFindings(); + } + this.saveLocalState(); + if (view === 'modules') { + await this.loadModulesData(); + } this.renderGraph(); }, + async loadModulesData() { + try { + const res = await fetch(apiUrl('/api/modules')); + this.modulesData = await res.json(); + } catch (e) { + console.error('加载模块地图失败:', e); + this.modulesData = []; + } + // Need providers to disperse fat packages in the module tree. + if (!this.allData) { + try { + const res = await fetch(apiUrl('/api/dependencies')); + this.allData = await res.json(); + } catch (e) { + console.error('加载依赖失败:', e); + } + } + }, + resetView() { this.currentPackage = null; + this.pyramidFocusProviderId = null; + this.pyramidFocusTypeId = null; this.currentView = 'providers'; this.packageSearch = ''; this.globalSearch = ''; @@ -1099,12 +1619,15 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 this.selectedNode = null; this.focusedType = null; this.focusedGraph = null; + this.filterPrefix = ''; this.saveLocalState(); + this.syncArchitectureUrl(); this.loadDependencies(); }, onDepthChange() { this.normalizeCurrentDepth(2); + this.syncArchitectureUrl(); if (this.focusedType) { if (this.focusedGraph === 'dependency') { this.showDependencyGraph(this.focusedType, 'type'); @@ -1128,13 +1651,43 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 if (!Number.isFinite(depth) || depth < 0) { depth = defaultDepth; } - // 防止极端值导致页面卡顿 if (depth > 128) { depth = 128; } + const max = Number(this.pyramidMaxLevel) || 0; + if (depth > 0 && max > 0 && depth > max) { + depth = max; + } return depth; }, + depthSelectOptions() { + const max = Math.max(1, Number(this.pyramidMaxLevel) || 1); + const preferred = [1, 2, 3, 5, 10]; + const values = [...new Set([ + ...preferred.filter((v) => v <= max), + max, + ])].sort((a, b) => a - b); + return [ + ...values.map((v) => ({ value: String(v), label: `前 ${v} 级` })), + { value: '0', label: '全部层级' }, + ]; + }, + + updatePyramidMaxLevel(levels) { + if (!levels || typeof levels.values !== 'function') return; + let max = 1; + for (const lv of levels.values()) { + const n = Number(lv) || 1; + if (n > max) max = n; + } + this.pyramidMaxLevel = max; + const cur = Number.parseInt(String(this.currentDepth ?? ''), 10); + if (Number.isFinite(cur) && cur > max) { + this.currentDepth = String(max); + } + }, + normalizeCurrentDepth(defaultDepth = 2) { const depth = this.parseDepthValue(defaultDepth); this.currentDepth = String(depth); @@ -1263,199 +1816,236 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 }, // 显示以某个类型为中心的依赖图(包含依赖者和被依赖者) - showDependencyGraph(targetType, nodeType) { - if (!this.allData) return; - - this.focusedType = targetType; - this.focusedGraph = 'dependency'; - this.focusedGroup = null; - - const depth = this.parseDepthValue(2); - const nodes = []; - const edges = []; - const nodeMap = new Map(); - const typePkgMap = this.buildTypePkgMap(); - - // 构建类型到 Provider 的映射 - const typeToProviders = new Map(); // outputType -> providers - const typeToConsumers = new Map(); // inputType -> providers that use it - - this.allData.providers.forEach(provider => { - const outputTypes = this.providerOutputTypes(provider); - outputTypes.forEach(outputType => { - if (!typeToProviders.has(outputType)) { - typeToProviders.set(outputType, []); - } - typeToProviders.get(outputType).push(provider); - }); - - (provider.input_types || []).forEach(inputType => { - if (!typeToConsumers.has(inputType)) { - typeToConsumers.set(inputType, []); - } - typeToConsumers.get(inputType).push(provider); + showDependencyGraph(targetType, nodeType, pkgHint = '') { + // Types: use package-qualified pyramid neighborhood (depth 0 = unlimited). + // Legacy short-type BFS broke when「全部层级」parsed as maxDepth 0. + if (nodeType === 'type' || nodeType == null || nodeType === '') { + this.focusTypeNeighborhood({ + id: String(targetType || '').includes('\x00') ? String(targetType) : '', + data: { + fullType: String(targetType || '').includes('\x00') + ? String(targetType).split('\x00').pop() + : String(targetType || ''), + packagePath: String(pkgHint || ''), + type: 'type', + }, }); + return; + } + // Fallback: still support non-type callers via neighborhood on output type. + this.focusTypeNeighborhood({ + data: { + fullType: String(targetType || ''), + packagePath: String(pkgHint || ''), + type: 'type', + }, }); + }, - // BFS 向上查找依赖(当前类型依赖什么) - const findDependencies = (startType, maxDepth) => { - const visited = new Set(); - const queue = [{ type: startType, level: 0 }]; - const result = []; - - while (queue.length > 0) { - const { type, level } = queue.shift(); - if (visited.has(type) || level > maxDepth) continue; - visited.add(type); - - result.push({ type, level, direction: 'dependency' }); - - // 找到生产这个类型的 providers,获取它们的输入类型 - const providers = typeToProviders.get(type) || []; - providers.forEach(p => { - (p.input_types || []).forEach(inputType => { - if (!visited.has(inputType)) { - queue.push({ type: inputType, level: level + 1 }); - } - }); - }); - } - return result; - }; + /** Focus types pyramid on one type and its upstream/downstream. */ + focusTypeNeighborhood(node) { + if (!node) return; + const helpers = this.graphHelpers(); + const data = node.data || {}; + const fullType = String(data.fullType || data.output_type || '').trim(); + const pkg = String(data.packagePath || data.output_pkg || '').trim(); + let seedId = String(node.id || '').trim(); + if ((!seedId || seedId === fullType) && helpers && helpers.typeNodeIdentity && fullType) { + seedId = helpers.typeNodeIdentity(fullType, pkg); + } + if (!seedId) seedId = fullType; + if (!seedId) return; - // BFS 向下查找被依赖者(什么依赖当前类型) - const findDependents = (startType, maxDepth) => { - const visited = new Set(); - const queue = [{ type: startType, level: 0 }]; - const result = []; + this.currentView = 'types'; + this.pyramidFocusTypeId = seedId; + this.pyramidFocusProviderId = null; + this.focusedType = null; + this.focusedGraph = null; + this.focusedGroup = null; + this.rightPanelTab = 'detail'; + this.rightPanelCollapsed = false; + this.dismissCanvasGuide(); + this.renderGraph(); + this.focusGraphNodeSoon(seedId); + }, - while (queue.length > 0) { - const { type, level } = queue.shift(); - if (visited.has(type) || level > maxDepth) continue; - visited.add(type); + /** Focus providers pyramid on one provider and its upstream/downstream. */ + focusProviderNeighborhood(node) { + if (!node) return; + const data = node.data || {}; + const seedId = String(node.id || data.id || '').trim(); + if (!seedId) return; - if (level > 0) { // 不重复添加起始节点 - result.push({ type, level, direction: 'dependent' }); - } + this.currentView = 'providers'; + this.pyramidFocusProviderId = seedId; + this.pyramidFocusTypeId = null; + this.focusedType = null; + this.focusedGraph = null; + this.focusedGroup = null; + this.rightPanelTab = 'detail'; + this.rightPanelCollapsed = false; + this.dismissCanvasGuide(); + this.renderGraph(); + this.focusGraphNodeSoon(seedId); + }, - // 找到使用这个类型作为输入的 providers,获取它们的输出类型 - const consumers = typeToConsumers.get(type) || []; - consumers.forEach(p => { - this.providerOutputTypes(p).forEach(outputType => { - if (outputType && !visited.has(outputType)) { - queue.push({ type: outputType, level: level + 1 }); - } - }); + focusGraphNodeSoon(seedId) { + this.fitGraphCameraSoon(); + setTimeout(() => { + if (!this.network || !this.lastGraphData || !seedId) return; + if (!this.lastGraphData.nodes.get(seedId)) return; + try { + this.network.selectNodes([seedId]); + this.network.focus(seedId, { + scale: 1.05, + animation: { duration: 280, easingFunction: 'easeInOutQuad' }, }); + const n = this.lastGraphData.nodes.get(seedId); + if (n) this.selectedNode = n; + } catch (e) { + // ignore } - return result; - }; - - // 获取依赖和被依赖者 - const dependencies = findDependencies(targetType, depth); - const dependents = findDependents(targetType, depth); - - // 添加所有节点 - const allTypes = [...dependencies, ...dependents]; - allTypes.forEach(({ type, level, direction }) => { - if (!nodeMap.has(type)) { - const isTarget = type === targetType; - let bgColor, borderColor; - - if (isTarget) { - bgColor = '#fde68a'; borderColor = '#f59e0b'; // 目标:黄色 - } else if (direction === 'dependency') { - bgColor = '#bbf7d0'; borderColor = '#22c55e'; // 依赖:绿色 - } else { - bgColor = '#fecaca'; borderColor = '#ef4444'; // 被依赖:红色 - } + }, 140); + }, - nodes.push({ - id: type, - label: this.formatTypeName(type), - title: `类型: ${type}\n层级: ${level}\n${direction === 'dependency' ? '← 依赖' : '→ 被依赖'}`, - color: { background: bgColor, border: borderColor }, - level: direction === 'dependency' ? -level : level, - data: { type: 'type', fullType: type, packagePath: typePkgMap.get(type) || '' } - }); - nodeMap.set(type, true); - } - }); + renderModulesGraph() { + const helpers = this.graphHelpers(); + if (!helpers || !helpers.buildModuleMapGraph) { + console.warn('[dix] graph helpers unavailable for module map'); + return; + } + this.focusedType = null; + this.focusedGraph = null; + this.focusedGroup = null; - // 添加边 - this.allData.providers.forEach(provider => { - const outputType = provider.output_type; - (provider.input_types || []).forEach(inputType => { - if (nodeMap.has(inputType) && nodeMap.has(outputType)) { - const edgeId = `${inputType}->${outputType}`; - if (!nodeMap.has(edgeId)) { - nodeMap.set(edgeId, true); - edges.push({ - from: inputType, - to: outputType, - arrows: 'to', - color: { color: '#9ca3af' } - }); - } - } - }); + let { nodes, edges } = helpers.buildModuleMapGraph(this.modulesData || [], { + providers: (this.allData && this.allData.providers) || [], + minDepth: 5, + fatThreshold: 12, }); - - // 渲染图形 - const aggregated = this.aggregateByGroups(nodes, edges, new Set([targetType])); - const filteredByPrefix = this.filterByPrefix(aggregated.nodes, aggregated.edges, new Set([targetType])); - + // Don't apply substring filterPrefix here — package tree ids are relative paths. const container = document.getElementById('network'); - const graphData = { - nodes: new vis.DataSet(filteredByPrefix.nodes), - edges: new vis.DataSet(filteredByPrefix.edges) + nodes = nodes.map((n) => ({ + ...n, + font: { ...(n.font || {}), size: 13 }, + widthConstraint: { maximum: 150 }, + data: { ...(n.data || {}), displayLabel: (n.data && n.data.displayLabel) || n.label }, + })); + const data = { + nodes: new vis.DataSet(nodes), + edges: new vis.DataSet(edges), + }; + this.lastGraphData = data; + + // Package TREE: hierarchical by path depth; containment edges only. + const options = this.getNetworkOptions('hierarchical'); + options.layout = { + hierarchical: { + enabled: true, + direction: 'UD', + sortMethod: 'directed', + shakeTowards: 'roots', + blockShifting: true, + edgeMinimization: true, + parentCentralization: true, + levelSeparation: 180, + nodeSpacing: 220, + treeSpacing: 240, + }, + }; + options.physics = { enabled: false }; + options.edges = { + ...(options.edges || {}), + smooth: { type: 'cubicBezier', roundness: 0.2 }, + width: 1.25, + color: { color: '#94a3b8', opacity: 0.85 }, + }; + options.interaction = { + ...(options.interaction || {}), + hover: true, + zoomView: true, + dragView: true, }; - this.lastGraphData = graphData; - this.lastGraphData = graphData; - this.lastGraphData = graphData; if (this.network) { this.network.destroy(); } - - this.network = new vis.Network(container, graphData, this.getNetworkOptions()); - - // 聚焦到目标节点 - setTimeout(() => { - try { - this.network.selectNodes([targetType]); - this.network.focus(targetType, { scale: 1, animation: true }); - } catch (e) { } - }, 100); + this.network = new vis.Network(container, data, options); this.network.on('click', params => { if (params.nodes.length > 0) { const nodeId = params.nodes[0]; - const node = graphData.nodes.get(nodeId); + const node = data.nodes.get(nodeId); this.selectedNode = node; - if (node && node.data && node.data.type === 'provider') { - this.openProviderDetailModal(node); - } + } else { + this.selectedNode = null; } }); - - // 双击展开该节点的依赖 this.network.on('doubleClick', params => { if (params.nodes.length > 0) { const nodeId = params.nodes[0]; - const node = graphData.nodes.get(nodeId); - if (node && node.data && node.data.type === 'group') { - this.toggleGroupExpand(node.data.group); - return; - } - if (node && node.data && node.data.fullType) { - this.showDependencyGraph(node.data.fullType, 'type'); + const node = data.nodes.get(nodeId); + if (node && node.data && node.data.type === 'module') { + const pkg = (node.data.packagePath || node.data.module && node.data.module.name || node.id); + this.selectPackage(pkg); } } }); + this.network.on('zoom', () => { + this.applyLabelLod(); + this.refreshGraphScaleLabel(); + }); + + setTimeout(() => { + if (!this.network) return; + this.network.fit({ animation: false, padding: 56 }); + // If still microscopic, focus the root instead of a hairline. + let scale = 1; + try { scale = this.network.getScale(); } catch { scale = 1; } + if (scale < 0.45 && nodes.length) { + const root = nodes.reduce((a, b) => ((a.level || 99) <= (b.level || 99) ? a : b), nodes[0]); + this.network.focus(root.id, { scale: 0.85, animation: false }); + } + this.applyLabelLod(); + }, 100); + const maxLevel = nodes.reduce((m, n) => Math.max(m, n.level || 1), 1); + this.densityWarning = { + show: true, + message: `模块包树:深度 ${maxLevel} · ${nodes.length} 个包节点(含路径父节点)。过肥包拆成 app/plugins/workers 等桶。双击进入 Providers。`, + hubs: [], + suggestModules: false, + suggestAggregate: false, + suggestKeepBusiness: false, + expanded: false, + minimized: true, + }; }, renderGraph() { + if (this.currentView === 'findings') { + if (this.network) { + try { this.network.destroy(); } catch (e) { /* ignore */ } + this.network = null; + } + this.lastGraphData = null; + this.graphScaleLabel = ''; + this.pyramidStatus = ''; + this.showCanvasGuide = false; + this.densityWarning = { + ...(this.densityWarning || {}), + show: false, + message: '', + hubs: [], + }; + if (!this.architectureFindings.length && this.allData) { + this.refreshArchitectureFindings(); + } + return; + } + if (this.currentView === 'modules') { + this.renderModulesGraph(); + return; + } if (!this.allData) return; this.focusedType = null; @@ -1468,13 +2058,59 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 const typePkgMap = this.buildTypePkgMap(); if (this.currentView === 'providers') { - this.allData.providers.forEach((provider) => { - const providerNodeId = provider.id; - const fnName = this.providerNodeLabel(provider); - const outputTypes = this.providerOutputTypes(provider); - - // Provider node - if (!nodeMap.has(providerNodeId)) { + const helpers = this.graphHelpers(); + const depth = this.parseDepthValue(2); + const seedIds = this.pyramidFocusProviderId ? [this.pyramidFocusProviderId] : []; + if (helpers && helpers.buildProvidersPyramidView) { + const built = helpers.buildProvidersPyramidView(this.allData.providers || [], { + depth, + packageName: this.currentPackage || '', + seedProviderIds: seedIds, + }); + const focusRoles = (seedIds.length && helpers.classifyRelatedRoles) + ? helpers.classifyRelatedRoles(built.nodes, built.edges, seedIds) + : new Map(); + built.nodes.forEach((n) => { + const provider = n.data || {}; + const label = this.providerNodeLabel(provider) || n.label; + const role = focusRoles.get(n.id) || ''; + nodes.push({ + ...n, + label, + title: this.buildProviderTooltip(provider) + + (role ? `\n角色: ${role}` : ''), + color: this.getProviderNodeColor(provider, role), + shape: 'box', + font: { size: 11 }, + level: n.level || 1, + data: { + ...provider, + type: 'provider', + packagePath: provider.output_pkg || provider.function_pkg || '', + displayLabel: label, + pyramidLevel: n.level || 1, + focusRole: role, + }, + }); + nodeMap.set(n.id, true); + }); + built.edges.forEach((e) => edges.push({ ...e })); + this._lastPyramidMeta = { + entries: built.entries || [], + depth: built.depth, + totalProviders: (this.allData.providers || []).length, + focused: seedIds.length > 0, + }; + this.updatePyramidMaxLevel(built.levels); + if (this._lastPyramidMeta) { + this._lastPyramidMeta.maxLevel = this.pyramidMaxLevel; + } + } else { + // Fallback: provider nodes only, no type ellipses. + this.allData.providers.forEach((provider) => { + const providerNodeId = provider.id; + if (nodeMap.has(providerNodeId)) return; + const fnName = this.providerNodeLabel(provider); nodes.push({ id: providerNodeId, label: fnName, @@ -1482,179 +2118,463 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 color: this.getProviderNodeColor(provider), shape: 'box', font: { size: 11 }, - data: { ...provider, type: 'provider', packagePath: provider.function_pkg || '' } + data: { + ...provider, + type: 'provider', + packagePath: provider.output_pkg || provider.function_pkg || '', + }, }); nodeMap.set(providerNodeId, true); + }); + this._lastPyramidMeta = null; + } + } else if (this.currentView === 'types') { + const helpers = this.graphHelpers(); + const depth = this.parseDepthValue(2); + const seedIds = this.pyramidFocusTypeId ? [this.pyramidFocusTypeId] : []; + if (helpers && helpers.buildTypesPyramidView) { + const built = helpers.buildTypesPyramidView(this.allData, { + depth, + packageName: this.currentPackage || '', + seedTypeIds: seedIds, + }); + const focusRoles = (seedIds.length && helpers.classifyRelatedRoles) + ? helpers.classifyRelatedRoles(built.nodes, built.edges, seedIds) + : new Map(); + built.nodes.forEach((n) => { + const fullType = (n.data && n.data.fullType) || n.id; + const pkgPath = (n.data && n.data.packagePath) || typePkgMap.get(fullType) || ''; + const label = (n.data && n.data.displayLabel) || n.label + || (helpers.labelTypeWithPackage + ? helpers.labelTypeWithPackage(fullType, pkgPath) + : this.formatTypeName(fullType)); + const role = focusRoles.get(n.id) || ''; + const roleColor = this.focusRoleColor(role); + nodes.push({ + ...n, + label, + title: (pkgPath ? pkgPath + '\n' : '') + fullType + + (role ? `\n角色: ${role}` : ''), + color: roleColor || (n.color || { background: '#bfdbfe', border: '#3b82f6' }), + shape: 'ellipse', + level: n.level || 1, + data: { + ...(n.data || {}), + type: 'type', + fullType, + packagePath: pkgPath, + displayLabel: label, + pyramidLevel: n.level || 1, + focusRole: role, + }, + }); + nodeMap.set(n.id, true); + }); + built.edges.forEach((e) => edges.push({ ...e })); + this._lastPyramidMeta = { + entries: built.entries || [], + depth: built.depth, + totalTypes: built.levels ? built.levels.size : nodes.length, + kind: 'types', + focused: seedIds.length > 0, + }; + this.updatePyramidMaxLevel(built.levels); + if (this._lastPyramidMeta) { + this._lastPyramidMeta.maxLevel = this.pyramidMaxLevel; } - - // Output type nodes + Provider -> Output edges - outputTypes.forEach((outType) => { - if (!nodeMap.has(outType)) { + } else { + this.allData.edges.forEach(edge => { + if (edge.type !== 'provider') return; + if (!nodeMap.has(edge.from)) { nodes.push({ - id: outType, - label: this.formatTypeName(outType), - title: '类型: ' + outType, + id: edge.from, + label: this.formatTypeName(edge.from), + title: '类型: ' + edge.from, color: { background: '#bfdbfe', border: '#3b82f6' }, - shape: 'ellipse', - font: { size: 10 }, - data: { type: 'type', fullType: outType, packagePath: typePkgMap.get(outType) || provider.output_pkg || '' } + data: { type: 'type', fullType: edge.from, packagePath: typePkgMap.get(edge.from) || '' } }); - nodeMap.set(outType, true); + nodeMap.set(edge.from, true); } - - edges.push({ - from: providerNodeId, - to: outType, - arrows: 'to', - color: { color: '#22c55e' } - }); - }); - - // Input types - (provider.input_types || []).forEach(inputType => { - if (!nodeMap.has(inputType)) { + if (!nodeMap.has(edge.to)) { nodes.push({ - id: inputType, - label: this.formatTypeName(inputType), - title: '类型: ' + inputType, + id: edge.to, + label: this.formatTypeName(edge.to), + title: '类型: ' + edge.to, color: { background: '#bfdbfe', border: '#3b82f6' }, - shape: 'ellipse', - font: { size: 10 }, - data: { type: 'type', fullType: inputType, packagePath: typePkgMap.get(inputType) || '' } + data: { type: 'type', fullType: edge.to, packagePath: typePkgMap.get(edge.to) || '' } }); - nodeMap.set(inputType, true); + nodeMap.set(edge.to, true); } - edges.push({ - from: inputType, - to: providerNodeId, + from: edge.to, + to: edge.from, arrows: 'to', - dashes: true, - color: { color: '#f59e0b' } + color: { color: '#9ca3af' } }); }); - }); - } else { - // Types view - this.allData.edges.forEach(edge => { - if (edge.type !== 'provider') return; + this._lastPyramidMeta = { kind: 'types' }; + } + } - if (!nodeMap.has(edge.from)) { - nodes.push({ - id: edge.from, - label: this.formatTypeName(edge.from), - title: '类型: ' + edge.from, - color: { background: '#bfdbfe', border: '#3b82f6' }, - data: { type: 'type', fullType: edge.from, packagePath: typePkgMap.get(edge.from) || '' } - }); - nodeMap.set(edge.from, true); - } + // Create network + const aggregated = this.aggregateByGroups(nodes, edges); + // Pyramid views already scoped by package/seeds — avoid substring cuts. + const filteredByPrefix = (this.currentView === 'providers' || this.currentView === 'types') + ? { nodes: aggregated.nodes, edges: aggregated.edges } + : this.filterByPrefix(aggregated.nodes, aggregated.edges); + let ns = filteredByPrefix.nodes; + let es = filteredByPrefix.edges; + + const helpers = this.graphHelpers(); + if (helpers && helpers.applyHiddenNodeSeeds && this.hiddenSeeds.length) { + const cut = helpers.applyHiddenNodeSeeds( + ns, + es, + this.hiddenSeeds + ); + ns = cut.nodes; + es = cut.edges; + if (this.selectedNode && cut.hiddenIds.has(this.selectedNode.id)) { + this.selectedNode = null; + } + } + this.refreshPackageSummary(); + let edgesDropped = 0; + if (this.edgeDeclutter && helpers && helpers.declutterEdges) { + const maxEdges = Math.max(200, ns.length * 3); + const cleaned = helpers.declutterEdges(ns, es, { + maxEdges, + hubLimit: 16, + }); + if (cleaned.decluttered) { + es = cleaned.edges; + edgesDropped = cleaned.dropped || 0; + } + } - if (!nodeMap.has(edge.to)) { - nodes.push({ - id: edge.to, - label: this.formatTypeName(edge.to), - title: '类型: ' + edge.to, - color: { background: '#bfdbfe', border: '#3b82f6' }, - data: { type: 'type', fullType: edge.to, packagePath: typePkgMap.get(edge.to) || '' } - }); - nodeMap.set(edge.to, true); + const cap = helpers && helpers.READABLE_NODE_CAP ? helpers.READABLE_NODE_CAP : 40; + const hierNodeBudget = helpers && helpers.HIERARCHICAL_NODE_BUDGET + ? helpers.HIERARCHICAL_NODE_BUDGET + : 150; + const hierEdgeBudget = helpers && helpers.HIERARCHICAL_EDGE_BUDGET + ? helpers.HIERARCHICAL_EDGE_BUDGET + : 400; + const over = ns.length > cap || es.length > cap * 3; + const prevWarn = this.densityWarning || {}; + const pyramidMeta = this._lastPyramidMeta; + let message = ''; + let suggestKeepBusiness = false; + this.pyramidStatus = ''; + this.showCanvasGuide = false; + if ((this.currentView === 'providers' || this.currentView === 'types') && pyramidMeta && pyramidMeta.entries) { + const entryN = (pyramidMeta.entries || []).length; + const depthLabel = pyramidMeta.depth > 0 ? pyramidMeta.depth : '全部'; + const kindLabel = this.currentView === 'types' ? 'Type' : 'Provider'; + const focused = !!(this.pyramidFocusProviderId || this.pyramidFocusTypeId || pyramidMeta.focused); + if (focused) { + this.pyramidStatus = `聚焦邻域 · ${ns.length} 节点 · ${es.length} 边 · 黄焦点 / 绿依赖 / 红被依赖`; + } else { + this.pyramidStatus = `${kindLabel} · ${entryN} 入口 · ${depthLabel} 层 · ${ns.length} 节点`; + } + const unscoped = !this.currentPackage && !this.pyramidFocusProviderId && !this.pyramidFocusTypeId; + this.showCanvasGuide = !this.canvasGuideDismissed + && !focused + && unscoped + && (this.currentView === 'providers' || this.currentView === 'types') + && (entryN > 12 || ns.length === 0); + if (entryN > 12 && unscoped) { + message = `入口较多(${entryN})。可选左侧包缩小范围`; + if (this.currentView === 'providers' && !(this.hiddenSeeds && this.hiddenSeeds.length)) { + message += ',或「只留业务入口」收敛 diag / Dix / plugins。'; + suggestKeepBusiness = true; + } else { + message += '。'; } - - edges.push({ - from: edge.from, - to: edge.to, - arrows: 'to', - color: { color: '#9ca3af' } - }); + } + } else if (over) { + message = `图规模较大(${ns.length} 节点 / ${es.length} 边)。可先看模块地图,或缩小左侧包范围。`; + } + if (edgesDropped > 0) { + message += (message ? ' ' : '') + + `已隐藏 ${edgesDropped} 条次要边。`; + } + const actionable = !!(suggestKeepBusiness || over || edgesDropped > 0); + this.densityWarning = { + show: actionable, + message, + hubs: (over && helpers && helpers.rankHubNodes) ? helpers.rankHubNodes(ns, es, 8) : [], + suggestModules: over && this.currentView !== 'modules', + suggestAggregate: over && !this.aggregateGroups && (this.groupRules || []).length > 0, + suggestKeepBusiness, + edgesDropped, + expanded: false, + minimized: actionable ? (prevWarn.show ? !!prevWarn.minimized : true) : false, + }; + if (helpers && helpers.applyGraphBudget + && this.currentView !== 'providers' + && this.currentView !== 'types' + && (ns.length > hierNodeBudget || es.length > hierEdgeBudget)) { + const beforeN = ns.length; + const bounded = helpers.applyGraphBudget(ns, es, { + nodes: hierNodeBudget, + edges: hierEdgeBudget, }); + ns = bounded.nodes; + es = bounded.edges; + if (bounded.degraded) { + this.densityWarning.show = true; + this.densityWarning.message = (this.densityWarning.message + ? this.densityWarning.message + ' ' + : '') + + `为可读性保留约 ${ns.length}/${beforeN} 个高连通节点;完整列表见右侧清单或缩小包范围。`; + if (!prevWarn.show) this.densityWarning.minimized = true; + } } - // Create network - const aggregated = this.aggregateByGroups(nodes, edges); - const filteredByPrefix = this.filterByPrefix(aggregated.nodes, aggregated.edges); - const container = document.getElementById('network'); + const preferred = this.currentLayout === 'force' ? 'physics' : 'hierarchical'; + const stripRisk = helpers && helpers.estimateHierarchicalStripRisk + ? helpers.estimateHierarchicalStripRisk(ns, es) + : { wide: false }; + const effective = helpers && helpers.resolveEffectiveLayout + ? helpers.resolveEffectiveLayout(preferred, ns.length) + : preferred; + if ((over || stripRisk.wide) && this.currentLayout === 'hierarchical') { + const scopeTip = stripRisk.wide + ? `图过宽(约 ${stripRisk.roots || '?'} 个根/岛)。请先选左侧包范围或改用模块地图。` + : ''; + if (scopeTip) { + this.densityWarning.show = true; + this.densityWarning.message = (this.densityWarning.message + ? this.densityWarning.message + ' ' + : '') + scopeTip; + if (!this.densityWarning.hubs.length && helpers && helpers.rankHubNodes) { + this.densityWarning.hubs = helpers.rankHubNodes(ns, es, 8); + } + this.densityWarning.suggestModules = this.currentView !== 'modules'; + if (!prevWarn.show) this.densityWarning.minimized = true; + } + } + const options = this.getNetworkOptions(effective); + const fontSize = ns.length <= 12 ? 15 : ns.length <= 24 ? 13 : 11; + ns = ns.map(n => ({ + ...n, + level: n.level || (n.data && n.data.pyramidLevel) || n.level, + font: { ...(n.font || {}), size: Math.max((n.font && n.font.size) || 0, fontSize) }, + data: { ...(n.data || {}), displayLabel: n.label }, + })); const data = { - nodes: new vis.DataSet(filteredByPrefix.nodes), - edges: new vis.DataSet(filteredByPrefix.edges) + nodes: new vis.DataSet(ns), + edges: new vis.DataSet(es) }; this.lastGraphData = data; - const options = this.getNetworkOptions(); - if (this.network) { + this.clearHidePreview(); this.network.destroy(); } this.network = new vis.Network(container, data, options); - // Click event - this.network.on('click', params => { - if (params.nodes.length > 0) { - const nodeId = params.nodes[0]; - const node = data.nodes.get(nodeId); - this.selectedNode = node; - if (node && node.data && node.data.type === 'provider') { - this.openProviderDetailModal(node); - } + const bindGraphInteractions = () => { + if (!this.network) return; + this.network.off('zoom'); + this.network.off('click'); + this.network.off('doubleClick'); + this.network.off('oncontext'); + this.clearHidePreview(); + this.graphContextMenu = null; + const canvas = document.getElementById('network'); + if (canvas && !canvas._dixCtxBound) { + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + canvas._dixCtxBound = true; } + this.network.on('zoom', () => { + this.applyLabelLod(); + this.refreshGraphScaleLabel(); }); - - // Double click to focus - this.network.on('doubleClick', params => { - if (params.nodes.length > 0) { - const nodeId = params.nodes[0]; - const node = data.nodes.get(nodeId); - if (node && node.data && node.data.type === 'group') { - this.toggleGroupExpand(node.data.group); + this.network.on('click', params => { + this.clearHidePreview(); + this.graphContextMenu = null; + if (params.nodes.length > 0) { + const nodeId = params.nodes[0]; + const node = data.nodes.get(nodeId); + // Shift+click: one-step hide (keep main graph tidy). + const ev = params.event && (params.event.srcEvent || params.event); + if (ev && ev.shiftKey) { + this.hideNodeAndDownstream(node); + return; + } + this.selectedNode = node; + } else { + this.selectedNode = null; + } + }); + this.network.on('oncontext', (params) => { + if (params.event && params.event.preventDefault) { + params.event.preventDefault(); + } + const dom = params.pointer && params.pointer.DOM; + const nodeId = dom ? this.network.getNodeAt(dom) : null; + if (!nodeId) { + this.clearHidePreview(); + this.graphContextMenu = null; return; } - if (node && node.data && node.data.type === 'type') { - this.focusOnType(node.data.fullType); + const node = data.nodes.get(nodeId); + if (!node) return; + this.selectedNode = node; + const count = this.estimateHiddenCount(node.id); + const rect = (canvas && canvas.getBoundingClientRect) ? canvas.getBoundingClientRect() : { left: 0, top: 0 }; + this.applyHidePreview(String(node.id)); + this.graphContextMenu = { + x: rect.left + (dom.x || 0), + y: rect.top + (dom.y || 0), + nodeId: String(node.id), + label: this.nodeDisplayLabel(node), + count, + packageOptions: this.packageHideOptions(node), + }; + }); + this.network.on('doubleClick', params => { + this.clearHidePreview(); + this.graphContextMenu = null; + if (params.nodes.length > 0) { + const nodeId = params.nodes[0]; + const node = data.nodes.get(nodeId); + if (node && node.data && node.data.type === 'group') { + this.toggleGroupExpand(node.data.group); + return; + } + if (node && node.data && node.data.type === 'module') { + this.filterPrefix = (node.data.module && node.data.module.name) || node.id; + this.switchView('providers'); + return; + } + if (node && node.data && node.data.type === 'provider') { + this.focusProviderNeighborhood(node); + return; + } + if (node && node.data && node.data.type === 'type') { + this.focusTypeNeighborhood(node); + return; + } + if (node && node.data) { + if (node.data.output_type) { + this.focusOnType(node.data.output_type); + } + } + } + }); + }; + + const applyReadableCamera = () => { + if (!this.network) return; + this.network.fit({ animation: false, padding: 48 }); + let scale = 1; + try { + scale = this.network.getScale(); + } catch { + scale = 1; + } + const cam = helpers && helpers.resolvePostFitCamera + ? helpers.resolvePostFitCamera(scale) + : (scale < 0.55 ? 'focus' : 'fit'); + if (cam === 'focus') { + if (this.currentLayout === 'hierarchical' && ns.length > 40) { + const pos = this.network.getViewPosition(); + this.network.moveTo({ scale: 0.32, position: pos, animation: false }); + this.densityWarning.show = true; + this.densityWarning.message = (this.densityWarning.message + ? this.densityWarning.message + ' ' + : '') + + `层级图较宽,当前约 ${ns.length} 个节点在画布中;拖拽查看,或缩小包范围 / 改用模块地图。`; + if (!prevWarn.show) this.densityWarning.minimized = true; + } else { + const focusId = helpers && helpers.pickFocusNodeId + ? helpers.pickFocusNodeId(ns, es, this.filterPrefix || this.focusedType || '') + : (ns[0] && ns[0].id); + if (focusId) { + this.network.focus(focusId, { scale: 1.05, animation: false }); + } + if (!this.densityWarning.show) { + this.densityWarning.show = true; + this.densityWarning.message = '数据过多导致全图过小。请缩小包范围或改用模块地图。'; + this.densityWarning.suggestModules = this.currentView !== 'modules'; + this.densityWarning.hubs = helpers && helpers.rankHubNodes + ? helpers.rankHubNodes(ns, es, 8) + : []; + this.densityWarning.minimized = true; + } } } - }); + this.applyLabelLod(); + }; + + bindGraphInteractions(); + if (effective === 'hierarchical') { + setTimeout(applyReadableCamera, 80); + } else { + this.network.once('stabilizationIterationsDone', applyReadableCamera); + setTimeout(applyReadableCamera, 450); + } }, - getNetworkOptions(forceHierarchical = false) { - const isHierarchical = forceHierarchical || this.currentLayout === 'hierarchical'; - const levelSeparation = 120; - const nodeSpacing = 150; - const treeSpacing = 150; + getNetworkOptions(layoutOverride = null) { + const mode = layoutOverride != null + ? layoutOverride + : (this.currentLayout === 'force' ? 'physics' : 'hierarchical'); + const isHierarchical = mode === 'hierarchical' || mode === true; + const usePhysics = mode === 'physics' || mode === 'force' || mode === false; + const architecturePyramid = (this.currentView === 'providers' || this.currentView === 'types') && isHierarchical; + // Boxes/ellipses need spacing wider than node width or siblings glue together. + const levelSeparation = architecturePyramid ? 200 : 150; + const nodeSpacing = architecturePyramid ? 260 : 180; + const treeSpacing = architecturePyramid ? 280 : 200; return { nodes: { shape: 'box', - font: { size: 12, face: 'system-ui, sans-serif' }, + font: { size: architecturePyramid ? 13 : 12, face: 'system-ui, sans-serif' }, borderWidth: 2, shadow: { enabled: true, size: 5, x: 2, y: 2 }, - margin: 8 + margin: 12, + widthConstraint: { maximum: architecturePyramid ? 140 : 160 }, }, edges: { - width: 1.5, + width: architecturePyramid ? 1 : 1.5, + color: architecturePyramid + ? { color: '#c4c9d4', opacity: 0.55, highlight: '#64748b' } + : undefined, smooth: isHierarchical - ? { type: 'cubicBezier', roundness: 0.4 } + ? { type: 'cubicBezier', roundness: architecturePyramid ? 0.25 : 0.4 } : { type: 'continuous' } }, physics: { - enabled: !isHierarchical, - stabilization: { iterations: 150 }, + enabled: usePhysics && !isHierarchical, + stabilization: { iterations: 180 }, barnesHut: { - gravitationalConstant: -2000, - springLength: 150 + gravitationalConstant: -3500, + springLength: 180, + springConstant: 0.04, + avoidOverlap: 0.8, } }, interaction: { hover: true, tooltipDelay: 100, zoomView: true, - dragView: true + dragView: true, + multiselect: false, }, layout: isHierarchical ? { hierarchical: { enabled: true, direction: 'UD', sortMethod: 'directed', + shakeTowards: 'roots', + blockShifting: true, + edgeMinimization: true, + parentCentralization: true, levelSeparation, nodeSpacing, treeSpacing @@ -1665,6 +2585,121 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 }; }, + applyLabelLod() { + if (!this.network || !this.lastGraphData || !this.lastGraphData.nodes) return; + const helpers = this.graphHelpers(); + if (!helpers || !helpers.labelLodVisibleIds) return; + let scale = 1; + try { + scale = this.network.getScale(); + } catch { + scale = 1; + } + const nodes = this.lastGraphData.nodes.get(); + const edges = this.lastGraphData.edges.get(); + const count = nodes.length; + const hideBelow = count > 80 ? 0.9 : (count > 50 ? 0.8 : 0.7); + const hubLimit = count > 100 ? 8 : (count > 60 ? 10 : 12); + const visible = helpers.labelLodVisibleIds(nodes, edges, scale, { hubLimit, hideBelow }); + if (this.selectedNode && this.selectedNode.id) visible.add(this.selectedNode.id); + if (this.pyramidFocusProviderId) visible.add(this.pyramidFocusProviderId); + if (this.pyramidFocusTypeId) visible.add(this.pyramidFocusTypeId); + nodes.forEach((n) => { + if (n && n.data && (n.data.focusRole === 'seed' || n.data.focusRole === 'both')) { + visible.add(n.id); + } + }); + const updates = nodes.map(n => { + const full = (n.data && n.data.displayLabel) || n.label || ''; + const show = visible.has(n.id); + return { + id: n.id, + label: show ? full : '·', + font: { + ...(n.font || {}), + size: show ? Math.max((n.font && n.font.size) || 12, 11) : 9, + }, + }; + }); + this.lastGraphData.nodes.update(updates); + }, + fitGraph() { + if (!this.network) return; + this.network.fit({ animation: { duration: 280, easingFunction: 'easeInOutQuad' }, padding: 48 }); + setTimeout(() => { + this.applyLabelLod(); + this.refreshGraphScaleLabel(); + }, 300); + }, + + refreshPackageSummary() { + const helpers = this.graphHelpers(); + if (!this.allData || !helpers) { + this.packageSummary = { providers: [], types: [] }; + return; + } + if (helpers.buildProviderInventory) { + this.packageSummary = helpers.buildProviderInventory( + this.allData, + this.currentPackage || "" + ); + return; + } + if (!this.currentPackage || !helpers.buildPackageSummary) { + this.packageSummary = { providers: [], types: [] }; + return; + } + this.packageSummary = helpers.buildPackageSummary(this.allData, this.currentPackage); + }, + + filteredInventoryProviders() { + const q = String(this.inventorySearch || this.globalSearch || "").trim().toLowerCase(); + const list = (this.packageSummary && this.packageSummary.providers) || []; + if (!q) return list; + return list.filter((p) => { + const hay = `${p.function_name || ""} ${p.output_type || ""} ${p.output_pkg || ""} ${p.label || ""}`.toLowerCase(); + return hay.includes(q); + }); + }, + focusSummaryProvider(item) { + if (!item) return; + const provider = (this.allData?.providers || []).find((p) => p.id === item.id) + || (this.allData?.providers || []).find((p) => p.function_name === item.function_name); + if (!provider) return; + this.focusProviderNeighborhood({ + id: provider.id, + data: { ...provider, type: 'provider' }, + }); + }, + + focusSummaryType(item) { + if (!item || !item.id) return; + this.focusTypeNeighborhood({ + id: item.id, + data: { + fullType: item.fullType || item.id, + packagePath: item.packagePath || '', + type: 'type', + }, + }); + }, + + focusHubNode() { + if (!this.network || !this.lastGraphData) return; + const helpers = this.graphHelpers(); + const nodes = this.lastGraphData.nodes.get(); + const edges = this.lastGraphData.edges.get(); + const focusId = helpers && helpers.pickFocusNodeId + ? helpers.pickFocusNodeId(nodes, edges, this.filterPrefix || this.focusedType || '') + : (nodes[0] && nodes[0].id); + if (!focusId) return; + this.network.focus(focusId, { + scale: 1.2, + animation: { duration: 280, easingFunction: 'easeInOutQuad' }, + }); + setTimeout(() => this.applyLabelLod(), 300); + }, + async focusOnType(typeName) { this.loading = true; this.focusedType = typeName; @@ -1738,6 +2773,8 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 if (node && node.data && node.data.type === 'provider') { this.openProviderDetailModal(node); } + } else { + this.selectedNode = null; } }); @@ -1875,12 +2912,46 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 if (node && node.data && node.data.type === 'provider') { this.openProviderDetailModal(node); } + } else { + this.selectedNode = null; } }); }, // Helpers + packageBucket(name) { + const parts = String(name || '').split('/').filter(Boolean); + for (const key of ['domain', 'plugins', 'infra', 'app', 'bootstrap', 'router']) { + if (parts.includes(key)) return key; + } + return 'other'; + }, + + packageLeafLabel(name) { + const parts = String(name || '').split('/').filter(Boolean); + for (const key of ['domain', 'plugins', 'infra']) { + const i = parts.indexOf(key); + if (i >= 0) { + const rest = parts.slice(i + 1).join('/'); + return rest || key; + } + } + return this.formatPackageName(name); + }, + + isPackageGroupOpen(key) { + if (Object.prototype.hasOwnProperty.call(this.packageGroupOpen || {}, key)) { + return !!this.packageGroupOpen[key]; + } + return key === 'domain' || key === 'app' || key === 'plugins'; + }, + + togglePackageGroup(key) { + const open = !this.isPackageGroupOpen(key); + this.packageGroupOpen = { ...(this.packageGroupOpen || {}), [key]: open }; + }, + formatPackageName(name) { if (!name) return '(anonymous)'; const parts = name.split('/'); @@ -1892,6 +2963,10 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 formatTypeName(typeName) { if (!typeName) return ''; + const helpers = this.graphHelpers(); + if (helpers && helpers.shortGraphLabel) { + return helpers.shortGraphLabel(typeName); + } let t = typeName.replace(/^\*/, '').replace(/^\[\]/, ''); const lastSlash = t.lastIndexOf('/'); if (lastSlash > -1) { @@ -1905,6 +2980,10 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 formatFunctionName(fnName) { if (!fnName) return 'unknown'; + const helpers = this.graphHelpers(); + if (helpers && helpers.shortGraphLabel) { + return helpers.shortGraphLabel(fnName); + } const lastSlash = fnName.lastIndexOf('/'); let name = lastSlash > -1 ? fnName.substring(lastSlash + 1) : fnName; if (name.length > 35) { @@ -2007,9 +3086,13 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 }, providerNodeLabel(provider) { - const base = this.formatFunctionName(provider.function_name); + const helpers = this.graphHelpers(); + const base = (helpers && helpers.providerDisplayLabel) + ? helpers.providerDisplayLabel(provider) + : (this.formatTypeName(provider.output_type) + || this.formatFunctionName(provider.function_name)); if (this.isProviderErrored(provider)) { - return `🚨 ${base}`; + return `! ${base}`; } return base; }, @@ -2030,16 +3113,59 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 return '最近有错误事件'; }, - getProviderNodeColor(provider) { + getProviderNodeColor(provider, focusRole = '') { if (this.isProviderErrored(provider)) { return { background: '#fee2e2', border: '#b91c1c' }; } if (this.isProviderTimedOut(provider)) { return { background: '#fecaca', border: '#dc2626' }; } + const roleColor = this.focusRoleColor(focusRole); + if (roleColor) return roleColor; return { background: '#bbf7d0', border: '#22c55e' }; }, + focusRoleColor(role) { + switch (role) { + case 'seed': + return { background: '#fde68a', border: '#f59e0b' }; + case 'dependency': + return { background: '#bbf7d0', border: '#16a34a' }; + case 'dependent': + return { background: '#fecaca', border: '#dc2626' }; + case 'both': + return { background: '#e9d5ff', border: '#9333ea' }; + default: + return null; + } + }, + + focusChipLabel() { + const id = this.pyramidFocusProviderId || this.pyramidFocusTypeId; + if (!id) return ''; + if (String(id).includes('\x00')) { + const [pkg, type] = String(id).split('\x00'); + const helpers = this.graphHelpers(); + if (helpers && helpers.labelTypeWithPackage) { + return helpers.labelTypeWithPackage(type, pkg); + } + return type || id; + } + if (this.lastGraphData && this.lastGraphData.nodes) { + const n = this.lastGraphData.nodes.get(id); + if (n) return this.nodeDisplayLabel(n) || id; + } + const s = String(id); + return s.length > 36 ? ('…' + s.slice(-34)) : s; + }, + + clearPyramidFocus() { + this.pyramidFocusProviderId = null; + this.pyramidFocusTypeId = null; + this.renderGraph(); + this.fitGraphCameraSoon(); + }, + findProviderByRuntimeStat(stat) { return (this.allData?.providers || []).find(p => p.function_name === stat.function_name && p.output_type === stat.output_type @@ -2132,7 +3258,7 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 const err = this.getProviderErrorFor(provider); if (err) { - tip += '🚨 最近错误: ' + (err.message || '-') + '\n'; + tip += '最近错误: ' + (err.message || '-') + '\n'; if (err.stage) { tip += '阶段: ' + err.stage + '\n'; } @@ -2211,6 +3337,31 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 if (typeof data.aggregateGroups === 'boolean') { this.aggregateGroups = data.aggregateGroups; } + if (typeof data.edgeDeclutter === 'boolean') { + this.edgeDeclutter = data.edgeDeclutter; + } + if (typeof data.persistHideSeeds === 'boolean') { + this.persistHideSeeds = data.persistHideSeeds; + } + if (typeof data.sidebarCollapsed === 'boolean') { + this.sidebarCollapsed = data.sidebarCollapsed; + } + if (typeof data.rightPanelCollapsed === 'boolean') { + this.rightPanelCollapsed = data.rightPanelCollapsed; + } + if (data.rightPanelTab === 'inventory' || data.rightPanelTab === 'detail') { + this.rightPanelTab = data.rightPanelTab; + } + if (data.currentView === 'providers' || data.currentView === 'types' + || data.currentView === 'modules' || data.currentView === 'findings') { + this.currentView = data.currentView; + } + if (typeof data.currentLayout === 'string' + && ['hierarchical', 'force'].includes(data.currentLayout)) { + this.currentLayout = data.currentLayout; + } else if (data.currentLayout === 'panorama') { + this.currentLayout = 'hierarchical'; + } if (Array.isArray(data.groupRules)) { this.groupRules = data.groupRules.map(g => ({ name: g.name, @@ -2228,6 +3379,13 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 try { const payload = { aggregateGroups: this.aggregateGroups, + edgeDeclutter: this.edgeDeclutter, + persistHideSeeds: this.persistHideSeeds, + sidebarCollapsed: this.sidebarCollapsed, + rightPanelCollapsed: this.rightPanelCollapsed, + rightPanelTab: this.rightPanelTab, + currentView: this.currentView, + currentLayout: this.currentLayout, groupRules: this.groupRules.map(g => ({ name: g.name, prefixes: g.prefixes || [] @@ -2239,6 +3397,756 @@ const API_BASE = window.DIX_BASE || ""; // 由 template.html 内联注入(服务 } }, + normalizeHiddenSeeds(data) { + if (!Array.isArray(data)) return []; + return data.filter((s) => s && (s.id || s.packagePrefix)).map((s) => ({ + id: String(s.id || ('pkg:' + s.packagePrefix)), + label: String(s.label || s.packagePrefix || s.id), + packagePrefix: s.packagePrefix ? String(s.packagePrefix) : undefined, + exact: !!s.exact, + })); + }, + + loadHiddenSeeds() { + try { + const raw = sessionStorage.getItem(this.hiddenSeedsKey); + if (raw) { + this.hiddenSeeds = this.normalizeHiddenSeeds(JSON.parse(raw)); + return; + } + if (this.persistHideSeeds) { + const persisted = localStorage.getItem(this.hiddenSeedsPersistKey); + if (persisted) { + this.hiddenSeeds = this.normalizeHiddenSeeds(JSON.parse(persisted)); + try { + sessionStorage.setItem(this.hiddenSeedsKey, JSON.stringify(this.hiddenSeeds)); + } catch (e) { + // ignore + } + return; + } + } + this.hiddenSeeds = []; + } catch (e) { + console.warn('[dix] failed to load hidden seeds', e); + this.hiddenSeeds = []; + } + }, + + saveHiddenSeeds() { + try { + sessionStorage.setItem(this.hiddenSeedsKey, JSON.stringify(this.hiddenSeeds)); + } catch (e) { + console.warn('[dix] failed to save hidden seeds', e); + } + try { + if (this.persistHideSeeds) { + localStorage.setItem(this.hiddenSeedsPersistKey, JSON.stringify(this.hiddenSeeds)); + } + } catch (e) { + console.warn('[dix] failed to persist hidden seeds', e); + } + this.syncArchitectureUrl(); + }, + + setPersistHideSeeds(enabled) { + this.persistHideSeeds = !!enabled; + this.saveLocalState(); + if (this.persistHideSeeds) { + this.saveHiddenSeeds(); + } else { + try { + localStorage.removeItem(this.hiddenSeedsPersistKey); + } catch (e) { + // ignore + } + } + }, + + scopeChips() { + const chips = []; + if (this.currentPackage) { + chips.push({ + key: 'pkg', + label: '包 ' + this.packageLeafLabel(this.currentPackage), + title: this.currentPackage, + }); + } + if (this.filterPrefix) { + chips.push({ + key: 'filter', + label: '过滤 ' + this.filterPrefix, + title: this.filterPrefix, + }); + } + const depth = String(this.currentDepth || '0'); + if (depth !== '0' && (this.currentView === 'providers' || this.currentView === 'types')) { + chips.push({ + key: 'depth', + label: '深度 ' + depth, + title: '金字塔截断深度', + }); + } + return chips; + }, + + clearScopeChip(key) { + if (key === 'pkg') { + this.selectPackage(null); + return; + } + if (key === 'filter') { + this.filterPrefix = ''; + this.renderGraph(); + return; + } + if (key === 'depth') { + this.currentDepth = '0'; + this.onDepthChange(); + } + }, + + async copyText(text) { + const value = String(text || '').trim(); + if (!value) return; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(value); + this.showHideToast('已复制标签', ''); + return; + } + } catch (e) { + // fall through + } + try { + const ta = document.createElement('textarea'); + ta.value = value; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + this.showHideToast('已复制标签', ''); + } catch (e) { + this.showHideToast('复制失败', ''); + } + }, + + copyContextLabel() { + const label = this.graphContextMenu && this.graphContextMenu.label; + this.clearHidePreview(); + this.graphContextMenu = null; + this.copyText(label); + }, + + /** URL wins over session when `hide` is present; also restore depth/pkg. */ + readArchitectureUrlState() { + try { + const params = new URLSearchParams(window.location.search); + if (params.has('hide')) { + this.hiddenSeeds = this.decodeHiddenSeedsParam(params.get('hide')); + try { + sessionStorage.setItem(this.hiddenSeedsKey, JSON.stringify(this.hiddenSeeds)); + } catch (e) { + // ignore + } + } + if (params.has('depth')) { + const d = String(params.get('depth') || '').trim(); + if (d !== '') this.currentDepth = d; + } + if (params.has('pkg')) { + const pkg = String(params.get('pkg') || '').trim(); + this.currentPackage = pkg || null; + if (pkg) this.sidebarCollapsed = false; + } + } catch (e) { + console.warn('[dix] failed to read architecture URL state', e); + } + }, + + encodeHiddenSeedsParam(seeds) { + const helpers = this.graphHelpers(); + if (helpers && helpers.encodeHiddenSeedsForUrl) { + return helpers.encodeHiddenSeedsForUrl(seeds); + } + return (seeds || []) + .map((s) => { + if (!s) return ''; + if (s.packagePrefix) return 'pkg:' + s.packagePrefix; + if (s.exact && s.id) return 'exact:' + s.id; + return String(s.id || ''); + }) + .filter(Boolean) + .join('|'); + }, + + decodeHiddenSeedsParam(param) { + const helpers = this.graphHelpers(); + if (helpers && helpers.decodeHiddenSeedsFromUrl) { + return helpers.decodeHiddenSeedsFromUrl(param); + } + if (param == null || String(param).trim() === '') return []; + return String(param).split('|').map((part) => { + const token = String(part || '').trim(); + if (!token) return null; + if (token.startsWith('pkg:')) { + const pref = token.slice(4).trim(); + if (!pref) return null; + return { + id: 'pkg:' + pref, + label: pref.split('/').filter(Boolean).slice(-2).join('/') || pref, + packagePrefix: pref, + }; + } + if (token.startsWith('exact:')) { + const id = token.slice(6).trim(); + if (!id) return null; + return { id, label: id, exact: true }; + } + return { id: token, label: token }; + }).filter(Boolean); + }, + + syncArchitectureUrl() { + try { + const url = new URL(window.location.href); + const encoded = this.encodeHiddenSeedsParam(this.hiddenSeeds); + if (encoded) url.searchParams.set('hide', encoded); + else url.searchParams.delete('hide'); + + const depth = String(this.currentDepth ?? '').trim(); + if (depth && depth !== '2') url.searchParams.set('depth', depth); + else url.searchParams.delete('depth'); + + if (this.currentPackage) url.searchParams.set('pkg', this.currentPackage); + else url.searchParams.delete('pkg'); + + const next = url.pathname + url.search + url.hash; + const cur = window.location.pathname + window.location.search + window.location.hash; + if (next !== cur) { + history.replaceState(null, '', next); + } + } catch (e) { + console.warn('[dix] failed to sync architecture URL', e); + } + }, + + cloneHiddenSeeds(seeds) { + return (seeds || []).map((s) => ({ + id: String(s.id), + label: String(s.label || s.id), + packagePrefix: s.packagePrefix ? String(s.packagePrefix) : undefined, + exact: !!s.exact, + })); + }, + + pushHideHistory() { + const snap = this.cloneHiddenSeeds(this.hiddenSeeds); + const next = [...(this.hideHistory || []), snap]; + const max = this.hideHistoryMax || 20; + this.hideHistory = next.length > max ? next.slice(next.length - max) : next; + }, + + selectedNodeHideLabel() { + return this.nodeDisplayLabel(this.selectedNode); + }, + + nodeDisplayLabel(node) { + if (!node) return ''; + const d = node.data || {}; + const raw = d.displayLabel || node.label || d.function_name || d.fullType || d.group + || d.packagePath || node.id || ''; + return String(raw).split('\n')[0].trim(); + }, + + estimateHiddenCount(nodeId) { + const helpers = this.graphHelpers(); + if (!helpers || !helpers.collectDownstreamNodeIds || !this.lastGraphData) { + return 1; + } + try { + const nodes = this.lastGraphData.nodes.get(); + const edges = this.lastGraphData.edges.get(); + return helpers.collectDownstreamNodeIds(nodes, edges, [String(nodeId)]).size || 1; + } catch (e) { + return 1; + } + }, + + estimatePackageHideCount(prefix) { + const helpers = this.graphHelpers(); + if (!helpers || !this.lastGraphData) return 0; + try { + const nodes = this.lastGraphData.nodes.get(); + const edges = this.lastGraphData.edges.get(); + const expanded = helpers.expandHiddenSeedsToNodeIds + ? helpers.expandHiddenSeedsToNodeIds(nodes, [{ packagePrefix: prefix }]) + : []; + if (!expanded.length) return 0; + return helpers.collectDownstreamNodeIds(nodes, edges, expanded).size || expanded.length; + } catch (e) { + return 0; + } + }, + + nodePackagePath(node) { + if (!node) return ''; + const d = node.data || {}; + if (d.type === 'module') { + return String(d.packagePath || node.id || ''); + } + return String(d.packagePath || d.output_pkg || '').trim(); + }, + + packageHideOptions(node) { + const pkg = this.nodePackagePath(node); + if (!pkg) return []; + const opts = []; + const push = (prefix, shortLabel) => { + if (!prefix || opts.some((o) => o.prefix === prefix)) return; + opts.push({ + prefix, + label: shortLabel || prefix.split('/').slice(-2).join('/'), + count: this.estimatePackageHideCount(prefix), + }); + }; + push(pkg, pkg.split('/').filter(Boolean).slice(-2).join('/') || pkg); + const parts = pkg.split('/').filter(Boolean); + const pluginsAt = parts.lastIndexOf('plugins'); + if (pluginsAt >= 0) { + push(parts.slice(0, pluginsAt + 1).join('/'), '…/plugins'); + } + const diagAt = parts.lastIndexOf('diag'); + if (diagAt >= 0) { + push(parts.slice(0, diagAt + 1).join('/'), '…/diag'); + } + const domainAt = parts.lastIndexOf('domain'); + if (domainAt >= 0 && parts.length > domainAt + 1) { + push(parts.slice(0, domainAt + 2).join('/'), '…/domain/' + parts[domainAt + 1]); + } + return opts.filter((o) => o.count > 0); + }, + + clearHidePreview() { + if (!this._hidePreviewBackup || !this.lastGraphData || !this.lastGraphData.nodes) { + this._hidePreviewBackup = null; + return; + } + try { + this.lastGraphData.nodes.update(this._hidePreviewBackup); + } catch (e) { + // network may already be destroyed + } + this._hidePreviewBackup = null; + }, + + applyHidePreview(seedId) { + this.clearHidePreview(); + const helpers = this.graphHelpers(); + if (!helpers || !helpers.collectDownstreamNodeIds || !this.lastGraphData) return; + const nodes = this.lastGraphData.nodes.get(); + const edges = this.lastGraphData.edges.get(); + const hide = helpers.collectDownstreamNodeIds(nodes, edges, [String(seedId)]); + if (!hide.size) return; + const backup = []; + const updates = []; + for (const n of nodes) { + backup.push({ + id: n.id, + color: n.color, + opacity: n.opacity, + borderWidth: n.borderWidth, + }); + if (hide.has(n.id)) { + updates.push({ + id: n.id, + borderWidth: 3, + color: { + background: (n.color && n.color.background) || '#fecaca', + border: '#e11d48', + highlight: n.color && n.color.highlight, + }, + opacity: 1, + }); + } else { + updates.push({ + id: n.id, + opacity: 0.22, + }); + } + } + this._hidePreviewBackup = backup; + try { + this.lastGraphData.nodes.update(updates); + } catch (e) { + this._hidePreviewBackup = null; + } + }, + + fitGraphCameraSoon() { + setTimeout(() => { + if (!this.network) return; + try { + this.network.fit({ animation: { duration: 220, easingFunction: 'easeInOutQuad' }, padding: 48 }); + this.applyLabelLod(); + } catch (e) { + // ignore + } + }, 100); + }, + + showHideToast(message, seedId, shortLabel = '') { + if (this._hideToastTimer) { + clearTimeout(this._hideToastTimer); + this._hideToastTimer = null; + } + const now = Date.now(); + const mergeMs = 2800; + const canUndo = (this.hideHistory || []).length > 0; + if ( + this.hideToast + && this._lastHideToastAt + && (now - this._lastHideToastAt) < mergeMs + && canUndo + ) { + const n = (this.hideToast.mergeCount || 1) + 1; + const recent = shortLabel || message; + this.hideToast = { + message: `已连续隐藏 ${n} 次` + (recent ? ` · 最近「${String(recent).slice(0, 28)}」` : ''), + seedId: String(seedId || this.hideToast.seedId || ''), + canUndo: true, + mergeCount: n, + }; + } else { + this.hideToast = { + message, + seedId: String(seedId || ''), + canUndo, + mergeCount: 1, + }; + } + this._lastHideToastAt = now; + this._hideToastTimer = setTimeout(() => { + this.hideToast = null; + this._hideToastTimer = null; + }, 5000); + }, + + hideNodeAndDownstream(node) { + if (!node || !node.id) return; + const id = String(node.id); + const label = this.nodeDisplayLabel(node) || id; + if (this.hiddenSeeds.some((s) => s.id === id)) { + this.graphContextMenu = null; + this.showHideToast(`「${label}」已在隐藏列表中`, id, label); + return; + } + const count = this.estimateHiddenCount(id); + this.pushHideHistory(); + this.hiddenSeeds = [ + ...this.hiddenSeeds, + { id, label }, + ]; + this.saveHiddenSeeds(); + this.selectedNode = null; + this.clearHidePreview(); + this.graphContextMenu = null; + this.hiddenListOpen = true; + this.renderGraph(); + this.fitGraphCameraSoon(); + const down = Math.max(0, count - 1); + this.showHideToast( + down > 0 ? `已隐藏「${label}」及 ${down} 个下游` : `已隐藏「${label}」`, + id, + label + ); + }, + + hideSelectedNodeAndDownstream() { + this.hideNodeAndDownstream(this.selectedNode); + }, + + hideFromContextMenu() { + if (!this.graphContextMenu) return; + const id = this.graphContextMenu.nodeId; + const node = (this.lastGraphData && this.lastGraphData.nodes) + ? this.lastGraphData.nodes.get(id) + : { id, label: this.graphContextMenu.label, data: { displayLabel: this.graphContextMenu.label } }; + this.hideNodeAndDownstream(node || { id, label: this.graphContextMenu.label }); + }, + + hidePackageFromContextMenu(prefix, label) { + this.hidePackagePrefix(prefix, label); + }, + + hidePackagePrefix(prefix, label) { + const pref = String(prefix || '').trim(); + if (!pref) return; + const id = 'pkg:' + pref; + if (this.hiddenSeeds.some((s) => s.id === id || s.packagePrefix === pref)) { + this.graphContextMenu = null; + this.clearHidePreview(); + this.showHideToast(`「${label || pref}」已在隐藏列表中`, id); + return; + } + const count = this.estimatePackageHideCount(pref); + this.pushHideHistory(); + this.hiddenSeeds = [ + ...this.hiddenSeeds, + { id, label: label || pref.split('/').slice(-2).join('/'), packagePrefix: pref }, + ]; + this.saveHiddenSeeds(); + this.selectedNode = null; + this.clearHidePreview(); + this.graphContextMenu = null; + this.hiddenListOpen = true; + this.renderGraph(); + this.fitGraphCameraSoon(); + this.showHideToast( + count > 1 ? `已隐藏包「${label || pref}」及相关 ${count} 个节点` : `已隐藏包「${label || pref}」`, + id, + label || pref + ); + }, + + /** Invert: hide everything outside the selected node's related neighborhood. */ + keepNeighborhoodOnly(node) { + const target = node || this.selectedNode; + if (!target || !target.id || !this.lastGraphData) return; + const helpers = this.graphHelpers(); + if (!helpers || !helpers.providerRelatedSubgraph) { + this.showHideToast('无法计算邻域', ''); + return; + } + const id = String(target.id); + const label = this.nodeDisplayLabel(target) || id; + const nodes = this.lastGraphData.nodes.get(); + const edges = this.lastGraphData.edges.get(); + const related = helpers.providerRelatedSubgraph(nodes, edges, [id]); + const keep = new Set((related.nodes || []).map((n) => String(n.id))); + if (!keep.size) keep.add(id); + const add = []; + for (const n of nodes) { + const nid = String(n.id); + if (keep.has(nid)) continue; + if (this.hiddenSeeds.some((s) => s.id === nid)) continue; + add.push({ + id: nid, + label: this.nodeDisplayLabel(n) || nid, + exact: true, + }); + } + if (!add.length) { + this.graphContextMenu = null; + this.clearHidePreview(); + this.showHideToast('邻域外没有可隐藏的节点', id, label); + return; + } + this.pushHideHistory(); + this.hiddenSeeds = [...this.hiddenSeeds, ...add]; + this.saveHiddenSeeds(); + this.clearHidePreview(); + this.graphContextMenu = null; + this.hiddenListOpen = true; + // Also enter focus mode so colors match neighborhood + if (target.data && target.data.type === 'type') { + this.pyramidFocusTypeId = id; + this.pyramidFocusProviderId = null; + } else { + this.pyramidFocusProviderId = id; + this.pyramidFocusTypeId = null; + } + this.renderGraph(); + this.fitGraphCameraSoon(); + this.showHideToast( + `只留「${label}」邻域(隐藏 ${add.length} 个节点)`, + add[0].id, + label + ); + }, + + keepNeighborhoodFromContextMenu() { + if (!this.graphContextMenu) return; + const id = this.graphContextMenu.nodeId; + const node = (this.lastGraphData && this.lastGraphData.nodes) + ? this.lastGraphData.nodes.get(id) + : { id, label: this.graphContextMenu.label, data: { displayLabel: this.graphContextMenu.label } }; + this.keepNeighborhoodOnly(node); + }, + + keepSelectedNeighborhoodOnly() { + this.keepNeighborhoodOnly(this.selectedNode); + }, + + previewPackageHide(prefix) { + if (!prefix || !this.lastGraphData) return; + const helpers = this.graphHelpers(); + if (!helpers || !helpers.expandHiddenSeedsToNodeIds) return; + const nodes = this.lastGraphData.nodes.get(); + const edges = this.lastGraphData.edges.get(); + const expanded = helpers.expandHiddenSeedsToNodeIds(nodes, [{ packagePrefix: prefix }]); + if (!expanded.length) return; + // Reuse node preview by temporarily painting expanded set + this.clearHidePreview(); + const hide = helpers.collectDownstreamNodeIds(nodes, edges, expanded); + const backup = []; + const updates = []; + for (const n of nodes) { + backup.push({ + id: n.id, + color: n.color, + opacity: n.opacity, + borderWidth: n.borderWidth, + }); + if (hide.has(n.id)) { + updates.push({ + id: n.id, + borderWidth: 3, + color: { + background: (n.color && n.color.background) || '#fecaca', + border: '#e11d48', + }, + opacity: 1, + }); + } else { + updates.push({ id: n.id, opacity: 0.22 }); + } + } + this._hidePreviewBackup = backup; + try { + this.lastGraphData.nodes.update(updates); + } catch (e) { + this._hidePreviewBackup = null; + } + }, + + keepBusinessEntriesOnly() { + if (!this.lastGraphData) { + this.renderGraph(); + } + const nodes = (this.lastGraphData && this.lastGraphData.nodes) + ? this.lastGraphData.nodes.get() + : []; + const add = []; + for (const n of nodes) { + const out = String((n.data && n.data.output_type) || n.label || ''); + const pkg = String((n.data && n.data.packagePath) || ''); + let hit = false; + let label = this.nodeDisplayLabel(n); + if (out.includes('dixinternal.Dix') || /\/dixinternal$/.test(pkg)) { + hit = true; + label = 'Dix'; + } else if (out.includes('TimeoutProbe')) { + hit = true; + label = 'TimeoutProbe'; + } else if (pkg.includes('/infra/diag') || out.includes('SlowRemote')) { + hit = true; + label = label || 'diag'; + } + if (!hit) continue; + const id = String(n.id); + if (this.hiddenSeeds.some((s) => s.id === id)) continue; + add.push({ id, label }); + } + // Also hide whole plugins package if present on canvas + const pluginPkgs = new Set(); + for (const n of nodes) { + const pkg = String((n.data && n.data.packagePath) || ''); + const parts = pkg.split('/').filter(Boolean); + const i = parts.lastIndexOf('plugins'); + if (i >= 0) pluginPkgs.add(parts.slice(0, i + 1).join('/')); + } + for (const pref of pluginPkgs) { + const id = 'pkg:' + pref; + if (this.hiddenSeeds.some((s) => s.id === id)) continue; + add.push({ id, label: '…/plugins', packagePrefix: pref }); + } + if (!add.length) { + this.showHideToast('当前画布没有可收敛的 diag/Dix/plugins 节点', ''); + return; + } + this.pushHideHistory(); + this.hiddenSeeds = [...this.hiddenSeeds, ...add]; + this.saveHiddenSeeds(); + this.hiddenListOpen = true; + this.clearHidePreview(); + this.graphContextMenu = null; + this.renderGraph(); + this.fitGraphCameraSoon(); + this.showHideToast(`已收敛主图(隐藏 ${add.length} 组噪声)`, add[add.length - 1].id); + }, + + hideInventoryProvider(p, event) { + if (event && event.stopPropagation) event.stopPropagation(); + if (!p || !p.id) return; + this.hideNodeAndDownstream({ + id: p.id, + label: p.label || p.function_name || p.id, + data: { ...p, type: 'provider', displayLabel: p.label || p.function_name }, + }); + }, + + undoLastHide() { + if (!(this.hideHistory && this.hideHistory.length)) return; + this.hiddenSeeds = this.cloneHiddenSeeds(this.hideHistory.pop()); + this.saveHiddenSeeds(); + if (!this.hiddenSeeds.length) this.hiddenListOpen = false; + this.hideToast = null; + if (this._hideToastTimer) { + clearTimeout(this._hideToastTimer); + this._hideToastTimer = null; + } + this.clearHidePreview(); + this.renderGraph(); + this.fitGraphCameraSoon(); + }, + + unhideSeed(id) { + const target = String(id || ''); + this.hiddenSeeds = this.hiddenSeeds.filter((s) => s.id !== target); + this.saveHiddenSeeds(); + if (!this.hiddenSeeds.length) this.hiddenListOpen = false; + this.clearHidePreview(); + this.renderGraph(); + this.fitGraphCameraSoon(); + }, + + clearHiddenSeeds() { + if (!this.hiddenSeeds.length) return; + this.pushHideHistory(); + this.hiddenSeeds = []; + this.saveHiddenSeeds(); + this.hiddenListOpen = false; + this.clearHidePreview(); + this.graphContextMenu = null; + this.renderGraph(); + this.fitGraphCameraSoon(); + }, + + isInventoryProviderHidden(p) { + if (!p || !p.id) return false; + const id = String(p.id); + if (this.hiddenSeeds.some((s) => s.id === id)) return true; + const pkg = String(p.output_pkg || p.packagePath || ''); + return this.hiddenSeeds.some((s) => { + const pref = s.packagePrefix; + if (!pref) return false; + return pkg === pref || pkg.startsWith(pref + '/'); + }); + }, + + inventoryProviderAction(p, event) { + if (this.isInventoryProviderHidden(p)) { + if (event && event.stopPropagation) event.stopPropagation(); + this.unhideSeed(p.id); + return; + } + this.hideInventoryProvider(p, event); + }, + aggregateByGroups(nodes, edges, protectedIds = new Set()) { if (!this.aggregateGroups || this.groupRules.length === 0) { this.groupMembers = {}; diff --git a/dixhttp/static/js/main.js b/dixhttp/static/js/main.js deleted file mode 100644 index 47ff584..0000000 --- a/dixhttp/static/js/main.js +++ /dev/null @@ -1,31 +0,0 @@ -// hash 路由:#/overview | #/graph | #/search | #/trace | #/diag(可带 ?a=b 查询) -window.DIX = window.DIX || {}; -(function () { - function parseHash() { - const h = location.hash.replace(/^#\/?/, ""); - const [view, qs] = h.split("?"); - return { view: view || "overview", query: new URLSearchParams(qs || "") }; - } - - function activateTab(name) { - document.querySelectorAll("#nav-tabs a").forEach(a => { - a.classList.toggle("active", a.dataset.view === name); - }); - } - - function render() { - const { view, query } = parseHash(); - const el = document.getElementById("view"); - const v = DIX.views[view]; - activateTab(view); - if (!v || typeof v.render !== "function") { - el.innerHTML = '

未知视图:' + DIX.esc(view) + "

"; - return; - } - el.innerHTML = '
加载中…
'; - v.render(el, query); - } - - window.addEventListener("hashchange", render); - document.addEventListener("DOMContentLoaded", render); -})(); diff --git a/dixhttp/static/js/views/diag.js b/dixhttp/static/js/views/diag.js deleted file mode 100644 index c7a7a90..0000000 --- a/dixhttp/static/js/views/diag.js +++ /dev/null @@ -1,37 +0,0 @@ -// 诊断:最近注入错误(含结构化根因/提示)+ provider 启动耗时 -window.DIX = window.DIX || {}; -DIX.views = DIX.views || {}; -DIX.views.diag = { - async render(el) { - el.innerHTML = `
加载诊断数据…
`; - try { - const [errors, stats] = await Promise.all([ - DIX.get("/api/errors", { limit: 30 }), - DIX.get("/api/runtime-stats", { limit: 40 }), - ]); - - const errRows = (errors || []).map(e => - `${DIX.esc(e.error_type || "error")}` + - `${DIX.esc(e.root_cause || e.message || "")}` + - `${DIX.esc((e.component || "").slice(0, 50))}` + - `${DIX.esc(e.hint || "")}`).join(""); - - const statRows = (stats || []).map(s => - `${DIX.esc(s.function_name)}` + - `${DIX.esc(s.output_type || "")}` + - `${s.call_count}` + - `${((s.average_duration || 0) / 1e6).toFixed(2)} ms` + - `${DIX.esc(s.last_error || "")}`).join(""); - - el.innerHTML = ` -

最近注入错误

- ${errRows ? `${errRows}
错误类型根因组件提示
` : '

没有错误记录。

'} -
-

Provider 启动耗时

- ${statRows ? `${statRows}
函数输出类型执行次数平均耗时最近错误
` : '

暂无执行记录。

'} -
`; - } catch (err) { - DIX.renderError(el, err); - } - }, -}; diff --git a/dixhttp/static/js/views/graph.js b/dixhttp/static/js/views/graph.js deleted file mode 100644 index 16f0948..0000000 --- a/dixhttp/static/js/views/graph.js +++ /dev/null @@ -1,374 +0,0 @@ -// 依赖图:四种模式——邻域子图 / 全局·Providers / 全局·类型依赖 / 模块图; -// 任意节点点击 → 右侧详情抽屉(信息 + 运行时状态 + 快捷操作)。 -window.DIX = window.DIX || {}; -DIX.views = DIX.views || {}; - -(function () { - const state = { - mode: "ego", layout: "hierarchical", depth: 0, prefix: "", - allData: null, runtimeStats: null, focus: null, network: null, - }; - - const pkgOf = s => (s || "").split(".").slice(0, -1).join(".") || "(anonymous)"; - const shortType = t => (t || "").replace(/^\*/, "").split(".").pop(); - - function toolbarHTML(query) { - return ` -
- - - - - - - - - - - -
-
-
-

节点详情

点击图中节点查看详情。
-
-

全局图:绿色实线=产物,黄色虚线=依赖;box=provider,椭圆=类型。单击节点看详情,双击类型节点聚焦。

`; - } - - async function loadData() { - if (!state.allData) state.allData = await DIX.get("/api/dependencies"); - if (!state.runtimeStats) { - try { state.runtimeStats = await DIX.get("/api/runtime-stats", { limit: 500 }); } - catch { state.runtimeStats = []; } - } - } - - function statFor(fnName) { - return (state.runtimeStats || []).find(s => s.function_name === fnName) || null; - } - - // ---------- 全局图构建(移植自旧版 renderGraph) ---------- - function buildGlobal() { - if (!state.allData) return { nodes: [], edges: [] }; - const providers = state.allData.providers || []; - const edges = state.allData.edges || []; - const nodes = [], gedges = []; - const nodeMap = new Map(); - const addType = label => { - if (nodeMap.has(label)) return; - nodeMap.set(label, true); - nodes.push({ - id: label, label: shortType(label), shape: "ellipse", font: { size: 13 }, - color: { background: "#bfdbfe", border: "#3b82f6" }, - data: { kind: "type", label, pkg: pkgOf(label) }, - }); - }; - if (state.mode === "providers") { - providers.forEach(p => { - if (!nodeMap.has(p.id)) { - nodeMap.set(p.id, true); - const st = statFor(p.function_name); - const isErr = !!(st && st.last_error); - nodes.push({ - id: p.id, label: shortType(p.output_type || p.function_name), - shape: "box", font: { size: 13 }, - color: { background: isErr ? "#fdeaea" : "#eef0ff", border: isErr ? "#dc2626" : "#4f5ce5" }, - data: { kind: "provider", label: p.id, provider: p, pkg: p.output_pkg || pkgOf(p.output_type) }, - }); - } - (p.output_types && p.output_types.length ? p.output_types : [p.output_type]).forEach(ot => { - if (!ot) return; - addType(ot); - gedges.push({ from: p.id, to: ot, color: { color: "#22c55e" }, arrows: "to" }); - }); - (p.input_types || []).forEach(it => { - if (!it) return; - addType(it); - gedges.push({ from: it, to: p.id, dashes: true, color: { color: "#f59e0b" }, arrows: "to" }); - }); - }); - } else { - edges.forEach(e => { - if (e.type !== "provider") return; - addType(e.from); - addType(e.to); - gedges.push({ from: e.from, to: e.to, color: { color: "#9ca3af" }, arrows: "to" }); - }); - } - return { nodes, edges: gedges }; - } - - function buildModules() { - return DIX.get("/api/modules").then(modules => { - const nodes = [], edges = []; - modules.forEach(m => { - nodes.push({ - id: m.name, label: shortType(m.name) + "\n(" + m.provider_count + "p/" + m.object_count + "o)", - shape: "box", font: { size: 12 }, color: { background: "#eef0ff", border: "#4f5ce5" }, - data: { kind: "module", label: m.name, module: m }, - }); - (m.depends_on || []).forEach(dep => edges.push({ from: m.name, to: dep, arrows: "to", color: { color: "#9ca3af" } })); - }); - return { nodes, edges }; - }); - } - - // ---------- 深度裁剪 + 前缀过滤 ---------- - function prune(nodes, edges) { - let ns = nodes, es = edges; - const prefix = state.prefix.trim(); - if (prefix) { - const keep = new Set(ns.filter(n => ((n.data && n.data.pkg) || n.id || "").includes(prefix)).map(n => n.id)); - es = es.filter(e => keep.has(e.from) && keep.has(e.to)); - ns = ns.filter(n => keep.has(n.id)); - } - if (state.depth > 0) { - const focus = state.focus || highestDegreeNode(ns, es); - if (focus) { - const adj = new Map(); - es.forEach(e => { - if (!adj.has(e.from)) adj.set(e.from, []); - if (!adj.has(e.to)) adj.set(e.to, []); - adj.get(e.from).push(e.to); - adj.get(e.to).push(e.from); - }); - const keep = new Set([focus]); - let frontier = [focus]; - for (let d = 0; d < state.depth; d++) { - const next = []; - frontier.forEach(id => (adj.get(id) || []).forEach(t => { if (!keep.has(t)) { keep.add(t); next.push(t); } })); - frontier = next; - } - ns = ns.filter(n => keep.has(n.id)); - es = es.filter(e => keep.has(e.from) && keep.has(e.to)); - } - } - return { nodes: ns, edges: es }; - } - - function highestDegreeNode(nodes, edges) { - const deg = new Map(); - edges.forEach(e => { - deg.set(e.from, (deg.get(e.from) || 0) + 1); - deg.set(e.to, (deg.get(e.to) || 0) + 1); - }); - let best = null, bestDeg = -1; - nodes.forEach(n => { - const d = deg.get(n.id) || 0; - if (d > bestDeg) { bestDeg = d; best = n.id; } - }); - return best; - } - - function renderNetwork(container, nodes, edges) { - if (state.network) state.network.destroy(); - const hierarchical = state.layout === "hierarchical"; - state.network = new vis.Network(container, { - nodes: new vis.DataSet(nodes), edges: new vis.DataSet(edges), - }, { - layout: hierarchical - ? { hierarchical: { direction: "LR", sortMethod: "hubsize" }, improvedLayout: true } - : { improvedLayout: true }, - edges: { smooth: hierarchical ? { type: "cubicBezier", roundness: 0.4 } : false }, - physics: { enabled: !hierarchical, stabilization: { iterations: 150 } }, - interaction: { hover: true }, - }); - state.network.once("afterDrawing", () => { - state.network.fit({ padding: 30 }); - if (state.network.getScale() < 0.9) state.network.moveTo({ scale: 0.9, offset: { x: 0, y: 0 } }); - }); - state.network.on("click", params => { - const id = params.nodes && params.nodes[0]; - const node = id !== undefined ? nodes.find(n => n.id === id) : null; - showDetail(node || null); - }); - state.network.on("doubleClick", params => { - const id = params.nodes && params.nodes[0]; - const node = id !== undefined ? nodes.find(n => n.id === id) : null; - if (node && node.data && node.data.kind === "type") { - state.focus = node.data.label; - state.mode = "types"; - document.getElementById("g-mode").value = "types"; - redraw(); - } - }); - } - - // ---------- 详情抽屉 ---------- - function showDetail(node) { - const box = document.getElementById("g-detail"); - if (!box) return; - if (!node) { box.innerHTML = '

节点详情

点击图中节点查看详情。
'; return; } - const d = node.data || {}; - if (d.kind === "provider") return providerDetail(box, d); - if (d.kind === "type") return typeDetail(box, d); - if (d.kind === "module") return moduleDetail(box, d); - box.innerHTML = '

' + DIX.esc(node.label || "") + '

'; - } - - function providerDetail(box, d) { - const p = d.provider || {}; - const st = statFor(p.function_name); - box.innerHTML = ` -

Provider

-

${DIX.esc(p.function_name || d.label)}

-

${DIX.esc(p.function_pkg || "")}${p.function_file ? " @ " + DIX.esc(p.function_file.split("/").pop()) + ":" + (p.function_line || "") : ""}

-

输出: ${(p.output_types && p.output_types.length ? p.output_types : [p.output_type]).filter(Boolean).map(t => '' + DIX.esc(t) + '').join("")}

-

输入: ${(p.input_types || []).map(t => '' + DIX.esc(t) + '').join("") || ''}

- ${st ? '

执行: ' + st.call_count + ' 次,平均 ' + (st.average_duration / 1e6).toFixed(2) + ' ms' + (st.last_error ? ' 最近错误: ' + DIX.esc(st.last_error) + '' : '') + '

' : '

尚未执行。

'} -

`; - document.getElementById("d-locate").onclick = () => { - state.mode = "providers"; - document.getElementById("g-mode").value = "providers"; - state.focus = p.output_type || d.label; - redraw(); - }; - } - - function typeDetail(box, d) { - const label = d.label; - const producers = (state.allData.providers || []).filter(p => - (p.output_types || []).includes(label) || p.output_type === label); - const objects = (state.allData.objects || []).filter(o => o.type === label); - box.innerHTML = ` -

类型

-

${DIX.esc(label)}

-

${DIX.esc(d.pkg || "")}

-

状态: ${objects.length ? '已实例化' : '未实例化'}

-

生产者:

-
${producers.map(p => '
' + DIX.esc(shortType(p.function_name)) + '
').join("") || '无(外部类型或缺失 provider)'}
-

对象: ${objects.length ? objects.map(o => '' + DIX.esc(o.group) + (o.is_initialized ? " ✓" : "") + '').join("") : ''}

-

- - -

`; - - box.querySelectorAll(".chip.click").forEach(chip => { - chip.addEventListener("click", () => { - const st2 = statFor(chip.dataset.fn); - if (st2) { - box.insertAdjacentHTML("beforeend", - '

' + DIX.esc(st2.function_name) + '

执行 ' + st2.call_count + - ' 次,平均 ' + (st2.average_duration / 1e6).toFixed(2) + ' ms' + - (st2.last_error ? ',最近错误: ' + DIX.esc(st2.last_error) : '') + '

'); - } - }); - }); - document.getElementById("d-ego").onclick = () => { - state.mode = "ego"; - document.getElementById("g-mode").value = "ego"; - document.getElementById("g-center").value = label; - redraw(); - }; - document.getElementById("d-focus").onclick = () => { - state.mode = "types"; - document.getElementById("g-mode").value = "types"; - state.focus = label; - redraw(); - }; - } - - function moduleDetail(box, d) { - const m = d.module || {}; - box.innerHTML = ` -

模块

-

${DIX.esc(m.name)}

-

类型 ${m.type_count} · provider ${m.provider_count} · 对象 ${m.object_count}

-

依赖模块: ${(m.depends_on || []).map(x => '' + DIX.esc(shortType(x)) + '').join("") || ''}

-

`; - document.getElementById("d-prefix").onclick = () => { - state.mode = "providers"; - document.getElementById("g-mode").value = "providers"; - state.prefix = m.name; - document.getElementById("g-prefix").value = m.name; - redraw(); - }; - } - - // ---------- 主流程 ---------- - async function redraw() { - const canvas = document.getElementById("graph-canvas"); - try { - await loadData(); // 详情抽屉也依赖 allData,所有模式都预载 - let graph; - if (state.mode === "modules") { - graph = await buildModules(); - } else if (state.mode === "ego") { - const center = document.getElementById("g-center").value.trim(); - const depth = document.getElementById("g-depth-ego").value; - const dir = document.getElementById("g-dir").value; - if (!center) { - canvas.innerHTML = '

输入中心类型,或切换到全局/模块图。

'; - return; - } - const view = await DIX.get("/api/ego", { center, depth, direction: dir }); - graph = { - nodes: view.nodes.map(n => ({ - id: n.label, label: shortType(n.label), shape: "ellipse", font: { size: 11 }, - color: { background: "#eef0ff", border: "#4f5ce5" }, - data: { kind: "type", label: n.label, pkg: n.pkg }, - })), - edges: view.edges.map(e => ({ from: e.from, to: e.to, arrows: "to", color: { color: "#9ca3af" } })), - }; - } else { - const built = buildGlobal(); - graph = prune(built.nodes, built.edges); - } - renderNetwork(canvas, graph.nodes, graph.edges); - } catch (err) { - DIX.renderError(canvas, err); - } - } - - DIX.views.graph = { - async render(el, query) { - el.innerHTML = toolbarHTML(query); - - state.mode = query.get("mode") || "ego"; - document.getElementById("g-mode").value = state.mode; - state.layout = query.get("layout") || "hierarchical"; - document.getElementById("g-layout").value = state.layout; - state.prefix = query.get("prefix") || ""; - document.getElementById("g-prefix").value = state.prefix; - state.depth = Number(query.get("depth")) || 0; - document.getElementById("g-depth-all").value = String(state.depth); - if (query.get("dir")) document.getElementById("g-dir").value = query.get("dir"); - - document.getElementById("g-mode").addEventListener("change", () => { - state.mode = document.getElementById("g-mode").value; - document.getElementById("g-ego-controls").style.display = state.mode === "ego" ? "inline-flex" : "none"; - redraw(); - }); - document.getElementById("g-layout").addEventListener("change", () => { - state.layout = document.getElementById("g-layout").value; - redraw(); - }); - document.getElementById("g-depth-all").addEventListener("change", () => { - state.depth = Number(document.getElementById("g-depth-all").value); - redraw(); - }); - document.getElementById("g-prefix").addEventListener("change", () => { - state.prefix = document.getElementById("g-prefix").value; - redraw(); - }); - document.getElementById("g-reset").addEventListener("click", () => { - state.focus = null; state.prefix = ""; state.depth = 0; - document.getElementById("g-prefix").value = ""; - document.getElementById("g-depth-all").value = "0"; - redraw(); - }); - document.getElementById("g-form").addEventListener("submit", ev => { - ev.preventDefault(); - state.mode = document.getElementById("g-mode").value; - redraw(); - }); - - document.getElementById("g-ego-controls").style.display = state.mode === "ego" ? "inline-flex" : "none"; - await redraw(); - }, - }; -})(); diff --git a/dixhttp/static/js/views/overview.js b/dixhttp/static/js/views/overview.js deleted file mode 100644 index 1566298..0000000 --- a/dixhttp/static/js/views/overview.js +++ /dev/null @@ -1,55 +0,0 @@ -// 概览:全局统计 + 解析热度 + 慢/错误 provider + 最近注入错误 -window.DIX = window.DIX || {}; -DIX.views = DIX.views || {}; -DIX.views.overview = { - async render(el) { - try { - const [stats, errors] = await Promise.all([ - DIX.get("/api/stats"), - DIX.get("/api/errors", { limit: 8 }), - ]); - el.innerHTML = ` -
-
${stats.provider_count}
Providers
-
${stats.object_count}
已创建对象
-
${stats.modules}
模块
-
${stats.package_count}
-
${stats.edge_count}
依赖边
-
-
-

解析热度 TopN

-

问题 Provider

-

最近注入错误

-

下一步

-

用顶部搜索或「检索」视图定位类型;在「依赖图」查看模块级关系并下钻;

-

「调用链」还原一次注入的完整解析过程;「诊断」排查启动耗时与错误。

-
-
`; - - const top = stats.top_resolved || []; - document.getElementById("ov-top").innerHTML = top.length - ? `` + - top.map(r => ``).join("") + - `
类型解析次数
${DIX.esc(r.type)}${r.count}
` - : '

暂无解析记录(尚未注入)。

'; - - const slow = stats.slow_providers || [], errored = stats.error_providers || []; - document.getElementById("ov-problem").innerHTML = - (slow.length ? `

慢 Provider

` + slow.map(n => `${DIX.esc(n)}`).join("") : "") + - (errored.length ? `

错误 Provider

` + errored.map(n => `${DIX.esc(n)}`).join("") : "") + - (!slow.length && !errored.length ? '

一切正常。

' : ""); - - const errs = errors || []; - document.getElementById("ov-errors").innerHTML = errs.length - ? `` + - errs.slice(0, 8).map(e => - `` + - `` + - ``).join("") + - `
类型根因组件
${DIX.esc(e.error_type || "error")}${DIX.esc(e.root_cause || e.message || "")}${DIX.esc((e.component || "").slice(0, 60))}
` - : '

没有最近的注入错误。

'; - } catch (err) { - DIX.renderError(el, err); - } - }, -}; diff --git a/dixhttp/static/js/views/search.js b/dixhttp/static/js/views/search.js deleted file mode 100644 index 2da8c2c..0000000 --- a/dixhttp/static/js/views/search.js +++ /dev/null @@ -1,65 +0,0 @@ -// 检索:服务端过滤(关键字/类别/模块/状态),结果可跳转依赖图 -window.DIX = window.DIX || {}; -DIX.views = DIX.views || {}; -DIX.views.search = { - async render(el, query) { - el.innerHTML = ` -
- - - - - -
-
输入条件后检索。
`; - - const run = async () => { - const box = document.getElementById("s-result"); - box.textContent = "检索中…"; - try { - const hits = await DIX.get("/api/search", { - q: document.getElementById("s-q").value, - kind: document.getElementById("s-kind").value, - state: document.getElementById("s-state").value, - module: document.getElementById("s-module").value, - limit: 100, - }); - if (!hits.length) { - box.innerHTML = '

没有命中。

'; - return; - } - box.innerHTML = ` - ` + - hits.map((h, i) => { - const stateBadge = h.state ? `${DIX.esc(h.state)}` : ""; - return `` + - `` + - `` + - `` + - ``; - }).join("") + `
类别名称模块状态
${DIX.esc(h.kind)}${DIX.esc(h.label)}${DIX.esc(h.pkg || "")}${stateBadge}
`; - box.querySelectorAll("button[data-i]").forEach(btn => { - btn.addEventListener("click", () => { - const h = hits[Number(btn.dataset.i)]; - // SearchHit 的 label 即该依赖的类型名(provider 节点为其输出类型),可直接作邻域中心 - location.hash = "#/graph?center=" + encodeURIComponent(h.label); - }); - }); - } catch (err) { - DIX.renderError(box.parentElement, err); - } - }; - - document.getElementById("s-form").addEventListener("submit", ev => { - ev.preventDefault(); - run(); - }); - if (query.get("q") || query.get("module") || query.get("state")) run(); - }, -}; diff --git a/dixhttp/static/js/views/trace.js b/dixhttp/static/js/views/trace.js deleted file mode 100644 index eb41876..0000000 --- a/dixhttp/static/js/views/trace.js +++ /dev/null @@ -1,79 +0,0 @@ -// 调用链:按 trace_id 分组的列表 + 服务端调用树还原 -window.DIX = window.DIX || {}; -DIX.views = DIX.views || {}; -DIX.views.trace = { - async render(el) { - el.innerHTML = `
加载 trace…
`; - const list = document.getElementById("t-list"); - - try { - const res = await DIX.get("/api/trace", { limit: 300 }); - const groups = new Map(); - for (const rec of res.records || []) { - if (!rec.trace_id) continue; // DI 点事件无 trace_id,不属于任何调用链 - let g = groups.get(rec.trace_id); - if (!g) { - g = { id: rec.trace_id, events: 0, errors: 0, lastTs: 0, ops: new Set() }; - groups.set(rec.trace_id, g); - } - g.events++; - if (rec.status === "error") g.errors++; - if (rec.occurred_at_unix_nano > g.lastTs) g.lastTs = rec.occurred_at_unix_nano; - } - const rows = [...groups.values()].sort((a, b) => b.lastTs - a.lastTs); - if (!rows.length) { - list.innerHTML = '

还没有 trace 事件(注入一次即产生)。

'; - return; - } - - list.innerHTML = `` + - rows.map((g, i) => { - const short = g.id.length > 18 ? g.id.slice(0, 8) + "…" + g.id.slice(-6) : g.id; - const badge = g.errors > 0 ? `${g.errors} 错误` : 'ok'; - return `` + - ``; - }).join("") + `
Trace事件错误
${DIX.esc(short)}${g.events}${badge}
`; - - list.querySelectorAll("button[data-i]").forEach(btn => { - btn.addEventListener("click", async () => { - const g = rows[Number(btn.dataset.i)]; - const box = document.getElementById("t-tree"); - box.innerHTML = '
还原调用树…
'; - try { - const tree = await DIX.get("/api/trace-tree", { trace_id: g.id }); - const container = box.querySelector(".card") || box; - container.innerHTML = `

trace ${DIX.esc(g.id)}

` + - ''; - const rootUl = document.getElementById("t-root"); - for (const node of tree.roots || []) { - rootUl.appendChild(DIX.views.trace.renderNode(node)); - } - } catch (err) { - DIX.renderError(box, err); - } - }); - }); - } catch (err) { - DIX.renderError(list, err); - } - }, - - renderNode(node) { - const li = document.createElement("li"); - const line = document.createElement("div"); - line.className = "node-line"; - const status = node.end ? node.end.status : (node.event.status || "start"); - const badge = status === "error" ? 'error' : status === "ok" ? 'ok' : ""; - const dur = node.end ? `${node.end.duration_ns / 1e6} ms` : ""; - line.innerHTML = - `${DIX.esc(node.event.operation)}` + badge + dur; - li.appendChild(line); - - if (node.children && node.children.length) { - const ul = document.createElement("ul"); - for (const c of node.children) ul.appendChild(DIX.views.trace.renderNode(c)); - li.appendChild(ul); - } - return li; - }, -}; diff --git a/dixhttp/static/vendor/alpine.min.js b/dixhttp/static/vendor/alpine.min.js new file mode 100644 index 0000000..2fdd6ec --- /dev/null +++ b/dixhttp/static/vendor/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(z([n,...e]),i);Ne(r,o)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=z([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>re(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>re(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` - - +
- -
-
-
- -

- 🔗 Dix 依赖关系可视化 -

-
- 📦 - - - Providers - 📎 - 依赖 - + +
+
+
+

Dix

+ +
-
- -
- + + + -
- - - - -
-
- -
- + +
+ + + @@ -503,6 +839,10 @@

分组

+

包含节点

包含节点
+
-
-

图例

-
-
-
- 目标节点 +
+

图例

+
+
+
+ 焦点 +
+
+
+ 依赖 +
+
+
+ 被依赖 +
+
+
+ 双向 +
+
+
+
+
+ 目标
-
-
- 依赖(上游) +
+
+ 依赖
-
-
- 被依赖(下游) +
+
+ 被依赖
-
-
- 类型节点 +
+
+ 类型
-
-
- 超时 Provider(Providers 视图) +
+
+ 超时
-
-
- 错误 Provider(Providers 视图) +
+
+ 错误
- - + +
+
+
+
画布快捷键
+ +
+
+
放大 / 缩小+ / −
+
平移方向键
+
适应全图0 · F
+
最大化 / 退出M · Esc
+
隐藏选中及下游H
+
撤销隐藏U
+
Shift+单击快速隐藏
+
双击节点聚焦邻域
+
本面板?
+
+
+
图例
-
🪟 Provider 详细信息
+
Provider 详细信息
- + + + diff --git a/dixinternal/api.go b/dixinternal/api.go index 8590951..60ddfea 100644 --- a/dixinternal/api.go +++ b/dixinternal/api.go @@ -164,20 +164,24 @@ 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 - FunctionFile string - FunctionLine int - InputTypes []string - InputPkgs []string + OutputType string `json:"output_type"` + OutputPkg string `json:"output_pkg"` + FunctionName string `json:"function_name"` + FunctionPkg string `json:"function_pkg"` + FunctionFile string `json:"function_file"` + FunctionLine int `json:"function_line"` + InputTypes []string `json:"input_types"` + InputPkgs []string `json:"input_pkgs"` + RegistrationID uint64 `json:"registration_id"` + ProviderID string `json:"provider_id"` } // ProviderRuntimeStats contains provider runtime metrics for diagnostics. type ProviderRuntimeStats struct { FunctionName string `json:"function_name"` OutputType string `json:"output_type"` + RegistrationID uint64 `json:"registration_id"` + ProviderID string `json:"provider_id"` CallCount int `json:"call_count"` TotalDuration time.Duration `json:"total_duration"` AverageDuration time.Duration `json:"average_duration"` @@ -199,6 +203,7 @@ type RecentError struct { Message string `json:"message"` RootCause string `json:"root_cause,omitempty"` Hint string `json:"hint,omitempty"` + TraceID string `json:"trace_id,omitempty"` TimedOut bool `json:"timed_out,omitempty"` Duration time.Duration `json:"duration,omitempty"` Timeout time.Duration `json:"timeout,omitempty"` @@ -215,37 +220,44 @@ func (dix *Dix) GetProviderDetails() []ProviderDetails { var inputTypes []string var inputPkgs []string seen := make(map[string]bool) + appendInput := func(typ reflect.Type) { + if typ == nil { + return + } + name := typ.String() + if name == "" { + return + } + pkg := resolveTypePkgPath(typ) + // Distinct packages can share type.String() (e.g. */handler.Handler). + key := pkg + "\x00" + name + if seen[key] { + return + } + seen[key] = true + inputTypes = append(inputTypes, name) + inputPkgs = append(inputPkgs, pkg) + } for _, input := range providerFn.inputList { if input.isStruct || input.typ.Kind() == reflect.Struct { for _, in := range getProvideAllInputs(input.typ) { - name := in.typ.String() - if name == "" || seen[name] { - continue - } - seen[name] = true - inputTypes = append(inputTypes, name) - inputPkgs = append(inputPkgs, resolveTypePkgPath(in.typ)) + appendInput(in.typ) } continue } - - name := input.typ.String() - if name == "" || seen[name] { - continue - } - seen[name] = true - inputTypes = append(inputTypes, name) - inputPkgs = append(inputPkgs, resolveTypePkgPath(input.typ)) + appendInput(input.typ) } details = append(details, ProviderDetails{ - OutputType: outputType.String(), - OutputPkg: resolveTypePkgPath(outputType), - FunctionName: fnName, - FunctionPkg: resolveFuncPkgPath(fnName), - FunctionFile: fnFile, - FunctionLine: fnLine, - InputTypes: inputTypes, - InputPkgs: inputPkgs, + OutputType: outputType.String(), + OutputPkg: resolveTypePkgPath(outputType), + FunctionName: fnName, + FunctionPkg: resolveFuncPkgPath(fnName), + FunctionFile: fnFile, + FunctionLine: fnLine, + InputTypes: inputTypes, + InputPkgs: inputPkgs, + RegistrationID: providerFn.registrationID, + ProviderID: fmt.Sprintf("provider_%d_%s", providerFn.registrationID, outputType.String()), }) } } @@ -256,23 +268,29 @@ func (dix *Dix) GetProviderDetails() []ProviderDetails { // This is helpful for startup latency diagnosis. func (dix *Dix) GetProviderRuntimeStats() []ProviderRuntimeStats { stats := make([]ProviderRuntimeStats, 0, len(dix.providers)) - seen := make(map[reflect.Value]bool) + seen := make(map[string]bool) for _, providerList := range dix.providers { for _, p := range providerList { - if p == nil || seen[p.fn] { + if p == nil { continue } - seen[p.fn] = true outputType := "" if p.output != nil && p.output.typ != nil { outputType = p.output.typ.String() } + identity := fmt.Sprintf("%d:%s", p.registrationID, outputType) + if seen[identity] { + continue + } + seen[identity] = true item := ProviderRuntimeStats{ - FunctionName: GetFnName(p.fn), - OutputType: outputType, + FunctionName: GetFnName(p.fn), + OutputType: outputType, + RegistrationID: p.registrationID, + ProviderID: fmt.Sprintf("provider_%d_%s", p.registrationID, outputType), } if s, ok := dix.providerStats[p.fn]; ok && s != nil { @@ -333,6 +351,7 @@ func (dix *Dix) GetRecentErrors(limit int) []RecentError { Message: r.Message, RootCause: r.RootCause, Hint: r.Hint, + TraceID: r.TraceID, TimedOut: r.TimedOut, Duration: r.Duration, Timeout: r.Timeout, diff --git a/dixinternal/dix.go b/dixinternal/dix.go index 40495f9..86816ba 100644 --- a/dixinternal/dix.go +++ b/dixinternal/dix.go @@ -70,6 +70,9 @@ type Dix struct { initializer map[reflect.Value]bool timedOut map[reflect.Value]bool graph *Graph + // registrationSeq identifies one top-level Provide call. Struct field + // projections reuse it; distinct calls to the same closure source do not. + registrationSeq uint64 // containerID 标识本容器(随机 16 hex),trace 事件携带它实现多容器隔离。 containerID string @@ -105,6 +108,7 @@ type recentErrorRecord struct { Message string RootCause string Hint string + TraceID string TimedOut bool Duration time.Duration Timeout time.Duration @@ -1117,7 +1121,7 @@ func (dix *Dix) inject(ctx context.Context, param any, opts ...Option) (err erro } // handleProvide registers a provider function for a specific output type -func (dix *Dix) handleProvide(fnVal reflect.Value, outType reflect.Type, inputs []*providerInputType) error { +func (dix *Dix) handleProvide(fnVal reflect.Value, outType reflect.Type, inputs []*providerInputType, registrationID uint64) error { traceFnName := GetFnTraceName(fnVal) dix.emitDIEvent("provide.register.start", "provider", traceFnName, @@ -1139,7 +1143,7 @@ func (dix *Dix) handleProvide(fnVal reflect.Value, outType reflect.Type, inputs } } - provider := &providerFn{fn: fnVal, inputList: inputs, hasError: hasError} + provider := &providerFn{fn: fnVal, inputList: inputs, hasError: hasError, registrationID: registrationID} // Register based on output kind switch outType.Kind() { @@ -1195,7 +1199,7 @@ func (dix *Dix) handleProvide(fnVal reflect.Value, outType reflect.Type, inputs dix.emitDIEvent("provide.register.struct_field.start", "provider", traceFnName, "declared_output_type", outType.String(), "field", field.Name, "field_type", field.Type.String()) // Recursive call - if err := dix.handleProvide(fnVal, field.Type, inputs); err != nil { + if err := dix.handleProvide(fnVal, field.Type, inputs, registrationID); err != nil { dix.emitDIEvent("provide.register.struct_field.failed", "provider", traceFnName, "declared_output_type", outType.String(), "field", field.Name, "field_type", field.Type.String(), "error", err) return err } @@ -1360,7 +1364,8 @@ func (dix *Dix) provide(param any) { inputs = append(inputs, parsedInputs...) } - if err := dix.handleProvide(fnVal, typ.Out(0), inputs); err != nil { + dix.registrationSeq++ + if err := dix.handleProvide(fnVal, typ.Out(0), inputs, dix.registrationSeq); err != nil { dix.emitDIEvent("provide.register.failed", "provider", traceFnName, "declared_output_type", typ.Out(0).String(), "error", err) panic(err) } diff --git a/dixinternal/graph_query.go b/dixinternal/graph_query.go index 963cdca..4f67267 100644 --- a/dixinternal/graph_query.go +++ b/dixinternal/graph_query.go @@ -18,6 +18,7 @@ type SearchHit struct { Label string `json:"label"` Pkg string `json:"pkg,omitempty"` Group string `json:"group,omitempty"` + External bool `json:"external,omitempty"` State string `json:"state,omitempty"` // instantiated|error|slow Provider string `json:"provider,omitempty"` } @@ -49,6 +50,7 @@ func (dix *Dix) SearchNodes(q, kind, module, state string, limit int) []SearchHi g := dix.graph g.mu.RLock() + defer g.mu.RUnlock() // 已实例化类型集合:存在 Object 节点 (type, group) 即视为实例化 @@ -195,10 +197,191 @@ func (dix *Dix) ModuleGraph() []ModuleInfo { return out } +// ModuleDependency is an aggregated cross-module relationship. +type ModuleDependency struct { + Name string `json:"name"` + EdgeCount int `json:"edge_count"` +} + +// ModuleDetailView is a bounded topology projection for one module. +type ModuleDetailView struct { + Name string `json:"name"` + TypeCount int `json:"type_count"` + ProviderCount int `json:"provider_count"` + ObjectCount int `json:"object_count"` + DependsOn []ModuleDependency `json:"depends_on,omitempty"` + DependedOnBy []ModuleDependency `json:"depended_on_by,omitempty"` + Nodes []SearchHit `json:"nodes"` + Edges []GraphEdge `json:"edges"` + Truncated bool `json:"truncated"` +} + +// ModuleView returns providers, produced outputs, declared dependencies, and +// external dependency types for one module. Results are deterministically +// bounded so a large module cannot force an unrenderable response. +func (dix *Dix) ModuleView(name string, nodeLimit, edgeLimit int) ModuleDetailView { + if nodeLimit <= 0 { + nodeLimit = 100 + } + if nodeLimit > 500 { + nodeLimit = 500 + } + if edgeLimit <= 0 { + edgeLimit = 300 + } + if edgeLimit > 1000 { + edgeLimit = 1000 + } + + view := ModuleDetailView{Name: name, Nodes: []SearchHit{}, Edges: []GraphEdge{}} + g := dix.graph + g.mu.RLock() + defer g.mu.RUnlock() + + instantiated := make(map[reflect.Type]bool) + for key := range g.nIndex { + if key.kind == NodeObject { + instantiated[key.typ] = true + } + } + + type edgeRef struct { + edge *Edge + from, to Node + } + var edges []edgeRef + degree := make(map[NodeID]int) + dependsOn := make(map[string]int) + dependedOnBy := make(map[string]int) + + for _, node := range g.nodes { + if node.Pkg != name { + continue + } + switch node.Kind { + case NodeType: + view.TypeCount++ + case NodeProvider: + view.ProviderCount++ + case NodeObject: + view.ObjectCount++ + } + } + + for _, edge := range g.eIndex { + if edge.Kind != EdgeProduced && edge.Kind != EdgeDeclared { + continue + } + from, to := g.nodes[edge.From], g.nodes[edge.To] + if from.Pkg != name && to.Pkg != name { + continue + } + edges = append(edges, edgeRef{edge: edge, from: from, to: to}) + degree[edge.From]++ + degree[edge.To]++ + + if edge.Kind != EdgeDeclared { + continue + } + if from.Pkg == name && to.Pkg != name { + dependsOn[to.Pkg]++ + } else if to.Pkg == name && from.Pkg != name { + dependedOnBy[from.Pkg]++ + } + } + + sort.Slice(edges, func(i, j int) bool { + left, right := edges[i], edges[j] + if left.from.Label != right.from.Label { + return left.from.Label < right.from.Label + } + if left.to.Label != right.to.Label { + return left.to.Label < right.to.Label + } + return left.edge.Kind < right.edge.Kind + }) + if len(edges) > edgeLimit { + edges = edges[:edgeLimit] + view.Truncated = true + } + + incident := make(map[NodeID]Node, nodeLimit) + for _, item := range edges { + incident[item.from.ID] = item.from + incident[item.to.ID] = item.to + } + nodes := make([]Node, 0, len(incident)) + for _, node := range incident { + nodes = append(nodes, node) + } + kindRank := map[NodeKind]int{NodeProvider: 0, NodeType: 1, NodeObject: 2} + sort.Slice(nodes, func(i, j int) bool { + left, right := nodes[i], nodes[j] + if kindRank[left.Kind] != kindRank[right.Kind] { + return kindRank[left.Kind] < kindRank[right.Kind] + } + if degree[left.ID] != degree[right.ID] { + return degree[left.ID] > degree[right.ID] + } + return left.Label < right.Label + }) + if len(nodes) > nodeLimit { + nodes = nodes[:nodeLimit] + view.Truncated = true + } + keep := make(map[NodeID]bool, len(nodes)) + for _, node := range nodes { + keep[node.ID] = true + } + + for _, node := range nodes { + hit := SearchHit{ + ID: uint32(node.ID), + Kind: nodeKindName(node.Kind), + Label: node.Label, + Pkg: node.Pkg, + Group: node.Group, + External: node.Pkg != name, + } + if node.Provider != nil { + hit.Provider = GetFnName(node.Provider.fn) + hit.State = dix.providerState(node.Provider) + } + if node.Kind == NodeType && instantiated[node.Type] { + hit.State = "instantiated" + } + view.Nodes = append(view.Nodes, hit) + } + + for _, item := range edges { + if !keep[item.from.ID] || !keep[item.to.ID] { + continue + } + view.Edges = append(view.Edges, GraphEdge{ + From: item.from.Label, + To: item.to.Label, + FromKind: nodeKindName(item.from.Kind), + ToKind: nodeKindName(item.to.Kind), + }) + } + + appendDeps := func(target *[]ModuleDependency, source map[string]int) { + for depName, count := range source { + *target = append(*target, ModuleDependency{Name: depName, EdgeCount: count}) + } + sort.Slice(*target, func(i, j int) bool { return (*target)[i].Name < (*target)[j].Name }) + } + appendDeps(&view.DependsOn, dependsOn) + appendDeps(&view.DependedOnBy, dependedOnBy) + return view +} + // GraphEdge 是邻域子图里的一条声明依赖边(类型 label 表示)。 type GraphEdge struct { - From string `json:"from"` - To string `json:"to"` + From string `json:"from"` + To string `json:"to"` + FromKind string `json:"from_kind,omitempty"` + ToKind string `json:"to_kind,omitempty"` } // GraphView 是邻域子图:节点摘要 + 声明边。 @@ -225,6 +408,15 @@ func (dix *Dix) EgoGraph(center string, depth int, direction string) GraphView { g := dix.graph g.mu.RLock() + // Ego visibility is not instantiation. Build the object-bearing set while + // the graph lock is held and project it after traversal. + instantiated := make(map[reflect.Type]bool) + for key := range g.nIndex { + if key.kind == NodeObject { + instantiated[key.typ] = true + } + } + type dEdge struct { from, to reflect.Type } @@ -284,7 +476,11 @@ func (dix *Dix) EgoGraph(center string, depth int, direction string) GraphView { view.Edges = append(view.Edges, GraphEdge{From: label(e.from), To: label(e.to)}) } for t := range seen { - view.Nodes = append(view.Nodes, SearchHit{Kind: "type", Label: label(t), Pkg: resolveTypePkgPath(t), State: "instantiated"}) + state := "" + if instantiated[t] { + state = "instantiated" + } + view.Nodes = append(view.Nodes, SearchHit{Kind: "type", Label: label(t), Pkg: resolveTypePkgPath(t), State: state}) } sort.Slice(view.Nodes, func(i, j int) bool { return view.Nodes[i].Label < view.Nodes[j].Label }) sort.Slice(view.Edges, func(i, j int) bool { @@ -310,12 +506,16 @@ func (dix *Dix) ResolvedTopN(n int) []ResolvedCount { g := dix.graph g.mu.RLock() defer g.mu.RUnlock() - counts := make([]ResolvedCount, 0, 8) + countsByType := make(map[string]int64) for _, e := range g.eIndex { if e.Kind == EdgeResolved && e.Count > 0 { - counts = append(counts, ResolvedCount{Type: g.nodes[e.To].Type.String(), Count: e.Count}) + countsByType[g.nodes[e.To].Type.String()] += e.Count } } + counts := make([]ResolvedCount, 0, len(countsByType)) + for typ, count := range countsByType { + counts = append(counts, ResolvedCount{Type: typ, Count: count}) + } sort.Slice(counts, func(i, j int) bool { if counts[i].Count != counts[j].Count { return counts[i].Count > counts[j].Count diff --git a/dixinternal/graph_query_projection_test.go b/dixinternal/graph_query_projection_test.go new file mode 100644 index 0000000..b373db7 --- /dev/null +++ b/dixinternal/graph_query_projection_test.go @@ -0,0 +1,59 @@ +package dixinternal + +import ( + "reflect" + "testing" +) + +type packageTarget struct{} + +func packageProvider() *packageTarget { return &packageTarget{} } + +func TestEgoInstantiatedUsesObjectNodes(t *testing.T) { + di := New() + di.graph.node(NodeType, reflect.TypeOf(&packageTarget{}), "", nil) + view := di.EgoGraph("*dixinternal.packageTarget", 1, "both") + for _, node := range view.Nodes { + if node.Label == "*dixinternal.packageTarget" && node.State == "instantiated" { + t.Fatal("missing provider must not be reported instantiated") + } + } + + di.Provide(func() *packageTarget { return &packageTarget{} }) + di.graph.addObject(reflect.TypeOf(&packageTarget{}), "") + view = di.EgoGraph("*dixinternal.packageTarget", 1, "both") + instantiated := false + for _, node := range view.Nodes { + if node.Label == "*dixinternal.packageTarget" && node.State == "instantiated" { + instantiated = true + } + } + if !instantiated { + t.Fatal("object-bearing ego node should be instantiated") + } +} + +func TestResolvedTopNAggregatesByType(t *testing.T) { + di := New() + targetType := reflect.TypeOf(&packageTarget{}) + + for i := 0; i < 2; i++ { + provider := &providerFn{fn: reflect.ValueOf(packageProvider), registrationID: uint64(i + 1)} + node := di.graph.providerNode(provider, targetType) + di.graph.markResolved(node, targetType) + } + + rows := di.ResolvedTopN(10) + found := 0 + for _, row := range rows { + if row.Type == "*dixinternal.packageTarget" { + found++ + if row.Count != 2 { + t.Fatalf("count = %d, want 2", row.Count) + } + } + } + if found != 1 { + t.Fatalf("type rows = %d, want 1", found) + } +} diff --git a/dixinternal/module_view_test.go b/dixinternal/module_view_test.go new file mode 100644 index 0000000..7d28dc7 --- /dev/null +++ b/dixinternal/module_view_test.go @@ -0,0 +1,72 @@ +package dixinternal + +import ( + "reflect" + "testing" +) + +type moduleViewTarget struct{} + +func newModuleViewTarget(*testing.T) *moduleViewTarget { + return &moduleViewTarget{} +} + +func TestModuleViewIncludesProducedAndDeclaredTopology(t *testing.T) { + di := New() + di.Provide(newModuleViewTarget) + module := resolveTypePkgPath(reflect.TypeOf(&moduleViewTarget{})) + + view := di.ModuleView(module, 100, 100) + if view.Name != module { + t.Fatalf("name = %q", view.Name) + } + if view.TypeCount < 2 { + t.Fatalf("summary = %+v", view) + } + providerFound, targetFound, externalFound := false, false, false + for _, node := range view.Nodes { + switch node.Label { + case "*dixinternal.moduleViewTarget": + if node.Kind == "provider" { + providerFound = true + } else { + targetFound = true + } + case "*testing.T": + externalFound = true + if !node.External { + t.Fatal("external dependency node was not marked") + } + } + } + if !providerFound || !targetFound || !externalFound { + t.Fatalf("expected provider, target, and external nodes: %+v", view.Nodes) + } + if len(view.Edges) < 1 { + t.Fatalf("expected declared topology, got %+v", view.Edges) + } + if len(view.DependsOn) == 0 || view.DependsOn[0].Name != "testing" { + t.Fatalf("depends on = %+v", view.DependsOn) + } +} + +func TestModuleViewBoundsNodesAndEdges(t *testing.T) { + di := New() + targetType := reflect.TypeOf(&moduleViewTarget{}) + for i := 0; i < 12; i++ { + provider := &providerFn{fn: reflect.ValueOf(newModuleViewTarget), registrationID: uint64(i + 1)} + node := di.graph.providerNode(provider, targetType) + di.graph.addProduced(node, targetType) + } + + view := di.ModuleView("github.com/pubgo/dix/v2/dixinternal", 2, 1) + if len(view.Nodes) != 2 { + t.Fatalf("nodes = %d, want 2", len(view.Nodes)) + } + if len(view.Edges) > 1 { + t.Fatalf("edges = %d, want <= 1", len(view.Edges)) + } + if !view.Truncated { + t.Fatal("expected truncated marker") + } +} diff --git a/dixinternal/provider.go b/dixinternal/provider.go index 09e698f..e91de91 100644 --- a/dixinternal/provider.go +++ b/dixinternal/provider.go @@ -37,10 +37,11 @@ type providerOutputType struct { } type providerFn struct { - fn reflect.Value - inputList []*providerInputType - output *providerOutputType - hasError bool + fn reflect.Value + inputList []*providerInputType + output *providerOutputType + hasError bool + registrationID uint64 } type providerCallResult struct { diff --git a/dixinternal/provider_identity_test.go b/dixinternal/provider_identity_test.go new file mode 100644 index 0000000..4115605 --- /dev/null +++ b/dixinternal/provider_identity_test.go @@ -0,0 +1,50 @@ +package dixinternal + +import ( + "reflect" + "testing" +) + +type identityA struct{} +type identityB struct{} +type identityAggregate struct { + A *identityA + B *identityB +} + +func TestStructProviderOutputsShareRegistrationID(t *testing.T) { + di := New() + di.Provide(func() identityAggregate { + return identityAggregate{A: &identityA{}, B: &identityB{}} + }) + + ids := make(map[string]uint64) + for _, provider := range di.providers[reflect.TypeOf(&identityA{})] { + ids["A"] = provider.registrationID + } + for _, provider := range di.providers[reflect.TypeOf(&identityB{})] { + ids["B"] = provider.registrationID + } + if ids["A"] == 0 || ids["A"] != ids["B"] { + t.Fatalf("struct outputs should share one registration ID: %#v", ids) + } +} + +func TestDistinctClosureRegistrationsHaveDistinctRegistrationIDs(t *testing.T) { + di := New() + for _, name := range []string{"first", "second"} { + value := name + di.Provide(func() *identityA { return &identityA{} }) + if value == "" { + t.Fatal("closure registration setup unexpectedly empty") + } + } + + providers := di.providers[reflect.TypeOf(&identityA{})] + if len(providers) != 2 { + t.Fatalf("expected 2 providers, got %d", len(providers)) + } + if providers[0].registrationID == providers[1].registrationID { + t.Fatal("distinct registrations must not share a registration ID") + } +} diff --git a/dixinternal/runtime_stats_identity_test.go b/dixinternal/runtime_stats_identity_test.go new file mode 100644 index 0000000..da54aa7 --- /dev/null +++ b/dixinternal/runtime_stats_identity_test.go @@ -0,0 +1,49 @@ +package dixinternal + +import ( + "reflect" + "testing" +) + +type runtimeTarget struct{ Name string } + +func TestRuntimeStatsIncludeConcreteProviderIdentity(t *testing.T) { + di := New() + di.Provide(func() *runtimeTarget { return &runtimeTarget{Name: "ready"} }) + _ = di.TryInject(func(*runtimeTarget) {}) + + stats := di.GetProviderRuntimeStats() + for _, stat := range stats { + if stat.OutputType != "*dixinternal.runtimeTarget" { + continue + } + if stat.RegistrationID == 0 { + t.Fatal("expected non-zero registration ID") + } + if stat.ProviderID == "" { + t.Fatal("expected provider ID") + } + if stat.CallCount != 1 { + t.Fatalf("call count = %d, want 1", stat.CallCount) + } + return + } + t.Fatalf("target stat not found in %+v", stats) +} + +func TestRuntimeStatsDoNotDeduplicateDistinctClosures(t *testing.T) { + di := New() + for i := 0; i < 2; i++ { + di.Provide(func() *runtimeTarget { return &runtimeTarget{} }) + } + stats := di.GetProviderRuntimeStats() + count := 0 + for _, stat := range stats { + if stat.OutputType == reflect.TypeOf(&runtimeTarget{}).String() { + count++ + } + } + if count != 2 { + t.Fatalf("provider stats = %d, want 2", count) + } +} diff --git a/docs/superpowers/plans/2026-09-05-dependency-visualization-unified.md b/docs/superpowers/plans/2026-09-05-dependency-visualization-unified.md new file mode 100644 index 0000000..448cc50 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-dependency-visualization-unified.md @@ -0,0 +1,634 @@ +# Dependency Visualization Unified Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish the unified `/next` dependency workflow so large containers stay readable (bounded module-first graphs) and failures are diagnosable (Issue → bounded graph → Trace) without breaking legacy APIs or the five-view shell. + +**Architecture:** Evolve existing dixhttp endpoints and `/next` views in place. Keep `/api/modules`, `/api/module`, `/api/ego`, `/api/issues`, `/api/trace`, and `/api/trace-tree` as the primary path. Default graph modes never eagerly load `/api/dependencies`. Cancel stale view requests with AbortController + a sequence load guard. Correlate diagnostics through `provider_id`, with function name only as fallback. + +**Tech Stack:** Go 1.24+, net/http/httptest, vendored vanilla JS, vis-network, hash routing, Node `node:test` for pure JS helpers. + +**Spec:** `docs/superpowers/specs/2026-09-05-dependency-visualization-unified-design.md` + +## Global Constraints + +- Do not introduce third-party Go or JavaScript dependencies. +- Preserve all existing public Dix APIs. +- Preserve legacy `/` UI and `/api/dependencies` compatibility; additive fields only. +- Keep `/next` five views; do not rebuild a three-pane shell. +- Graph budgets: module map 100/300, module drill-down 150/400, ego 100/300, global advanced 150/400. +- Issue severity values stay `error` and `slow` (existing API); UI may style `slow` as warn. +- Every commit must leave `go test -race ./...` green in the root module. +- JS pure helpers must pass `node --test dixhttp/static/js/graph_view.test.mjs`. + +## File Map + +| File | Responsibility | +|---|---| +| `dixhttp/static/js/api.js` | `DIX.get(path, params, options)` with optional `AbortSignal` | +| `dixhttp/static/js/graph_state.mjs` | Mode resolution, budgets, load guard, issue hashes, hub ranking, trace filter helpers | +| `dixhttp/static/js/graph_view.test.mjs` | Pure JS contract tests | +| `dixhttp/static/js/views/graph.js` | Bounded graph modes, cancellation, density warning + hubs, drawer → Trace | +| `dixhttp/static/js/views/trace.js` | Hash prefilter + optional auto-open tree | +| `dixhttp/static/js/views/overview.js` | Issues feed (already wired; verify after hash/API changes) | +| `dixhttp/server.go` | `IssueInfo` + `buildIssues` (+ optional `trace_id`) | +| `dixinternal/dix.go` / `dixinternal/api.go` | Optional TraceID on recent errors | +| `dixhttp/README.md` / `dixhttp/README_zh.md` | Document default workflow | + +--- + +### Task 1: Cancel Stale Graph Requests (Correctly) + +**Files:** +- Modify: `dixhttp/static/js/api.js` +- Modify: `dixhttp/static/js/graph_state.mjs` +- Modify: `dixhttp/static/js/views/graph.js` +- Test: `dixhttp/static/js/graph_view.test.mjs` + +**Interfaces:** +- Consumes: existing `DIX.get`, `window.DIXGraphState` +- Produces: `DIX.get(path, params, options = {})` with `options.signal`; `createLoadGuard()` → `{ begin(): { seq:number }, isCurrent(seq:number): boolean }` + +**Bug to avoid:** the previous attempt passed `{ seq }` objects into `isCurrent`. Always pass numeric `seq`. + +- [ ] **Step 1: Write the failing load-guard test** + +Append to `dixhttp/static/js/graph_view.test.mjs`: + +```js +test("stale graph loads are rejected by sequence", async () => { + const { createLoadGuard } = await import("./graph_state.mjs"); + const guard = createLoadGuard(); + const first = guard.begin(); + const second = guard.begin(); + assert.equal(guard.isCurrent(first.seq), false); + assert.equal(guard.isCurrent(second.seq), true); +}); +``` + +- [ ] **Step 2: Run the focused JS test and verify RED** + +Run: + +```bash +node --test dixhttp/static/js/graph_view.test.mjs +``` + +Expected: FAIL because `createLoadGuard` is not exported. + +- [ ] **Step 3: Implement API signal support and load guard** + +In `dixhttp/static/js/api.js`, change `DIX.get` to: + +```js +DIX.get = async function (path, params, options = {}) { + let url = base + path; + if (params) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== "") qs.set(k, v); + } + const q = qs.toString(); + if (q) url += "?" + q; + } + const resp = await fetch(url, { signal: options.signal }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(resp.status + " " + text.slice(0, 200)); + } + return resp.json(); +}; +``` + +In `dixhttp/static/js/graph_state.mjs`, add and export: + +```js +export function createLoadGuard() { + let current = 0; + return { + begin() { + current += 1; + return { seq: current }; + }, + isCurrent(seq) { + return seq === current; + }, + }; +} +``` + +Expose on `window.DIXGraphState` together with existing helpers. + +- [ ] **Step 4: Wire graph.js cancellation** + +In `dixhttp/static/js/views/graph.js` state, add `loadGuard: null, abortController: null`. + +Add helpers: + +```js +function beginLoad() { + state.loadGuard ||= window.DIXGraphState.createLoadGuard(); + state.abortController?.abort(); + const { seq } = state.loadGuard.begin(); + state.abortController = new AbortController(); + return { seq, signal: state.abortController.signal }; +} + +function isStale(seq) { + return !state.loadGuard.isCurrent(seq); +} + +function isAbortError(err) { + return err && (err.name === "AbortError" || /aborted/i.test(String(err.message || err))); +} +``` + +Update `loadRuntimeStats`, `loadProviderData`, `loadModuleData`, `buildModules`, and ego/`DIX.get` calls inside `redraw` to accept/pass `signal`. At each await boundary in `redraw`, return early when `isStale(seq)`. In `catch`, ignore abort errors: + +```js +} catch (err) { + if (isAbortError(err)) return; + DIX.renderError(canvas, err); +} +``` + +- [ ] **Step 5: Run JS tests GREEN** + +```bash +node --test dixhttp/static/js/graph_view.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add dixhttp/static/js/api.js dixhttp/static/js/graph_state.mjs dixhttp/static/js/views/graph.js dixhttp/static/js/graph_view.test.mjs +git commit -m "$(cat <<'EOF' +fix(dixhttp): cancel stale graph requests safely + +EOF +)" +``` + +--- + +### Task 2: Align Module Budget and Lock Issue Navigation Hashes + +**Files:** +- Modify: `dixhttp/static/js/views/graph.js` +- Modify: `dixhttp/static/js/graph_state.mjs` (hashes already exist; keep behavior) +- Test: `dixhttp/static/js/graph_view.test.mjs` + +**Interfaces:** +- Consumes: `issueGraphHash`, `issueTraceHash` +- Produces: `budgets.module = { nodes: 150, edges: 400 }`; `loadModuleData` uses `limit: 150, edge_limit: 400` + +- [ ] **Step 1: Write failing navigation + budget tests** + +Append: + +```js +test("issues have deterministic graph and trace links", async () => { + const { issueGraphHash, issueTraceHash } = await import("./graph_state.mjs"); + const issue = { + output_type: "*app.Service", + provider: "app.NewService", + module: "app/service", + }; + assert.equal(issueGraphHash(issue), "#/graph?mode=ego¢er=*app.Service"); + assert.equal(issueGraphHash({ ...issue, output_type: "" }), "#/graph?mode=module&module=app%2Fservice"); + assert.equal(issueTraceHash(issue), "#/trace?provider=app.NewService&output_type=*app.Service&status=error"); + assert.equal( + issueTraceHash({ ...issue, severity: "slow" }), + "#/trace?provider=app.NewService&output_type=*app.Service&status=slow" + ); +}); + +test("resolveGraphMode accepts module drilldown", async () => { + const { resolveGraphMode } = await import("./graph_state.mjs"); + assert.equal(resolveGraphMode(new URLSearchParams("mode=module")), "module"); +}); +``` + +Update `issueTraceHash` if needed so `severity: "slow"` yields `status=slow`; default remains `error`. + +- [ ] **Step 2: Run JS tests; fix hash helper if RED** + +```bash +node --test dixhttp/static/js/graph_view.test.mjs +``` + +- [ ] **Step 3: Align module budgets in graph.js** + +Change: + +```js +const budgets = { + modules: { nodes: 100, edges: 300 }, + module: { nodes: 150, edges: 400 }, + ego: { nodes: 100, edges: 300 }, + providers: { nodes: 150, edges: 400 }, + types: { nodes: 150, edges: 400 }, +}; +``` + +And: + +```js +async function loadModuleData(module, signal) { + return DIX.get("/api/module", { name: module, limit: 150, edge_limit: 400 }, { signal }); +} +``` + +(If Task 1 did not yet add `signal`, keep the third argument optional.) + +- [ ] **Step 4: Run JS + focused Go module API test** + +```bash +node --test dixhttp/static/js/graph_view.test.mjs +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +go test ./dixhttp -run TestHandleModuleReturnsBoundedTopology -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add dixhttp/static/js/views/graph.js dixhttp/static/js/graph_state.mjs dixhttp/static/js/graph_view.test.mjs +git commit -m "$(cat <<'EOF' +fix(dixhttp): align module graph budget and issue links + +EOF +)" +``` + +--- + +### Task 3: Density Warning Offers Hubs Table + +**Files:** +- Modify: `dixhttp/static/js/graph_state.mjs` +- Modify: `dixhttp/static/js/views/graph.js` +- Test: `dixhttp/static/js/graph_view.test.mjs` + +**Interfaces:** +- Consumes: `applyGraphBudget` +- Produces: `rankHubNodes(nodes, edges, limit = 10) → [{ id, degree }]` + +- [ ] **Step 1: Write failing hub-rank test** + +```js +test("rankHubNodes returns highest-degree nodes first", async () => { + const { rankHubNodes } = await import("./graph_state.mjs"); + const nodes = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const edges = [ + { from: "a", to: "b" }, + { from: "a", to: "c" }, + { from: "b", to: "c" }, + ]; + assert.deepEqual(rankHubNodes(nodes, edges, 2).map((h) => h.id), ["a", "b"]); +}); +``` + +- [ ] **Step 2: Run test RED** + +```bash +node --test dixhttp/static/js/graph_view.test.mjs +``` + +- [ ] **Step 3: Implement rankHubNodes** + +```js +export function rankHubNodes(nodes, edges, limit = 10) { + const degree = new Map(); + for (const node of nodes) degree.set(node.id, 0); + for (const edge of edges) { + degree.set(edge.from, (degree.get(edge.from) || 0) + 1); + degree.set(edge.to, (degree.get(edge.to) || 0) + 1); + } + return [...nodes] + .map((node) => ({ id: node.id, degree: degree.get(node.id) || 0 })) + .sort((a, b) => b.degree - a.degree || String(a.id).localeCompare(String(b.id))) + .slice(0, limit); +} +``` + +Export on `window.DIXGraphState`. + +- [ ] **Step 4: Enrich density warning UI in graph.js** + +When `bounded.degraded || graph.truncated`, set `#g-budget` innerHTML (not only textContent) to: + +1. One-line warning with node/edge counts +2. A compact hubs table from `rankHubNodes(bounded.nodes, bounded.edges, 8)` +3. Hint chips/actions already implied by copy: reduce module / depth / use search + +Keep styling minimal with existing `.tbl` / `.muted` classes. + +- [ ] **Step 5: Run JS tests GREEN** + +```bash +node --test dixhttp/static/js/graph_view.test.mjs +``` + +- [ ] **Step 6: Commit** + +```bash +git add dixhttp/static/js/graph_state.mjs dixhttp/static/js/views/graph.js dixhttp/static/js/graph_view.test.mjs +git commit -m "$(cat <<'EOF' +feat(dixhttp): show hub table when graph budget truncates + +EOF +)" +``` + +--- + +### Task 4: Trace View Hash Prefilter + +**Files:** +- Modify: `dixhttp/static/js/graph_state.mjs` +- Modify: `dixhttp/static/js/views/trace.js` +- Test: `dixhttp/static/js/graph_view.test.mjs` + +**Interfaces:** +- Consumes: hash query `provider`, `output_type`, `status`, `trace_id` +- Produces: `matchTraceRecord(rec, filter) → boolean`; `filterTraceRecords(records, filter) → records` + +Trace API records expose fields such as `provider` / `provider_function`, `output_type`, `status`, `trace_id` (match whatever `/api/trace` already returns; inspect one fixture response while implementing). + +- [ ] **Step 1: Write failing filter tests** + +```js +test("filterTraceRecords keeps matching provider and status", async () => { + const { filterTraceRecords } = await import("./graph_state.mjs"); + const records = [ + { trace_id: "aa", provider_function: "app.NewA", output_type: "*A", status: "error" }, + { trace_id: "bb", provider_function: "app.NewB", output_type: "*B", status: "ok" }, + { trace_id: "cc", provider_function: "app.NewA", output_type: "*A", status: "ok" }, + ]; + const filtered = filterTraceRecords(records, { + provider: "app.NewA", + output_type: "*A", + status: "error", + }); + assert.deepEqual(filtered.map((r) => r.trace_id), ["aa"]); +}); +``` + +- [ ] **Step 2: Run RED, then implement helpers** + +```js +export function filterTraceRecords(records = [], filter = {}) { + return records.filter((rec) => matchTraceRecord(rec, filter)); +} + +export function matchTraceRecord(rec = {}, filter = {}) { + if (filter.trace_id && rec.trace_id !== filter.trace_id) return false; + const provider = rec.provider_function || rec.provider || ""; + if (filter.provider && provider !== filter.provider) return false; + if (filter.output_type && rec.output_type !== filter.output_type) return false; + if (filter.status === "error" && rec.status !== "error") return false; + if (filter.status === "slow") { + // slow is a feed severity, not always an event status; keep provider/output matches + return true; + } + return true; +} +``` + +Tune field names against real `/api/trace` JSON before committing. + +- [ ] **Step 3: Wire trace.js** + +In `DIX.views.trace.render(el, query)` (accept `query` from router like other views; if `main.js` already passes query, use it; otherwise parse `location.hash`): + +1. Build `filter` from `provider`, `output_type`, `status`, `trace_id`. +2. Apply `filterTraceRecords` before grouping. +3. Sort groups with errors first, then newest. +4. If `filter.trace_id` is set and present, auto-open that tree once. +5. Show a muted banner when filters are active, with a clear-filter control that resets hash to `#/trace`. + +`main.js` already calls `v.render(el, query)` for every view — do not change it unless the signature is broken. + +Match filter fields against `dixtrace.Event` JSON: `provider_function`, `output_type`, `status`, `trace_id`. + +- [ ] **Step 4: Run JS tests + smoke Go compile** + +```bash +node --test dixhttp/static/js/graph_view.test.mjs +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +go test ./dixhttp -count=1 +``` + +- [ ] **Step 5: Commit** + +```bash +git add dixhttp/static/js/graph_state.mjs dixhttp/static/js/views/trace.js dixhttp/static/js/graph_view.test.mjs +git commit -m "$(cat <<'EOF' +feat(dixhttp): prefilter trace view from issue hash + +EOF +)" +``` + +--- + +### Task 5: Graph Drawer Jump to Trace + +**Files:** +- Modify: `dixhttp/static/js/views/graph.js` +- Modify: `dixhttp/static/js/graph_state.mjs` if a small helper helps +- Test: `dixhttp/static/js/graph_view.test.mjs` + +**Interfaces:** +- Consumes: `issueTraceHash({ provider, output_type, severity })` +- Produces: drawer button `#d-trace` setting `location.hash` + +- [ ] **Step 1: Add helper test for provider-detail trace hash** + +```js +test("provider drawer can build trace hash from node identity", async () => { + const { issueTraceHash } = await import("./graph_state.mjs"); + assert.equal( + issueTraceHash({ provider: "app.NewService", output_type: "*app.Service", severity: "error" }), + "#/trace?provider=app.NewService&output_type=*app.Service&status=error" + ); +}); +``` + +- [ ] **Step 2: Add drawer button in type/provider detail** + +In `typeDetail` / provider detail rendering inside `graph.js`, add: + +```html + +``` + +Wire: + +```js +document.getElementById("d-trace").onclick = () => { + location.hash = window.DIXGraphState.issueTraceHash({ + provider: providerFnName || "", + output_type: label, + severity: "error", + }); +}; +``` + +Use the best available provider function name from node `data` / selected provider chips. If only a type label exists, still jump with `output_type`. + +- [ ] **Step 3: Manual sanity via example is optional; automated JS tests must PASS** + +```bash +node --test dixhttp/static/js/graph_view.test.mjs +``` + +- [ ] **Step 4: Commit** + +```bash +git add dixhttp/static/js/views/graph.js dixhttp/static/js/graph_view.test.mjs +git commit -m "$(cat <<'EOF' +feat(dixhttp): jump from graph drawer to filtered trace + +EOF +)" +``` + +--- + +### Task 6: Optional TraceID on Issues + +**Files:** +- Modify: `dixinternal/dix.go` (`recentErrorRecord` / `recordRecentErrorWithContext`) +- Modify: `dixinternal/api.go` (`RecentError`) +- Modify: `dixhttp/server.go` (`IssueInfo`, `buildIssues`) +- Test: `dixhttp/server_issues_test.go` +- Test: optional `dixinternal` focused test if TraceID capture needs one + +**Interfaces:** +- Consumes: current span TraceID from inject/provider context when available +- Produces: `RecentError.TraceID string \`json:"trace_id,omitempty"\``; `IssueInfo.TraceID string \`json:"trace_id,omitempty"\`` +- Updates: `issueTraceHash` prefers `trace_id` query when present + +Minimal capture path: in `recordRecentErrorWithContext`, if `dixtrace` has a current span TraceID accessor already used elsewhere in dixinternal, copy it into the record. If no accessor exists without new public API, implement only the additive struct/JSON fields + `buildIssues` projection, leave TraceID empty at runtime, and rely on Task 4 provider/output filters. Do not invent a second TraceID generator. + +- [ ] **Step 1: Extend IssueInfo test expectations** + +In `dixhttp/server_issues_test.go`, add a unit assertion on `buildIssues` that when `RecentError.TraceID` is set, the projected issue keeps it. + +```go +recent := []dixinternal.RecentError{{ + ErrorType: "provider_error", + Message: "boom", + TraceID: "abc123", + OutputType: "*app.Service", + ProviderFunction: "app.NewService", + OccurredAtUnixNano: 1, +}} +issues := buildIssues(nil, recent, nil, 0, 10) +if issues[0].TraceID != "abc123" { + t.Fatalf("trace id = %q", issues[0].TraceID) +} +``` + +- [ ] **Step 2: Run RED (missing field), then add fields and projection** + +Add `TraceID` to internal recent-error record, `RecentError`, and `IssueInfo`; copy in `buildIssues`. + +- [ ] **Step 3: Prefer trace_id in issueTraceHash** + +```js +export function issueTraceHash(issue = {}) { + const params = new URLSearchParams(); + if (issue.trace_id) params.set("trace_id", issue.trace_id); + if (issue.provider) params.set("provider", issue.provider); + if (issue.output_type) params.set("output_type", issue.output_type); + params.set("status", issue.severity === "slow" ? "slow" : "error"); + return "#/trace?" + params.toString(); +} +``` + +Update JS tests accordingly. + +- [ ] **Step 4: Run tests** + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +go test ./dixhttp ./dixinternal -run 'Issue|RecentError|Trace' -count=1 +node --test dixhttp/static/js/graph_view.test.mjs +``` + +- [ ] **Step 5: Commit** + +```bash +git add dixinternal/dix.go dixinternal/api.go dixhttp/server.go dixhttp/server_issues_test.go dixhttp/static/js/graph_state.mjs dixhttp/static/js/graph_view.test.mjs +git commit -m "$(cat <<'EOF' +feat(dixhttp): propagate optional trace id on issues + +EOF +)" +``` + +--- + +### Task 7: Docs and Full Verification + +**Files:** +- Modify: `dixhttp/README.md` +- Modify: `dixhttp/README_zh.md` +- Modify: `docs/superpowers/specs/2026-09-05-dependency-visualization-unified-design.md` only if behavior notes need a one-line status flip + +**Interfaces:** +- Consumes: delivered behavior from Tasks 1–6 +- Produces: README sections describing Issue → Graph → Trace and budgets + +- [ ] **Step 1: Update README workflow bullets** + +Document: + +1. `/next#/graph` defaults to module map and does not load `/api/dependencies`. +2. Module drill-down budget 150/400; density warning includes hubs. +3. Overview Issues jump to ego/module graph and filtered Trace. +4. Graph drawer can open filtered Trace. +5. Legacy `/` and `/api/dependencies` remain available. + +- [ ] **Step 2: Full verification** + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +node --test dixhttp/static/js/graph_view.test.mjs +go test -race ./... +go test ./example/http -count=1 +``` + +Expected: all PASS. + +- [ ] **Step 3: Commit** + +```bash +git add dixhttp/README.md dixhttp/README_zh.md +git commit -m "$(cat <<'EOF' +docs(dixhttp): document unified dependency diagnosis workflow + +EOF +)" +``` + +--- + +## Final Review Checklist + +- [ ] Stale graph requests abort and do not overwrite newer views. +- [ ] Module drill-down uses 150/400 budgets. +- [ ] Issue hash navigation to graph/trace is tested. +- [ ] Density truncation shows hubs table, not silent cut-only. +- [ ] Trace view honors provider/output/status/trace_id filters. +- [ ] Graph drawer can open Trace in ≤1 click from a selected node. +- [ ] Optional issue `trace_id` is additive and omitted when unknown. +- [ ] Default `/next` path still avoids eager `/api/dependencies`. +- [ ] Legacy UI/API remain reachable. +- [ ] `go test -race ./...` and JS helper tests pass. diff --git a/docs/superpowers/plans/2026-09-05-scale-graph-usability.md b/docs/superpowers/plans/2026-09-05-scale-graph-usability.md new file mode 100644 index 0000000..dcd24a0 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-scale-graph-usability.md @@ -0,0 +1,1000 @@ +# Scale-Safe Dependency Visualization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make dependency visualization correct and usable at approximately 100 modules, 200 providers, and 400 objects by introducing registration-aware data and bounded module-first views. + +**Architecture:** Add a stable registration identity to provider registrations, correlate runtime state by provider/output identity, stop source-line aggregation from creating false edges, and make the new graph UI module-first with explicit rendering budgets. Existing legacy APIs remain compatible while the new UI becomes the supported scale path. + +**Tech Stack:** Go 1.24+, net/http/httptest, reflection-based Dix graph, vendored vanilla JS, vis-network, hash routing. + +**Spec:** `docs/superpowers/specs/2026-09-05-scale-graph-usability-design.md` + +## Global Constraints + +- Do not introduce third-party Go or JavaScript dependencies. +- Preserve all existing public Dix APIs. +- Preserve legacy `/api/dependencies` response compatibility except additive fields and corrected edges/identities. +- Use TDD for every behavior change; run focused tests before implementation and after implementation. +- Keep graph phase-one budgets at module map 100 nodes/300 edges, module detail 150 nodes/400 edges, ego maximum depth 5. +- Do not show an empty default ego graph; `/next#/graph` defaults to module map. +- Every commit must leave `go test -race ./...` green in the root module. + +--- + +### Task 1: Add Logical Provider Registration Identity + +**Files:** +- Modify: `dixinternal/provider.go` +- Modify: `dixinternal/dix.go:1119-1220` +- Modify: `dixinternal/dix.go:1350-1370` +- Test: `dixinternal/provider_identity_test.go` + +**Interfaces:** +- Consumes: private `providerFn`, `Dix.handleProvide`, and `Dix.provide`. +- Produces: `providerFn.registrationID uint64`; `handleProvide(fnVal reflect.Value, outType reflect.Type, inputs []*providerInputType, registrationID uint64) error`. + +- [x] **Step 1: Write the failing identity tests** + +Create `dixinternal/provider_identity_test.go`: + +```go +package dixinternal + +import ( + "reflect" + "testing" +) + +type identityA struct{} +type identityB struct{} +type identityAggregate struct { + A *identityA + B *identityB +} + +func TestStructProviderOutputsShareRegistrationID(t *testing.T) { + di := New() + di.Provide(func() identityAggregate { + return identityAggregate{A: &identityA{}, B: &identityB{}} + }) + + ids := make(map[string]uint64) + for _, provider := range di.providers[reflect.TypeOf(&identityA{})] { + ids["A"] = provider.registrationID + } + for _, provider := range di.providers[reflect.TypeOf(&identityB{})] { + ids["B"] = provider.registrationID + } + if ids["A"] == 0 || ids["A"] != ids["B"] { + t.Fatalf("struct outputs should share one registration ID: %#v", ids) + } +} + +func TestDistinctClosureRegistrationsHaveDistinctRegistrationIDs(t *testing.T) { + di := New() + for _, name := range []string{"first", "second"} { + value := name + di.Provide(func() *identityA { return &identityA{} }) + if value == "" { + t.Fatal("closure registration setup unexpectedly empty") + } + } + + providers := di.providers[reflect.TypeOf(&identityA{})] + if len(providers) != 2 { + t.Fatalf("expected 2 providers, got %d", len(providers)) + } + if providers[0].registrationID == providers[1].registrationID { + t.Fatal("distinct registrations must not share a registration ID") + } +} +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test ./dixinternal -run Test.*(RegistrationID|RegistrationIDs) -count=1 +``` + +Expected: compilation fails because `registrationID` and the new `handleProvide` signature do not exist. + +- [x] **Step 3: Implement registration identity** + +In `dixinternal/provider.go`, add the field: + +```go +type providerFn struct { + fn reflect.Value + inputList []*providerInputType + output *providerOutputType + hasError bool + registrationID uint64 +} +``` + +In `dixinternal/dix.go`, add a container counter and change the private signature: + +```go +func (dix *Dix) handleProvide(fnVal reflect.Value, outType reflect.Type, inputs []*providerInputType, registrationID uint64) error { + // existing implementation, but construct: + provider := &providerFn{fn: fnVal, inputList: inputs, hasError: hasError, registrationID: registrationID} +``` + +Pass the same `registrationID` into the recursive struct-field call. At the top-level call in `provide`, allocate a new identity: + +```go +dix.registrationSeq++ +if err := dix.handleProvide(fnVal, typ.Out(0), inputs, dix.registrationSeq); err != nil { +``` + +Add `registrationSeq uint64` beside `graph` in the private `Dix` struct. Container writes are documented single-threaded, so no atomic is required. + +- [x] **Step 4: Run the focused test and verify GREEN** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test ./dixinternal -run Test.*(RegistrationID|RegistrationIDs) -count=1 +``` + +Expected: both tests pass. + +- [x] **Step 5: Run the root race suite** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test -race ./... +``` + +Expected: all packages pass. + +- [x] **Step 6: Commit** + +```bash +git add dixinternal/provider.go dixinternal/dix.go dixinternal/provider_identity_test.go +git commit -m "feat(dixinternal): add provider registration identity" +``` + +--- + +### Task 2: Expose Stable Provider IDs and Fix Dependency Aggregation + +**Files:** +- Modify: `dixinternal/api.go:200-260` +- Modify: `dixhttp/server.go:650-820` +- Test: `dixhttp/server_provider_identity_test.go` + +**Interfaces:** +- Consumes: `providerFn.registrationID` from Task 1. +- Produces: `ProviderDetails.ProviderID string`, `ProviderInfo.ProviderIDs []string`, and `providerAggregateKey(detail dixinternal.ProviderDetails) string`. + +- [x] **Step 1: Write the failing API projection test** + +Create `dixhttp/server_provider_identity_test.go`: + +```go +package dixhttp + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + dix "github.com/pubgo/dix/v2" + "github.com/pubgo/dix/v2/dixinternal" +) + +type projectionInput struct{} +type projectionOutputA struct{} +type projectionOutputB struct{} +type projectionAggregate struct { + A *projectionOutputA + B *projectionOutputB +} + +func TestDependenciesPreserveRegistrationAndOutputIdentity(t *testing.T) { + container := dix.New() + dix.Provide(container, func() projectionAggregate { + return projectionAggregate{A: &projectionOutputA{}, B: &projectionOutputB{}} + }) + + server := NewServer(container) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("GET", "/api/dependencies", nil)) + if recorder.Code != 200 { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + + var response DependencyData + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + + registrationIDs := map[uint64]bool{} + providerIDs := map[string]bool{} + for _, provider := range response.Providers { + if provider.OutputType != "*dixhttp.projectionOutputA" && provider.OutputType != "*dixhttp.projectionOutputB" { + continue + } + if provider.RegistrationID == 0 || len(provider.ProviderIDs) == 0 { + t.Fatalf("provider identities must not be empty: %+v", provider) + } + registrationIDs[provider.RegistrationID] = true + for _, providerID := range provider.ProviderIDs { + providerIDs[providerID] = true + } + } + if len(registrationIDs) != 1 { + t.Fatalf("one struct registration should produce one registration_id, got %v", registrationIDs) + } + if len(providerIDs) != 2 { + t.Fatalf("two outputs should produce two provider IDs, got %v", providerIDs) + } +} + +func TestDistinctSameLineRegistrationsDoNotCartesianProductEdges(t *testing.T) { + container := dix.New() + for i := 0; i < 2; i++ { + dix.Provide(container, func() *projectionOutputA { return &projectionOutputA{} }) + } + + server := NewServer(container) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("GET", "/api/dependencies", nil)) + + var response DependencyData + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + targetCount := 0 + for _, provider := range response.Providers { + if provider.OutputType == "*dixhttp.projectionOutputA" { + targetCount++ + } + } + if targetCount != 2 { + t.Fatalf("expected distinct provider nodes, got %d", targetCount) + } + for _, provider := range response.Providers { + if provider.OutputType != "*dixhttp.projectionOutputA" { + continue + } + if len(provider.InputTypes)*len(provider.OutputTypes) > 1 { + t.Fatalf("unrelated input/output pairs created a Cartesian product: %+v", provider) + } + } +} +``` + +- [x] **Step 2: Run the focused HTTP test and verify RED** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test ./dixhttp -run Test(DependenciesPreserve|DistinctSameLine) -count=1 +``` + +Expected: compilation fails because `RegistrationID` and `ProviderID` do not exist. + +- [x] **Step 3: Project and aggregate by registration identity** + +In `dixinternal.ProviderDetails`, add: + +```go +RegistrationID uint64 `json:"registration_id"` +ProviderID string `json:"provider_id"` +``` + +In `GetProviderDetails`, construct IDs: + +```go +registrationID := providerFn.registrationID +providerID := fmt.Sprintf("provider_%d_%s", registrationID, outputType.String()) +``` + +In `dixhttp.ProviderInfo`, add: + +```go +RegistrationID uint64 `json:"registration_id"` +ProviderIDs []string `json:"provider_ids"` +``` + +A logical registration with multiple outputs must expose one concrete `provider_id` per output in `ProviderIDs`. It cannot use a single `provider_id` without losing runtime-state correlation. + +Change the aggregate bucket key to: + +```go +func providerAggregateKey(detail dixinternal.ProviderDetails) string { + if detail.RegistrationID != 0 { + return fmt.Sprintf("registration_%d", detail.RegistrationID) + } + // Keep the legacy fallback only for externally constructed fixtures. + if detail.FunctionFile != "" && detail.FunctionLine > 0 { + return fmt.Sprintf("%s:%d", detail.FunctionFile, detail.FunctionLine) + } + if detail.FunctionName != "" { + return detail.FunctionName + } + if detail.OutputType != "" { + return detail.OutputType + } + return "unknown" +} +``` + +Copy `RegistrationID` into `ProviderInfo`, and collect every output-specific `detail.ProviderID` into `ProviderIDs`. Edge generation must iterate actual detail input/output pairs while accumulating bucket metadata; it must not regenerate from merged `InputTypes × OutputTypes`. + +Refactor the bucket to retain raw `dixinternal.ProviderDetails` values so edge building can preserve exact relationships. + +- [x] **Step 4: Run the focused HTTP test and verify GREEN** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test ./dixhttp -run Test(DependenciesPreserve|DistinctSameLine) -count=1 +``` + +Expected: both tests pass. + +- [x] **Step 5: Run existing dependency regression tests** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test ./dixhttp -run TestHandleDependencies -count=1 +``` + +Expected: pass, including the existing multi-output aggregation test. + +- [x] **Step 6: Commit** + +```bash +git add dixinternal/api.go dixhttp/server.go dixhttp/server_provider_identity_test.go +git commit -m "fix(dixhttp): preserve provider identity and dependency edges" +``` + +--- + +### Task 3: Correlate Runtime Stats by Provider Identity + +**Files:** +- Modify: `dixinternal/api.go:170-310` +- Modify: `dixhttp/static/js/views/graph.js:42-55` +- Modify: `dixhttp/static/js/views/graph.js:213-240` +- Test: `dixinternal/runtime_stats_identity_test.go` + +**Interfaces:** +- Consumes: `providerFn.registrationID`. +- Produces: `ProviderRuntimeStats.RegistrationID uint64`, `ProviderRuntimeStats.ProviderID string`, and `statForProvider(provider ProviderInfo) ProviderRuntimeStats | null`. + +- [x] **Step 1: Write the failing runtime identity test** + +Create `dixinternal/runtime_stats_identity_test.go`: + +```go +package dixinternal + +import ( + "reflect" + "testing" +) + +type runtimeTarget struct{ Name string } + +func TestRuntimeStatsIncludeConcreteProviderIdentity(t *testing.T) { + di := New() + di.Provide(func() *runtimeTarget { return &runtimeTarget{Name: "ready"} }) + _ = di.TryInject(func(*runtimeTarget) {}) + + stats := di.GetProviderRuntimeStats() + for _, stat := range stats { + if stat.OutputType != "*dixinternal.runtimeTarget" { + continue + } + if stat.RegistrationID == 0 { + t.Fatal("expected non-zero registration ID") + } + if stat.ProviderID == "" { + t.Fatal("expected provider ID") + } + if stat.CallCount != 1 { + t.Fatalf("call count = %d, want 1", stat.CallCount) + } + return + } + t.Fatalf("target stat not found in %+v", stats) +} + +func TestRuntimeStatsDoNotDeduplicateDistinctClosures(t *testing.T) { + di := New() + for i := 0; i < 2; i++ { + di.Provide(func() *runtimeTarget { return &runtimeTarget{} }) + } + stats := di.GetProviderRuntimeStats() + count := 0 + for _, stat := range stats { + if stat.OutputType == reflect.TypeOf(&runtimeTarget{}).String() { + count++ + } + } + if count != 2 { + t.Fatalf("provider stats = %d, want 2", count) + } +} +``` + +- [x] **Step 2: Run the focused runtime test and verify RED** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test ./dixinternal -run TestRuntimeStats -count=1 +``` + +Expected: compilation fails because identity fields do not exist. + +- [x] **Step 3: Emit concrete identity fields** + +Add fields to `ProviderRuntimeStats`: + +```go +RegistrationID uint64 `json:"registration_id"` +ProviderID string `json:"provider_id"` +``` + +In `GetProviderRuntimeStats`, replace function-name-only deduplication with concrete provider/output identity: + +```go +seen := make(map[string]bool) +identity := fmt.Sprintf("%d:%s", p.registrationID, outputType) +if seen[identity] { + continue +} +seen[identity] = true +item.RegistrationID = p.registrationID +item.ProviderID = fmt.Sprintf("provider_%d_%s", p.registrationID, outputType) +``` + +Update the graph JS stat lookup to use: + +```js +function statForProvider(provider) { + const stats = state.runtimeStats || []; + for (const providerID of provider.provider_ids || []) { + const exact = stats.find(s => s.provider_id === providerID); + if (exact) return exact; + } + return stats.find(s => !s.provider_id && s.function_name === provider.function_name) || null; +} +``` + +Use this helper in provider detail and error coloring. Keep the fallback only for stale cached responses. + +- [x] **Step 4: Run the focused runtime test and verify GREEN** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test ./dixinternal -run TestRuntimeStats -count=1 +``` + +Expected: both tests pass. + +- [x] **Step 5: Run race tests** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test -race ./... +``` + +Expected: all packages pass. + +- [x] **Step 6: Commit** + +```bash +git add dixinternal/api.go dixhttp/static/js/views/graph.js dixinternal/runtime_stats_identity_test.go +git commit -m "fix(dixinternal): identify runtime stats by provider output" +``` + +--- + +### Task 4: Correct Module, Ego State, and Resolved Aggregation + +**Files:** +- Modify: `dixinternal/graph_query.go:40-260` +- Modify: `dixinternal/graph_query.go:265-360` +- Modify: `dixhttp/server.go:414-540` +- Test: `dixinternal/graph_query_projection_test.go` +- Test: `dixhttp/server_packages_test.go` + +**Interfaces:** +- Consumes: `Graph` nodes/edges and cached provider details. +- Produces: accurate `ModuleGraph`, `EgoGraph` instantiated state, `ResolvedTopN`, and `PackageInfo`. + +- [x] **Step 1: Write failing projection tests** + +Create `dixinternal/graph_query_projection_test.go`: + +```go +package dixinternal + +import ( + "reflect" + "testing" +) + +type packageTarget struct{} + +func TestEgoInstantiatedUsesObjectNodes(t *testing.T) { + di := New() + di.graph.node(NodeType, reflect.TypeOf(&packageTarget{}), "", nil) + view := di.EgoGraph("*dixinternal.packageTarget", 1, "both") + for _, node := range view.Nodes { + if node.Label == "*dixinternal.packageTarget" && node.State == "instantiated" { + t.Fatal("missing provider must not be reported instantiated") + } + } + + di.Provide(func() *packageTarget { return &packageTarget{} }) + di.graph.addObject(reflect.TypeOf(&packageTarget{}), "") + view = di.EgoGraph("*dixinternal.packageTarget", 1, "both") + instantiated := false + for _, node := range view.Nodes { + if node.Label == "*dixinternal.packageTarget" && node.State == "instantiated" { + instantiated = true + } + } + if !instantiated { + t.Fatal("object-bearing ego node should be instantiated") + } +} + +func TestResolvedTopNAggregatesByType(t *testing.T) { + di := New() + di.Provide(func() *packageTarget { return &packageTarget{} }) + for i := 0; i < 3; i++ { + _, _ = di.TryInject(func(*packageTarget) {}) + } + + rows := di.ResolvedTopN(10) + found := 0 + for _, row := range rows { + if row.Type == "*dixinternal.packageTarget" { + found++ + if row.Count != 3 { + t.Fatalf("count = %d, want 3", row.Count) + } + } + } + if found != 1 { + t.Fatalf("type rows = %d, want 1", found) + } +} +``` + +Create `dixhttp/server_packages_test.go`: + +```go +package dixhttp + +import "testing" + +func TestPackageInfoUsesResolvedOutputPackage(t *testing.T) { + details := []dixinternal.ProviderDetails{ + {OutputType: "*main.Plugin[main.RoleReader]", OutputPkg: "main"}, + {OutputType: "*billing.Client", OutputPkg: "example/billing"}, + } + packages := buildPackageInfos(details) + if len(packages) != 2 { + t.Fatalf("packages = %+v", packages) + } + if packages[0].Name != "main" || packages[1].Name != "example/billing" { + t.Fatalf("malformed generic package path retained: %+v", packages) + } +} +``` + +Add the required import `github.com/pubgo/dix/v2/dixinternal` to the HTTP test. + +- [x] **Step 2: Run focused projection tests and verify RED** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test ./dixinternal -run Test(EgoInstantiated|ResolvedTopNAggregates) -count=1; go test ./dixhttp -run TestPackageInfoUses -count=1 +``` + +Expected: ego/state and aggregation/package helper failures. + +- [x] **Step 3: Fix the three projections** + +In `EgoGraph`, build an instantiated set from object nodes before creating nodes: + +```go +instantiated := make(map[reflect.Type]bool) +for key := range g.nIndex { + if key.kind == NodeObject { + instantiated[key.typ] = true + } +} +``` + +Set node state to `"instantiated"` only when `instantiated[t]`; otherwise omit state. + +In `ResolvedTopN`, accumulate counts by type before sorting: + +```go +countsByType := make(map[string]int64) +for _, e := range g.eIndex { + if e.Kind == EdgeResolved && e.Count > 0 { + countsByType[g.nodes[e.To].Type.String()] += e.Count + } +} +``` + +Then sort and truncate the materialized rows. + +Extract package construction from `HandlePackages` into: + +```go +func buildPackageInfos(details []dixinternal.ProviderDetails) []PackageInfo +``` + +Use `detail.OutputPkg` when non-empty and `(anonymous)` when empty. Do not call `extractPackage(detail.OutputType)` for provider package grouping. Return rows sorted by `Name` so API output and tests are deterministic. + +- [x] **Step 4: Run focused projection tests and verify GREEN** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test ./dixinternal -run Test(EgoInstantiated|ResolvedTopNAggregates) -count=1; go test ./dixhttp -run TestPackageInfoUses -count=1 +``` + +Expected: all focused tests pass. + +- [x] **Step 5: Commit** + +```bash +git add dixinternal/graph_query.go dixhttp/server.go dixinternal/graph_query_projection_test.go dixhttp/server_packages_test.go +git commit -m "fix(dixhttp): correct graph projection state and modules" +``` + +--- + +### Task 5: Make the New Graph Default Module-First and Bounded + +**Files:** +- Modify: `dixhttp/static/js/views/graph.js:1-420` +- Test: `dixhttp/static/js/graph_view.test.mjs` + +**Interfaces:** +- Consumes: `/api/modules`, `/api/ego`, and identity-aware `ProviderInfo`. +- Produces: `resolveGraphMode(query)`, `applyGraphBudget(nodes, edges, budget)`, and module-first graph state. + +- [x] **Step 1: Write failing pure JS view tests** + +Create `dixhttp/static/js/graph_view.test.mjs`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; + +test("graph defaults to module map instead of empty ego", async () => { + const { resolveGraphMode } = await import("./graph_state.mjs"); + assert.equal(resolveGraphMode(new URLSearchParams()), "modules"); + assert.equal(resolveGraphMode(new URLSearchParams("mode=ego")), "ego"); +}); + +test("graphs degrade to table metadata above explicit budgets", async () => { + const { applyGraphBudget } = await import("./graph_state.mjs"); + const nodes = Array.from({ length: 101 }, (_, i) => ({ id: String(i) })); + const edges = Array.from({ length: 301 }, (_, i) => ({ from: String(i % 100), to: String((i + 1) % 100) })); + const result = applyGraphBudget(nodes, edges, { nodes: 100, edges: 300 }); + assert.equal(result.nodes.length, 100); + assert.equal(result.edges.length, 300); + assert.equal(result.degraded, true); +}); +``` + +- [x] **Step 2: Run JS tests and verify RED** + +Run: + +```bash +node --test dixhttp/static/js/graph_view.test.mjs +``` + +Expected: module resolution fails because `graph_state.mjs` does not exist. + +- [x] **Step 3: Extract and implement pure graph state helpers** + +Create `dixhttp/static/js/graph_state.mjs`: + +```js +export function resolveGraphMode(query) { + const mode = (query.get("mode") || "").trim(); + return ["modules", "ego", "providers", "types"].includes(mode) ? mode : "modules"; +} + +export function applyGraphBudget(nodes, edges, budget = { nodes: 100, edges: 300 }) { + if (nodes.length <= budget.nodes && edges.length <= budget.edges) { + return { nodes, edges, degraded: false }; + } + const degree = new Map(); + for (const edge of edges) { + degree.set(edge.from, (degree.get(edge.from) || 0) + 1); + degree.set(edge.to, (degree.get(edge.to) || 0) + 1); + } + const keptNodes = new Set([...nodes].sort((a, b) => + (degree.get(b.id) || 0) - (degree.get(a.id) || 0) || + String(a.id).localeCompare(String(b.id)) + ).slice(0, budget.nodes).map(node => node.id)); + const keptEdges = edges.filter(edge => + keptNodes.has(edge.from) && keptNodes.has(edge.to) + ).slice(0, budget.edges); + return { + nodes: nodes.filter(node => keptNodes.has(node.id)), + edges: keptEdges, + degraded: true, + }; +} +``` + +Create a browser adapter `dixhttp/static/js/graph_state.js` that exposes the same functions on `window.DIXGraphState`, either by duplicating the small pure logic or via a generated embedded bundle only if the build already supports one. + +Update `views/graph.js` to import/use these helpers, default the select control to `modules`, and render a visible density warning when `degraded` is true. + +- [x] **Step 4: Remove eager full dependencies loading** + +Change `loadData()` so only providers/types detail views request `/api/dependencies`; module and ego views must not preload it. Request runtime stats in parallel and cache by provider identity. The graph draw path must use `/api/modules` for module mode and `/api/ego` for ego mode. + +- [x] **Step 5: Run JS tests** + +Run: + +```bash +node --test dixhttp/static/js/graph_view.test.mjs +``` + +Expected: both tests pass. + +- [x] **Step 6: Run Go tests** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test -race ./... +``` + +Expected: all packages pass. + +- [x] **Step 7: Commit** + +```bash +git add dixhttp/static/js/graph_state.mjs dixhttp/static/js/graph_state.js dixhttp/static/js/views/graph.js dixhttp/static/js/graph_view.test.mjs +git commit -m "feat(dixhttp): default graph to bounded module map" +``` + +--- + +### Task 6: Responsive Graph Layout and Manual Scale Verification + +**Files:** +- Modify: `dixhttp/static/css/app.css:100-150` +- Modify: `dixhttp/static/js/views/graph.js:15-40` +- Test: `dixhttp/http_scale_e2e_test.go` + +**Interfaces:** +- Consumes: module-first UI and graph budgets. +- Produces: stable graph canvas sizing and an HTTP smoke test proving `/next` defaults to module assets. + +- [x] **Step 1: Write the failing HTTP/UI smoke test** + +Create `dixhttp/http_scale_e2e_test.go`: + +```go +package dixhttp + +import ( + "net/http/httptest" + "strings" + "testing" + + dix "github.com/pubgo/dix/v2" +) + +func TestNextGraphDefaultsToModuleFirstAssets(t *testing.T) { + container := dix.New() + server := NewServer(container) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("GET", "/next", nil)) + + if recorder.Code != 200 { + t.Fatalf("status = %d", recorder.Code) + } + body := recorder.Body.String() + for _, asset := range []string{"static/js/views/graph.js", "static/js/main.js"} { + if !strings.Contains(body, asset) { + t.Fatalf("missing asset %s", asset) + } + } +} +``` + +- [x] **Step 2: Run smoke test and verify RED or baseline** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test ./dixhttp -run TestNextGraphDefaults -count=1 +``` + +Expected: this route already serves the assets and should pass; if it fails, fix routing before UI CSS work. + +- [x] **Step 3: Lock canvas geometry** + +Update `app.css`: + +```css +.graph-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 320px); + align-items: stretch; +} + +#graph-canvas { + height: clamp(420px, calc(100vh - 220px), 900px); + min-width: 300px; +} + +@media (max-width: 900px) { + .graph-layout { grid-template-columns: minmax(0, 1fr); } + #graph-canvas { height: 70vh; min-height: 420px; } + #g-detail { position: static; max-height: 45vh; overflow: auto; } +} +``` + +Apply `class="graph-layout"` to the graph grid and use `minmax(0, 1fr)` for all toolbar inputs that can overflow. + +- [x] **Step 4: Run automated checks** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test -race ./... && node --test dixhttp/static/js/graph_view.test.mjs +``` + +Expected: all checks pass. + +- [x] **Step 5: Run the scale fixture manually** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; (cd example && DIX_HTTP_ADDR=127.0.0.1:18099 go run ./http) +``` + +Inspect: + +```text +http://127.0.0.1:18099/next#/graph +``` + +Confirm module map is visible, no full dependency request occurs on first render, and the canvas remains usable at 700px viewport width. Stop the fixture after verification. + +- [x] **Step 6: Commit** + +```bash +git add dixhttp/http_scale_e2e_test.go dixhttp/static/css/app.css dixhttp/static/js/views/graph.js +git commit -m "fix(dixhttp): keep scaled graph views usable" +``` + +--- + +### Task 7: Documentation, Example Fixture, and Full Verification + +**Files:** +- Modify: `dixhttp/README.md` +- Modify: `dixhttp/README_zh.md` +- Modify: `example/http/main.go` +- Test: `example/http/scale_shape_test.go` + +**Interfaces:** +- Consumes: completed phase-one behavior. +- Produces: documented scale workflow and a repeatable near-target fixture assertion. + +- [x] **Step 1: Write the failing example shape test** + +Create `example/http/scale_shape_test.go`: + +```go +package main + +import "testing" + +func TestScaleFixtureShape(t *testing.T) { + container := buildContainer() + modules := container.ModuleGraph() + providers := container.GetProviderDetails() + objects := container.GetObjects() + + objectCount := 0 + for _, groups := range objects { + for _, values := range groups { + objectCount += len(values) + } + } + if len(modules) < 10 { + t.Fatalf("modules = %d, want at least 10", len(modules)) + } + if len(providers) < 190 { + t.Fatalf("providers = %d, want at least 190", len(providers)) + } + if objectCount < 170 { + t.Fatalf("objects = %d, want at least 170", objectCount) + } +} +``` + +`buildContainer()` already returns `*dix.Dix` and is in package `main`; call it directly. Do not add another fixture builder unless production initialization changes. + +- [x] **Step 2: Run example shape test and verify RED or baseline** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test -C example ./http -run TestScaleFixtureShape -count=1 +``` + +Expected: pass if the existing fixture already satisfies the bounds; otherwise extract the helper and make the test pass. + +- [x] **Step 3: Document the module-first workflow** + +In both README files, replace claims that the default graph is a full graph. Document: + +```text +Default graph = module map +Module click = bounded module detail +Type click/search = ego graph +Object = state shown in type/provider details +Over-budget graph = density warning plus table/Top-K path +``` + +Document `/api/dependencies` as a compatibility/full-data endpoint, not the first-scale UI data source. + +- [x] **Step 4: Run complete verification** + +Run: + +```bash +unset GOROOT; export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; go test -race ./...; go vet ./...; go build -C example ./...; go test -C example -count=1 ./... +``` + +Expected: every command succeeds. + +- [x] **Step 5: Commit** + +```bash +git add dixhttp/README.md dixhttp/README_zh.md example/http/main.go example/http/scale_shape_test.go +git commit -m "docs(dixhttp): document scale-first dependency workflow" +``` + +--- + +## Final Review Checklist + +- [x] Generic registrations do not merge merely because they share source line. +- [x] Struct multi-output registrations still aggregate as one logical registration. +- [x] Runtime errors and durations attach to the correct provider/output pair. +- [x] Ego state does not claim instantiation without an object node. +- [x] `/next#/graph` renders module map on first load. +- [x] Module map does not request `/api/dependencies`. +- [x] Graph budgets and degradation warning are active. +- [x] Narrow viewport keeps graph canvas usable. +- [x] Root race tests, vet, example build, and example tests pass. +- [x] Documentation matches actual default behavior. diff --git a/docs/superpowers/plans/2026-09-06-legacy-first-architecture-viz.md b/docs/superpowers/plans/2026-09-06-legacy-first-architecture-viz.md new file mode 100644 index 0000000..2e5f386 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-legacy-first-architecture-viz.md @@ -0,0 +1,567 @@ +# Legacy-First Architecture Visualization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the legacy `/` Dix UI readable for large DI architecture review (module/provider/type/group lenses), port useful `/next` graph helpers into it, then delete `/next`. + +**Architecture:** Keep Alpine + `template.html` + `legacy/app.js` as the only UI. Load shared `graph_state.mjs` / selected workbench helpers onto `window` for legacy `renderGraph`. Add module-map view, density banner (lens prompt → hubs → short labels → layout → Top-K last), then remove `/next` route and five-view shell. + +**Tech Stack:** Go embed static FS, Alpine.js, vis-network, Mermaid (already vendored), ES modules for shared helpers, `node --test` for pure JS. + +## Global Constraints + +- Single UI entry: `/` only after Task 7; no parallel five-view shell. +- Preserve all existing legacy capabilities (package sidebar, group rules, prefix, depth, layouts, SVG/Mermaid, diagnostic modals, Trace). +- Default view remains `providers` unless user switches. +- Objects are not default architecture canvas nodes (providers/types/modules/groups only). +- Public Dix APIs: additive fields only; no new third-party JS/Go deps. +- Crowding degradation order is fixed: prompt coarser lens → semantic collapse hint → hubs → short labels → layout/camera → Top-K last. +- Do not silently switch the user’s current view; one-click apply from banner is OK. +- Spec: `docs/superpowers/specs/2026-09-06-legacy-first-architecture-viz-design.md` + +## File Map + +| File | Responsibility | +|---|---| +| `dixhttp/static/js/graph_state.mjs` | Shared labels, budget, hubs, layout/camera, star positions (keep; drop next-only defaults later) | +| `dixhttp/static/js/graph_workbench.mjs` | Optional: group/filter helpers if legacy chooses to call them; otherwise leave until needed | +| `dixhttp/static/js/graph_view.test.mjs` | Node tests for shared helpers; remove next-hash-only cases when APIs removed | +| `dixhttp/template.html` | Load mjs; module map button; density banner DOM | +| `dixhttp/static/js/legacy/app.js` | Wire module map, labels, budget, banner, layout into `renderGraph` | +| `dixhttp/server.go` | Remove `/next` handler | +| `dixhttp/http_scale_e2e_test.go` | Assert `/` embeds graph helpers, not `/next` | +| `dixhttp/scripts/graph_layout_e2e.mjs` | Point health check at `/` | +| `dixhttp/README.md`, `README_zh.md` | Single-UI docs | +| Delete | `static/index.html`, `static/js/main.js`, `static/js/views/*`, `static/js/api.js` if only used by next | + +--- + +### Task 1: Load shared graph helpers in legacy HTML + +**Files:** +- Modify: `dixhttp/template.html` (script tags near bottom) +- Test: manual + later Go test in Task 7 + +**Interfaces:** +- Consumes: `graph_state.mjs` already assigns `window.DIXGraphState` +- Produces: legacy page can call `window.DIXGraphState.shortGraphLabel` after load + +- [ ] **Step 1: Add module scripts before `legacy/app.js`** + +In `dixhttp/template.html`, before the `legacy/app.js` script tag, insert: + +```html + + + +``` + +Bump `legacy/app.js` query from `legacy1` to `legacy-arch1` so caches refresh. + +- [ ] **Step 2: Guard helper access in `app.js`** + +Near the top of the Alpine `app()` return object methods area (or a small helper method), add: + +```javascript +graphHelpers() { + return window.DIXGraphState || null; +}, +``` + +Do not call helpers during synchronous script evaluate; only from `init` / `renderGraph` (after modules ran). + +- [ ] **Step 3: Smoke in browser or curl** + +Run demo server, open `/`, in DevTools: `typeof window.DIXGraphState.shortGraphLabel === 'function'`. + +- [ ] **Step 4: Commit** + +```bash +git add dixhttp/template.html dixhttp/static/js/legacy/app.js +git commit -m "$(cat <<'EOF' +chore(dixhttp): load shared graph helpers in legacy UI + +EOF +)" +``` + +--- + +### Task 2: Short labels on architecture nodes (TDD on helper already exists) + +**Files:** +- Modify: `dixhttp/static/js/legacy/app.js` (`formatTypeName`, provider display labels) +- Test: `dixhttp/static/js/graph_view.test.mjs` (existing `shortGraphLabel` test must still pass) + +**Interfaces:** +- Consumes: `DIXGraphState.shortGraphLabel(name: string): string` +- Produces: node `label` short; `title` keeps full name + +- [ ] **Step 1: Confirm existing test still passes** + +Run: `node --test dixhttp/static/js/graph_view.test.mjs --test-name-pattern shortGraphLabel` +Expected: PASS + +- [ ] **Step 2: Wire `formatTypeName` through short label** + +Replace body of `formatTypeName` to keep full name in tooltip path but shorten display: + +```javascript +formatTypeName(typeName) { + const full = String(typeName || ''); + const helpers = this.graphHelpers(); + if (helpers && helpers.shortGraphLabel) { + return helpers.shortGraphLabel(full); + } + // existing fallback truncation if any + return full; +}, +``` + +Ensure wherever nodes set `title: '类型: ' + outType` they still use the **full** type string (already true in `renderGraph`). + +- [ ] **Step 3: Shorten provider box labels similarly** + +In `providerNodeLabel`, if the label is a long function path, prefer: + +```javascript +providerNodeLabel(provider) { + const raw = /* existing computation */; + const helpers = this.graphHelpers(); + if (helpers && helpers.shortGraphLabel) { + return helpers.shortGraphLabel(raw); + } + return raw; +}, +``` + +Keep full identity in `title` / `buildProviderTooltip`. + +- [ ] **Step 4: Manual check** + +Open `/`, Providers view: labels short; hover title shows full path. + +- [ ] **Step 5: Commit** + +```bash +git add dixhttp/static/js/legacy/app.js +git commit -m "$(cat <<'EOF' +feat(dixhttp): shorten legacy graph labels via shared helper + +EOF +)" +``` + +--- + +### Task 3: Module map view in legacy toolbar + +**Files:** +- Modify: `dixhttp/template.html` (view buttons) +- Modify: `dixhttp/static/js/legacy/app.js` (`switchView`, `renderGraph`, fetch modules) +- Test: add node test for building module nodes/edges from API-shaped data (pure function preferred) + +**Interfaces:** +- Consumes: `GET /api/modules` → `[{ name, provider_count, object_count, type_count, depends_on }]` +- Consumes: `DIXGraphState.layoutStarPositions`, `shortGraphLabel` +- Produces: `currentView === 'modules'` renders module graph; objects only as count in label text, not separate nodes + +- [ ] **Step 1: Write failing test for module graph builder** + +Add to `dixhttp/static/js/graph_state.mjs` (or a tiny `legacy_graph_build.mjs` if you want isolation): + +```javascript +export function buildModuleMapGraph(modules = []) { + const nodes = modules.map((m) => ({ + id: m.name, + label: `${shortGraphLabel(m.name)}\n(${m.provider_count || 0}p/${m.object_count || 0}o)`, + shape: 'box', + data: { type: 'module', module: m, packagePath: m.name }, + })); + const edges = []; + for (const m of modules) { + for (const dep of m.depends_on || []) { + edges.push({ from: m.name, to: dep, arrows: 'to' }); + } + } + return { nodes, edges }; +} +``` + +Test in `graph_view.test.mjs`: + +```javascript +test("buildModuleMapGraph uses modules not objects as nodes", async () => { + const { buildModuleMapGraph } = await import("./graph_state.mjs"); + const { nodes, edges } = buildModuleMapGraph([ + { name: "app/a", provider_count: 2, object_count: 5, depends_on: ["app/b"] }, + { name: "app/b", provider_count: 1, object_count: 1, depends_on: [] }, + ]); + assert.equal(nodes.length, 2); + assert.equal(edges.length, 1); + assert.equal(nodes[0].data.type, "module"); +}); +``` + +- [ ] **Step 2: Run test — expect FAIL then implement — expect PASS** + +Run: `node --test dixhttp/static/js/graph_view.test.mjs --test-name-pattern buildModuleMapGraph` + +- [ ] **Step 3: Add toolbar button** + +In `template.html` next to Providers / Types: + +```html + +``` + +- [ ] **Step 4: Implement `renderModulesGraph` path in `app.js`** + +- Add `modulesData: null` to state. +- In `switchView('modules')`, fetch `/api/modules` if needed, set `currentView`, call `renderGraph`. +- At start of `renderGraph`, if `currentView === 'modules'`: + +```javascript +if (this.currentView === 'modules') { + const built = window.DIXGraphState.buildModuleMapGraph(this.modulesData || []); + // apply prefix filter if set; skip provider/type construction + // then aggregateByGroups only if meaningful for modules; apply budget; draw with star layout + ... + return; +} +``` + +Use `layoutStarPositions` to set `x`/`y` on nodes when `modules` view and `nodes.length >= 2`. Disable physics for that draw (match `/next` star behavior). + +- [ ] **Step 5: Double-click module → set `filterPrefix` to module name and `switchView('providers')`** + +```javascript +if (node.data.type === 'module') { + this.filterPrefix = node.data.module.name; + this.switchView('providers'); +} +``` + +- [ ] **Step 6: Manual check on example app** + +Module map shows ~10 boxes with clear separation; double-click drills to providers scoped by prefix. + +- [ ] **Step 7: Commit** + +```bash +git add dixhttp/template.html dixhttp/static/js/legacy/app.js dixhttp/static/js/graph_state.mjs dixhttp/static/js/graph_view.test.mjs +git commit -m "$(cat <<'EOF' +feat(dixhttp): add module map lens to legacy graph UI + +EOF +)" +``` + +--- + +### Task 4: Density banner, hubs, and Top-K last + +**Files:** +- Modify: `dixhttp/template.html` (banner container above `#network`) +- Modify: `dixhttp/static/js/legacy/app.js` (`renderGraph` post-filter pipeline) +- Test: existing `applyGraphBudget` / `rankHubNodes` tests + +**Interfaces:** +- Consumes: `applyGraphBudget(nodes, edges, { nodes, edges })`, `rankHubNodes(nodes, edges, limit)`, `READABLE_NODE_CAP` +- Produces: `densityWarning` state `{ show, message, hubs[], suggestModules, suggestAggregate }` + +- [ ] **Step 1: Add banner markup** + +Above the network canvas in `template.html`: + +```html +
+

+
+ + +
+ + + +
耦合枢纽度数
+
+``` + +Initialize `densityWarning: { show: false, message: '', hubs: [], suggestModules: false, suggestAggregate: false }`. + +- [ ] **Step 2: Insert pipeline after aggregate + prefix, before `vis.Network`** + +Order must be: + +1. Build nodes/edges for current view +2. `aggregateByGroups` +3. `filterByPrefix` +4. If `nodes.length > READABLE_NODE_CAP` (or budget): set banner (`suggestModules` if view is providers/types; `suggestAggregate` if `!aggregateGroups && groupRules.length`) +5. `rankHubNodes(..., 8)` into banner +6. `applyGraphBudget(..., { nodes: READABLE_NODE_CAP, edges: READABLE_NODE_CAP * 3 })` **last** +7. Create network from bounded nodes + +```javascript +const helpers = this.graphHelpers(); +const cap = helpers.READABLE_NODE_CAP; +let ns = filteredByPrefix.nodes; +let es = filteredByPrefix.edges; +const over = ns.length > cap || es.length > cap * 3; +this.densityWarning = { + show: over, + message: over + ? `图规模过大(${ns.length} 节点 / ${es.length} 边)。建议先看模块地图或按分组聚合审查组织;下列为耦合枢纽。` + : '', + hubs: over ? helpers.rankHubNodes(ns, es, 8) : [], + suggestModules: over && this.currentView !== 'modules', + suggestAggregate: over && !this.aggregateGroups && (this.groupRules || []).length > 0, +}; +if (over) { + const bounded = helpers.applyGraphBudget(ns, es, { nodes: cap, edges: cap * 3 }); + ns = bounded.nodes; + es = bounded.edges; +} +``` + +- [ ] **Step 3: Run unit tests** + +Run: `node --test dixhttp/static/js/graph_view.test.mjs` +Expected: all PASS + +- [ ] **Step 4: Manual check** + +Providers view on example app shows amber banner + hubs when over cap; clicking「模块地图」switches without losing package sidebar tools. + +- [ ] **Step 5: Commit** + +```bash +git add dixhttp/template.html dixhttp/static/js/legacy/app.js +git commit -m "$(cat <<'EOF' +feat(dixhttp): density banner and hub-first budget on legacy graph + +EOF +)" +``` + +--- + +### Task 5: Readable layout/camera for large legacy graphs + +**Files:** +- Modify: `dixhttp/static/js/legacy/app.js` (`getNetworkOptions`, post-create camera) +- Test: existing `resolveEffectiveLayout` / `resolveCameraStrategy` / `layoutStarPositions` tests + +**Interfaces:** +- Consumes: `resolveEffectiveLayout(preferred, nodeCount)`, `resolveCameraStrategy(mode, nodeCount)`, `pickFocusNodeId` + +- [ ] **Step 1: Map legacy layout select to helper** + +Legacy uses `currentLayout: 'hierarchical' | 'force'`. Map: + +```javascript +const preferred = this.currentLayout === 'force' ? 'physics' : 'hierarchical'; +const effective = helpers.resolveEffectiveLayout(preferred, nodeCount); +``` + +When `effective === 'physics'`, use forceAtlas-style options already used for `force`; when hierarchical and over cap, helper returns physics — honor it even if select says hierarchical (banner can note「已自动改用分散布局」). + +- [ ] **Step 2: Camera after draw** + +```javascript +const mode = this.currentView === 'modules' ? 'modules' : 'providers'; +const camera = helpers.resolveCameraStrategy(mode, ns.length); +const focusId = helpers.pickFocusNodeId(ns, es, this.filterPrefix || ''); +if (camera === 'fit' || this.currentView === 'modules') { + this.network.fit({ animation: false, padding: 48 }); +} else if (focusId) { + this.network.focus(focusId, { scale: 1.1, animation: false }); +} +``` + +For modules + star positions, physics off (Task 3). + +- [ ] **Step 3: Manual check** + +Large Providers graph is not a single vertical bead-string; module map fits in view. + +- [ ] **Step 4: Commit** + +```bash +git add dixhttp/static/js/legacy/app.js +git commit -m "$(cat <<'EOF' +feat(dixhttp): apply readable layout and camera on dense legacy graphs + +EOF +)" +``` + +--- + +### Task 6: Package sidebar as architectural scope (copy + behavior check) + +**Files:** +- Modify: `dixhttp/template.html` (sidebar heading/placeholder text) +- Modify: `dixhttp/static/js/legacy/app.js` only if click handler does not already set prefix + redraw + +**Interfaces:** +- Produces: unchanged filtering semantics; clearer UX framing + +- [ ] **Step 1: Update sidebar copy** + +Change labels to emphasize scope, e.g. heading「包范围」and placeholder「选择包以缩小架构切片…」. + +- [ ] **Step 2: Verify click sets `filterPrefix` and calls `renderGraph`** + +If existing handler only highlights, align to: set prefix, keep current view, redraw (existing behavior preferred). + +- [ ] **Step 3: Commit** + +```bash +git add dixhttp/template.html dixhttp/static/js/legacy/app.js +git commit -m "$(cat <<'EOF' +docs(dixhttp): frame package sidebar as architecture scope + +EOF +)" +``` + +--- + +### Task 7: Delete `/next` and retarget tests/docs + +**Files:** +- Modify: `dixhttp/server.go` (remove route + `HandleNextIndex`) +- Modify: `dixhttp/http_scale_e2e_test.go` +- Modify: `dixhttp/scripts/graph_layout_e2e.mjs` +- Modify: `dixhttp/README.md`, `dixhttp/README_zh.md` +- Delete: `dixhttp/static/index.html`, `dixhttp/static/js/main.js`, `dixhttp/static/js/api.js`, `dixhttp/static/js/views/overview.js`, `graph.js`, `search.js`, `trace.js`, `diag.js` (all next-only) +- Modify: `dixhttp/static/js/graph_view.test.mjs` — remove tests that only exist for next hash routing (`issueGraphHash` may stay if still used by legacy later; if unused, keep helpers but drop next-only assertions that require five-view hashes, or keep helpers for future Trace jumps) +- Modify: `docs/superpowers/specs/2026-09-05-dependency-visualization-unified-design.md` — add status line `Superseded by 2026-09-06-legacy-first-architecture-viz-design.md` + +**Interfaces:** +- Produces: `GET /next` → 404; `GET /` includes `graph_state.mjs` and module map button markup + +- [ ] **Step 1: Rewrite Go e2e test** + +```go +func TestLegacyGraphEmbedsSharedHelpers(t *testing.T) { + container := dix.New() + server := NewServer(container) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("GET", "/", nil)) + if recorder.Code != 200 { + t.Fatalf("status = %d", recorder.Code) + } + body := recorder.Body.String() + for _, asset := range []string{"static/js/graph_state.mjs", "static/js/legacy/app.js", "模块地图"} { + if !strings.Contains(body, asset) { + t.Fatalf("missing %s", asset) + } + } + if strings.Contains(body, "static/js/views/graph.js") { + t.Fatalf("legacy index must not reference next graph.js") + } +} +``` + +- [ ] **Step 2: Run test — FAIL (next still present / assertion)** then remove `/next` handler and delete next static files — PASS + +Also add a quick test that `/next` returns 404 if desired: + +```go +server.ServeHTTP(rec, httptest.NewRequest("GET", "/next", nil)) +if rec.Code != 404 { t.Fatalf(...) } +``` + +- [ ] **Step 3: Update `graph_layout_e2e.mjs`** + +Health check `fetch(`${BASE}/`)` and assert `graph_state.mjs` + `legacy/app.js` in HTML (not `graph.js`). + +- [ ] **Step 4: Update README / README_zh** + +State clearly: visualization UI is `/` only; remove “alongside `/next`” and next workbench bullets; document module map + density banner briefly. + +- [ ] **Step 5: Run full verification** + +```bash +node --test dixhttp/static/js/graph_view.test.mjs +go test -race ./dixhttp/... +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add -A dixhttp docs/superpowers/specs +git commit -m "$(cat <<'EOF' +chore(dixhttp): remove /next shell; legacy UI is sole entry + +EOF +)" +``` + +--- + +### Task 8: End-to-end acceptance against the spec + +**Files:** none new (verification only) + +- [ ] **Step 1: Run demo** + +`DIX_HTTP_ADDR=127.0.0.1:18099 go run -C example ./http` + +- [ ] **Step 2: Checklist** + +1. `/next` 404 +2. `/` loads; Providers default +3. 模块地图 clear boundaries +4. Dense Providers shows banner + hubs; one-click to modules / aggregate +5. Group rules, prefix, SVG, Mermaid, Trace modal still work +6. No object-only nodes on canvas + +- [ ] **Step 3: Commit only if doc tweaks needed** + +```bash +git commit -m "$(cat <<'EOF' +docs(dixhttp): note legacy-first acceptance for architecture viz + +EOF +)" +``` + +--- + +## Spec coverage self-check + +| Spec requirement | Task | +|---|---| +| Single UI `/`, delete `/next` | 7 | +| Preserve legacy capabilities | 3–6 (additive), 8 checklist | +| Four lenses (module/provider/type/group) | 3 + existing providers/types + aggregate | +| Crowding order (prompt → aggregate → hubs → labels → layout → Top-K) | 2,4,5 | +| Objects not default canvas nodes | 3 builder + 8 | +| Port helpers from next | 1–5 | +| API stable / no new deps | Global + all tasks | +| Acceptance example-scale | 8 | + +## Placeholder scan + +No TBD steps; commands and code sketches included for each task. diff --git a/docs/superpowers/plans/2026-09-06-provider-panorama-zoom-lod.md b/docs/superpowers/plans/2026-09-06-provider-panorama-zoom-lod.md new file mode 100644 index 0000000..3755c66 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-provider-panorama-zoom-lod.md @@ -0,0 +1,93 @@ +# Provider Panorama + Zoom LOD Implementation Plan + +> **Status: CANCELLED (2026-09-07)** — 全景 layout removed from product; do not continue this plan. + +**Goal (historical):** Add an explicit Providers「全景」layout that places every provider on a 2D package map, with same-canvas zoom LOD (structure far / names near), without hijacking 层级/力导向. + +**Architecture:** Pure helpers in `graph_state.mjs` compute panorama `{x,y}` and LOD label maps; legacy `renderGraph` applies them when `currentLayout === 'panorama'`. Hierarchical and force paths stay unchanged. Panorama disables vis hierarchical + physics and does not apply Top-K budget. + +**Tech Stack:** Alpine legacy UI, vis-network, shared ESM helpers + `node --test`. + +**Spec:** `docs/superpowers/specs/2026-09-06-provider-panorama-zoom-lod-design.md` + +## Global Constraints + +- Panorama path: **all** providers in scope stay on canvas (no Top-K). +- Never silently swap 层级 ↔ 力导向 ↔ 全景. +- LOD changes labels only, never removes nodes/edges. +- Providers global default layout: **全景**. +- Cache bump static assets after UI wiring. + +## File map + +| File | Responsibility | +|---|---| +| `dixhttp/static/js/graph_state.mjs` | `layoutPanoramaPositions`, `resolveLodBand`, `labelLodByBand` | +| `dixhttp/static/js/graph_view.test.mjs` | Unit tests for helpers | +| `dixhttp/static/js/legacy/app.js` | Apply panorama in `renderGraph`; skip budget; camera; persist layout | +| `dixhttp/template.html` | Layout ``; tip copy | + +--- + +### Task 1: Panorama positions helper (TDD) + +**Files:** +- Modify: `dixhttp/static/js/graph_state.mjs` +- Test: `dixhttp/static/js/graph_view.test.mjs` + +**Interfaces:** +- Produces: `layoutPanoramaPositions(nodes, opts?) → { [id]: { x, y } }` +- Uses: `nodePackageKey(node)` for grouping; deterministic sort by package then id + +- [x] **Step 1: Write failing tests** for multi-package grid, deterministic coords, single-package stack +- [x] **Step 2: Run tests — expect FAIL** +- [x] **Step 3: Implement `layoutPanoramaPositions`** +- [x] **Step 4: Run tests — expect PASS** + +--- + +### Task 2: Zoom LOD bands (TDD) + +**Files:** +- Modify: `dixhttp/static/js/graph_state.mjs` +- Test: `dixhttp/static/js/graph_view.test.mjs` + +**Interfaces:** +- Produces: `resolveLodBand(scale) → 'overview' | 'mid' | 'detail'` +- Produces: `labelLodByBand(nodes, edges, scale, opts?) → Map` (overview: package short name on one rep per package; mid: hubs+reps; detail: all short labels) + +- [x] **Step 1: Write failing tests** for bands and overview package reps +- [x] **Step 2: Implement helpers; export on `window.DIXGraphState`** +- [x] **Step 3: Tests PASS** + +--- + +### Task 3: Wire panorama into legacy Providers render + +**Files:** +- Modify: `dixhttp/static/js/legacy/app.js` +- Modify: `dixhttp/template.html` + +- [x] Default `currentLayout: 'panorama'` +- [x] Add layout option 全景 in template +- [x] In `renderGraph` when layout is panorama (providers/modules-compatible nodes): skip `applyGraphBudget`; compute positions; `getNetworkOptions` with hierarchical off + physics off; assign x/y/fixed; use `labelLodByBand` in `applyLabelLod` +- [x] Persist `currentLayout` in localStorage with other prefs +- [x] Density tip for hierarchical+wide: suggest 全景 (do not auto-switch) +- [x] Panorama tip: “缩放过小看结构;放大读 Provider 名” +- [x] Bump `?v=legacy-arch8` + +- [x] **Verify:** `node --test dixhttp/static/js/graph_view.test.mjs` && `go test ./dixhttp/...` + +--- + +### Task 4: Smoke acceptance + +- [ ] Manual: global Providers + 全景 → 2D package clusters, all nodes present +- [ ] Zoom out → package labels; zoom in → provider names +- [ ] Switch to 层级 → hierarchical; back to 全景 → panorama again + +--- + +## Done when + +Acceptance criteria in the spec §Acceptance all hold; helpers covered by unit tests. diff --git a/docs/superpowers/plans/2026-09-06-structure-panorama-inventory.md b/docs/superpowers/plans/2026-09-06-structure-panorama-inventory.md new file mode 100644 index 0000000..345c3e8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-structure-panorama-inventory.md @@ -0,0 +1,28 @@ +# Structure Panorama + Inventory (Approach C) Plan + +> **Status: CANCELLED (2026-09-07)** — 全景 removed; keep Provider inventory + package scope / 模块地图 instead. + +**Goal (historical):** Replace “literal all-provider panorama” with dual channels: readable **package-structure graph** + complete **provider inventory**. + +**Architecture:** Panorama with no package scope draws package nodes and cross-package edges from `allData`. Right rail always lists every provider (or scoped list). Click package → `selectPackage`; click inventory row → focus/detail. Hierarchical/force unchanged. + +**Tech Stack:** Alpine legacy UI, `graph_state.mjs`, node tests. + +**Spec amend:** `docs/superpowers/specs/2026-09-06-provider-panorama-zoom-lod-design.md` → Approach C. + +--- + +### Task 1: `buildPanoramaStructureGraph` + inventory helper (TDD) + +- [ ] Tests for package nodes, cross-pkg edges, inventory completeness +- [ ] Implement helpers; export on window + +### Task 2: Wire legacy render + sidebar + +- [ ] Panorama global → structure graph; panorama + package → provider map +- [ ] Always show inventory; double-click package drills scope +- [ ] Simplify panorama tips; bump `legacy-arch13` + +### Task 3: Verify + +- [ ] `node --test` + `go test ./dixhttp/...` diff --git a/docs/superpowers/plans/2026-09-07-http-example-microservice-layout.md b/docs/superpowers/plans/2026-09-07-http-example-microservice-layout.md new file mode 100644 index 0000000..8b2a98e --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-http-example-microservice-layout.md @@ -0,0 +1,178 @@ +# HTTP Example Microservice Layout Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure `example/http` into bootstrap + app + router + infra + plugins(interface/impls) + domain vertical slices with per-package Provide. + +**Architecture:** Each leaf package owns types + `Provide(*dix.Dix)`. `bootstrap.Build` is the only assembler. Plugins expose interfaces; impls contribute via `map[string]T` namespaces. + +**Tech Stack:** Go, dix/v2, dixhttp, existing `example/http` tests. + +## Global Constraints + +- No domain-root `provide.go` / `Providers()` facade +- Provide only inside owning packages +- Three pyramid entries: Application, ScaleFixture, TimeoutProbe +- ≥190 providers, ≥10 modules +- Spec: `docs/superpowers/specs/2026-09-07-http-example-microservice-layout-design.md` + +--- + +### Task 1: Domain layer packages (10 domains × 5 layers) + +**Files:** +- Create: `example/http/domain//{models,infra,logic,service,handler}/*.go` for each of: analytics, billing, identity, inventory, media, notification, searchx, shipping, storage, workflow +- Delete: `example/http/domain//.go` + +**Interfaces:** +- Produces per domain: `Config`, `Client`, `Regions`, `Repo`, `Service`, `Handler` with same dependency chain as today’s flat package; each file exports `func Provide(di *dix.Dix)`. + +- [ ] **Step 1: Generate domain packages** + +Use a generator (inline Go or shell) so every domain matches billing: + +```go +// domain/billing/models/config.go +package models + +import "github.com/pubgo/dix/v2" + +type Config struct { + Env string + Timeout string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func() *Config { + return &Config{Env: "prod", Timeout: "3s"} + }) +} +``` + +```go +// domain/billing/infra/client.go +package infra + +import ( + "github.com/pubgo/dix/v2" + "github.com/pubgo/dix/example/http/domain/billing/models" +) + +type Client struct{ Config *models.Config } +type Regions map[string]*Client + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *models.Config) *Client { return &Client{Config: c} }) + dix.Provide(di, func(c *models.Config) Regions { + return Regions{"cn": {Config: c}, "us": {Config: c}, "eu": {Config: c}} + }) +} +``` + +```go +// domain/billing/logic/repo.go +package logic + +import ( + "github.com/pubgo/dix/v2" + "github.com/pubgo/dix/example/http/domain/billing/infra" +) + +type Repo struct{ Client *infra.Client } + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *infra.Client) *Repo { return &Repo{Client: c} }) +} +``` + +```go +// domain/billing/service/service.go +package service + +import ( + "github.com/pubgo/dix/v2" + "github.com/pubgo/dix/example/http/domain/billing/logic" +) + +type Service struct{ Repo *logic.Repo } + +func Provide(di *dix.Dix) { + dix.Provide(di, func(r *logic.Repo) *Service { return &Service{Repo: r} }) +} +``` + +```go +// domain/billing/handler/handler.go +package handler + +import ( + "github.com/pubgo/dix/v2" + binfra "github.com/pubgo/dix/example/http/domain/billing/infra" + "github.com/pubgo/dix/example/http/domain/billing/service" +) + +type Handler struct{ Service *service.Service } + +func Provide(di *dix.Dix) { + dix.Provide(di, func(s *service.Service, regions binfra.Regions) *Handler { + _ = regions + return &Handler{Service: s} + }) +} +``` + +Repeat for all 10 domains (only import path / comments change). Delete old flat files. + +- [ ] **Step 2: Compile-check domains** + +Run: `cd example/http && go build ./domain/...` +Expected: success + +--- + +### Task 2: infra + plugins + app + router + +**Files:** +- Create: `example/http/infra/logger/logger.go` +- Create: `example/http/infra/diag/diag.go` +- Create: `example/http/infra/scale/scale.go` (enough distinct Provide calls to keep ≥190 total) +- Create: `example/http/plugins/plugin.go` (interfaces + Platform Provide) +- Create: `example/http/plugins//*.go` for ~20 impls (auth, billing, cache, … vault) each providing map namespaces +- Create: `example/http/app/application.go` +- Create: `example/http/router/server.go` + +**Interfaces:** +- `plugins.Plugin` with `Name() string`; `plugins.Worker` with `Name() string`; `plugins.Platform` with `Names []string` +- Each impl: `Provide` returns `map[string]plugins.Plugin` and `map[string]plugins.Worker` depending on Plugin map entry +- Platform Provide consumes `map[string]plugins.Worker` + +- [ ] **Step 1: Implement logger, diag, scale, plugins, app, router as above** + +Scale: register ≥100 tiny named provider types OR ≥100 single-key map contributions via separate `Provide` funcs so total container providers ≥190 after domains+plugins. + +- [ ] **Step 2: `go build ./infra/... ./plugins/... ./app/... ./router/...`** + +Expected: success + +--- + +### Task 3: bootstrap + thin main + tests + +**Files:** +- Create: `example/http/bootstrap/container.go`, `run.go` +- Modify: `example/http/main.go` (thin) +- Delete: `example/http/plugins.go` +- Modify: `example/http/main_test.go`, `scale_shape_test.go` + +**Interfaces:** +- `bootstrap.Build() *dix.Dix` +- `bootstrap.Run() error` +- Tests call `bootstrap.Build()` or `main` wrapper `buildContainer()` → `bootstrap.Build()` + +- [ ] **Step 1: Wire bootstrap.Build calling all Provide in order** +- [ ] **Step 2: Update tests** — plugin count = `len(platform.Names)` expected (2 × impl count if plugin+worker names); pyramid still 3 entries +- [ ] **Step 3: `go test ./example/http/...`** + +Expected: PASS + +- [ ] **Step 4: Commit** (only if user asks) diff --git a/docs/superpowers/plans/2026-09-08-architecture-ui-polish-roadmap.md b/docs/superpowers/plans/2026-09-08-architecture-ui-polish-roadmap.md new file mode 100644 index 0000000..5914604 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-architecture-ui-polish-roadmap.md @@ -0,0 +1,127 @@ +# Architecture UI Polish Roadmap + +**Date:** 2026-09-08 +**Status:** Living roadmap (not a single sprint) +**Scope:** Legacy `/` Providers · Types · Module map — readability, hide/filter, pyramid depth UX +**Related:** `docs/superpowers/specs/2026-09-08-hide-node-downstream-design.md` + +## Principles + +- Ship small vertical slices; each phase should be demoable in the http example. +- Prefer canvas/session UX over server API changes unless data identity is wrong. +- Do not block unrelated dix/core work on this roadmap. + +--- + +## Phase 0 — Done (baseline) + +| Item | Notes | +|------|--------| +| Hide node + downstream | Right-click / Shift+click / toolbar / inventory | +| Session-shared hide seeds | Providers · Types · Module map | +| Toast + undo / restore list | | +| Label disambiguation | `billing/handler.Handler`, `auth.Worker` | +| Pkg-aware provider edges | Avoid false cross-domain fan-out | +| Microservice example layout | domain layers + bootstrap + plugins | + +--- + +## Phase 1 — Clarity & fewer surprises (near-term) + +**Goal:** Users understand what the graph is showing without asking “为什么只有 N 级 / 藏错了吗”. +**Target window:** next 1–2 focused sessions when touching dixhttp UI. + +| # | Item | Why | Acceptance | +|---|------|-----|------------| +| 1.1 | [x] Depth select shows **actual max level** (disable or omit empty 5/10) | Stops “深度 10 只有 4 级” confusion | Depth options ≤ computed max; label e.g. `全部 (4 级)` | +| 1.2 | [x] Hide **preview highlight** before commit (context menu open) | Prevents accidental truncation | Downstream nodes outline while menu open; cancel clears | +| 1.3 | [x] Auto-fit after hide / restore | Graph doesn’t stay zoomed on empty space | After hide/unhide, camera fit once | +| 1.4 | [x] Inventory marks hidden rows | Completeness channel stays honest | Hidden providers grey +「已隐」; 复 restores | + +**Exit:** Phase 1 checklist green on `example/http` demo; one short note in changelog / commit message. + +--- + +## Phase 2 — Faster “main graph” workflows (next) + +**Goal:** Reach a clean business-only pyramid in ≤2 actions. + +| # | Item | Why | Acceptance | +|---|------|-----|------------| +| 2.1 | [x] Context menu: **hide package / subtree** (e.g. `…/plugins`) | Scale fixtures & plugin forests | One action hides all matching packagePath prefix + downstream | +| 2.2 | [x] Preset: **只留业务入口** (hide Dix + diag Timeout chain + plugins) | Default noise off the tip | One toolbar action; reversible via 已隐藏 | +| 2.3 | [x] Keyboard: `H` hide selected, `U` undo last, `Esc` close menu | Power users | Hint on context menu | +| 2.4 | [x] Module map right-click = same menu as Providers | Consistency | Module containment / package hide works via shared bindGraphInteractions | + +**Exit:** Can demo “hide plugins + diag → clean Application chain” in under 10 seconds. + +--- + +## Phase 3 — Shareable & durable state (later) + +**Goal:** Same filtered view across reloads / teammates without teaching click paths. + +| # | Item | Why | Acceptance | +|---|------|-----|------------| +| 3.1 | [x] URL sync for hide seeds (+ optional depth/package) | Share “the graph I’m looking at” | Reload restores; link opens same cut | +| 3.2 | [x] Hide history stack (multi-undo) | Continuous trimming sessions | Undo >1 step; history capped (e.g. 20) | +| 3.3 | [x] Toast stacking / placement vs density tip | Less UI collision | Density tip left, hide toast right; no overlap | + +**Exit:** Shared URL reproduces hide set; multi-undo feels safe. + +--- + +## Phase 4 — Structural / data (opportunistic) + +**Goal:** Fix root causes of crowding, not only filters. + +| # | Item | Why | When | +|---|------|-----|------| +| 4.1 | [x] Stronger type identity in API if still collapsing | Fewer bogus edges | `input_pkgs` + pkg\\x00type dedupe in GetProviderDetails / aggregate / graph edges | +| 4.2 | [x] Example scale via real domains, not pads | Meaningful module map | `example/http` microservice layout (no ScaleFixture) | +| 4.3 | [x] Soft guidance when entry count > N | Onboarding | Density tip CTA「只留业务入口」when entries > 12, unscoped | + +--- + +## Suggested cadence + +| Cadence | Focus | +|---------|--------| +| **Whenever shipping a dixhttp UI fix** | Prefer pulling **one** Phase 1 item into the same PR | +| **Dedicated polish half-day** | Finish remaining Phase 1, start 2.1–2.2 | +| **After Phase 2 feels good** | Phase 3 URL/history | +| **Opportunistic** | Phase 4 with related core/example work | + +Do **not** schedule Phase 3–4 as blockers for other features. + +--- + +## Tracking + +- Keep this file as the queue; check items off in place when done (`[x]`). +- New ideas: append under the right phase or a `## Backlog` section below — don’t start a parallel doc unless scope splits. + +## Backlog (unsorted) + +- [x] Merge consecutive hide toasts into one line +- [x] “Hide everything except selected neighborhood” invert mode +- [x] Persist hide seeds beyond session (opt-in localStorage) +- [x] Further canvas LOD / label density tweaks after layout-v1 bake-in + +--- + +## Layout chrome (2026-09-08) + +Bold IA pass (`arch-layout-v1` / `legacy-arch29`+): compact header, primary toolbar +「更多」, grouped package list, right inspector tabs, density tip only when actionable, no floating Trace FAB. + +Focus neighborhood polish (`legacy-arch32`): yellow/green/red role coloring, status「聚焦邻域」,「退出聚焦」chip. + +Camera chrome (`legacy-arch35`): floating +/−/fit/focus/maximize, keyboard shortcuts, graph-stage fullscreen. + +Usability batch (`legacy-arch36`–`37`): `?` shortcuts help, arrow pan, immersive maximize, scope chips, copy label, opt-in persist hide, chrome prefs, denser LOD, remember last view. + +Architecture findings (`legacy-arch38`): default **体检** home — `buildArchitectureFindings` (cross_bucket / super_hub / entry_fanout / fat_package); click → scoped graph; chip back. + +## Next concrete pick + +Bake-in findings with example/http; tune thresholds; optionally CLI/CI export later. diff --git a/docs/superpowers/specs/2026-09-05-dependency-visualization-unified-design.md b/docs/superpowers/specs/2026-09-05-dependency-visualization-unified-design.md new file mode 100644 index 0000000..fd20e75 --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-dependency-visualization-unified-design.md @@ -0,0 +1,178 @@ +# Dependency Visualization Unified Design + +> Status: superseded by `2026-09-06-legacy-first-architecture-viz-design.md` +> Approach was: evolve `/next` five-view shell — product direction moved to legacy-first. +> Date: 2026-09-05 · Branch context: `codex/scale-graph-usability` +> Related: `docs/superpowers/specs/2026-09-04-graph-trace-redesign-design.md`, +> `docs/superpowers/specs/2026-09-05-scale-graph-usability-design.md` + +## Problem + +Dix containers at approximately 100 modules, 200 providers, and 400 objects make a single global dependency graph unreadable. Two product jobs fail together today: + +1. **Structure readability** — too many providers/objects; dense canvases are hard to browse and reason about. +2. **Failure diagnosis** — when `Provide` / `Inject` misbehaves, call trees, errors, and graph context are not one continuous path. + +Identity and module-first work on `codex/scale-graph-usability` fixed false edges and defaulted `/next#/graph` to a module map, but the issue → graph → trace loop and request-cancellation polish are incomplete. This design unifies both jobs without replacing the existing five-view shell or breaking legacy surfaces. + +## Goals + +1. Keep the default graph always small and actionable (module map → module drill-down → ego neighborhood). +2. Treat objects as state and detail by default, not as required graph nodes. +3. Make diagnostics navigable: from an issue to a bounded graph or trace tree in at most three interactions. +4. Correlate runtime metrics, issues, graph nodes, and traces by stable `provider_id`. +5. Preserve existing public Dix APIs, legacy `/` UI, and existing `/api/*` field compatibility (additive only). + +## Non-Goals + +- Replace `vis-network` or introduce WebGL. +- Remove legacy `/` UI or five-view `/next` tabs. +- Draw every object as a default graph node. +- Rebuild the shell as a single three-pane workspace (deferred alternative). +- Introduce a parallel full `/api/graph/*` namespace in this iteration (reuse `/api/modules`, `/api/module`, `/api/ego`, `/api/issues`). + +## Product Approach + +**Chosen:** evolve the existing `/next` five-view information architecture into one workflow: + +`Issue → locate → bounded graph → call tree → runtime detail` + +Rejected for this iteration: + +- **Issue Hub default** — shortest debug path, but weak for structure-first browsing. +- **Single three-pane shell** — best context continuity, highest breakage and layout cost. + +## Information Architecture + +| View | Primary question | Default content | +|---|---|---| +| Overview | Is the system healthy? | Stats cards + Issues feed + jump actions | +| Graph | How do dependencies connect? | Module map → module drill-down → type ego; full providers/types only as advanced modes | +| Search | Where is the target? | Server search; hits jump to ego/module or detail | +| Trace | How did this inject run? | Trace list (failures first) + TraceTree; prefilter from issue/graph | +| Diag | What is slow or wrong at startup? | Runtime stats, error tables, error-type help | + +### Path A — Structure (anti-density) + +1. Open Graph → module map. +2. Select module → bounded in-module topology (providers/types; objects not default nodes). +3. Select type → ego neighborhood (depth default 2, max 5). +4. Open global providers/types only as advanced mode, with an explicit density warning. + +### Path B — Diagnosis (anti-fragmentation) + +1. Overview Issues (or Diag) shows error/slow items. +2. One click to bounded graph (ego or module). +3. One click to Trace tree (prefiltered by provider/output; open tree directly when `trace_id` exists). +4. Graph node drawer exposes declared deps, runtime state, and jump-to-trace. + +## Bounded Views and Density Policy + +| View | Node cap | Edge cap | Source | +|---|---|---|---| +| Module map | 100 | 300 | `/api/modules` | +| Module drill-down | 150 | 400 | `/api/module` | +| Ego | 100 | 300 | `/api/ego` | +| Global providers/types | 150 | 400 | `/api/dependencies` (advanced only) | + +Degradation order: + +1. Server truncates with `limit` / `edge_limit` and returns truncation/degraded metadata. +2. Client applies degree Top-K budget (`applyGraphBudget`). +3. UI shows an explicit density warning with current N/M caps. +4. Same screen offers table / Top-K hubs / narrower scope (module, depth, prefix). +5. Objects stay in drawers and diagnostic tables unless the user deliberately drills to instance detail later. + +Correctness rules: + +1. Distinct registrations keep distinct identities even when they share a source line. +2. Struct multi-output registrations may share one registration identity. +3. Edges are real declared relationships; never synthesize `inputs × outputs` after aggregation. +4. Ego “instantiated” is true only when an object node exists. +5. A graph may hide or degrade; it must not silently show false relationships. +6. In-flight graph/issues/trace requests cancel when the view or hash changes. + +Canvas rules: + +- Preserve a usable minimum graph height. +- Narrow layouts move details into a drawer/bottom sheet instead of shrinking the canvas. +- Encode `mode` / `module` / `center` / `depth` (and related filters) in the hash query. + +## Issue → Graph → Trace Contract + +### `/api/issues` + +Merge injection failures, provider failures, and slow providers into one actionable feed. + +Minimum fields per issue: + +- `severity` (`error` | `warn`, optional `info`) +- `title` +- `provider` +- `provider_id` (when known) +- `output_type` +- `module` +- `root_cause` +- optional `trace_id` + +Navigation: + +- Graph button → `#/graph?mode=ego¢er=`, else `mode=module&module=...`, else `mode=providers&prefix=` +- Trace button → `#/trace?provider=...&output_type=...&status=error|slow`, or direct tree when `trace_id` is present +- Graph drawer “view resolve path” uses the same Trace prefilter rules + +Correlation key across issues, runtime-stats, graph nodes, and trace attributes: `provider_id`. Function name is display/fallback only. + +## API Contract + +Primary (enhance in place): + +- `GET /api/modules` +- `GET /api/module?name=&limit=&edge_limit=` +- `GET /api/ego?center=&depth=&direction=` +- `GET /api/issues?limit=` +- `GET /api/trace`, `GET /api/trace-tree?trace_id=` +- `GET /api/search`, `/api/stats`, `/api/runtime-stats`, `/api/errors` + +Compatibility: + +- `GET /api/dependencies` remains available; `/next` default paths must not eagerly load it. +- Legacy `/` UI and existing response fields remain; new fields are additive. +- No new third-party Go or JS dependencies in this iteration. + +Deferred: + +- Full `/api/graph/{summary,modules,module,ego,node}` namespace rename/consolidation. + +## Compatibility Boundaries + +- Do not break public Dix APIs. +- Do not remove legacy `/` UI. +- Do not change existing `/api/*` field meanings; additive fields only. +- `/next` defaults and navigation may evolve (module-first, issues entry, request cancellation). +- Do not replace the rendering engine in this iteration. + +## Delivery Phases + +| Phase | Goal | Outcomes | +|---|---|---| +| P0 Stabilize | Finish half-done branch pieces | Reintroduce reliable request cancellation; align module budget to 150/400; lock Issues navigation contract with tests | +| P1 Density | Structure readability | Default path avoids `/api/dependencies`; density warning + table/Top-K alternatives; narrow-viewport canvas floor | +| P2 Diagnosis loop | Failure navigability | Complete Issues fields; graph drawer → Trace; Trace prefilters; ≤3-interaction acceptance cases | +| P3 Docs & fixture | Regressible | Scale fixture walkthrough; README matches `/next` defaults | + +## Acceptance + +1. At ~100 providers / hundreds of objects, the default module map stays interactive and labels remain readable. +2. Module drill-down and ego respect budgets; over-budget views degrade explicitly with no false edges. +3. Overview Issue → bounded graph in ≤2 clicks; → Trace tree in ≤3 clicks. +4. Runtime errors/durations attach to the correct node via `provider_id`. +5. `go test -race ./...` and `example/http` tests pass; legacy full-graph entry remains reachable. +6. Documentation matches actual `/next` default behavior. + +## Success Signal + +A developer can complete both jobs on a large container without relying on an unreadable global graph: + +1. understand module-level dependencies and drill to relevant types; +2. jump from a failure or slowdown to the call tree and related dependency context. diff --git a/docs/superpowers/specs/2026-09-05-scale-graph-usability-design.md b/docs/superpowers/specs/2026-09-05-scale-graph-usability-design.md new file mode 100644 index 0000000..142ffbd --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-scale-graph-usability-design.md @@ -0,0 +1,81 @@ +# Scale-Safe Dependency Visualization Design + +## Problem + +The dependency UI is usable for small containers, but it does not scale to the target shape of approximately 100 modules, 200 providers, and 400 objects. The issue is not primarily browser capacity. The UI answers several different questions with one global graph, and derived provider data can multiply edges and collapse distinct provider instances. + +An example container with 10 business modules, 200 provider graph nodes, and 192 objects produced 72 aggregated providers and 3,860 edges from `/api/dependencies`. This happened because providers from the same generic factory source line were merged and their inputs and outputs were later cross-producted. + +## Goals + +1. Preserve provider identity so generic factories, multi-output providers, runtime metrics, errors, traces, and graph nodes can be correlated correctly. +2. Make dependency edges reflect actual declared provider relationships instead of Cartesian products. +3. Make the first graph experience module-centric, bounded, and actionable. +4. Keep objects as state and detail rather than requiring every object to be a graph node. +5. Guarantee bounded views: default module map, module drill-down, and ego graph each have explicit node and edge limits. +6. Keep legacy endpoints and views compatible while the new model becomes the default. + +## Non-Goals + +This iteration does not replace `vis-network`, introduce WebGL, remove the legacy UI, or expose every object as a graph node. Those are follow-ups only if bounded module and ego views still fail usability targets. + +## Data Model + +Provider registration identity is the correlation key. A logical registration may produce multiple graph outputs for a struct provider, but two calls to the same generic factory are distinct registrations. Runtime state remains attached to the concrete provider/output pair. + +The visualization provider shape gains: + +- `registration_id`: stable identity for one logical provider registration within the current process. +- `provider_id`: stable identity for the registration/output pair used by API clients. + +Dependency projections must group outputs by registration identity, not source file and line. The full projection must emit one declared edge per real input/output relationship; it must never synthesize all `inputs × outputs` relationships after aggregation. + +Module data uses `reflect.Type.PkgPath()`-derived module identity. Generic type names must not be parsed as package paths. + +## API Behavior + +Phase one evolves existing endpoints: + +- `/api/dependencies` uses registration-aware provider identity and emits real edges. +- `/api/runtime-stats` includes registration and provider identities. +- `/api/modules` remains the bounded module aggregate. +- `/api/ego` reports instantiated state only when an object node exists. +- `/api/stats` aggregates resolved counts by type and reports internally consistent module/type/provider counts. +- `/api/packages` uses provider output package data and never displays malformed generic package names such as `main.Plugin[main`. + +The next major endpoint group will be `/api/graph/summary`, `/api/graph/modules`, `/api/graph/module`, `/api/graph/ego`, `/api/graph/node`, and `/api/issues`. Those endpoints should serve progressive disclosure and issue-centric debugging. + +## Frontend Behavior + +The default graph mode is module map. Selecting a module opens a bounded module view; selecting a type opens an ego graph; selecting a provider opens provider details and runtime state. + +Rendering budgets are explicit: + +- Module map: at most 100 nodes and 300 edges. +- Module detail: at most 150 nodes and 400 edges. +- Ego graph: default depth 2, maximum depth 5, and an edge cap. + +When data exceeds a budget, the UI shows a density warning and offers tables, Top-K hubs, or a narrower selection instead of rendering an unreadable graph. + +The graph canvas must retain a usable minimum size. Narrow layouts move details into a drawer or bottom sheet instead of shrinking the canvas to an unusable area. + +State and filters are encoded in the hash query so views can be refreshed and shared. Requests use cancellation when a view changes. Runtime metrics join by provider identity and output type, not function name alone. + +## Correctness Rules + +1. Two registrations from the same source line can have different identities. +2. Outputs from one struct-producing registration can share a registration identity. +3. Runtime stats are keyed by concrete provider/output, not only function name. +4. An ego node is instantiated only when an object node exists. +5. Package/module names must be valid package paths or `(anonymous)`. +6. A graph may be hidden or degraded, but it must not silently show false relationships. + +## Acceptance + +- Main and example tests pass with race detection. +- A generic provider fixture produces distinct provider identities and one edge per actual input/output pair. +- Runtime stats correlate to the correct provider/output pair. +- `/next#/graph` defaults to the module map and renders useful content without first loading `/api/dependencies`. +- Module map respects the phase-one node/edge budgets. +- Graph canvas remains usable at narrow viewports. +- Diagnostics can navigate from an issue to the provider graph or trace tree in at most three interactions. diff --git a/docs/superpowers/specs/2026-09-06-legacy-first-architecture-viz-design.md b/docs/superpowers/specs/2026-09-06-legacy-first-architecture-viz-design.md new file mode 100644 index 0000000..50f4def --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-legacy-first-architecture-viz-design.md @@ -0,0 +1,147 @@ +# Legacy-First DI Architecture Visualization Design + +> Status: approved for planning · Approach: enhance legacy `/`, delete `/next` +> Date: 2026-09-06 · Branch context: `codex/scale-graph-usability` +> Supersedes (for product direction): `2026-09-05-dependency-visualization-unified-design.md` +> Related: `2026-09-05-scale-graph-usability-design.md`, `2026-09-04-graph-trace-redesign-design.md` + +## Problem + +Dix containers with tens to hundreds of providers make the legacy `/` canvas feel like an unreadable blob: hierarchical “bead strings,” long package labels, and mixed concerns on one graph. + +A parallel `/next` five-view shell added module maps, budgets, and layout helpers, but: + +1. Maintenance cost of two UIs is too high. +2. `/next` did not replace the legacy workstation users still need. +3. Generic “make the graph prettier” tactics miss the real job. + +The primary job is **dependency-injection architecture review**: see whether project wiring is organized sanely and where to reorganize. Debugging inject failures matters less here because call-chain Trace already covers it. + +Providers (who produces what) and objects (what has been instantiated) are different questions and must not share the same default canvas treatment. + +## Goals + +1. Keep a **single UI**: legacy `/` (Alpine + existing tools). +2. Preserve **all existing legacy capabilities** (package sidebar, group rules, prefix, depth, layouts, SVG/Mermaid, diagnostic modals, Trace, etc.). +3. Make large graphs readable for **architecture organization**, not only for pixel density. +4. Support four architectural lenses: **package/module**, **providers**, **types**, **business groups**. +5. Port useful `/next` pieces into legacy; **delete** the `/next` shell and route. +6. Keep public Dix APIs stable (additive fields only); no new third-party dependencies. + +## Non-Goals + +- Rebuilding a second five-view product shell. +- Making Trace/diagnosis the main graph workflow. +- Drawing every object as a default architecture node. +- Replacing vis-network or Alpine in this iteration. +- Silently changing the user’s current view mode without an explicit action. + +## Product Approach + +**Chosen:** Approach A — enhance legacy `/`, embed useful `/next` rendering helpers, then delete `/next`. + +Rejected: + +- Dual-track `/` + `/next` (maintenance cost). +- Full rewrite of the workstation chrome. + +**Default entry:** global Providers graph may remain the default (existing habit). Crowding is handled by semantic degradation and clearer lenses, not by removing the Providers entry. + +## Jobs and Lenses + +### Primary job + +Answer: *Is the DI dependency structure organized reasonably? Where should we optimize boundaries?* + +### Secondary job + +Inject/runtime failures — Trace modals remain the main path; graph only keeps helpful jumps. + +### Four lenses (all required) + +| Lens | Question | Canvas nodes | Edges | Objects | +|---|---|---|---|---| +| Package / module | Are cross-boundary deps sane? | Packages or modules (prefer `/api/modules`) | Cross-package/module deps | Counts/badges only | +| Providers | Who produces what? | Providers + I/O types (existing) | Produce / depend edges (existing) | Detail/status only | +| Types | Is type coupling too dense? | Type nodes (existing) | Type→type edges (existing) | Detail/status only | +| Business groups | How do domains couple? | Group nodes via existing aggregate rules | Inter-group edges; expand in-group | Detail/status only | + +### UI mapping (no capability loss) + +- Toolbar: keep **Providers** / **Types**; add **Module map** (ported from `/next`). +- **Business groups**: keep **按分组聚合** (not a fifth mutually exclusive mode that fights Providers/Types). Under module map, aggregation means further collapsing modules by group rules when matched. +- Package sidebar: clicking a package sets **architectural scope** (prefix) and redraws — framed as choosing a slice, not only “filter noise.” + +## Crowding Solution (DI-semantic, not generic layout-only) + +Crowding happens when an **architecture question** is answered with an **inventory-level canvas** (all providers × types × objects). + +Fixed degradation order when the current canvas exceeds a readable budget: + +1. **Prompt a coarser lens** — banner suggesting Module map or enabling group aggregation; one-click apply allowed; do not silently switch the user’s view. +2. **Semantic collapse** — if aggregation is on, prefer inter-group edges; if off, recommend turning it on. +3. **Mark coupling hubs** — highest-degree types/packages/groups (architecture smells); click focuses or sets prefix scope. +4. **Short labels** — shorten package/type display names; full name on hover/detail (from `/next`). +5. **Layout / camera** — avoid thin hierarchical bead-strings on large graphs; use readable layout + fit/focus (from `/next` strategies), wired into legacy `renderGraph`. +6. **Top-K truncation last** — keep highest-connectivity nodes, declare truncation, show hub table and narrow-scope actions. + +**Objects never enter the architecture node pool by default** — they amplify false density. + +Soft budget starting points (tunable): canvas readability soft cap ~40–60 structural nodes; providers/types composition may use ~150/400 before soft truncation — aligned with prior scale work, applied inside legacy. + +## Port from `/next` / Delete `/next` + +### Port into legacy + +- Module map rendering (`/api/modules`, readable/star layout). +- `shortGraphLabel`, layout/camera helpers, hub ranking, budget helpers. +- Optional bounded drill-downs already exposed by API (`/api/module`, `/api/ego`) where they improve scoped reading without removing full-graph modes. +- Stable issue/error → graph/Trace link helpers if diagnostic modals still lack them. + +Delivered as helpers consumed by Alpine `renderGraph` / toolbar — **not** as a second shell. + +### Delete + +- `GET /next` and `HandleNextIndex`. +- Five-view shell: `static/index.html`, `static/js/main.js`, `static/js/views/*` used only by `/next`. +- Next-only docs/recommendations; README states `/` is the only UI. +- Next-shell-only frontend tests; keep/repurpose pure logic tests onto shared modules used by legacy. + +### Keep as shared logic + +- Testable pieces of `graph_state.mjs` / `graph_workbench.mjs` (labels, budget, hubs, group aggregate helpers) referenced by legacy; strip next-only wiring. + +## Implementation Boundaries + +### Touch + +- `dixhttp/template.html` — module map control; density banner/hub table; sidebar copy for scope. +- `dixhttp/static/js/legacy/app.js` — render pipeline hooks for labels, degradation, module map, camera. +- Shared mjs helpers + node tests. +- `dixhttp/server.go` — remove `/next`. +- README / superseded design notes. + +### Do not touch (this iteration) + +- Public API semantics (additive only). +- Trace/diagnostic modal product rewrite. +- New JS/Go third-party dependencies. +- Forced default-view change away from Providers unless user later asks. + +### Risks + +- Large `app.js`: change render in small steps; regress group/prefix/SVG/Mermaid/depth. +- Banner vs silent mode switch: prompt + optional one-click only. + +## Acceptance Criteria + +1. Example-scale app (~10 modules, ~100 providers): module map shows clear boundaries; global Providers over budget shows banner + hubs instead of a silent bead-string. +2. Group aggregate, package prefix, Providers/Types/Module switching behave as today plus module map — no removed tools. +3. Objects are not default architecture canvas nodes. +4. `/next` removed; `/` is the only visualization UI entry. +5. `node --test` for shared graph helpers passes; `go test ./dixhttp/...` passes. + +## Open Parameters (planning may tune, not reopen product direction) + +- Exact soft-cap numbers and when auto-layout overrides hierarchical. +- Whether module map uses package list identity vs `/api/modules` labels when they diverge — prefer `/api/modules` for cross-module edges, keep package sidebar for scope. diff --git a/docs/superpowers/specs/2026-09-06-provider-panorama-zoom-lod-design.md b/docs/superpowers/specs/2026-09-06-provider-panorama-zoom-lod-design.md new file mode 100644 index 0000000..202505d --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-provider-panorama-zoom-lod-design.md @@ -0,0 +1,146 @@ +# Provider Panorama + Zoom LOD Design + +> **Status: CANCELLED (2026-09-07)** — 全景 layout removed from product. Completeness stays via right-rail Provider inventory + package scope / 模块地图; canvas layouts remain 层级 / 力导向 only. +> Parent: `2026-09-06-legacy-first-architecture-viz-design.md` +> Branch context: `codex/scale-graph-usability` + +## Historical note + +Earlier drafts (Approach B grid + LOD, Approach C structure panorama + inventory) are retained below for context only. Do not re-introduce a `panorama` layout option without a new approved design. + +--- + +## Approach C (locked 2026-09-07) — superseded by removal + +**Dual channel — do not put completeness and readability on the same canvas encoding:** + +| Channel | Job | Encoding | +|---|---|---| +| **Structure panorama** | Perceive organization / cross-boundary coupling | Package-level nodes + inter-package edges (default when 布局=全景 and scope=全部) | +| **Provider inventory** | Guarantee every provider is reachable | Scrollable list (right rail); search; click → detail / scoped graph | + +- Package is an **abstraction tier for the structure graph**, not merely a filter that “gives up” on panorama. +- Drill-down: click/double-click a package node (or pick from left sidebar) → scoped Providers canvas may still show that package’s providers. +- Hierarchical / force layouts stay literal provider+type graphs for users who want them; tip steers large global graphs to 全景结构 + 清单. + +### Non-goals (Approach C) + +- Making every provider label simultaneously readable on one fitted canvas. +- Treating package scope as the only completeness mechanism. + +--- + +## Problem (original) + +Global Providers with hierarchical layout becomes a thin horizontal strip. Fit-to-screen makes every node a speck. Auto-swapping hierarchical → physics was the wrong fix (hijacked user layout choice). Top-K truncation conflicts with the product need for a **full panorama of every provider**. + +## Goals + +1. **All providers remain reachable** via inventory (Approach C); structure canvas uses package abstraction by default. +2. **Same canvas, zoom-stratified reading** where useful on scoped provider maps. +3. Explicit **全景 (panorama)** layout = structure map at global scope. +4. **层级 / 力导向 stay honest**: selecting them uses that algorithm; never silently rewrite the user’s choice. +5. Keep existing tools (package scope, group aggregate, edge declutter, module map, details, Trace). + +## Non-Goals + +- Separate minimap widget (user chose same-canvas zoom LOD). +- Forcing package selection before drawing. +- Replacing hierarchical with physics under the hood. +- Drawing objects as default architecture nodes. +- Perfect simultaneous readability of every label at overview zoom (impossible at scale; LOD handles this). + +## Product Decisions (locked) + +| Decision | Choice | +|---|---| +| Default lens when opening `/` | Unchanged unless noted below: Providers may remain default | +| Full inventory | Panorama shows **all** providers in scope (global or selected package) | +| Dual-layer UX | **Same canvas zoom LOD** (not minimap, not mode toggle) | +| Layout honesty | Explicit **全景** option; hierarchical/force never auto-swapped | +| Providers default layout | **全景** when opening Providers at global scope; user can switch to 层级/力导向 | + +## Design + +### 1. Layout option: 全景 + +Toolbar `布局` gains: + +- **全景** (new, recommended for large Providers) +- 层级布局 (unchanged semantics) +- 力导向 (unchanged semantics) + +**Panorama algorithm (client-side, deterministic):** + +1. Group provider nodes by package key (`output_pkg` / `function_pkg` / node `packagePath`; fallback `"_"`). +2. Sort packages by name; sort providers within package by id/name. +3. Place packages on a **grid of columns** (column count ≈ `ceil(sqrt(packageCount))`), each package a vertical stack of provider boxes with fixed cell size. +4. Package header is not a separate graph node; at overview zoom the **visible label** for a region is the package short name (via LOD), not an extra inventory node. +5. Cross-package edges kept; same-package edges optional via existing 边降噪. +6. Disable vis hierarchical layout and physics for panorama; set `{x,y}` (and optionally `fixed.x/y` during initial paint) so positions stay stable. + +Types view may keep current layouts for this iteration; panorama is required for **Providers** first. Module map stays its own view. + +### 2. Zoom LOD (same canvas) + +Three bands (tunable constants): + +| Band | Approx. scale | Labels shown | +|---|---|---| +| Overview | `< ~0.55` | Package short names on a representative node per package (or hub-only if single package); other nodes show `·` or empty | +| Mid | `~0.55–0.9` | Hubs + package reps; more provider short labels appear by degree | +| Detail | `> ~0.9` | All provider short labels in viewport; full name on hover/title | + +Rules: + +- **Nodes and edges are never removed by LOD** — only label visibility/text changes. +- Existing `labelLodVisibleIds` / `shortGraphLabel` are extended for package-band behavior; do not invent a second shell. +- Initial camera for panorama: **fit** the full map (structure panorama). User zooms for names. Density tip may say: “缩放过小看结构;放大读 Provider 名”. +- 「适应全图」 = fit panorama. 「聚焦枢纽」 = focus highest-degree provider at detail scale (~1.05). + +### 3. Crowding interaction (revised for panorama) + +When Providers + 全景 + global scope: + +1. Prefer panorama packing over hierarchical strip. +2. Edge declutter may still hide same-package edges (toggle remains). +3. Group aggregate still available; if on, panorama packs **group nodes** (or expanded members) consistently — do not drop providers from the dataset unless user enabled aggregation. +4. **No auto layout swap.** +5. Soft Top-K budget is **off for panorama** (all providers). Soft budget may still apply to hierarchical/force if those paths remain dense — declare in UI if truncated. Panorama path must not truncate. + +Package sidebar still scopes the panorama to one package when selected (fewer columns; detail labels appear earlier). + +### 4. Honesty / messaging + +- If user selects 层级 and the graph is wide, tip suggests switching to **全景** or selecting a package — **do not** change the dropdown for them. +- When auto-defaulting Providers → 全景 on first load of a large global graph, set the layout control to 全景 so UI matches reality. + +## Acceptance + +1. Global Providers + 全景: every provider in current API payload appears as a node; fit shows a 2D package map (not a 1-row strip). +2. Zoom out: package-oriented labels dominate; zoom in: provider names become readable without removing nodes. +3. Switching to 层级 uses hierarchical layout; switching back to 全景 restores panorama positions; no silent overrides. +4. Package scope + 全景 still shows all providers in that package. +5. Existing module map / aggregate / edge declutter / details / Trace still work. +6. Unit tests cover panorama position determinism and LOD band label selection. + +## Implementation Boundaries + +### Touch + +- `dixhttp/template.html` — layout select option 全景; tip copy. +- `dixhttp/static/js/legacy/app.js` — Providers render path for panorama positions + camera; persist layout choice. +- `dixhttp/static/js/graph_state.mjs` (+ tests) — `layoutPanoramaPositions`, LOD band helpers. +- Cache bump on static assets. + +### Do not touch + +- Server dependency APIs (unless a bug blocks package keys). +- Reintroducing `/next`. +- Minimap DOM widget. + +## Open questions (resolved) + +- Minimap vs zoom LOD → **zoom LOD**. +- Truncate for readability → **no on panorama path**. +- Hijack hierarchical → **no**; add explicit 全景 instead. diff --git a/docs/superpowers/specs/2026-09-07-http-example-microservice-layout-design.md b/docs/superpowers/specs/2026-09-07-http-example-microservice-layout-design.md new file mode 100644 index 0000000..b20d478 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-http-example-microservice-layout-design.md @@ -0,0 +1,99 @@ +# HTTP Example Microservice Layout Design + +**Date:** 2026-09-07 +**Status:** Approved +**Scope:** Restructure `example/http` into a clear microservice-style Go package layout so ModuleGraph / 模块地图 reflects real system boundaries (not visualization virtual buckets). + +## Goal + +Organize the dixhttp scale demo as a complete small system: + +- Vertical domain slices with real packages: `models` → `infra` → `logic` → `service` → `handler` +- Shared shells: `app`, `router`, `infra/*`, `plugins`, `bootstrap` +- Each package registers its own `Provide(di)` — **no** domain-root aggregator `provide.go` +- `bootstrap` is the only DI assembly / startup orchestration entry +- Plugins: public **interfaces**, several **implementations**, each with a Provider, consumers see interfaces only + +## Non-goals + +- Changing dixhttp UI semantics (providers pyramid, module map algorithm) beyond what falls out of real packages +- Keeping the old `Plugin[T]` / 60-role generic sea as the primary plugin model +- Domain-level `Providers()` / `provide.go` facades + +## Package tree + +```text +example/http/ + main.go # thin: bootstrap.Run() + bootstrap/ + container.go # Build() — call every module Provide in order + run.go # pre-create, startup diagnostics, hand off to router + app/ + application.go # Application aggregate + Provide + router/ + server.go # dixhttp listen / serve + infra/ + logger/ # Logger interface + ConsoleLogger Provide + diag/ # SlowRemote / TimeoutProbe + startup demo types + plugins/ + plugin.go # Plugin / Worker / Platform interfaces + Platform Provide + auth/ # impl + Provide → map[string]plugins.Plugin (and Worker) + billing/ + cache/ + … # several impl packages (not one mega main package) + domain/ + billing/ + models/ # Config + Provide + infra/ # Client, Regions + Provide + logic/ # Repo + Provide + service/ # Service + Provide + handler/ # Handler + Provide + analytics|identity|inventory|media|notification|searchx|shipping|storage|workflow/ + (same five layers) +``` + +## Rules + +1. **Provide lives in the owning package.** Bootstrap imports packages and calls `Provide(*dix.Dix)`; it does not define domain providers. +2. **No domain-root `provide.go`.** There is no `domain/billing.Providers`. +3. **Import paths are the module identity.** Prefer short package names (`models`, `handler`, …) under each domain path; bootstrap uses import aliases when needed. +4. **Plugins** + - Contract package `plugins` exports `Plugin`, `Worker`, and `Platform` (or equivalent). + - Each implementation package provides `map[string]plugins.Plugin` / `map[string]plugins.Worker` (dix namespace merge) so multiple impls coexist. + - `Platform` depends on `map[string]plugins.Worker` (or Plugin) and exposes names/counts to `Application`. + - `Application` depends on `*plugins.Platform` and domain handlers/services — not concrete plugin impl types. +5. **Pyramid entries:** `*app.Application` and `*diag.TimeoutProbe` (no artificial ScaleFixture). +6. **Scale comes from real packages** (domain layers + plugin impls), not pad providers. + +## Wiring order (bootstrap) + +1. `infra/logger` +2. All `domain/*/models|infra|logic|service|handler` (models before infra before logic before service before handler; domains independent) +3. All `plugins/` then `plugins` Platform +4. `app` Application +5. `infra/diag` timeout chain (entry #2) + +`main` only calls `bootstrap.Run()`. + +## Test contracts + +| Test | Expectation after change | +|------|---------------------------| +| `TestBuildContainerWiresApplication` | Ten domain services wired; plugin name count = number of registered plugin/worker namespaces (not 120) | +| `TestDemoContainerShape` | ≥10 modules; provider/object floors from real domain+plugins | +| `TestPyramidHasTwoBusinessEntries` | Exactly two unconsumed business entry providers (Application, TimeoutProbe) | + +## Migration + +1. Add new packages; generate ten identical domain layer trees from the former flat `domain//.go`. +2. Replace `plugins.go` with interface + impl packages. +3. Move Application / Logger / Scale / Timeout / server into `app` / `infra` / `router`. +4. Point tests at `bootstrap.Build()` (or keep `buildContainer` as a one-liner wrapper in main for minimal test churn). +5. Delete old flat domain files and `plugins.go` bodies in `main`. +6. Optionally simplify module-map virtual coarse buckets once real packages disperse providers. + +## Success criteria + +- `go test ./example/http/...` passes +- ModuleGraph paths show `domain/.../handler`, `plugins/auth`, `bootstrap` not used as a provider home +- Browser 模块地图 shows deep real packages without relying on visualization-only splits for domains diff --git a/docs/superpowers/specs/2026-09-07-providers-pyramid-view-design.md b/docs/superpowers/specs/2026-09-07-providers-pyramid-view-design.md new file mode 100644 index 0000000..bd44865 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-providers-pyramid-view-design.md @@ -0,0 +1,31 @@ +# Providers Pyramid View Design + +> Status: implemented 2026-09-07 · Branch: `codex/scale-graph-usability` + +## Goal + +Providers 视图只画 Provider,并按依赖金字塔分层:塔尖是少数入口,深度控件裁层,包/Provider 过滤看相关上下游。 + +## Decisions + +| Topic | Choice | +|---|---| +| Nodes | Provider only(Type 压成依赖边) | +| Edge | `A→B` = A 依赖 B(消费 B 的产出) | +| Entry (塔尖) | 未被其它 Provider 消费(子图内 indegree 0) | +| Depth N | 只保留 level ≤ N;`0` = 不截层 | +| Package filter | 包内 Provider 为种子,展开相关上下游,再套深度 | +| Provider filter | 双击或清单点选设种子,同上 | +| Crowding | 深度 + 包/Provider 范围;不再靠 Type 节点或 Top-K 伪装全景 | + +## Non-goals + +- 不恢复「全景」布局 +- Types 视图仍为 type→type 图 +- 不在服务端重算层级(当前 client helpers 足够 example 规模) + +## Implementation + +- Helpers: `dixhttp/static/js/graph_state.mjs` — `buildProviderDependencyGraph`, `assignProviderPyramidLevels`, `truncateProviderPyramid`, `providerRelatedSubgraph`, `buildProvidersPyramidView` +- UI: `dixhttp/static/js/legacy/app.js` Providers `renderGraph` path; `pyramidFocusProviderId` +- Cache: `legacy-arch15` diff --git a/docs/superpowers/specs/2026-09-08-hide-node-downstream-design.md b/docs/superpowers/specs/2026-09-08-hide-node-downstream-design.md new file mode 100644 index 0000000..542ca5c --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-hide-node-downstream-design.md @@ -0,0 +1,27 @@ +# Hide Node And Downstream Design + +**Date:** 2026-09-08 +**Status:** Implemented +**Scope:** Legacy architecture UI — hide selected canvas nodes and their depends-on descendants across Providers / Types / Module map. + +## Behavior + +- **Primary:** right-click node →「隐藏此节点及下游 (N)」 +- **Fast path:** Shift+click node; or select then toolbar「隐藏及下游」 +- **Inventory:** hover Provider row →「隐」 +- Remove the seed and all downstream nodes (edge direction: consumer → dependency) +- Upstream consumers remain; edges into hidden set are dropped +- Seeds in `sessionStorage`; shared by Providers / Types / Module map +- Toast with undo; dropdown「已隐藏 N」to restore + +## Non-goals + +- Package-path blacklist UI +- Objects canvas +- Persist hide beyond session without URL (opt-in localStorage is backlog) + +## Persistence (Phase 3) + +- `sessionStorage` + URL query: `hide` (pipe-separated seeds / `pkg:prefix`), optional `depth`, `pkg` +- URL `hide` overrides session on load; changes `replaceState` the address bar +- Multi-step undo via in-memory snapshot stack (cap 20); `U` / toast 撤销 diff --git a/docs/superpowers/specs/2026-09-09-architecture-findings-design.md b/docs/superpowers/specs/2026-09-09-architecture-findings-design.md new file mode 100644 index 0000000..7d7f064 --- /dev/null +++ b/docs/superpowers/specs/2026-09-09-architecture-findings-design.md @@ -0,0 +1,27 @@ +# Architecture Findings (结论优先体检) + +> Status: implemented in UI · Date: 2026-09-09 · Approach A: frontend rules in `graph_state.mjs` + +## Goal + +Default `/` answers *where is the DI structure unhealthy?* via a findings list, not a full Providers graph. + +## UX + +- Default `currentView: 'findings'` +- Click finding → Providers/Types/Modules with package or neighborhood scope; chip「← 体检」returns +- Empty state: clean message + open full graph +- Runtime `/api/issues` unchanged (errors/slow), separate from static structure smells + +## v1 rules + +| kind | trigger | severity | +|---|---|---| +| `cross_bucket` | plugins/infra → domain, or domainA → domainB | warn | +| `super_hub` | provider degree ≥ 12 | warn | +| `entry_fanout` | indegree-0 entries ≥ 8 | info | +| `fat_package` | providers in one package ≥ 12 | info | + +## API + +`buildArchitectureFindings(allData, opts?)` → Finding[] with `action.view` / `focus*` / `package` / `keepNeighborhood`. diff --git a/example/http/app/application.go b/example/http/app/application.go new file mode 100644 index 0000000..db39d4a --- /dev/null +++ b/example/http/app/application.go @@ -0,0 +1,76 @@ +package app + +import ( + "github.com/pubgo/dix/v2" + + analyticshandler "github.com/pubgo/dix/example/http/domain/analytics/handler" + analyticssvc "github.com/pubgo/dix/example/http/domain/analytics/service" + billinghandler "github.com/pubgo/dix/example/http/domain/billing/handler" + billingsvc "github.com/pubgo/dix/example/http/domain/billing/service" + identityhandler "github.com/pubgo/dix/example/http/domain/identity/handler" + identitysvc "github.com/pubgo/dix/example/http/domain/identity/service" + inventoryhandler "github.com/pubgo/dix/example/http/domain/inventory/handler" + inventorysvc "github.com/pubgo/dix/example/http/domain/inventory/service" + mediahandler "github.com/pubgo/dix/example/http/domain/media/handler" + mediasvc "github.com/pubgo/dix/example/http/domain/media/service" + notifyhandler "github.com/pubgo/dix/example/http/domain/notification/handler" + notifysvc "github.com/pubgo/dix/example/http/domain/notification/service" + searchhandler "github.com/pubgo/dix/example/http/domain/searchx/handler" + searchsvc "github.com/pubgo/dix/example/http/domain/searchx/service" + shippinghandler "github.com/pubgo/dix/example/http/domain/shipping/handler" + shippingsvc "github.com/pubgo/dix/example/http/domain/shipping/service" + storagehandler "github.com/pubgo/dix/example/http/domain/storage/handler" + storagesvc "github.com/pubgo/dix/example/http/domain/storage/service" + workflowhandler "github.com/pubgo/dix/example/http/domain/workflow/handler" + workflowsvc "github.com/pubgo/dix/example/http/domain/workflow/service" + "github.com/pubgo/dix/example/http/infra/logger" + "github.com/pubgo/dix/example/http/plugins" +) + +// Application 应用聚合根:引用十个域服务 + 插件平台。 +type Application struct { + Logger logger.Logger + Billing *billingsvc.Service + Inventory *inventorysvc.Service + Shipping *shippingsvc.Service + Identity *identitysvc.Service + Analytics *analyticssvc.Service + Notify *notifysvc.Service + Search *searchsvc.Service + Storage *storagesvc.Service + Media *mediasvc.Service + Workflow *workflowsvc.Service + Plugins []string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func( + log logger.Logger, + billingH *billinghandler.Handler, + inventoryH *inventoryhandler.Handler, + shippingH *shippinghandler.Handler, + identityH *identityhandler.Handler, + analyticsH *analyticshandler.Handler, + notifyH *notifyhandler.Handler, + searchH *searchhandler.Handler, + storageH *storagehandler.Handler, + mediaH *mediahandler.Handler, + workflowH *workflowhandler.Handler, + platform *plugins.Platform, + ) *Application { + return &Application{ + Logger: log, + Billing: billingH.Service, + Inventory: inventoryH.Service, + Shipping: shippingH.Service, + Identity: identityH.Service, + Analytics: analyticsH.Service, + Notify: notifyH.Service, + Search: searchH.Service, + Storage: storageH.Service, + Media: mediaH.Service, + Workflow: workflowH.Service, + Plugins: platform.Names, + } + }) +} diff --git a/example/http/bootstrap/container.go b/example/http/bootstrap/container.go new file mode 100644 index 0000000..aa27cc9 --- /dev/null +++ b/example/http/bootstrap/container.go @@ -0,0 +1,207 @@ +package bootstrap + +import ( + "log" + "net/http" + "time" + + "github.com/pubgo/dix/v2" + "github.com/pubgo/dix/v2/dixhttp" + + "github.com/pubgo/dix/example/http/app" + analyticshandler "github.com/pubgo/dix/example/http/domain/analytics/handler" + analyticsinfra "github.com/pubgo/dix/example/http/domain/analytics/infra" + analyticslogic "github.com/pubgo/dix/example/http/domain/analytics/logic" + analyticsmodels "github.com/pubgo/dix/example/http/domain/analytics/models" + analyticssvc "github.com/pubgo/dix/example/http/domain/analytics/service" + billinghandler "github.com/pubgo/dix/example/http/domain/billing/handler" + billinginfra "github.com/pubgo/dix/example/http/domain/billing/infra" + billinglogic "github.com/pubgo/dix/example/http/domain/billing/logic" + billingmodels "github.com/pubgo/dix/example/http/domain/billing/models" + billingsvc "github.com/pubgo/dix/example/http/domain/billing/service" + identityhandler "github.com/pubgo/dix/example/http/domain/identity/handler" + identityinfra "github.com/pubgo/dix/example/http/domain/identity/infra" + identitylogic "github.com/pubgo/dix/example/http/domain/identity/logic" + identitymodels "github.com/pubgo/dix/example/http/domain/identity/models" + identitysvc "github.com/pubgo/dix/example/http/domain/identity/service" + inventoryhandler "github.com/pubgo/dix/example/http/domain/inventory/handler" + inventoryinfra "github.com/pubgo/dix/example/http/domain/inventory/infra" + inventorylogic "github.com/pubgo/dix/example/http/domain/inventory/logic" + inventorymodels "github.com/pubgo/dix/example/http/domain/inventory/models" + inventorysvc "github.com/pubgo/dix/example/http/domain/inventory/service" + mediahandler "github.com/pubgo/dix/example/http/domain/media/handler" + mediainfra "github.com/pubgo/dix/example/http/domain/media/infra" + medialogic "github.com/pubgo/dix/example/http/domain/media/logic" + mediamodels "github.com/pubgo/dix/example/http/domain/media/models" + mediasvc "github.com/pubgo/dix/example/http/domain/media/service" + notifyhandler "github.com/pubgo/dix/example/http/domain/notification/handler" + notifyinfra "github.com/pubgo/dix/example/http/domain/notification/infra" + notifylogic "github.com/pubgo/dix/example/http/domain/notification/logic" + notifymodels "github.com/pubgo/dix/example/http/domain/notification/models" + notifysvc "github.com/pubgo/dix/example/http/domain/notification/service" + searchhandler "github.com/pubgo/dix/example/http/domain/searchx/handler" + searchinfra "github.com/pubgo/dix/example/http/domain/searchx/infra" + searchlogic "github.com/pubgo/dix/example/http/domain/searchx/logic" + searchmodels "github.com/pubgo/dix/example/http/domain/searchx/models" + searchsvc "github.com/pubgo/dix/example/http/domain/searchx/service" + shippinghandler "github.com/pubgo/dix/example/http/domain/shipping/handler" + shippinginfra "github.com/pubgo/dix/example/http/domain/shipping/infra" + shippinglogic "github.com/pubgo/dix/example/http/domain/shipping/logic" + shippingmodels "github.com/pubgo/dix/example/http/domain/shipping/models" + shippingsvc "github.com/pubgo/dix/example/http/domain/shipping/service" + storagehandler "github.com/pubgo/dix/example/http/domain/storage/handler" + storageinfra "github.com/pubgo/dix/example/http/domain/storage/infra" + storagelogic "github.com/pubgo/dix/example/http/domain/storage/logic" + storagemodels "github.com/pubgo/dix/example/http/domain/storage/models" + storagesvc "github.com/pubgo/dix/example/http/domain/storage/service" + workflowhandler "github.com/pubgo/dix/example/http/domain/workflow/handler" + workflowinfra "github.com/pubgo/dix/example/http/domain/workflow/infra" + workflowlogic "github.com/pubgo/dix/example/http/domain/workflow/logic" + workflowmodels "github.com/pubgo/dix/example/http/domain/workflow/models" + workflowsvc "github.com/pubgo/dix/example/http/domain/workflow/service" + "github.com/pubgo/dix/example/http/infra/diag" + "github.com/pubgo/dix/example/http/infra/logger" + "github.com/pubgo/dix/example/http/plugins" + "github.com/pubgo/dix/example/http/plugins/auth" + pluginbilling "github.com/pubgo/dix/example/http/plugins/billing" + "github.com/pubgo/dix/example/http/plugins/cache" + "github.com/pubgo/dix/example/http/plugins/email" + "github.com/pubgo/dix/example/http/plugins/export" + "github.com/pubgo/dix/example/http/plugins/graphql" + "github.com/pubgo/dix/example/http/plugins/importx" + "github.com/pubgo/dix/example/http/plugins/job" + "github.com/pubgo/dix/example/http/plugins/kafka" + "github.com/pubgo/dix/example/http/plugins/login" + "github.com/pubgo/dix/example/http/plugins/metrics" + pluginnotify "github.com/pubgo/dix/example/http/plugins/notify" + "github.com/pubgo/dix/example/http/plugins/oauth" + "github.com/pubgo/dix/example/http/plugins/queue" + "github.com/pubgo/dix/example/http/plugins/report" + pluginsearch "github.com/pubgo/dix/example/http/plugins/search" + "github.com/pubgo/dix/example/http/plugins/session" + "github.com/pubgo/dix/example/http/plugins/tenant" + "github.com/pubgo/dix/example/http/plugins/upload" + "github.com/pubgo/dix/example/http/plugins/vault" + "github.com/pubgo/dix/example/http/router" +) + +// Build 装配完整演示容器:各模块自行 Provide,本包只负责编排顺序。 +func Build() *dix.Dix { + di := dix.New( + dix.WithProviderTimeout(200*time.Millisecond), + dix.WithSlowProviderThreshold(80*time.Millisecond), + ) + + logger.Provide(di) + + analyticsmodels.Provide(di) + analyticsinfra.Provide(di) + analyticslogic.Provide(di) + analyticssvc.Provide(di) + analyticshandler.Provide(di) + + billingmodels.Provide(di) + billinginfra.Provide(di) + billinglogic.Provide(di) + billingsvc.Provide(di) + billinghandler.Provide(di) + + identitymodels.Provide(di) + identityinfra.Provide(di) + identitylogic.Provide(di) + identitysvc.Provide(di) + identityhandler.Provide(di) + + inventorymodels.Provide(di) + inventoryinfra.Provide(di) + inventorylogic.Provide(di) + inventorysvc.Provide(di) + inventoryhandler.Provide(di) + + mediamodels.Provide(di) + mediainfra.Provide(di) + medialogic.Provide(di) + mediasvc.Provide(di) + mediahandler.Provide(di) + + notifymodels.Provide(di) + notifyinfra.Provide(di) + notifylogic.Provide(di) + notifysvc.Provide(di) + notifyhandler.Provide(di) + + searchmodels.Provide(di) + searchinfra.Provide(di) + searchlogic.Provide(di) + searchsvc.Provide(di) + searchhandler.Provide(di) + + shippingmodels.Provide(di) + shippinginfra.Provide(di) + shippinglogic.Provide(di) + shippingsvc.Provide(di) + shippinghandler.Provide(di) + + storagemodels.Provide(di) + storageinfra.Provide(di) + storagelogic.Provide(di) + storagesvc.Provide(di) + storagehandler.Provide(di) + + workflowmodels.Provide(di) + workflowinfra.Provide(di) + workflowlogic.Provide(di) + workflowsvc.Provide(di) + workflowhandler.Provide(di) + + auth.Provide(di) + pluginbilling.Provide(di) + cache.Provide(di) + email.Provide(di) + export.Provide(di) + graphql.Provide(di) + importx.Provide(di) + job.Provide(di) + kafka.Provide(di) + login.Provide(di) + metrics.Provide(di) + pluginnotify.Provide(di) + oauth.Provide(di) + queue.Provide(di) + report.Provide(di) + pluginsearch.Provide(di) + session.Provide(di) + tenant.Provide(di) + upload.Provide(di) + vault.Provide(di) + plugins.Provide(di) + + app.Provide(di) + diag.Provide(di) + + return di +} + +func preCreateObjects(di *dix.Dix) { + if err := di.TryInject(func(a *app.Application) { + log.Printf("✅ Application created: modules=10 plugins=%d", len(a.Plugins)) + }); err != nil { + log.Printf("⚠️ pre-create injection failed: %v", err) + } + _ = di.TryInject(func(workers map[string]plugins.Worker) { + _ = workers + }) +} + +// Run 构建容器、预热对象、跑启动诊断并启动可视化 HTTP。 +func Run() error { + di := Build() + preCreateObjects(di) + diag.RunStartupErrorScenarios(di) + log.Println("") + server := dixhttp.NewServer(di) + if err := router.StartVisualizationServer(server); err != nil && err != http.ErrServerClosed { + return err + } + return nil +} diff --git a/example/http/domain/analytics/analytics.go b/example/http/domain/analytics/analytics.go deleted file mode 100644 index 1a04d10..0000000 --- a/example/http/domain/analytics/analytics.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package analytics 提供数据分析域的示例组件:配置 → 客户端 → 仓储 → 服务 → 处理器 -// 五层链路,外加多区域命名空间连接(演示对象规模与模块级视图)。 -package analytics - -import ( - "github.com/pubgo/dix/v2" -) - -// Config 数据分析配置。 -type Config struct { - Env string - Timeout string -} - -// Client 数据分析下游客户端。 -type Client struct { - Config *Config -} - -// Repo 数据分析仓储层。 -type Repo struct { - Client *Client -} - -// Service 数据分析服务层。 -type Service struct { - Repo *Repo -} - -// Handler 数据分析协议处理器。 -type Handler struct { - Service *Service -} - -// Regions 多区域连接(命名空间 map:一次 provider 产出多个对象)。 -type Regions map[string]*Client - -// Providers 注册数据分析域的全部 provider。 -func Providers(di *dix.Dix) { - dix.Provide(di, func() *Config { - return &Config{Env: "prod", Timeout: "3s"} - }) - dix.Provide(di, func(c *Config) *Client { - return &Client{Config: c} - }) - dix.Provide(di, func(c *Config) Regions { - return Regions{ - "cn": {Config: c}, - "us": {Config: c}, - "eu": {Config: c}, - } - }) - dix.Provide(di, func(c *Client) *Repo { - return &Repo{Client: c} - }) - dix.Provide(di, func(r *Repo) *Service { - return &Service{Repo: r} - }) - dix.Provide(di, func(s *Service) *Handler { - return &Handler{Service: s} - }) -} diff --git a/example/http/domain/analytics/handler/handler.go b/example/http/domain/analytics/handler/handler.go new file mode 100644 index 0000000..ce1f9bb --- /dev/null +++ b/example/http/domain/analytics/handler/handler.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/pubgo/dix/v2" + + dinfr "github.com/pubgo/dix/example/http/domain/analytics/infra" + "github.com/pubgo/dix/example/http/domain/analytics/service" +) + +// Handler analytics 协议处理器。 +type Handler struct { + Service *service.Service +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(s *service.Service, regions dinfr.Regions) *Handler { + _ = regions + return &Handler{Service: s} + }) +} diff --git a/example/http/domain/analytics/infra/client.go b/example/http/domain/analytics/infra/client.go new file mode 100644 index 0000000..185af0c --- /dev/null +++ b/example/http/domain/analytics/infra/client.go @@ -0,0 +1,28 @@ +package infra + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/analytics/models" +) + +// Client analytics 下游客户端。 +type Client struct { + Config *models.Config +} + +// Regions 多区域连接。 +type Regions map[string]*Client + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *models.Config) *Client { + return &Client{Config: c} + }) + dix.Provide(di, func(c *models.Config) Regions { + return Regions{ + "cn": {Config: c}, + "us": {Config: c}, + "eu": {Config: c}, + } + }) +} diff --git a/example/http/domain/analytics/logic/repo.go b/example/http/domain/analytics/logic/repo.go new file mode 100644 index 0000000..e618011 --- /dev/null +++ b/example/http/domain/analytics/logic/repo.go @@ -0,0 +1,18 @@ +package logic + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/analytics/infra" +) + +// Repo analytics 仓储 / 领域逻辑。 +type Repo struct { + Client *infra.Client +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *infra.Client) *Repo { + return &Repo{Client: c} + }) +} diff --git a/example/http/domain/analytics/models/config.go b/example/http/domain/analytics/models/config.go new file mode 100644 index 0000000..1a9d830 --- /dev/null +++ b/example/http/domain/analytics/models/config.go @@ -0,0 +1,15 @@ +package models + +import "github.com/pubgo/dix/v2" + +// Config analytics 域配置。 +type Config struct { + Env string + Timeout string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func() *Config { + return &Config{Env: "prod", Timeout: "3s"} + }) +} diff --git a/example/http/domain/analytics/service/service.go b/example/http/domain/analytics/service/service.go new file mode 100644 index 0000000..d16ca55 --- /dev/null +++ b/example/http/domain/analytics/service/service.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/analytics/logic" +) + +// Service analytics 应用服务。 +type Service struct { + Repo *logic.Repo +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(r *logic.Repo) *Service { + return &Service{Repo: r} + }) +} diff --git a/example/http/domain/billing/billing.go b/example/http/domain/billing/billing.go deleted file mode 100644 index 6094acb..0000000 --- a/example/http/domain/billing/billing.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package billing 提供计费域的示例组件:配置 → 客户端 → 仓储 → 服务 → 处理器 -// 五层链路,外加多区域命名空间连接(演示对象规模与模块级视图)。 -package billing - -import ( - "github.com/pubgo/dix/v2" -) - -// Config 计费配置。 -type Config struct { - Env string - Timeout string -} - -// Client 计费下游客户端。 -type Client struct { - Config *Config -} - -// Repo 计费仓储层。 -type Repo struct { - Client *Client -} - -// Service 计费服务层。 -type Service struct { - Repo *Repo -} - -// Handler 计费协议处理器。 -type Handler struct { - Service *Service -} - -// Regions 多区域连接(命名空间 map:一次 provider 产出多个对象)。 -type Regions map[string]*Client - -// Providers 注册计费域的全部 provider。 -func Providers(di *dix.Dix) { - dix.Provide(di, func() *Config { - return &Config{Env: "prod", Timeout: "3s"} - }) - dix.Provide(di, func(c *Config) *Client { - return &Client{Config: c} - }) - dix.Provide(di, func(c *Config) Regions { - return Regions{ - "cn": {Config: c}, - "us": {Config: c}, - "eu": {Config: c}, - } - }) - dix.Provide(di, func(c *Client) *Repo { - return &Repo{Client: c} - }) - dix.Provide(di, func(r *Repo) *Service { - return &Service{Repo: r} - }) - dix.Provide(di, func(s *Service) *Handler { - return &Handler{Service: s} - }) -} diff --git a/example/http/domain/billing/handler/handler.go b/example/http/domain/billing/handler/handler.go new file mode 100644 index 0000000..15770d5 --- /dev/null +++ b/example/http/domain/billing/handler/handler.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/pubgo/dix/v2" + + dinfr "github.com/pubgo/dix/example/http/domain/billing/infra" + "github.com/pubgo/dix/example/http/domain/billing/service" +) + +// Handler billing 协议处理器。 +type Handler struct { + Service *service.Service +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(s *service.Service, regions dinfr.Regions) *Handler { + _ = regions + return &Handler{Service: s} + }) +} diff --git a/example/http/domain/billing/infra/client.go b/example/http/domain/billing/infra/client.go new file mode 100644 index 0000000..dfd249c --- /dev/null +++ b/example/http/domain/billing/infra/client.go @@ -0,0 +1,28 @@ +package infra + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/billing/models" +) + +// Client billing 下游客户端。 +type Client struct { + Config *models.Config +} + +// Regions 多区域连接。 +type Regions map[string]*Client + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *models.Config) *Client { + return &Client{Config: c} + }) + dix.Provide(di, func(c *models.Config) Regions { + return Regions{ + "cn": {Config: c}, + "us": {Config: c}, + "eu": {Config: c}, + } + }) +} diff --git a/example/http/domain/billing/logic/repo.go b/example/http/domain/billing/logic/repo.go new file mode 100644 index 0000000..9b22857 --- /dev/null +++ b/example/http/domain/billing/logic/repo.go @@ -0,0 +1,18 @@ +package logic + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/billing/infra" +) + +// Repo billing 仓储 / 领域逻辑。 +type Repo struct { + Client *infra.Client +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *infra.Client) *Repo { + return &Repo{Client: c} + }) +} diff --git a/example/http/domain/billing/models/config.go b/example/http/domain/billing/models/config.go new file mode 100644 index 0000000..f930900 --- /dev/null +++ b/example/http/domain/billing/models/config.go @@ -0,0 +1,15 @@ +package models + +import "github.com/pubgo/dix/v2" + +// Config billing 域配置。 +type Config struct { + Env string + Timeout string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func() *Config { + return &Config{Env: "prod", Timeout: "3s"} + }) +} diff --git a/example/http/domain/billing/service/service.go b/example/http/domain/billing/service/service.go new file mode 100644 index 0000000..274590d --- /dev/null +++ b/example/http/domain/billing/service/service.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/billing/logic" +) + +// Service billing 应用服务。 +type Service struct { + Repo *logic.Repo +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(r *logic.Repo) *Service { + return &Service{Repo: r} + }) +} diff --git a/example/http/domain/identity/handler/handler.go b/example/http/domain/identity/handler/handler.go new file mode 100644 index 0000000..995a3a3 --- /dev/null +++ b/example/http/domain/identity/handler/handler.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/pubgo/dix/v2" + + dinfr "github.com/pubgo/dix/example/http/domain/identity/infra" + "github.com/pubgo/dix/example/http/domain/identity/service" +) + +// Handler identity 协议处理器。 +type Handler struct { + Service *service.Service +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(s *service.Service, regions dinfr.Regions) *Handler { + _ = regions + return &Handler{Service: s} + }) +} diff --git a/example/http/domain/identity/identity.go b/example/http/domain/identity/identity.go deleted file mode 100644 index dcc8c5a..0000000 --- a/example/http/domain/identity/identity.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package identity 提供身份认证域的示例组件:配置 → 客户端 → 仓储 → 服务 → 处理器 -// 五层链路,外加多区域命名空间连接(演示对象规模与模块级视图)。 -package identity - -import ( - "github.com/pubgo/dix/v2" -) - -// Config 身份认证配置。 -type Config struct { - Env string - Timeout string -} - -// Client 身份认证下游客户端。 -type Client struct { - Config *Config -} - -// Repo 身份认证仓储层。 -type Repo struct { - Client *Client -} - -// Service 身份认证服务层。 -type Service struct { - Repo *Repo -} - -// Handler 身份认证协议处理器。 -type Handler struct { - Service *Service -} - -// Regions 多区域连接(命名空间 map:一次 provider 产出多个对象)。 -type Regions map[string]*Client - -// Providers 注册身份认证域的全部 provider。 -func Providers(di *dix.Dix) { - dix.Provide(di, func() *Config { - return &Config{Env: "prod", Timeout: "3s"} - }) - dix.Provide(di, func(c *Config) *Client { - return &Client{Config: c} - }) - dix.Provide(di, func(c *Config) Regions { - return Regions{ - "cn": {Config: c}, - "us": {Config: c}, - "eu": {Config: c}, - } - }) - dix.Provide(di, func(c *Client) *Repo { - return &Repo{Client: c} - }) - dix.Provide(di, func(r *Repo) *Service { - return &Service{Repo: r} - }) - dix.Provide(di, func(s *Service) *Handler { - return &Handler{Service: s} - }) -} diff --git a/example/http/domain/identity/infra/client.go b/example/http/domain/identity/infra/client.go new file mode 100644 index 0000000..4328cac --- /dev/null +++ b/example/http/domain/identity/infra/client.go @@ -0,0 +1,28 @@ +package infra + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/identity/models" +) + +// Client identity 下游客户端。 +type Client struct { + Config *models.Config +} + +// Regions 多区域连接。 +type Regions map[string]*Client + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *models.Config) *Client { + return &Client{Config: c} + }) + dix.Provide(di, func(c *models.Config) Regions { + return Regions{ + "cn": {Config: c}, + "us": {Config: c}, + "eu": {Config: c}, + } + }) +} diff --git a/example/http/domain/identity/logic/repo.go b/example/http/domain/identity/logic/repo.go new file mode 100644 index 0000000..4b8d978 --- /dev/null +++ b/example/http/domain/identity/logic/repo.go @@ -0,0 +1,18 @@ +package logic + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/identity/infra" +) + +// Repo identity 仓储 / 领域逻辑。 +type Repo struct { + Client *infra.Client +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *infra.Client) *Repo { + return &Repo{Client: c} + }) +} diff --git a/example/http/domain/identity/models/config.go b/example/http/domain/identity/models/config.go new file mode 100644 index 0000000..b148e0b --- /dev/null +++ b/example/http/domain/identity/models/config.go @@ -0,0 +1,15 @@ +package models + +import "github.com/pubgo/dix/v2" + +// Config identity 域配置。 +type Config struct { + Env string + Timeout string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func() *Config { + return &Config{Env: "prod", Timeout: "3s"} + }) +} diff --git a/example/http/domain/identity/service/service.go b/example/http/domain/identity/service/service.go new file mode 100644 index 0000000..b9d6589 --- /dev/null +++ b/example/http/domain/identity/service/service.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/identity/logic" +) + +// Service identity 应用服务。 +type Service struct { + Repo *logic.Repo +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(r *logic.Repo) *Service { + return &Service{Repo: r} + }) +} diff --git a/example/http/domain/inventory/handler/handler.go b/example/http/domain/inventory/handler/handler.go new file mode 100644 index 0000000..e894e6c --- /dev/null +++ b/example/http/domain/inventory/handler/handler.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/pubgo/dix/v2" + + dinfr "github.com/pubgo/dix/example/http/domain/inventory/infra" + "github.com/pubgo/dix/example/http/domain/inventory/service" +) + +// Handler inventory 协议处理器。 +type Handler struct { + Service *service.Service +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(s *service.Service, regions dinfr.Regions) *Handler { + _ = regions + return &Handler{Service: s} + }) +} diff --git a/example/http/domain/inventory/infra/client.go b/example/http/domain/inventory/infra/client.go new file mode 100644 index 0000000..879e66e --- /dev/null +++ b/example/http/domain/inventory/infra/client.go @@ -0,0 +1,28 @@ +package infra + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/inventory/models" +) + +// Client inventory 下游客户端。 +type Client struct { + Config *models.Config +} + +// Regions 多区域连接。 +type Regions map[string]*Client + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *models.Config) *Client { + return &Client{Config: c} + }) + dix.Provide(di, func(c *models.Config) Regions { + return Regions{ + "cn": {Config: c}, + "us": {Config: c}, + "eu": {Config: c}, + } + }) +} diff --git a/example/http/domain/inventory/inventory.go b/example/http/domain/inventory/inventory.go deleted file mode 100644 index 4ef383c..0000000 --- a/example/http/domain/inventory/inventory.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package inventory 提供库存域的示例组件:配置 → 客户端 → 仓储 → 服务 → 处理器 -// 五层链路,外加多区域命名空间连接(演示对象规模与模块级视图)。 -package inventory - -import ( - "github.com/pubgo/dix/v2" -) - -// Config 库存配置。 -type Config struct { - Env string - Timeout string -} - -// Client 库存下游客户端。 -type Client struct { - Config *Config -} - -// Repo 库存仓储层。 -type Repo struct { - Client *Client -} - -// Service 库存服务层。 -type Service struct { - Repo *Repo -} - -// Handler 库存协议处理器。 -type Handler struct { - Service *Service -} - -// Regions 多区域连接(命名空间 map:一次 provider 产出多个对象)。 -type Regions map[string]*Client - -// Providers 注册库存域的全部 provider。 -func Providers(di *dix.Dix) { - dix.Provide(di, func() *Config { - return &Config{Env: "prod", Timeout: "3s"} - }) - dix.Provide(di, func(c *Config) *Client { - return &Client{Config: c} - }) - dix.Provide(di, func(c *Config) Regions { - return Regions{ - "cn": {Config: c}, - "us": {Config: c}, - "eu": {Config: c}, - } - }) - dix.Provide(di, func(c *Client) *Repo { - return &Repo{Client: c} - }) - dix.Provide(di, func(r *Repo) *Service { - return &Service{Repo: r} - }) - dix.Provide(di, func(s *Service) *Handler { - return &Handler{Service: s} - }) -} diff --git a/example/http/domain/inventory/logic/repo.go b/example/http/domain/inventory/logic/repo.go new file mode 100644 index 0000000..00f934f --- /dev/null +++ b/example/http/domain/inventory/logic/repo.go @@ -0,0 +1,18 @@ +package logic + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/inventory/infra" +) + +// Repo inventory 仓储 / 领域逻辑。 +type Repo struct { + Client *infra.Client +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *infra.Client) *Repo { + return &Repo{Client: c} + }) +} diff --git a/example/http/domain/inventory/models/config.go b/example/http/domain/inventory/models/config.go new file mode 100644 index 0000000..6271dd9 --- /dev/null +++ b/example/http/domain/inventory/models/config.go @@ -0,0 +1,15 @@ +package models + +import "github.com/pubgo/dix/v2" + +// Config inventory 域配置。 +type Config struct { + Env string + Timeout string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func() *Config { + return &Config{Env: "prod", Timeout: "3s"} + }) +} diff --git a/example/http/domain/inventory/service/service.go b/example/http/domain/inventory/service/service.go new file mode 100644 index 0000000..61a6aeb --- /dev/null +++ b/example/http/domain/inventory/service/service.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/inventory/logic" +) + +// Service inventory 应用服务。 +type Service struct { + Repo *logic.Repo +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(r *logic.Repo) *Service { + return &Service{Repo: r} + }) +} diff --git a/example/http/domain/media/handler/handler.go b/example/http/domain/media/handler/handler.go new file mode 100644 index 0000000..acbce38 --- /dev/null +++ b/example/http/domain/media/handler/handler.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/pubgo/dix/v2" + + dinfr "github.com/pubgo/dix/example/http/domain/media/infra" + "github.com/pubgo/dix/example/http/domain/media/service" +) + +// Handler media 协议处理器。 +type Handler struct { + Service *service.Service +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(s *service.Service, regions dinfr.Regions) *Handler { + _ = regions + return &Handler{Service: s} + }) +} diff --git a/example/http/domain/media/infra/client.go b/example/http/domain/media/infra/client.go new file mode 100644 index 0000000..1bdc454 --- /dev/null +++ b/example/http/domain/media/infra/client.go @@ -0,0 +1,28 @@ +package infra + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/media/models" +) + +// Client media 下游客户端。 +type Client struct { + Config *models.Config +} + +// Regions 多区域连接。 +type Regions map[string]*Client + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *models.Config) *Client { + return &Client{Config: c} + }) + dix.Provide(di, func(c *models.Config) Regions { + return Regions{ + "cn": {Config: c}, + "us": {Config: c}, + "eu": {Config: c}, + } + }) +} diff --git a/example/http/domain/media/logic/repo.go b/example/http/domain/media/logic/repo.go new file mode 100644 index 0000000..ec63789 --- /dev/null +++ b/example/http/domain/media/logic/repo.go @@ -0,0 +1,18 @@ +package logic + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/media/infra" +) + +// Repo media 仓储 / 领域逻辑。 +type Repo struct { + Client *infra.Client +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *infra.Client) *Repo { + return &Repo{Client: c} + }) +} diff --git a/example/http/domain/media/media.go b/example/http/domain/media/media.go deleted file mode 100644 index 7ec9bea..0000000 --- a/example/http/domain/media/media.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package media 提供媒体处理域的示例组件:配置 → 客户端 → 仓储 → 服务 → 处理器 -// 五层链路,外加多区域命名空间连接(演示对象规模与模块级视图)。 -package media - -import ( - "github.com/pubgo/dix/v2" -) - -// Config 媒体处理配置。 -type Config struct { - Env string - Timeout string -} - -// Client 媒体处理下游客户端。 -type Client struct { - Config *Config -} - -// Repo 媒体处理仓储层。 -type Repo struct { - Client *Client -} - -// Service 媒体处理服务层。 -type Service struct { - Repo *Repo -} - -// Handler 媒体处理协议处理器。 -type Handler struct { - Service *Service -} - -// Regions 多区域连接(命名空间 map:一次 provider 产出多个对象)。 -type Regions map[string]*Client - -// Providers 注册媒体处理域的全部 provider。 -func Providers(di *dix.Dix) { - dix.Provide(di, func() *Config { - return &Config{Env: "prod", Timeout: "3s"} - }) - dix.Provide(di, func(c *Config) *Client { - return &Client{Config: c} - }) - dix.Provide(di, func(c *Config) Regions { - return Regions{ - "cn": {Config: c}, - "us": {Config: c}, - "eu": {Config: c}, - } - }) - dix.Provide(di, func(c *Client) *Repo { - return &Repo{Client: c} - }) - dix.Provide(di, func(r *Repo) *Service { - return &Service{Repo: r} - }) - dix.Provide(di, func(s *Service) *Handler { - return &Handler{Service: s} - }) -} diff --git a/example/http/domain/media/models/config.go b/example/http/domain/media/models/config.go new file mode 100644 index 0000000..6d1d1f4 --- /dev/null +++ b/example/http/domain/media/models/config.go @@ -0,0 +1,15 @@ +package models + +import "github.com/pubgo/dix/v2" + +// Config media 域配置。 +type Config struct { + Env string + Timeout string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func() *Config { + return &Config{Env: "prod", Timeout: "3s"} + }) +} diff --git a/example/http/domain/media/service/service.go b/example/http/domain/media/service/service.go new file mode 100644 index 0000000..915082a --- /dev/null +++ b/example/http/domain/media/service/service.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/media/logic" +) + +// Service media 应用服务。 +type Service struct { + Repo *logic.Repo +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(r *logic.Repo) *Service { + return &Service{Repo: r} + }) +} diff --git a/example/http/domain/notification/handler/handler.go b/example/http/domain/notification/handler/handler.go new file mode 100644 index 0000000..420e93f --- /dev/null +++ b/example/http/domain/notification/handler/handler.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/pubgo/dix/v2" + + dinfr "github.com/pubgo/dix/example/http/domain/notification/infra" + "github.com/pubgo/dix/example/http/domain/notification/service" +) + +// Handler notification 协议处理器。 +type Handler struct { + Service *service.Service +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(s *service.Service, regions dinfr.Regions) *Handler { + _ = regions + return &Handler{Service: s} + }) +} diff --git a/example/http/domain/notification/infra/client.go b/example/http/domain/notification/infra/client.go new file mode 100644 index 0000000..c02b811 --- /dev/null +++ b/example/http/domain/notification/infra/client.go @@ -0,0 +1,28 @@ +package infra + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/notification/models" +) + +// Client notification 下游客户端。 +type Client struct { + Config *models.Config +} + +// Regions 多区域连接。 +type Regions map[string]*Client + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *models.Config) *Client { + return &Client{Config: c} + }) + dix.Provide(di, func(c *models.Config) Regions { + return Regions{ + "cn": {Config: c}, + "us": {Config: c}, + "eu": {Config: c}, + } + }) +} diff --git a/example/http/domain/notification/logic/repo.go b/example/http/domain/notification/logic/repo.go new file mode 100644 index 0000000..9c58eb4 --- /dev/null +++ b/example/http/domain/notification/logic/repo.go @@ -0,0 +1,18 @@ +package logic + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/notification/infra" +) + +// Repo notification 仓储 / 领域逻辑。 +type Repo struct { + Client *infra.Client +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *infra.Client) *Repo { + return &Repo{Client: c} + }) +} diff --git a/example/http/domain/notification/models/config.go b/example/http/domain/notification/models/config.go new file mode 100644 index 0000000..c9e7ba7 --- /dev/null +++ b/example/http/domain/notification/models/config.go @@ -0,0 +1,15 @@ +package models + +import "github.com/pubgo/dix/v2" + +// Config notification 域配置。 +type Config struct { + Env string + Timeout string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func() *Config { + return &Config{Env: "prod", Timeout: "3s"} + }) +} diff --git a/example/http/domain/notification/notification.go b/example/http/domain/notification/notification.go deleted file mode 100644 index 9c4f74e..0000000 --- a/example/http/domain/notification/notification.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package notification 提供通知域的示例组件:配置 → 客户端 → 仓储 → 服务 → 处理器 -// 五层链路,外加多区域命名空间连接(演示对象规模与模块级视图)。 -package notification - -import ( - "github.com/pubgo/dix/v2" -) - -// Config 通知配置。 -type Config struct { - Env string - Timeout string -} - -// Client 通知下游客户端。 -type Client struct { - Config *Config -} - -// Repo 通知仓储层。 -type Repo struct { - Client *Client -} - -// Service 通知服务层。 -type Service struct { - Repo *Repo -} - -// Handler 通知协议处理器。 -type Handler struct { - Service *Service -} - -// Regions 多区域连接(命名空间 map:一次 provider 产出多个对象)。 -type Regions map[string]*Client - -// Providers 注册通知域的全部 provider。 -func Providers(di *dix.Dix) { - dix.Provide(di, func() *Config { - return &Config{Env: "prod", Timeout: "3s"} - }) - dix.Provide(di, func(c *Config) *Client { - return &Client{Config: c} - }) - dix.Provide(di, func(c *Config) Regions { - return Regions{ - "cn": {Config: c}, - "us": {Config: c}, - "eu": {Config: c}, - } - }) - dix.Provide(di, func(c *Client) *Repo { - return &Repo{Client: c} - }) - dix.Provide(di, func(r *Repo) *Service { - return &Service{Repo: r} - }) - dix.Provide(di, func(s *Service) *Handler { - return &Handler{Service: s} - }) -} diff --git a/example/http/domain/notification/service/service.go b/example/http/domain/notification/service/service.go new file mode 100644 index 0000000..908ca57 --- /dev/null +++ b/example/http/domain/notification/service/service.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/notification/logic" +) + +// Service notification 应用服务。 +type Service struct { + Repo *logic.Repo +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(r *logic.Repo) *Service { + return &Service{Repo: r} + }) +} diff --git a/example/http/domain/searchx/handler/handler.go b/example/http/domain/searchx/handler/handler.go new file mode 100644 index 0000000..7db9a12 --- /dev/null +++ b/example/http/domain/searchx/handler/handler.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/pubgo/dix/v2" + + dinfr "github.com/pubgo/dix/example/http/domain/searchx/infra" + "github.com/pubgo/dix/example/http/domain/searchx/service" +) + +// Handler searchx 协议处理器。 +type Handler struct { + Service *service.Service +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(s *service.Service, regions dinfr.Regions) *Handler { + _ = regions + return &Handler{Service: s} + }) +} diff --git a/example/http/domain/searchx/infra/client.go b/example/http/domain/searchx/infra/client.go new file mode 100644 index 0000000..ce70153 --- /dev/null +++ b/example/http/domain/searchx/infra/client.go @@ -0,0 +1,28 @@ +package infra + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/searchx/models" +) + +// Client searchx 下游客户端。 +type Client struct { + Config *models.Config +} + +// Regions 多区域连接。 +type Regions map[string]*Client + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *models.Config) *Client { + return &Client{Config: c} + }) + dix.Provide(di, func(c *models.Config) Regions { + return Regions{ + "cn": {Config: c}, + "us": {Config: c}, + "eu": {Config: c}, + } + }) +} diff --git a/example/http/domain/searchx/logic/repo.go b/example/http/domain/searchx/logic/repo.go new file mode 100644 index 0000000..bc2b553 --- /dev/null +++ b/example/http/domain/searchx/logic/repo.go @@ -0,0 +1,18 @@ +package logic + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/searchx/infra" +) + +// Repo searchx 仓储 / 领域逻辑。 +type Repo struct { + Client *infra.Client +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *infra.Client) *Repo { + return &Repo{Client: c} + }) +} diff --git a/example/http/domain/searchx/models/config.go b/example/http/domain/searchx/models/config.go new file mode 100644 index 0000000..294a490 --- /dev/null +++ b/example/http/domain/searchx/models/config.go @@ -0,0 +1,15 @@ +package models + +import "github.com/pubgo/dix/v2" + +// Config searchx 域配置。 +type Config struct { + Env string + Timeout string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func() *Config { + return &Config{Env: "prod", Timeout: "3s"} + }) +} diff --git a/example/http/domain/searchx/searchx.go b/example/http/domain/searchx/searchx.go deleted file mode 100644 index 5fb117b..0000000 --- a/example/http/domain/searchx/searchx.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package searchx 提供搜索域的示例组件:配置 → 客户端 → 仓储 → 服务 → 处理器 -// 五层链路,外加多区域命名空间连接(演示对象规模与模块级视图)。 -package searchx - -import ( - "github.com/pubgo/dix/v2" -) - -// Config 搜索配置。 -type Config struct { - Env string - Timeout string -} - -// Client 搜索下游客户端。 -type Client struct { - Config *Config -} - -// Repo 搜索仓储层。 -type Repo struct { - Client *Client -} - -// Service 搜索服务层。 -type Service struct { - Repo *Repo -} - -// Handler 搜索协议处理器。 -type Handler struct { - Service *Service -} - -// Regions 多区域连接(命名空间 map:一次 provider 产出多个对象)。 -type Regions map[string]*Client - -// Providers 注册搜索域的全部 provider。 -func Providers(di *dix.Dix) { - dix.Provide(di, func() *Config { - return &Config{Env: "prod", Timeout: "3s"} - }) - dix.Provide(di, func(c *Config) *Client { - return &Client{Config: c} - }) - dix.Provide(di, func(c *Config) Regions { - return Regions{ - "cn": {Config: c}, - "us": {Config: c}, - "eu": {Config: c}, - } - }) - dix.Provide(di, func(c *Client) *Repo { - return &Repo{Client: c} - }) - dix.Provide(di, func(r *Repo) *Service { - return &Service{Repo: r} - }) - dix.Provide(di, func(s *Service) *Handler { - return &Handler{Service: s} - }) -} diff --git a/example/http/domain/searchx/service/service.go b/example/http/domain/searchx/service/service.go new file mode 100644 index 0000000..10e8cdf --- /dev/null +++ b/example/http/domain/searchx/service/service.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/searchx/logic" +) + +// Service searchx 应用服务。 +type Service struct { + Repo *logic.Repo +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(r *logic.Repo) *Service { + return &Service{Repo: r} + }) +} diff --git a/example/http/domain/shipping/handler/handler.go b/example/http/domain/shipping/handler/handler.go new file mode 100644 index 0000000..d65a160 --- /dev/null +++ b/example/http/domain/shipping/handler/handler.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/pubgo/dix/v2" + + dinfr "github.com/pubgo/dix/example/http/domain/shipping/infra" + "github.com/pubgo/dix/example/http/domain/shipping/service" +) + +// Handler shipping 协议处理器。 +type Handler struct { + Service *service.Service +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(s *service.Service, regions dinfr.Regions) *Handler { + _ = regions + return &Handler{Service: s} + }) +} diff --git a/example/http/domain/shipping/infra/client.go b/example/http/domain/shipping/infra/client.go new file mode 100644 index 0000000..238edb1 --- /dev/null +++ b/example/http/domain/shipping/infra/client.go @@ -0,0 +1,28 @@ +package infra + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/shipping/models" +) + +// Client shipping 下游客户端。 +type Client struct { + Config *models.Config +} + +// Regions 多区域连接。 +type Regions map[string]*Client + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *models.Config) *Client { + return &Client{Config: c} + }) + dix.Provide(di, func(c *models.Config) Regions { + return Regions{ + "cn": {Config: c}, + "us": {Config: c}, + "eu": {Config: c}, + } + }) +} diff --git a/example/http/domain/shipping/logic/repo.go b/example/http/domain/shipping/logic/repo.go new file mode 100644 index 0000000..e451cb6 --- /dev/null +++ b/example/http/domain/shipping/logic/repo.go @@ -0,0 +1,18 @@ +package logic + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/shipping/infra" +) + +// Repo shipping 仓储 / 领域逻辑。 +type Repo struct { + Client *infra.Client +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *infra.Client) *Repo { + return &Repo{Client: c} + }) +} diff --git a/example/http/domain/shipping/models/config.go b/example/http/domain/shipping/models/config.go new file mode 100644 index 0000000..e0d5446 --- /dev/null +++ b/example/http/domain/shipping/models/config.go @@ -0,0 +1,15 @@ +package models + +import "github.com/pubgo/dix/v2" + +// Config shipping 域配置。 +type Config struct { + Env string + Timeout string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func() *Config { + return &Config{Env: "prod", Timeout: "3s"} + }) +} diff --git a/example/http/domain/shipping/service/service.go b/example/http/domain/shipping/service/service.go new file mode 100644 index 0000000..25fef48 --- /dev/null +++ b/example/http/domain/shipping/service/service.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/shipping/logic" +) + +// Service shipping 应用服务。 +type Service struct { + Repo *logic.Repo +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(r *logic.Repo) *Service { + return &Service{Repo: r} + }) +} diff --git a/example/http/domain/shipping/shipping.go b/example/http/domain/shipping/shipping.go deleted file mode 100644 index a135b95..0000000 --- a/example/http/domain/shipping/shipping.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package shipping 提供物流域的示例组件:配置 → 客户端 → 仓储 → 服务 → 处理器 -// 五层链路,外加多区域命名空间连接(演示对象规模与模块级视图)。 -package shipping - -import ( - "github.com/pubgo/dix/v2" -) - -// Config 物流配置。 -type Config struct { - Env string - Timeout string -} - -// Client 物流下游客户端。 -type Client struct { - Config *Config -} - -// Repo 物流仓储层。 -type Repo struct { - Client *Client -} - -// Service 物流服务层。 -type Service struct { - Repo *Repo -} - -// Handler 物流协议处理器。 -type Handler struct { - Service *Service -} - -// Regions 多区域连接(命名空间 map:一次 provider 产出多个对象)。 -type Regions map[string]*Client - -// Providers 注册物流域的全部 provider。 -func Providers(di *dix.Dix) { - dix.Provide(di, func() *Config { - return &Config{Env: "prod", Timeout: "3s"} - }) - dix.Provide(di, func(c *Config) *Client { - return &Client{Config: c} - }) - dix.Provide(di, func(c *Config) Regions { - return Regions{ - "cn": {Config: c}, - "us": {Config: c}, - "eu": {Config: c}, - } - }) - dix.Provide(di, func(c *Client) *Repo { - return &Repo{Client: c} - }) - dix.Provide(di, func(r *Repo) *Service { - return &Service{Repo: r} - }) - dix.Provide(di, func(s *Service) *Handler { - return &Handler{Service: s} - }) -} diff --git a/example/http/domain/storage/handler/handler.go b/example/http/domain/storage/handler/handler.go new file mode 100644 index 0000000..d36815b --- /dev/null +++ b/example/http/domain/storage/handler/handler.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/pubgo/dix/v2" + + dinfr "github.com/pubgo/dix/example/http/domain/storage/infra" + "github.com/pubgo/dix/example/http/domain/storage/service" +) + +// Handler storage 协议处理器。 +type Handler struct { + Service *service.Service +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(s *service.Service, regions dinfr.Regions) *Handler { + _ = regions + return &Handler{Service: s} + }) +} diff --git a/example/http/domain/storage/infra/client.go b/example/http/domain/storage/infra/client.go new file mode 100644 index 0000000..602ba72 --- /dev/null +++ b/example/http/domain/storage/infra/client.go @@ -0,0 +1,28 @@ +package infra + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/storage/models" +) + +// Client storage 下游客户端。 +type Client struct { + Config *models.Config +} + +// Regions 多区域连接。 +type Regions map[string]*Client + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *models.Config) *Client { + return &Client{Config: c} + }) + dix.Provide(di, func(c *models.Config) Regions { + return Regions{ + "cn": {Config: c}, + "us": {Config: c}, + "eu": {Config: c}, + } + }) +} diff --git a/example/http/domain/storage/logic/repo.go b/example/http/domain/storage/logic/repo.go new file mode 100644 index 0000000..9f87b80 --- /dev/null +++ b/example/http/domain/storage/logic/repo.go @@ -0,0 +1,18 @@ +package logic + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/storage/infra" +) + +// Repo storage 仓储 / 领域逻辑。 +type Repo struct { + Client *infra.Client +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *infra.Client) *Repo { + return &Repo{Client: c} + }) +} diff --git a/example/http/domain/storage/models/config.go b/example/http/domain/storage/models/config.go new file mode 100644 index 0000000..cf1c317 --- /dev/null +++ b/example/http/domain/storage/models/config.go @@ -0,0 +1,15 @@ +package models + +import "github.com/pubgo/dix/v2" + +// Config storage 域配置。 +type Config struct { + Env string + Timeout string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func() *Config { + return &Config{Env: "prod", Timeout: "3s"} + }) +} diff --git a/example/http/domain/storage/service/service.go b/example/http/domain/storage/service/service.go new file mode 100644 index 0000000..1e819ef --- /dev/null +++ b/example/http/domain/storage/service/service.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/storage/logic" +) + +// Service storage 应用服务。 +type Service struct { + Repo *logic.Repo +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(r *logic.Repo) *Service { + return &Service{Repo: r} + }) +} diff --git a/example/http/domain/storage/storage.go b/example/http/domain/storage/storage.go deleted file mode 100644 index 981d136..0000000 --- a/example/http/domain/storage/storage.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package storage 提供对象存储域的示例组件:配置 → 客户端 → 仓储 → 服务 → 处理器 -// 五层链路,外加多区域命名空间连接(演示对象规模与模块级视图)。 -package storage - -import ( - "github.com/pubgo/dix/v2" -) - -// Config 对象存储配置。 -type Config struct { - Env string - Timeout string -} - -// Client 对象存储下游客户端。 -type Client struct { - Config *Config -} - -// Repo 对象存储仓储层。 -type Repo struct { - Client *Client -} - -// Service 对象存储服务层。 -type Service struct { - Repo *Repo -} - -// Handler 对象存储协议处理器。 -type Handler struct { - Service *Service -} - -// Regions 多区域连接(命名空间 map:一次 provider 产出多个对象)。 -type Regions map[string]*Client - -// Providers 注册对象存储域的全部 provider。 -func Providers(di *dix.Dix) { - dix.Provide(di, func() *Config { - return &Config{Env: "prod", Timeout: "3s"} - }) - dix.Provide(di, func(c *Config) *Client { - return &Client{Config: c} - }) - dix.Provide(di, func(c *Config) Regions { - return Regions{ - "cn": {Config: c}, - "us": {Config: c}, - "eu": {Config: c}, - } - }) - dix.Provide(di, func(c *Client) *Repo { - return &Repo{Client: c} - }) - dix.Provide(di, func(r *Repo) *Service { - return &Service{Repo: r} - }) - dix.Provide(di, func(s *Service) *Handler { - return &Handler{Service: s} - }) -} diff --git a/example/http/domain/workflow/handler/handler.go b/example/http/domain/workflow/handler/handler.go new file mode 100644 index 0000000..00c2265 --- /dev/null +++ b/example/http/domain/workflow/handler/handler.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/pubgo/dix/v2" + + dinfr "github.com/pubgo/dix/example/http/domain/workflow/infra" + "github.com/pubgo/dix/example/http/domain/workflow/service" +) + +// Handler workflow 协议处理器。 +type Handler struct { + Service *service.Service +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(s *service.Service, regions dinfr.Regions) *Handler { + _ = regions + return &Handler{Service: s} + }) +} diff --git a/example/http/domain/workflow/infra/client.go b/example/http/domain/workflow/infra/client.go new file mode 100644 index 0000000..0f0edf6 --- /dev/null +++ b/example/http/domain/workflow/infra/client.go @@ -0,0 +1,28 @@ +package infra + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/workflow/models" +) + +// Client workflow 下游客户端。 +type Client struct { + Config *models.Config +} + +// Regions 多区域连接。 +type Regions map[string]*Client + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *models.Config) *Client { + return &Client{Config: c} + }) + dix.Provide(di, func(c *models.Config) Regions { + return Regions{ + "cn": {Config: c}, + "us": {Config: c}, + "eu": {Config: c}, + } + }) +} diff --git a/example/http/domain/workflow/logic/repo.go b/example/http/domain/workflow/logic/repo.go new file mode 100644 index 0000000..40f6c68 --- /dev/null +++ b/example/http/domain/workflow/logic/repo.go @@ -0,0 +1,18 @@ +package logic + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/workflow/infra" +) + +// Repo workflow 仓储 / 领域逻辑。 +type Repo struct { + Client *infra.Client +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(c *infra.Client) *Repo { + return &Repo{Client: c} + }) +} diff --git a/example/http/domain/workflow/models/config.go b/example/http/domain/workflow/models/config.go new file mode 100644 index 0000000..c1b1a6b --- /dev/null +++ b/example/http/domain/workflow/models/config.go @@ -0,0 +1,15 @@ +package models + +import "github.com/pubgo/dix/v2" + +// Config workflow 域配置。 +type Config struct { + Env string + Timeout string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func() *Config { + return &Config{Env: "prod", Timeout: "3s"} + }) +} diff --git a/example/http/domain/workflow/service/service.go b/example/http/domain/workflow/service/service.go new file mode 100644 index 0000000..d81e1e6 --- /dev/null +++ b/example/http/domain/workflow/service/service.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/domain/workflow/logic" +) + +// Service workflow 应用服务。 +type Service struct { + Repo *logic.Repo +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(r *logic.Repo) *Service { + return &Service{Repo: r} + }) +} diff --git a/example/http/domain/workflow/workflow.go b/example/http/domain/workflow/workflow.go deleted file mode 100644 index 6999659..0000000 --- a/example/http/domain/workflow/workflow.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package workflow 提供工作流域的示例组件:配置 → 客户端 → 仓储 → 服务 → 处理器 -// 五层链路,外加多区域命名空间连接(演示对象规模与模块级视图)。 -package workflow - -import ( - "github.com/pubgo/dix/v2" -) - -// Config 工作流配置。 -type Config struct { - Env string - Timeout string -} - -// Client 工作流下游客户端。 -type Client struct { - Config *Config -} - -// Repo 工作流仓储层。 -type Repo struct { - Client *Client -} - -// Service 工作流服务层。 -type Service struct { - Repo *Repo -} - -// Handler 工作流协议处理器。 -type Handler struct { - Service *Service -} - -// Regions 多区域连接(命名空间 map:一次 provider 产出多个对象)。 -type Regions map[string]*Client - -// Providers 注册工作流域的全部 provider。 -func Providers(di *dix.Dix) { - dix.Provide(di, func() *Config { - return &Config{Env: "prod", Timeout: "3s"} - }) - dix.Provide(di, func(c *Config) *Client { - return &Client{Config: c} - }) - dix.Provide(di, func(c *Config) Regions { - return Regions{ - "cn": {Config: c}, - "us": {Config: c}, - "eu": {Config: c}, - } - }) - dix.Provide(di, func(c *Client) *Repo { - return &Repo{Client: c} - }) - dix.Provide(di, func(r *Repo) *Service { - return &Service{Repo: r} - }) - dix.Provide(di, func(s *Service) *Handler { - return &Handler{Service: s} - }) -} diff --git a/example/http/infra/diag/diag.go b/example/http/infra/diag/diag.go new file mode 100644 index 0000000..bb5c747 --- /dev/null +++ b/example/http/infra/diag/diag.go @@ -0,0 +1,150 @@ +package diag + +import ( + "errors" + "log" + "time" + + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/infra/logger" +) + +// SlowRemoteClient 模拟外部慢依赖(触发 provider_timeout)。 +type SlowRemoteClient struct{ Ready bool } + +// TimeoutProbe 触发 SlowRemoteClient 的解析(金字塔 entry #3)。 +type TimeoutProbe struct{ Client *SlowRemoteClient } + +// StartupMissingDependency 模拟注入缺失依赖。 +type StartupMissingDependency struct{} + +// StartupResolveInputMissing 模拟 provider 输入依赖缺失。 +type StartupResolveInputMissing struct{} + +// StartupResolveInputProbe 用于触发 StartupResolveInputMissing 的解析。 +type StartupResolveInputProbe struct{} + +// StartupBrokenComponent 模拟 provider 返回 error。 +type StartupBrokenComponent struct{} + +// StartupPanicComponent 模拟 provider panic。 +type StartupPanicComponent struct{} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(log logger.Logger) *SlowRemoteClient { + log.Info("[demo] SlowRemoteClient start (expected timeout)") + time.Sleep(450 * time.Millisecond) + return &SlowRemoteClient{Ready: true} + }) + dix.Provide(di, func(client *SlowRemoteClient) *TimeoutProbe { + return &TimeoutProbe{Client: client} + }) +} + +func logStartupScenarioResult(di *dix.Dix, scenario string, err error, previousCount int) { + if err == nil { + return + } + recent := di.GetRecentErrors(0) + if len(recent) == 0 { + log.Printf("⚠️ [startup-diagnostic][%s] err=%v (no recent error records)", scenario, err) + return + } + newCount := len(recent) - previousCount + if newCount <= 0 { + newCount = 1 + } + if newCount > len(recent) { + newCount = len(recent) + } + log.Printf("⚠️ [startup-diagnostic][%s] captured %d record(s)", scenario, newCount) + for i := newCount - 1; i >= 0; i-- { + item := recent[i] + log.Printf(" - record=%d type=%s op=%s message=%s", + newCount-i, item.ErrorType, item.Operation, item.Message) + if item.Hint != "" { + log.Printf(" hint=%s", item.Hint) + } + } +} + +// RunStartupErrorScenarios 在启动阶段统一触发多类可识别错误。 +func RunStartupErrorScenarios(di *dix.Dix) { + log.Println("🧪 Running startup error diagnostics...") + + before := len(di.GetRecentErrors(0)) + if err := di.TryProvide(nil); err != nil { + logStartupScenarioResult(di, "invalid_provider_registration", err, before) + } + + before = len(di.GetRecentErrors(0)) + if err := di.TryInject(func(*StartupMissingDependency) {}); err != nil { + logStartupScenarioResult(di, "inject_missing_dependency", err, before) + } + + { + tmp := dix.New() + logger.Provide(tmp) + dix.Provide(tmp, func(*StartupResolveInputMissing) *StartupResolveInputProbe { + return &StartupResolveInputProbe{} + }) + before = len(tmp.GetRecentErrors(0)) + if err := tmp.TryInject(func(*StartupResolveInputProbe) {}); err != nil { + logStartupScenarioResult(tmp, "provider_input_unresolved", err, before) + } + } + + { + tmp := dix.New() + logger.Provide(tmp) + dix.Provide(tmp, func(log logger.Logger) (*StartupBrokenComponent, error) { + log.Info("[demo] StartupBrokenComponent returns intentional error") + return nil, errors.New("demo startup: provider return error") + }) + before = len(tmp.GetRecentErrors(0)) + if err := tmp.TryInject(func(*StartupBrokenComponent) {}); err != nil { + logStartupScenarioResult(tmp, "provider_return_error", err, before) + } + } + + before = len(di.GetRecentErrors(0)) + if err := di.TryInject(func(log logger.Logger) error { + log.Info("[demo] inject callback returns intentional error") + return errors.New("demo startup: inject callback error") + }); err != nil { + logStartupScenarioResult(di, "inject_callback_error", err, before) + } + + { + tmp := dix.New() + logger.Provide(tmp) + dix.Provide(tmp, func(log logger.Logger) *StartupPanicComponent { + log.Info("[demo] StartupPanicComponent panics intentionally") + panic("demo startup: provider panic") + }) + before = len(tmp.GetRecentErrors(0)) + if err := tmp.TryInject(func(*StartupPanicComponent) {}); err != nil { + logStartupScenarioResult(tmp, "provider_panic", err, before) + } + } + + before = len(di.GetRecentErrors(0)) + if err := di.TryInject(func(*TimeoutProbe) {}); err != nil { + logStartupScenarioResult(di, "provider_timeout", err, before) + } + + cycleDI := dix.New() + type cycleA struct{} + type cycleB struct{} + type cycleC struct{} + dix.Provide(cycleDI, func(*cycleC) *cycleA { return &cycleA{} }) + dix.Provide(cycleDI, func(*cycleA) *cycleB { return &cycleB{} }) + dix.Provide(cycleDI, func(*cycleB) *cycleC { return &cycleC{} }) + beforeCycle := len(cycleDI.GetRecentErrors(0)) + if err := cycleDI.TryInject(func(*cycleA) {}); err != nil { + logStartupScenarioResult(cycleDI, "dependency_cycle(temp_container)", err, beforeCycle) + } + + log.Println("🧪 Startup diagnostics done. Visit /api/errors to verify error_type/hint recognition.") +} diff --git a/example/http/infra/logger/logger.go b/example/http/infra/logger/logger.go new file mode 100644 index 0000000..f61c1ec --- /dev/null +++ b/example/http/infra/logger/logger.go @@ -0,0 +1,25 @@ +package logger + +import ( + "log" + + "github.com/pubgo/dix/v2" +) + +// Logger 全局日志接口。 +type Logger interface { + Info(msg string) + Error(msg string) +} + +// ConsoleLogger 默认实现。 +type ConsoleLogger struct{ Prefix string } + +func (c *ConsoleLogger) Info(msg string) { log.Printf("[INFO] %s", msg) } +func (c *ConsoleLogger) Error(msg string) { log.Printf("[ERROR] %s", msg) } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() Logger { + return &ConsoleLogger{Prefix: "app"} + }) +} diff --git a/example/http/main.go b/example/http/main.go index 8229662..0bc233f 100644 --- a/example/http/main.go +++ b/example/http/main.go @@ -1,11 +1,8 @@ // 【功能】dixhttp 依赖图可视化:大规模端到端综合示例。 // -// 【原理】构造真实项目规模的容器(十个域模块 + 泛型插件/工作器族, -// 约 200 个 provider、300 个对象),并触发多类可诊断错误,用于: -// - 体验五视图 UI(概览/依赖图/检索/调用链/诊断)的交互流程; -// - 验证大规模下"模块下钻 + 邻域子图 + 服务端检索"的可用性。 -// -// 入门请先看 example/inject-func 与 example/inject-struct。 +// 【原理】按微服务分层组织包(bootstrap / app / router / infra / plugins / +// domain/*/models|infra|logic|service|handler),构造真实项目规模的容器并 +// 触发多类可诊断错误,用于验证 legacy `/` UI。 // // 【运行】 // @@ -14,340 +11,26 @@ // // 【可选环境变量】 // -// DIX_HTTP_ADDR=:8080 # 服务监听地址 -// DIX_TRACE_DI=true # 控制台逐步 DI trace -// DIX_DIAG_FILE=.local/dix-diag.jsonl # JSONL 诊断文件 -// -// 【预期行为】启动后打印 API 端点清单并阻塞服务, -// 浏览器打开 http://localhost:8080 查看依赖图与诊断页。 +// DIX_HTTP_ADDR=:8080 +// DIX_TRACE_DI=true +// DIX_DIAG_FILE=.local/dix-diag.jsonl package main import ( - "errors" "log" - "net" - "net/http" - "os" - "time" "github.com/pubgo/dix/v2" - "github.com/pubgo/dix/v2/dixhttp" - "github.com/pubgo/dix/example/http/domain/analytics" - "github.com/pubgo/dix/example/http/domain/billing" - "github.com/pubgo/dix/example/http/domain/identity" - "github.com/pubgo/dix/example/http/domain/inventory" - "github.com/pubgo/dix/example/http/domain/media" - "github.com/pubgo/dix/example/http/domain/notification" - "github.com/pubgo/dix/example/http/domain/searchx" - "github.com/pubgo/dix/example/http/domain/shipping" - "github.com/pubgo/dix/example/http/domain/storage" - "github.com/pubgo/dix/example/http/domain/workflow" + "github.com/pubgo/dix/example/http/bootstrap" ) -// Logger 全局日志接口。 -type Logger interface { - Info(msg string) - Error(msg string) -} - -// ConsoleLogger 默认实现。 -type ConsoleLogger struct{ Prefix string } - -func (c *ConsoleLogger) Info(msg string) { log.Printf("[INFO] %s", msg) } -func (c *ConsoleLogger) Error(msg string) { log.Printf("[ERROR] %s", msg) } - -const defaultHTTPAddr = ":8080" - -// ==================== 诊断演示用的合成组件 ==================== - -// SlowRemoteClient 模拟外部慢依赖(触发 provider_timeout)。 -type SlowRemoteClient struct{ Ready bool } - -// TimeoutProbe 触发 SlowRemoteClient 的解析。 -type TimeoutProbe struct{ Client *SlowRemoteClient } - -// StartupMissingDependency 模拟注入缺失依赖。 -type StartupMissingDependency struct{} - -// StartupResolveInputMissing 模拟 provider 输入依赖缺失。 -type StartupResolveInputMissing struct{} - -// StartupResolveInputProbe 用于触发 StartupResolveInputMissing 的解析。 -type StartupResolveInputProbe struct{} - -// StartupBrokenComponent 模拟 provider 返回 error。 -type StartupBrokenComponent struct{} - -// StartupPanicComponent 模拟 provider panic。 -type StartupPanicComponent struct{} - -// ==================== 应用主结构 ==================== - -// Application 应用聚合根:引用十个域模块的服务,是整棵依赖图的汇点。 -type Application struct { - Logger Logger - Billing *billing.Service - Inventory *inventory.Service - Shipping *shipping.Service - Identity *identity.Service - Analytics *analytics.Service - Notify *notification.Service - Search *searchx.Service - Storage *storage.Service - Media *media.Service - Workflow *workflow.Service - Plugins []string -} - -// ==================== HTTP 服务器 ==================== - -func startVisualizationServer(server *dixhttp.Server) error { - addr := os.Getenv("DIX_HTTP_ADDR") - if addr == "" { - addr = defaultHTTPAddr - } - - ln, err := net.Listen("tcp", addr) - if err != nil { - if addr == defaultHTTPAddr { - log.Printf("⚠️ Port %s unavailable (%v), trying a random available port...", addr, err) - ln, err = net.Listen("tcp", ":0") - } - if err != nil { - return err - } - } - - actualAddr := ln.Addr().String() - displayAddr := actualAddr - if _, port, splitErr := net.SplitHostPort(actualAddr); splitErr == nil && port != "" { - displayAddr = "localhost:" + port - } - - log.Printf("🚀 Starting HTTP server on http://%s", displayAddr) - log.Printf("📊 Open http://%s in your browser: overview / graph / search / trace / diag", displayAddr) - log.Println("📡 API endpoints:") - log.Println(" - GET /api/dependencies - JSON data of dependencies") - log.Println(" - GET /api/modules - module-level aggregation") - log.Println(" - GET /api/ego - neighborhood subgraph") - log.Println(" - GET /api/search - server-side graph search") - log.Println(" - GET /api/stats - overview statistics") - log.Println(" - GET /api/runtime-stats - provider startup timings") - log.Println(" - GET /api/errors - recent inject errors") - log.Println(" - GET /api/diagnostics - DIX_DIAG_FILE records") - log.Println(" - GET /api/trace - dixtrace event query") - log.Println(" - GET /api/trace-tree - nested call tree per trace") - return (&http.Server{Handler: server}).Serve(ln) -} - -// ==================== 启动诊断场景 ==================== - -func logStartupScenarioResult(di *dix.Dix, scenario string, err error, previousCount int) { - if err == nil { - return - } - - recent := di.GetRecentErrors(0) - if len(recent) == 0 { - log.Printf("⚠️ [startup-diagnostic][%s] err=%v (no recent error records)", scenario, err) - return - } - - newCount := len(recent) - previousCount - if newCount <= 0 { - newCount = 1 - } - if newCount > len(recent) { - newCount = len(recent) - } - - log.Printf("⚠️ [startup-diagnostic][%s] captured %d record(s)", scenario, newCount) - for i := newCount - 1; i >= 0; i-- { - item := recent[i] - log.Printf(" - record=%d type=%s op=%s message=%s", - newCount-i, item.ErrorType, item.Operation, item.Message) - if item.Hint != "" { - log.Printf(" hint=%s", item.Hint) - } - } -} - -// runStartupErrorScenarios 在启动阶段统一触发多类可识别错误, -// 验证 /api/errors 的 error_type/hint 识别与定位能力。 -func runStartupErrorScenarios(di *dix.Dix) { - log.Println("🧪 Running startup error diagnostics...") - - before := len(di.GetRecentErrors(0)) - if err := di.TryProvide(nil); err != nil { - logStartupScenarioResult(di, "invalid_provider_registration", err, before) - } - - before = len(di.GetRecentErrors(0)) - if err := di.TryInject(func(*StartupMissingDependency) {}); err != nil { - logStartupScenarioResult(di, "inject_missing_dependency", err, before) - } - - dix.Provide(di, func(*StartupResolveInputMissing) *StartupResolveInputProbe { - return &StartupResolveInputProbe{} - }) - before = len(di.GetRecentErrors(0)) - if err := di.TryInject(func(*StartupResolveInputProbe) {}); err != nil { - logStartupScenarioResult(di, "provider_input_unresolved", err, before) - } - - dix.Provide(di, func(logger Logger) (*StartupBrokenComponent, error) { - logger.Info("[demo] StartupBrokenComponent returns intentional error") - return nil, errors.New("demo startup: provider return error") - }) - before = len(di.GetRecentErrors(0)) - if err := di.TryInject(func(*StartupBrokenComponent) {}); err != nil { - logStartupScenarioResult(di, "provider_return_error", err, before) - } - - before = len(di.GetRecentErrors(0)) - if err := di.TryInject(func(logger Logger) error { - logger.Info("[demo] inject callback returns intentional error") - return errors.New("demo startup: inject callback error") - }); err != nil { - logStartupScenarioResult(di, "inject_callback_error", err, before) - } - - dix.Provide(di, func(logger Logger) *StartupPanicComponent { - logger.Info("[demo] StartupPanicComponent panics intentionally") - panic("demo startup: provider panic") - }) - before = len(di.GetRecentErrors(0)) - if err := di.TryInject(func(*StartupPanicComponent) {}); err != nil { - logStartupScenarioResult(di, "provider_panic", err, before) - } - - before = len(di.GetRecentErrors(0)) - if err := di.TryInject(func(*TimeoutProbe) {}); err != nil { - logStartupScenarioResult(di, "provider_timeout", err, before) - } - - // 循环依赖演示使用临时容器,避免污染主容器。 - cycleDI := dix.New() - type cycleA struct{} - type cycleB struct{} - type cycleC struct{} - dix.Provide(cycleDI, func(*cycleC) *cycleA { return &cycleA{} }) - dix.Provide(cycleDI, func(*cycleA) *cycleB { return &cycleB{} }) - dix.Provide(cycleDI, func(*cycleB) *cycleC { return &cycleC{} }) - beforeCycle := len(cycleDI.GetRecentErrors(0)) - if err := cycleDI.TryInject(func(*cycleA) {}); err != nil { - logStartupScenarioResult(cycleDI, "dependency_cycle(temp_container)", err, beforeCycle) - } - - log.Println("🧪 Startup diagnostics done. Visit /api/errors to verify error_type/hint recognition.") -} - -// buildContainer 注册示例的全部组件并返回容器: -// 十个域模块(billing/workflow/…) + 泛型插件/工作器族 + 聚合根 Application, -// 另含用于诊断演示的慢依赖/错误场景 provider。 -// 装配契约由 main_test.go 的 TestBuildContainerWiresApplication 锁定。 +// buildContainer 测试入口,转发到 bootstrap.Build。 func buildContainer() *dix.Dix { - di := dix.New( - dix.WithProviderTimeout(200*time.Millisecond), - dix.WithSlowProviderThreshold(80*time.Millisecond), - ) - - // 基础组件 - dix.Provide(di, func() Logger { return &ConsoleLogger{Prefix: "app"} }) - - // 十个域模块:每域五层链路(配置→客户端→仓储→服务→处理器)+ 多区域连接 - analytics.Providers(di) - billing.Providers(di) - identity.Providers(di) - inventory.Providers(di) - media.Providers(di) - notification.Providers(di) - searchx.Providers(di) - shipping.Providers(di) - storage.Providers(di) - workflow.Providers(di) - - // 泛型插件/工作器族(百余合成依赖,形成 Plugin -> Worker 链路) - activators := registerPlugins(di) - - // 聚合根:引用全部域服务,是依赖图的汇点 - dix.Provide(di, func( - logger Logger, - billing *billing.Service, - inventory *inventory.Service, - shipping *shipping.Service, - identity *identity.Service, - analytics *analytics.Service, - notify *notification.Service, - search *searchx.Service, - storage *storage.Service, - media *media.Service, - workflow *workflow.Service, - ) *Application { - return &Application{ - Logger: logger, - Billing: billing, - Inventory: inventory, - Shipping: shipping, - Identity: identity, - Analytics: analytics, - Notify: notify, - Search: search, - Storage: storage, - Media: media, - Workflow: workflow, - Plugins: pluginNames, - } - }) - - // 预创建对象:执行插件 provider,让 objects 视图有内容 - for _, activate := range activators { - activate() - } - - // 模拟慢依赖:刻意超过 ProviderTimeout(用于可视化排查) - dix.Provide(di, func(logger Logger) *SlowRemoteClient { - logger.Info("[demo] SlowRemoteClient start (expected timeout)") - time.Sleep(450 * time.Millisecond) - return &SlowRemoteClient{Ready: true} - }) - dix.Provide(di, func(client *SlowRemoteClient) *TimeoutProbe { - return &TimeoutProbe{Client: client} - }) - - // 错误场景 provider(诊断演示) - dix.Provide(di, func(logger Logger) (*StartupBrokenComponent, error) { - return nil, errors.New("demo startup: provider return error") - }) - dix.Provide(di, func(logger Logger) *StartupPanicComponent { - panic("demo startup: provider panic") - }) - - return di -} - -// preCreateObjects 通过函数注入触发核心对象创建, -// 让 dixhttp 的 objects 视图在启动后即有内容可展示。 -func preCreateObjects(di *dix.Dix) { - if err := di.TryInject(func(app *Application, logger Logger) { - log.Printf("✅ Application created: modules=10 plugins=%d", len(app.Plugins)) - }); err != nil { - log.Printf("⚠️ pre-create injection failed: %v", err) - } + return bootstrap.Build() } func main() { - di := buildContainer() - preCreateObjects(di) - - // 启动阶段触发多类可识别错误,验证 error_type/hint 识别 - runStartupErrorScenarios(di) - - log.Println("") - - server := dixhttp.NewServer(di) - if err := startVisualizationServer(server); err != nil && err != http.ErrServerClosed { + if err := bootstrap.Run(); err != nil { log.Fatal("Server error:", err) } } diff --git a/example/http/main_test.go b/example/http/main_test.go index 2d5b236..48f7e6c 100644 --- a/example/http/main_test.go +++ b/example/http/main_test.go @@ -1,46 +1,48 @@ package main -import "testing" +import ( + "testing" -// 锁定 example/http 的端到端装配契约:十个域模块 + 插件族 + 聚合根, -// 全链路(Config → Client → Repo → Service → Handler)可解析、已实例化。 + "github.com/pubgo/dix/example/http/app" +) + +// 锁定 example/http 的端到端装配契约:十个域模块 + 插件族 + 聚合根。 func TestBuildContainerWiresApplication(t *testing.T) { di := buildContainer() - var app *Application - if err := di.TryInject(func(a *Application) { app = a }); err != nil { + var application *app.Application + if err := di.TryInject(func(a *app.Application) { application = a }); err != nil { t.Fatalf("TryInject(Application): %v", err) } - if app.Logger == nil { + if application.Logger == nil { t.Fatal("logger not wired") } services := map[string]any{ - "billing": app.Billing, - "inventory": app.Inventory, - "shipping": app.Shipping, - "identity": app.Identity, - "analytics": app.Analytics, - "notify": app.Notify, - "search": app.Search, - "storage": app.Storage, - "media": app.Media, - "workflow": app.Workflow, + "billing": application.Billing, + "inventory": application.Inventory, + "shipping": application.Shipping, + "identity": application.Identity, + "analytics": application.Analytics, + "notify": application.Notify, + "search": application.Search, + "storage": application.Storage, + "media": application.Media, + "workflow": application.Workflow, } for name, svc := range services { if svc == nil { t.Fatalf("domain service %s not wired", name) } } - if len(app.Plugins) != 120 { - t.Fatalf("plugins = %d, want 120", len(app.Plugins)) + if len(application.Plugins) != 40 { + t.Fatalf("plugins = %d, want 40 (20 keys + 20 worker names)", len(application.Plugins)) } - // 领域链路抽检:计费服务 → 仓储 → 客户端 → 配置 - if app.Billing.Repo == nil || app.Billing.Repo.Client == nil || app.Billing.Repo.Client.Config == nil { - t.Fatalf("billing chain not resolved: %+v", app.Billing) + if application.Billing.Repo == nil || application.Billing.Repo.Client == nil || application.Billing.Repo.Client.Config == nil { + t.Fatalf("billing chain not resolved: %+v", application.Billing) } - if app.Billing.Repo.Client.Config.Env != "prod" { - t.Fatalf("config value = %+v", app.Billing.Repo.Client.Config) + if application.Billing.Repo.Client.Config.Env != "prod" { + t.Fatalf("config value = %+v", application.Billing.Repo.Client.Config) } } diff --git a/example/http/plugins.go b/example/http/plugins.go deleted file mode 100644 index 8f23f60..0000000 --- a/example/http/plugins.go +++ /dev/null @@ -1,233 +0,0 @@ -package main - -// 本文件为批量合成的"插件/工作器"依赖族:用泛型实例化在编译期产出 -// 大量相互独立的依赖类型,把演示容器撑到真实项目规模。 - -import "github.com/pubgo/dix/v2" - -// Plugin 是第一族合成依赖的载体。 -type Plugin[T any] struct { - Name string - Version string -} - -// Worker 是第二族合成依赖的载体,依赖同角色的 Plugin(形成链路)。 -type Worker[T any] struct { - Name string - Batch int -} - -type ( - RoleAuthReader struct{} - RoleAuthWriter struct{} - RoleAuthValidator struct{} - RoleBillingReader struct{} - RoleBillingWriter struct{} - RoleBillingValidator struct{} - RoleCacheReader struct{} - RoleCacheWriter struct{} - RoleCacheValidator struct{} - RoleEmailReader struct{} - RoleEmailWriter struct{} - RoleEmailValidator struct{} - RoleExportReader struct{} - RoleExportWriter struct{} - RoleExportValidator struct{} - RoleGraphqlReader struct{} - RoleGraphqlWriter struct{} - RoleGraphqlValidator struct{} - RoleImportReader struct{} - RoleImportWriter struct{} - RoleImportValidator struct{} - RoleJobReader struct{} - RoleJobWriter struct{} - RoleJobValidator struct{} - RoleKafkaReader struct{} - RoleKafkaWriter struct{} - RoleKafkaValidator struct{} - RoleLoginReader struct{} - RoleLoginWriter struct{} - RoleLoginValidator struct{} - RoleMetricsReader struct{} - RoleMetricsWriter struct{} - RoleMetricsValidator struct{} - RoleNotifyReader struct{} - RoleNotifyWriter struct{} - RoleNotifyValidator struct{} - RoleOauthReader struct{} - RoleOauthWriter struct{} - RoleOauthValidator struct{} - RoleQueueReader struct{} - RoleQueueWriter struct{} - RoleQueueValidator struct{} - RoleReportReader struct{} - RoleReportWriter struct{} - RoleReportValidator struct{} - RoleSearchReader struct{} - RoleSearchWriter struct{} - RoleSearchValidator struct{} - RoleSessionReader struct{} - RoleSessionWriter struct{} - RoleSessionValidator struct{} - RoleTenantReader struct{} - RoleTenantWriter struct{} - RoleTenantValidator struct{} - RoleUploadReader struct{} - RoleUploadWriter struct{} - RoleUploadValidator struct{} - RoleVaultReader struct{} - RoleVaultWriter struct{} - RoleVaultValidator struct{} -) - -var pluginNames []string - -// registerPlugin 注册一个角色插件 provider,返回用于预创建对象的激活函数。 -func registerPlugin[T any](di *dix.Dix, name string) func() { - dix.Provide(di, func() *Plugin[T] { - pluginNames = append(pluginNames, name) - return &Plugin[T]{Name: name, Version: "1.0.0"} - }) - return func() { _ = dix.Inject(di, func(p *Plugin[T]) {}) } -} - -// registerWorker 注册依赖同角色插件的工作器 provider。 -func registerWorker[T any](di *dix.Dix, name string) func() { - dix.Provide(di, func(p *Plugin[T]) *Worker[T] { - pluginNames = append(pluginNames, name+".worker") - return &Worker[T]{Name: name, Batch: 8} - }) - return func() { _ = dix.Inject(di, func(w *Worker[T]) {}) } -} - -// registerPlugins 批量注册角色插件与工作器,返回全部激活函数。 -func registerPlugins(di *dix.Dix) (activators []func()) { - for _, r := range []struct { - name string - reg func(*dix.Dix, string) func() - }{ - {"authreader", registerPlugin[RoleAuthReader]}, - {"authreader.worker", registerWorker[RoleAuthReader]}, - {"authwriter", registerPlugin[RoleAuthWriter]}, - {"authwriter.worker", registerWorker[RoleAuthWriter]}, - {"authvalidator", registerPlugin[RoleAuthValidator]}, - {"authvalidator.worker", registerWorker[RoleAuthValidator]}, - {"billingreader", registerPlugin[RoleBillingReader]}, - {"billingreader.worker", registerWorker[RoleBillingReader]}, - {"billingwriter", registerPlugin[RoleBillingWriter]}, - {"billingwriter.worker", registerWorker[RoleBillingWriter]}, - {"billingvalidator", registerPlugin[RoleBillingValidator]}, - {"billingvalidator.worker", registerWorker[RoleBillingValidator]}, - {"cachereader", registerPlugin[RoleCacheReader]}, - {"cachereader.worker", registerWorker[RoleCacheReader]}, - {"cachewriter", registerPlugin[RoleCacheWriter]}, - {"cachewriter.worker", registerWorker[RoleCacheWriter]}, - {"cachevalidator", registerPlugin[RoleCacheValidator]}, - {"cachevalidator.worker", registerWorker[RoleCacheValidator]}, - {"emailreader", registerPlugin[RoleEmailReader]}, - {"emailreader.worker", registerWorker[RoleEmailReader]}, - {"emailwriter", registerPlugin[RoleEmailWriter]}, - {"emailwriter.worker", registerWorker[RoleEmailWriter]}, - {"emailvalidator", registerPlugin[RoleEmailValidator]}, - {"emailvalidator.worker", registerWorker[RoleEmailValidator]}, - {"exportreader", registerPlugin[RoleExportReader]}, - {"exportreader.worker", registerWorker[RoleExportReader]}, - {"exportwriter", registerPlugin[RoleExportWriter]}, - {"exportwriter.worker", registerWorker[RoleExportWriter]}, - {"exportvalidator", registerPlugin[RoleExportValidator]}, - {"exportvalidator.worker", registerWorker[RoleExportValidator]}, - {"graphqlreader", registerPlugin[RoleGraphqlReader]}, - {"graphqlreader.worker", registerWorker[RoleGraphqlReader]}, - {"graphqlwriter", registerPlugin[RoleGraphqlWriter]}, - {"graphqlwriter.worker", registerWorker[RoleGraphqlWriter]}, - {"graphqlvalidator", registerPlugin[RoleGraphqlValidator]}, - {"graphqlvalidator.worker", registerWorker[RoleGraphqlValidator]}, - {"importreader", registerPlugin[RoleImportReader]}, - {"importreader.worker", registerWorker[RoleImportReader]}, - {"importwriter", registerPlugin[RoleImportWriter]}, - {"importwriter.worker", registerWorker[RoleImportWriter]}, - {"importvalidator", registerPlugin[RoleImportValidator]}, - {"importvalidator.worker", registerWorker[RoleImportValidator]}, - {"jobreader", registerPlugin[RoleJobReader]}, - {"jobreader.worker", registerWorker[RoleJobReader]}, - {"jobwriter", registerPlugin[RoleJobWriter]}, - {"jobwriter.worker", registerWorker[RoleJobWriter]}, - {"jobvalidator", registerPlugin[RoleJobValidator]}, - {"jobvalidator.worker", registerWorker[RoleJobValidator]}, - {"kafkareader", registerPlugin[RoleKafkaReader]}, - {"kafkareader.worker", registerWorker[RoleKafkaReader]}, - {"kafkawriter", registerPlugin[RoleKafkaWriter]}, - {"kafkawriter.worker", registerWorker[RoleKafkaWriter]}, - {"kafkavalidator", registerPlugin[RoleKafkaValidator]}, - {"kafkavalidator.worker", registerWorker[RoleKafkaValidator]}, - {"loginreader", registerPlugin[RoleLoginReader]}, - {"loginreader.worker", registerWorker[RoleLoginReader]}, - {"loginwriter", registerPlugin[RoleLoginWriter]}, - {"loginwriter.worker", registerWorker[RoleLoginWriter]}, - {"loginvalidator", registerPlugin[RoleLoginValidator]}, - {"loginvalidator.worker", registerWorker[RoleLoginValidator]}, - {"metricsreader", registerPlugin[RoleMetricsReader]}, - {"metricsreader.worker", registerWorker[RoleMetricsReader]}, - {"metricswriter", registerPlugin[RoleMetricsWriter]}, - {"metricswriter.worker", registerWorker[RoleMetricsWriter]}, - {"metricsvalidator", registerPlugin[RoleMetricsValidator]}, - {"metricsvalidator.worker", registerWorker[RoleMetricsValidator]}, - {"notifyreader", registerPlugin[RoleNotifyReader]}, - {"notifyreader.worker", registerWorker[RoleNotifyReader]}, - {"notifywriter", registerPlugin[RoleNotifyWriter]}, - {"notifywriter.worker", registerWorker[RoleNotifyWriter]}, - {"notifyvalidator", registerPlugin[RoleNotifyValidator]}, - {"notifyvalidator.worker", registerWorker[RoleNotifyValidator]}, - {"oauthreader", registerPlugin[RoleOauthReader]}, - {"oauthreader.worker", registerWorker[RoleOauthReader]}, - {"oauthwriter", registerPlugin[RoleOauthWriter]}, - {"oauthwriter.worker", registerWorker[RoleOauthWriter]}, - {"oauthvalidator", registerPlugin[RoleOauthValidator]}, - {"oauthvalidator.worker", registerWorker[RoleOauthValidator]}, - {"queuereader", registerPlugin[RoleQueueReader]}, - {"queuereader.worker", registerWorker[RoleQueueReader]}, - {"queuewriter", registerPlugin[RoleQueueWriter]}, - {"queuewriter.worker", registerWorker[RoleQueueWriter]}, - {"queuevalidator", registerPlugin[RoleQueueValidator]}, - {"queuevalidator.worker", registerWorker[RoleQueueValidator]}, - {"reportreader", registerPlugin[RoleReportReader]}, - {"reportreader.worker", registerWorker[RoleReportReader]}, - {"reportwriter", registerPlugin[RoleReportWriter]}, - {"reportwriter.worker", registerWorker[RoleReportWriter]}, - {"reportvalidator", registerPlugin[RoleReportValidator]}, - {"reportvalidator.worker", registerWorker[RoleReportValidator]}, - {"searchreader", registerPlugin[RoleSearchReader]}, - {"searchreader.worker", registerWorker[RoleSearchReader]}, - {"searchwriter", registerPlugin[RoleSearchWriter]}, - {"searchwriter.worker", registerWorker[RoleSearchWriter]}, - {"searchvalidator", registerPlugin[RoleSearchValidator]}, - {"searchvalidator.worker", registerWorker[RoleSearchValidator]}, - {"sessionreader", registerPlugin[RoleSessionReader]}, - {"sessionreader.worker", registerWorker[RoleSessionReader]}, - {"sessionwriter", registerPlugin[RoleSessionWriter]}, - {"sessionwriter.worker", registerWorker[RoleSessionWriter]}, - {"sessionvalidator", registerPlugin[RoleSessionValidator]}, - {"sessionvalidator.worker", registerWorker[RoleSessionValidator]}, - {"tenantreader", registerPlugin[RoleTenantReader]}, - {"tenantreader.worker", registerWorker[RoleTenantReader]}, - {"tenantwriter", registerPlugin[RoleTenantWriter]}, - {"tenantwriter.worker", registerWorker[RoleTenantWriter]}, - {"tenantvalidator", registerPlugin[RoleTenantValidator]}, - {"tenantvalidator.worker", registerWorker[RoleTenantValidator]}, - {"uploadreader", registerPlugin[RoleUploadReader]}, - {"uploadreader.worker", registerWorker[RoleUploadReader]}, - {"uploadwriter", registerPlugin[RoleUploadWriter]}, - {"uploadwriter.worker", registerWorker[RoleUploadWriter]}, - {"uploadvalidator", registerPlugin[RoleUploadValidator]}, - {"uploadvalidator.worker", registerWorker[RoleUploadValidator]}, - {"vaultreader", registerPlugin[RoleVaultReader]}, - {"vaultreader.worker", registerWorker[RoleVaultReader]}, - {"vaultwriter", registerPlugin[RoleVaultWriter]}, - {"vaultwriter.worker", registerWorker[RoleVaultWriter]}, - {"vaultvalidator", registerPlugin[RoleVaultValidator]}, - {"vaultvalidator.worker", registerWorker[RoleVaultValidator]}, - } { - activators = append(activators, r.reg(di, r.name)) - } - return activators -} diff --git a/example/http/plugins/auth/plugin.go b/example/http/plugins/auth/plugin.go new file mode 100644 index 0000000..67c03ab --- /dev/null +++ b/example/http/plugins/auth/plugin.go @@ -0,0 +1,30 @@ +package auth + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "auth": &plugin{name: "auth"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["auth"] + _ = p + return map[string]plugins.Worker{ + "auth": &worker{name: "auth.worker"}, + } + }) +} diff --git a/example/http/plugins/billing/plugin.go b/example/http/plugins/billing/plugin.go new file mode 100644 index 0000000..ee841a0 --- /dev/null +++ b/example/http/plugins/billing/plugin.go @@ -0,0 +1,30 @@ +package billing + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "billing": &plugin{name: "billing"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["billing"] + _ = p + return map[string]plugins.Worker{ + "billing": &worker{name: "billing.worker"}, + } + }) +} diff --git a/example/http/plugins/cache/plugin.go b/example/http/plugins/cache/plugin.go new file mode 100644 index 0000000..5187c27 --- /dev/null +++ b/example/http/plugins/cache/plugin.go @@ -0,0 +1,30 @@ +package cache + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "cache": &plugin{name: "cache"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["cache"] + _ = p + return map[string]plugins.Worker{ + "cache": &worker{name: "cache.worker"}, + } + }) +} diff --git a/example/http/plugins/email/plugin.go b/example/http/plugins/email/plugin.go new file mode 100644 index 0000000..dc79aa9 --- /dev/null +++ b/example/http/plugins/email/plugin.go @@ -0,0 +1,30 @@ +package email + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "email": &plugin{name: "email"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["email"] + _ = p + return map[string]plugins.Worker{ + "email": &worker{name: "email.worker"}, + } + }) +} diff --git a/example/http/plugins/export/plugin.go b/example/http/plugins/export/plugin.go new file mode 100644 index 0000000..b4fb7a3 --- /dev/null +++ b/example/http/plugins/export/plugin.go @@ -0,0 +1,30 @@ +package export + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "export": &plugin{name: "export"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["export"] + _ = p + return map[string]plugins.Worker{ + "export": &worker{name: "export.worker"}, + } + }) +} diff --git a/example/http/plugins/graphql/plugin.go b/example/http/plugins/graphql/plugin.go new file mode 100644 index 0000000..6b2438c --- /dev/null +++ b/example/http/plugins/graphql/plugin.go @@ -0,0 +1,30 @@ +package graphql + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "graphql": &plugin{name: "graphql"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["graphql"] + _ = p + return map[string]plugins.Worker{ + "graphql": &worker{name: "graphql.worker"}, + } + }) +} diff --git a/example/http/plugins/importx/plugin.go b/example/http/plugins/importx/plugin.go new file mode 100644 index 0000000..468d673 --- /dev/null +++ b/example/http/plugins/importx/plugin.go @@ -0,0 +1,30 @@ +package importx + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "import": &plugin{name: "import"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["import"] + _ = p + return map[string]plugins.Worker{ + "import": &worker{name: "import.worker"}, + } + }) +} diff --git a/example/http/plugins/job/plugin.go b/example/http/plugins/job/plugin.go new file mode 100644 index 0000000..32b1446 --- /dev/null +++ b/example/http/plugins/job/plugin.go @@ -0,0 +1,30 @@ +package job + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "job": &plugin{name: "job"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["job"] + _ = p + return map[string]plugins.Worker{ + "job": &worker{name: "job.worker"}, + } + }) +} diff --git a/example/http/plugins/kafka/plugin.go b/example/http/plugins/kafka/plugin.go new file mode 100644 index 0000000..fb7ac59 --- /dev/null +++ b/example/http/plugins/kafka/plugin.go @@ -0,0 +1,30 @@ +package kafka + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "kafka": &plugin{name: "kafka"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["kafka"] + _ = p + return map[string]plugins.Worker{ + "kafka": &worker{name: "kafka.worker"}, + } + }) +} diff --git a/example/http/plugins/login/plugin.go b/example/http/plugins/login/plugin.go new file mode 100644 index 0000000..9d6f92f --- /dev/null +++ b/example/http/plugins/login/plugin.go @@ -0,0 +1,30 @@ +package login + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "login": &plugin{name: "login"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["login"] + _ = p + return map[string]plugins.Worker{ + "login": &worker{name: "login.worker"}, + } + }) +} diff --git a/example/http/plugins/metrics/plugin.go b/example/http/plugins/metrics/plugin.go new file mode 100644 index 0000000..ba1b472 --- /dev/null +++ b/example/http/plugins/metrics/plugin.go @@ -0,0 +1,30 @@ +package metrics + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "metrics": &plugin{name: "metrics"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["metrics"] + _ = p + return map[string]plugins.Worker{ + "metrics": &worker{name: "metrics.worker"}, + } + }) +} diff --git a/example/http/plugins/notify/plugin.go b/example/http/plugins/notify/plugin.go new file mode 100644 index 0000000..32f9668 --- /dev/null +++ b/example/http/plugins/notify/plugin.go @@ -0,0 +1,30 @@ +package notify + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "notify": &plugin{name: "notify"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["notify"] + _ = p + return map[string]plugins.Worker{ + "notify": &worker{name: "notify.worker"}, + } + }) +} diff --git a/example/http/plugins/oauth/plugin.go b/example/http/plugins/oauth/plugin.go new file mode 100644 index 0000000..4b66498 --- /dev/null +++ b/example/http/plugins/oauth/plugin.go @@ -0,0 +1,30 @@ +package oauth + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "oauth": &plugin{name: "oauth"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["oauth"] + _ = p + return map[string]plugins.Worker{ + "oauth": &worker{name: "oauth.worker"}, + } + }) +} diff --git a/example/http/plugins/plugin.go b/example/http/plugins/plugin.go new file mode 100644 index 0000000..ba12f66 --- /dev/null +++ b/example/http/plugins/plugin.go @@ -0,0 +1,35 @@ +package plugins + +import "github.com/pubgo/dix/v2" + +// Plugin 插件契约:实现方注册到 map[string]Plugin 命名空间。 +type Plugin interface { + Name() string +} + +// Worker 工作器契约:依赖同名 Plugin 命名空间,对外仍只暴露接口。 +type Worker interface { + Name() string +} + +// Platform 聚合全部 Worker,供 Application 消费(避免多入口)。 +type Platform struct { + WorkerCount int + Names []string +} + +func Provide(di *dix.Dix) { + dix.Provide(di, func(workers map[string]Worker) *Platform { + names := make([]string, 0, len(workers)*2) + for key, w := range workers { + names = append(names, key) + if w != nil { + names = append(names, w.Name()) + } + } + return &Platform{ + WorkerCount: len(workers), + Names: names, + } + }) +} diff --git a/example/http/plugins/queue/plugin.go b/example/http/plugins/queue/plugin.go new file mode 100644 index 0000000..61acb43 --- /dev/null +++ b/example/http/plugins/queue/plugin.go @@ -0,0 +1,30 @@ +package queue + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "queue": &plugin{name: "queue"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["queue"] + _ = p + return map[string]plugins.Worker{ + "queue": &worker{name: "queue.worker"}, + } + }) +} diff --git a/example/http/plugins/report/plugin.go b/example/http/plugins/report/plugin.go new file mode 100644 index 0000000..0b1aaab --- /dev/null +++ b/example/http/plugins/report/plugin.go @@ -0,0 +1,30 @@ +package report + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "report": &plugin{name: "report"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["report"] + _ = p + return map[string]plugins.Worker{ + "report": &worker{name: "report.worker"}, + } + }) +} diff --git a/example/http/plugins/search/plugin.go b/example/http/plugins/search/plugin.go new file mode 100644 index 0000000..7ee24fb --- /dev/null +++ b/example/http/plugins/search/plugin.go @@ -0,0 +1,30 @@ +package search + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "search": &plugin{name: "search"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["search"] + _ = p + return map[string]plugins.Worker{ + "search": &worker{name: "search.worker"}, + } + }) +} diff --git a/example/http/plugins/session/plugin.go b/example/http/plugins/session/plugin.go new file mode 100644 index 0000000..3aa241a --- /dev/null +++ b/example/http/plugins/session/plugin.go @@ -0,0 +1,30 @@ +package session + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "session": &plugin{name: "session"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["session"] + _ = p + return map[string]plugins.Worker{ + "session": &worker{name: "session.worker"}, + } + }) +} diff --git a/example/http/plugins/tenant/plugin.go b/example/http/plugins/tenant/plugin.go new file mode 100644 index 0000000..5a6ab51 --- /dev/null +++ b/example/http/plugins/tenant/plugin.go @@ -0,0 +1,30 @@ +package tenant + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "tenant": &plugin{name: "tenant"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["tenant"] + _ = p + return map[string]plugins.Worker{ + "tenant": &worker{name: "tenant.worker"}, + } + }) +} diff --git a/example/http/plugins/upload/plugin.go b/example/http/plugins/upload/plugin.go new file mode 100644 index 0000000..6e1a69c --- /dev/null +++ b/example/http/plugins/upload/plugin.go @@ -0,0 +1,30 @@ +package upload + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "upload": &plugin{name: "upload"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["upload"] + _ = p + return map[string]plugins.Worker{ + "upload": &worker{name: "upload.worker"}, + } + }) +} diff --git a/example/http/plugins/vault/plugin.go b/example/http/plugins/vault/plugin.go new file mode 100644 index 0000000..8bb2e84 --- /dev/null +++ b/example/http/plugins/vault/plugin.go @@ -0,0 +1,30 @@ +package vault + +import ( + "github.com/pubgo/dix/v2" + + "github.com/pubgo/dix/example/http/plugins" +) + +type plugin struct{ name string } + +func (p *plugin) Name() string { return p.name } + +type worker struct{ name string } + +func (w *worker) Name() string { return w.name } + +func Provide(di *dix.Dix) { + dix.Provide(di, func() map[string]plugins.Plugin { + return map[string]plugins.Plugin{ + "vault": &plugin{name: "vault"}, + } + }) + dix.Provide(di, func(all map[string]plugins.Plugin) map[string]plugins.Worker { + p := all["vault"] + _ = p + return map[string]plugins.Worker{ + "vault": &worker{name: "vault.worker"}, + } + }) +} diff --git a/example/http/router/server.go b/example/http/router/server.go new file mode 100644 index 0000000..0c3b929 --- /dev/null +++ b/example/http/router/server.go @@ -0,0 +1,52 @@ +package router + +import ( + "log" + "net" + "net/http" + "os" + + "github.com/pubgo/dix/v2/dixhttp" +) + +const defaultHTTPAddr = ":8080" + +// StartVisualizationServer 启动 dixhttp 可视化服务。 +func StartVisualizationServer(server *dixhttp.Server) error { + addr := os.Getenv("DIX_HTTP_ADDR") + if addr == "" { + addr = defaultHTTPAddr + } + + ln, err := net.Listen("tcp", addr) + if err != nil { + if addr == defaultHTTPAddr { + log.Printf("⚠️ Port %s unavailable (%v), trying a random available port...", addr, err) + ln, err = net.Listen("tcp", ":0") + } + if err != nil { + return err + } + } + + actualAddr := ln.Addr().String() + displayAddr := actualAddr + if _, port, splitErr := net.SplitHostPort(actualAddr); splitErr == nil && port != "" { + displayAddr = "localhost:" + port + } + + log.Printf("🚀 Starting HTTP server on http://%s", displayAddr) + log.Printf("📊 Open http://%s in your browser (legacy DI architecture UI)", displayAddr) + log.Println("📡 API endpoints:") + log.Println(" - GET /api/dependencies - JSON data of dependencies") + log.Println(" - GET /api/modules - module-level aggregation") + log.Println(" - GET /api/ego - neighborhood subgraph") + log.Println(" - GET /api/search - server-side graph search") + log.Println(" - GET /api/stats - overview statistics") + log.Println(" - GET /api/runtime-stats - provider startup timings") + log.Println(" - GET /api/errors - recent inject errors") + log.Println(" - GET /api/diagnostics - DIX_DIAG_FILE records") + log.Println(" - GET /api/trace - dixtrace event query") + log.Println(" - GET /api/trace-tree - nested call tree per trace") + return (&http.Server{Handler: server}).Serve(ln) +} diff --git a/example/http/scale_shape_test.go b/example/http/scale_shape_test.go new file mode 100644 index 0000000..bdb0c84 --- /dev/null +++ b/example/http/scale_shape_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/pubgo/dix/v2/dixhttp" + + "github.com/pubgo/dix/example/http/app" +) + +func TestDemoContainerShape(t *testing.T) { + container := buildContainer() + if err := container.TryInject(func(*app.Application) {}); err != nil { + t.Fatal(err) + } + modules := container.ModuleGraph() + providers := container.GetProviderDetails() + objects := container.GetObjects() + + objectCount := 0 + for _, groups := range objects { + for _, values := range groups { + objectCount += len(values) + } + } + if len(modules) < 10 { + t.Fatalf("modules = %d, want at least 10", len(modules)) + } + // 真实分层后规模来自 domain + plugins,不再依赖人造 ScaleFixture。 + if len(providers) < 90 { + t.Fatalf("providers = %d, want at least 90", len(providers)) + } + if objectCount < 80 { + t.Fatalf("objects = %d, want at least 80", objectCount) + } +} + +func TestPyramidHasTwoBusinessEntries(t *testing.T) { + container := buildContainer() + server := dixhttp.NewServer(container) + rec := httptest.NewRecorder() + server.ServeHTTP(rec, httptest.NewRequest("GET", "/api/dependencies", nil)) + if rec.Code != 200 { + t.Fatalf("status = %d", rec.Code) + } + var payload struct { + Providers []struct { + ID string `json:"id"` + OutputType string `json:"output_type"` + InputTypes []string `json:"input_types"` + } `json:"providers"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + + typeProducers := map[string][]string{} + for _, p := range payload.Providers { + out := p.OutputType + if out == "" { + continue + } + typeProducers[out] = append(typeProducers[out], p.ID) + } + consumed := map[string]bool{} + for _, p := range payload.Providers { + for _, in := range p.InputTypes { + for _, producerID := range typeProducers[in] { + if producerID != p.ID { + consumed[producerID] = true + } + } + } + } + + var entries []string + for _, p := range payload.Providers { + if consumed[p.ID] { + continue + } + if strings.Contains(p.OutputType, "dixinternal") { + continue + } + entries = append(entries, p.OutputType) + } + if len(entries) != 2 { + t.Fatalf("business pyramid entries = %d (%v), want exactly 2 (Application, TimeoutProbe)", len(entries), entries) + } + want := map[string]bool{ + "Application": false, + "TimeoutProbe": false, + } + for _, e := range entries { + switch { + case strings.Contains(e, "Application"): + want["Application"] = true + case strings.Contains(e, "TimeoutProbe"): + want["TimeoutProbe"] = true + } + } + for name, ok := range want { + if !ok { + t.Fatalf("missing expected entry kind %s in %v", name, entries) + } + } +}