Skip to content

feat: schema ER diagram#30

Merged
mickamy merged 4 commits into
mainfrom
feat/ui-erd
Jun 30, 2026
Merged

feat: schema ER diagram#30
mickamy merged 4 commits into
mainfrom
feat/ui-erd

Conversation

@mickamy

@mickamy mickamy commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the final roadmap item, Schema viewer ER diagram. A new /schema page 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

Surface Change
internal/ui/erd.go (new) Turns the introspected schema into a laid-out diagram. Nodes are tables (showing PK and single-column FK columns); edges are foreign keys, clipped to box borders with the arrowhead at the referenced end. Layout is a deterministic Fruchterman-Reingold force simulation — golden-angle spiral init, fixed iteration count, no RNG — so the same schema always produces the same picture.
internal/ui/handlers.go layoutData.ERD plus a schemaDiagram handler that builds the view from s.schema.Tables.
internal/ui/ui.go GET /schema route; registers add / sub / mul template funcs for SVG coordinate math.
internal/ui/templates/content_erd.html (new) Renders an inline <svg> from the server-computed layout (theme-aware fill-*/stroke-* with dark: 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.html Dispatches the new content template and adds a sidebar "⊞ Schema diagram" link.
internal/ui/static/css/tailwind.css Regenerated via make ui-css for the new fill-*/stroke-* utilities.
internal/ui/ui_test.go Tests for node/edge/marker rendering, layout determinism (two identical renders match), the empty state, and the sidebar link.
README.md Ticks the last roadmap item and updates the status line — the UI-polish roadmap is now complete.

Design

  • Server-side layout → inline SVG: fits the existing SSR + string-assertion test pattern, satisfies the external-library constraint, and is deterministic (which the tests rely on).
  • Nodes show only key columns (PK + single-column FK), keeping boxes compact while conveying the relationships an ER diagram is about.
  • Interactivity is additive: pan/zoom only transforms the viewport <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-drift will not fail.
  • Verified in a real browser (headless Chrome via Puppeteer): the diagram renders correctly in light and dark, the zoom controls and node-click navigation work, and a pan gesture does not trigger navigation.

@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.15152% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.80%. Comparing base (2bded9b) to head (0daaa73).

Files with missing lines Patch % Lines
internal/ui/erd.go 94.80% 4 Missing and 4 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/ui/erd.go Outdated
Comment on lines +86 to +87
sorted := make([]schema.Table, len(tables))
copy(sorted, tables)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
sorted := make([]schema.Table, len(tables))
copy(sorted, tables)
sorted := slices.Clone(tables)
References
  1. Prefer using standard library slices helpers (such as slices.Clone and slices.Contains) instead of manual slice operations (like append([]T(nil), ...) or manual loops) to write more idiomatic and clean Go code.

Comment on lines +101 to +103
<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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
<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
  1. Use text-zinc-600 for body and label text in light mode (on white or bg-zinc-50 backgrounds) as it provides sufficient contrast (~7.4:1) to meet WCAG AA guidelines, avoiding unnecessary over-darkening to text-zinc-700.

Comment on lines +90 to +93
viewport.addEventListener('mousedown', (e) => {
dragging = true; moved = false; sx = e.clientX - tx; sy = e.clientY - ty;
viewport.classList.add('cursor-grabbing');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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');
});

Comment thread internal/ui/erd.go Outdated
Comment on lines +134 to +139
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0daaa73 — sizeNode now uses utf8.RuneCountInString so multi-byte table/column names are not over-measured.

@mickamy

mickamy commented Jun 30, 2026

Copy link
Copy Markdown
Owner Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +31 to +34
{{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}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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}}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 .

Comment thread internal/ui/erd.go Outdated
Comment on lines +86 to +87
sorted := make([]schema.Table, len(tables))
copy(sorted, tables)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
sorted := make([]schema.Table, len(tables))
copy(sorted, tables)
sorted := slices.Clone(tables)
References
  1. Prefer using standard library slices helpers (such as slices.Clone and slices.Contains) instead of manual slice operations to write more idiomatic and clean Go code.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0daaa73 — used slices.Clone(tables).

Comment on lines +90 to +93
viewport.addEventListener('mousedown', (e) => {
dragging = true; moved = false; sx = e.clientX - tx; sy = e.clientY - ty;
viewport.classList.add('cursor-grabbing');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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');
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0daaa73 — restricted the drag to the left button (e.button !== 0 returns).

Comment on lines +45 to +46
{{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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
{{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
  1. Use text-zinc-600 for body and label text in light mode (on white or bg-zinc-50 backgrounds) as it provides sufficient contrast (~7.4:1) to meet WCAG AA guidelines, avoiding unnecessary over-darkening to text-zinc-700.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/ui/handlers.go
Comment on lines +198 to +204
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),
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mickamy

mickamy commented Jun 30, 2026

Copy link
Copy Markdown
Owner Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
<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
  1. Use text-zinc-600 (or fill-zinc-600 for SVG text) for body and label text in light mode on white or bg-zinc-50 backgrounds to provide sufficient contrast (~7.4:1) to meet WCAG AA guidelines, avoiding unnecessary over-darkening to text-zinc-700.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +101 to +103
<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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
<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
  1. Use text-zinc-600 for body and label text in light mode on white or bg-zinc-50 backgrounds to provide sufficient contrast (~7.4:1) to meet WCAG AA guidelines, avoiding unnecessary over-darkening to text-zinc-700

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mickamy mickamy merged commit 67d91db into main Jun 30, 2026
13 checks passed
@mickamy mickamy deleted the feat/ui-erd branch June 30, 2026 23:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant