feat: schema ER diagram#30
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #30 +/- ##
==========================================
+ Coverage 87.30% 87.80% +0.49%
==========================================
Files 37 38 +1
Lines 2418 2582 +164
==========================================
+ Hits 2111 2267 +156
- Misses 205 209 +4
- Partials 102 106 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request introduces a deterministic schema ERD (Entity Relationship Diagram) viewer, implementing force-directed layout simulation in Go and rendering an interactive SVG diagram with zoom and pan controls in the UI. The feedback suggests using standard library slices.Clone for cleaner slice copying, utilizing utf8.RuneCountInString to accurately size nodes with multi-byte characters, restricting the viewport drag listener to the primary mouse button, and adjusting light-mode text contrast to text-zinc-600 for WCAG AA compliance.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| sorted := make([]schema.Table, len(tables)) | ||
| copy(sorted, tables) |
There was a problem hiding this comment.
Following the project's general rules, prefer using standard library slices helpers like slices.Clone instead of manual slice allocation and copying. This makes the code more idiomatic and clean.
Note: You will need to import the "slices" package in this file.
| sorted := make([]schema.Table, len(tables)) | |
| copy(sorted, tables) | |
| sorted := slices.Clone(tables) |
References
- Prefer using standard library
sliceshelpers (such asslices.Cloneandslices.Contains) instead of manual slice operations (likeappend([]T(nil), ...)or manual loops) to write more idiomatic and clean Go code.
| <a href="/schema" | ||
| class="block px-2 py-1 mb-3 rounded text-sm {{if eq .ContentTmpl "content_erd.html"}}bg-zinc-200 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50{{else}}text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-800{{end}}" | ||
| {{if eq .ContentTmpl "content_erd.html"}}aria-current="page"{{end}}>⊞ Schema diagram</a> |
There was a problem hiding this comment.
Following the project's general rules, use text-zinc-600 instead of text-zinc-700 for body and label text in light mode on bg-zinc-50 backgrounds to ensure proper contrast and WCAG AA compliance.
| <a href="/schema" | |
| class="block px-2 py-1 mb-3 rounded text-sm {{if eq .ContentTmpl "content_erd.html"}}bg-zinc-200 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50{{else}}text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-800{{end}}" | |
| {{if eq .ContentTmpl "content_erd.html"}}aria-current="page"{{end}}>⊞ Schema diagram</a> | |
| <a href="/schema" | |
| class="block px-2 py-1 mb-3 rounded text-sm {{if eq .ContentTmpl "content_erd.html"}}bg-zinc-200 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50{{else}}text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-800{{end}}" | |
| {{if eq .ContentTmpl "content_erd.html"}}aria-current="page"{{end}}>⊞ Schema diagram</a> |
References
- Use
text-zinc-600for body and label text in light mode (on white orbg-zinc-50backgrounds) as it provides sufficient contrast (~7.4:1) to meet WCAG AA guidelines, avoiding unnecessary over-darkening totext-zinc-700.
| viewport.addEventListener('mousedown', (e) => { | ||
| dragging = true; moved = false; sx = e.clientX - tx; sy = e.clientY - ty; | ||
| viewport.classList.add('cursor-grabbing'); | ||
| }); |
There was a problem hiding this comment.
The current mousedown listener triggers the drag/pan behavior on any mouse click, including right-clicks (which open context menus) or middle-clicks. Restricting this to the primary (left) mouse button (e.button === 0) prevents unexpected panning when users perform other mouse actions.
| viewport.addEventListener('mousedown', (e) => { | |
| dragging = true; moved = false; sx = e.clientX - tx; sy = e.clientY - ty; | |
| viewport.classList.add('cursor-grabbing'); | |
| }); | |
| viewport.addEventListener('mousedown', (e) => { | |
| if (e.button !== 0) return; | |
| dragging = true; moved = false; sx = e.clientX - tx; sy = e.clientY - ty; | |
| viewport.classList.add('cursor-grabbing'); | |
| }); |
| widest := float64(len(n.Name)) | ||
| for _, c := range n.Columns { | ||
| // +3 leaves room for the "PK"/"FK" marker the template appends. | ||
| if w := float64(len(c.Name) + 3); w > widest { | ||
| widest = w | ||
| } |
There was a problem hiding this comment.
Using len() on strings returns the number of bytes rather than the character/rune count. For table or column names containing multi-byte UTF-8 characters, this can result in over-estimating the visual width of the node. Using utf8.RuneCountInString ensures correct sizing for non-ASCII names.
Note: You will need to import the "unicode/utf8" package in this file.
| widest := float64(len(n.Name)) | |
| for _, c := range n.Columns { | |
| // +3 leaves room for the "PK"/"FK" marker the template appends. | |
| if w := float64(len(c.Name) + 3); w > widest { | |
| widest = w | |
| } | |
| widest := float64(utf8.RuneCountInString(n.Name)) | |
| for _, c := range n.Columns { | |
| // +3 leaves room for the "PK"/"FK" marker the template appends. | |
| if w := float64(utf8.RuneCountInString(c.Name) + 3); w > widest { | |
| widest = w | |
| } |
There was a problem hiding this comment.
Done in 0daaa73 — sizeNode now uses utf8.RuneCountInString so multi-byte table/column names are not over-measured.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces an interactive, deterministic Entity-Relationship Diagram (ERD) to the admin UI, laying out tables and foreign-key relationships using a Fruchterman-Reingold force simulation. Key feedback includes rendering self-referencing edges as curved SVG paths so they are visible, restricting diagram dragging to the left mouse button, using slices.Clone for idiomatic slice copying, adjusting the light-mode label color to fill-zinc-600 for style compliance, and pre-computing the ERD layout during server initialization to avoid redundant calculations on every request.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| {{range .Edges}} | ||
| <line x1="{{.X1}}" y1="{{.Y1}}" x2="{{.X2}}" y2="{{.Y2}}" | ||
| class="stroke-zinc-400 dark:stroke-zinc-600" stroke-width="1.5" marker-end="url(#erd-arrow)"></line> | ||
| {{end}} |
There was a problem hiding this comment.
The Go code in erd.go flags self-referencing edges with Self: true and sets their coordinates to the right border of the box. However, the template renders all edges as a straight <line>. This causes self-loops to be drawn as a straight vertical line directly on top of the box's right border, making them invisible or look like a rendering glitch.
Using an SVG <path> with an arc (A) command for self-edges solves this beautifully without requiring complex template math.
{{range .Edges}}
{{if .Self}}
<path d="M {{.X1}} {{.Y1}} A 20 20 0 0 1 {{.X2}} {{.Y2}}" fill="none"
class="stroke-zinc-400 dark:stroke-zinc-600" stroke-width="1.5" marker-end="url(#erd-arrow)"></path>
{{else}}
<line x1="{{.X1}}" y1="{{.Y1}}" x2="{{.X2}}" y2="{{.Y2}}"
class="stroke-zinc-400 dark:stroke-zinc-600" stroke-width="1.5" marker-end="url(#erd-arrow)"></line>
{{end}}
{{end}}There was a problem hiding this comment.
Good catch — fixed in 0daaa73. Self-referencing FKs now render as an arc (the straight line sat on the box border and was invisible). Added a test that a self-only schema draws a path and no .
| sorted := make([]schema.Table, len(tables)) | ||
| copy(sorted, tables) |
There was a problem hiding this comment.
According to the general rules, we should prefer standard library slices helpers instead of manual slice operations. We can use slices.Clone to duplicate the tables slice more cleanly and idiomatically. Note that you will need to import the "slices" package.
| sorted := make([]schema.Table, len(tables)) | |
| copy(sorted, tables) | |
| sorted := slices.Clone(tables) |
References
- Prefer using standard library
sliceshelpers (such asslices.Cloneandslices.Contains) instead of manual slice operations to write more idiomatic and clean Go code.
| viewport.addEventListener('mousedown', (e) => { | ||
| dragging = true; moved = false; sx = e.clientX - tx; sy = e.clientY - ty; | ||
| viewport.classList.add('cursor-grabbing'); | ||
| }); |
There was a problem hiding this comment.
The mousedown event listener triggers dragging for any mouse button click (including right-click and middle-click). This can cause the diagram to pan unexpectedly when a user right-clicks to open a context menu. Restricting the drag start to the left mouse button (e.button === 0) improves the user experience.
| viewport.addEventListener('mousedown', (e) => { | |
| dragging = true; moved = false; sx = e.clientX - tx; sy = e.clientY - ty; | |
| viewport.classList.add('cursor-grabbing'); | |
| }); | |
| viewport.addEventListener('mousedown', (e) => { | |
| if (e.button !== 0) return; | |
| dragging = true; moved = false; sx = e.clientX - tx; sy = e.clientY - ty; | |
| viewport.classList.add('cursor-grabbing'); | |
| }); |
There was a problem hiding this comment.
Done in 0daaa73 — restricted the drag to the left button (e.button !== 0 returns).
| {{range $i, $col := .Columns}} | ||
| <text x="10" y="{{add 40 (mul $i 18)}}" class="fill-zinc-700 dark:fill-zinc-300" style="font-size:12px">{{$col.Name}}</text> |
There was a problem hiding this comment.
According to the general rules, we should use text-zinc-600 (or fill-zinc-600 for SVG) for body and label text in light mode on white backgrounds to avoid unnecessary over-darkening to zinc-700 while maintaining WCAG AA compliance.
| {{range $i, $col := .Columns}} | |
| <text x="10" y="{{add 40 (mul $i 18)}}" class="fill-zinc-700 dark:fill-zinc-300" style="font-size:12px">{{$col.Name}}</text> | |
| {{range $i, $col := .Columns}} | |
| <text x="10" y="{{add 40 (mul $i 18)}}" class="fill-zinc-600 dark:fill-zinc-300" style="font-size:12px">{{$col.Name}}</text> |
References
- Use
text-zinc-600for body and label text in light mode (on white orbg-zinc-50backgrounds) as it provides sufficient contrast (~7.4:1) to meet WCAG AA guidelines, avoiding unnecessary over-darkening totext-zinc-700.
There was a problem hiding this comment.
Keeping text-zinc-700 here. It's the secondary-text shade used across the UI in light mode (e.g., the adjacent sidebar table links are text-zinc-700 dark:text-zinc-300), so dropping just this link to zinc-600 would make it lighter than its neighbors. Both pass WCAG AA comfortably (zinc-700 on zinc-50 is ~10:1, zinc-600 ~7.4:1); the earlier "prefer zinc-600" note was about not over-darkening muted text, which is a different level than secondary text.
| func (s *Server) schemaDiagram(w http.ResponseWriter, r *http.Request) { | ||
| s.renderLayout(w, r, layoutData{ | ||
| Title: "Schema — adms", | ||
| ContentTmpl: "content_erd.html", | ||
| ERD: buildERD(s.schema.Tables), | ||
| }) | ||
| } |
There was a problem hiding this comment.
The schemaDiagram handler calls buildERD(s.schema.Tables) on every single HTTP request. Since the database schema is static after startup, running a 500-iteration Fruchterman-Reingold force simulation repeatedly is highly inefficient and redundant.
Consider pre-computing the erdView once during server initialization (e.g., in ui.New) and storing it on the Server struct to serve it instantly.
There was a problem hiding this comment.
Done in 0daaa73 — precomputed the layout once in ui.New and stored it on the Server; the handler now serves the cached erdView instead of re-running the simulation per request.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request implements an interactive, deterministic SVG-based Entity Relationship Diagram (ERD) schema viewer for the admin UI. It adds a force-directed layout algorithm in Go, updates templates and Tailwind styles to support dragging and zooming, and integrates the diagram into the sidebar. The review feedback suggests minor adjustments to text colors in light mode (changing zinc-700 to zinc-600 for column names and inactive sidebar links) to ensure WCAG AA contrast compliance.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| <rect y="20" width="{{.W}}" height="6" class="fill-zinc-100 dark:fill-zinc-800"></rect> | ||
| <text x="10" y="17" class="fill-zinc-900 dark:fill-zinc-100" style="font-size:13px;font-weight:600">{{.Name}}</text> | ||
| {{range $i, $col := .Columns}} | ||
| <text x="10" y="{{add 40 (mul $i 18)}}" class="fill-zinc-700 dark:fill-zinc-300" style="font-size:12px">{{$col.Name}}</text> |
There was a problem hiding this comment.
To ensure WCAG AA compliance and maintain proper visual hierarchy, use fill-zinc-600 instead of fill-zinc-700 for the column name text in light mode. This provides sufficient contrast (~7.4:1) on the white background of the table node while avoiding unnecessary over-darkening.
| <text x="10" y="{{add 40 (mul $i 18)}}" class="fill-zinc-700 dark:fill-zinc-300" style="font-size:12px">{{$col.Name}}</text> | |
| <text x="10" y="{{add 40 (mul $i 18)}}" class="fill-zinc-600 dark:fill-zinc-300" style="font-size:12px">{{$col.Name}}</text> |
References
- Use
text-zinc-600(orfill-zinc-600for SVG text) for body and label text in light mode on white orbg-zinc-50backgrounds to provide sufficient contrast (~7.4:1) to meet WCAG AA guidelines, avoiding unnecessary over-darkening totext-zinc-700.
There was a problem hiding this comment.
Same as the earlier thread on this — keeping fill-zinc-700 for the column names. It already meets WCAG AA on white (zinc-700 ≈ 10:1, well above 4.5:1), so this isn't a compliance fix. zinc-700 is the secondary-text level in the node hierarchy (table name zinc-900 > columns zinc-700 > key markers zinc-500); dropping to zinc-600 would narrow that gap for no accessibility gain.
| <a href="/schema" | ||
| class="block px-2 py-1 mb-3 rounded text-sm {{if eq .ContentTmpl "content_erd.html"}}bg-zinc-200 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50{{else}}text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-800{{end}}" | ||
| {{if eq .ContentTmpl "content_erd.html"}}aria-current="page"{{end}}>⊞ Schema diagram</a> |
There was a problem hiding this comment.
To ensure WCAG AA compliance and maintain proper visual hierarchy, use text-zinc-600 instead of text-zinc-700 for the inactive sidebar link in light mode. This provides sufficient contrast (~7.4:1) on the bg-zinc-50 background while avoiding unnecessary over-darkening.
| <a href="/schema" | |
| class="block px-2 py-1 mb-3 rounded text-sm {{if eq .ContentTmpl "content_erd.html"}}bg-zinc-200 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50{{else}}text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-800{{end}}" | |
| {{if eq .ContentTmpl "content_erd.html"}}aria-current="page"{{end}}>⊞ Schema diagram</a> | |
| <a href="/schema" | |
| class="block px-2 py-1 mb-3 rounded text-sm {{if eq .ContentTmpl "content_erd.html"}}bg-zinc-200 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50{{else}}text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-800{{end}}" | |
| {{if eq .ContentTmpl "content_erd.html"}}aria-current="page"{{end}}>⊞ Schema diagram</a> |
References
- Use
text-zinc-600for body and label text in light mode on white orbg-zinc-50backgrounds to provide sufficient contrast (~7.4:1) to meet WCAG AA guidelines, avoiding unnecessary over-darkening totext-zinc-700
There was a problem hiding this comment.
Keeping text-zinc-700 here, as on the earlier review. It passes AA comfortably (~10:1 on bg-zinc-50), and it deliberately matches the adjacent sidebar table links, which are text-zinc-700 dark:text-zinc-300 — changing only this link to zinc-600 would make it lighter than its neighbours. The "prefer zinc-600" guidance was about not over-darkening muted text; secondary text sits one level up at zinc-700 across the UI.
Summary
Implements the final roadmap item, Schema viewer ER diagram. A new
/schemapage renders the whole database as an entity-relationship diagram: one box per table, one arrow per foreign key. Layout and SVG are produced server-side, so it stays within the no-external-JS-library constraint (CSP / Tailwind-only bundle) and is deterministic and testable.What landed
internal/ui/erd.go(new)internal/ui/handlers.golayoutData.ERDplus aschemaDiagramhandler that builds the view froms.schema.Tables.internal/ui/ui.goGET /schemaroute; registersadd/sub/multemplate funcs for SVG coordinate math.internal/ui/templates/content_erd.html(new)<svg>from the server-computed layout (theme-awarefill-*/stroke-*withdark:variants, an arrowhead marker, table nodes as<a>links to their schema page). A small script adds pan (drag), zoom (wheel + buttons + reset), and suppresses node navigation mid-drag. Empty state for a schema with no tables.internal/ui/templates/layout.htmlinternal/ui/static/css/tailwind.cssmake ui-cssfor the newfill-*/stroke-*utilities.internal/ui/ui_test.goREADME.mdDesign
<g>; it does not affect the computed layout, so the diagram is fully usable without JS (nodes are plain links).Test plan
make test -race— all packages green, including the four new ERD tests.make lint— 0 issues.make ui-css— regenerated;ui-css-driftwill not fail.