Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dbui

A React ERD viewer for any database on your machine.

Inspired by Truss, which draws a Laravel app's migrations — dbui drops the framework requirement. It discovers the database servers already running on your machine, lets you pick one, lists its databases, and draws the schema you choose as an interactive crow's-foot diagram. No migrations to parse, no config, no project layout to conform to: point it at anything PostgreSQL or MySQL/MariaDB it can reach.

The sample blog schema drawn by dbui

dbui/
  server/   Express API - discovery, connections, introspection (PostgreSQL + MySQL/MariaDB)
  web/      React + Vite UI - SVG diagram canvas, table index, details panel

Run it

npm install
npm run dev            # API on :4477, UI on http://localhost:5477

npm run dev runs both processes; Ctrl-C stops both. For a single-port build:

npm start              # builds web/ and serves UI + API from http://127.0.0.1:4477

The API binds to 127.0.0.1 only.

Variable Effect
DBUI_PORT API port (default 4477)
DBUI_POOL_MAX connections per database (default 4) - lower it for servers with a tiny max_connections
DBUI_DATABASE_URL, DATABASE_URL, POSTGRES_URL, MYSQL_URL, PG* pre-seed a discovery candidate

Tests (npm test) cover the PostgreSQL catalog queries — against a real Postgres running in-process via PGlite, no server needed — plus type formatting, the scale controls (scope, detail, focus, neighbour cap, bundling, hub folding) and both layout modes.

The flow

  1. Discover. The start screen lists database servers found on this machine, from five independent sources:

    Source How
    docker container docker ps for images that look like a database; the published host port, and the credentials from the container's own env vars (POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB, MYSQL_*). Stopped containers are listed too, marked as not running.
    local process lsof LISTEN sockets owned by a postgres/mysqld process — catches non-standard ports.
    unix socket /tmp/.s.PGSQL.*, /var/run/postgresql/*, Homebrew MySQL socket paths.
    open port TCP probe of the default ports (5432-5435, 6432, 54320, 54322; 3306-3308). Add more in the "Also scan ports" box.
    environment DATABASE_URL, POSTGRES_URL, MYSQL_URL, PG* variables in the API process's environment.

    Anything not found — a remote host, an SSH tunnel — goes through Connect manually.

    The start screen listing a PostgreSQL server found in a Docker container

  2. Pick a database. After connecting, dbui lists every database you can connect to, with owner, size and encoding.

  3. Read the diagram. Tables become cards (type / column / key, exactly like Truss), foreign keys become crow's-foot edges labelled with the constraint name.

    Clicking a card selects it: everything not directly related dims, and the details panel lists the columns, both directions of every foreign key, and the indexes.

    The posts table selected, with the details panel showing its columns, references and indexes

No server handy? Open the sample schema draws a bundled blog schema offline. It is generated from server/test/fixture.sql — the same fixture the tests introspect — by npm run sample --workspace server, so the offline diagram is real introspection output rather than hand-written JSON.

Reading a big schema

A 344-table schema drawn all at once, every column, every wire, is a hairball — it fits at 24% zoom, where no label is legible. Four controls exist to prevent that, and on a large database they are all on by default.

Detail level (top right) decides how much of each table is drawn:

Level Card Layout
overview name only its own tight grid per schema — 116 tables land at ~80% zoom instead of 24%
keys primary, foreign and unique columns full-size layout
full every column (capped, see below) full-size layout
auto follows the zoom between keys and full full-size layout

auto deliberately never selects overview: that level has its own layout size, and a level that follows the zoom and changes the layout feeds back into the zoom that fitting produces. keys and full share one layout — key columns are a subset of all columns — so crossing that threshold never moves a card. Cards always reserve their full-detail footprint, so no detail level can make two of them overlap.

Scope — the schema picker next to the database name. A database with more than 60 tables opens on its largest schema. Foreign keys that leave the scope are not drawn as wires across the canvas; they become chips on the card that owns them (→ persons, ← 36 refs). Clicking a chip focuses that table.

Focus — double-click a card (or a table in the list, or press E) to draw only that table and everything within 1–3 foreign-key hops, ignoring the scope. It is laid out in layers, left to right: the focused table, then each hop beside it in columns sized to the screen. Esc clears it. This is the way to read a schema this size.

Focus on the users table: one hop, four of nine tables drawn, the rest shown as chips

Tables with a hundred relationships

Most real schemas have one — a persons or users table that a hundred others point at. No arrangement of 100 cards is readable, and a ring is just a starburst, so a focus view caps what it draws and aggregates the rest:

  • The cap. Twelve neighbour cards (adjustable under viewneighbours drawn), chosen nearest-hop first, then by how connected they are, then alphabetically.
  • Bundles. Everything withheld becomes a dashed card per schema carrying a count, sample table names and one aggregate wire (11 tables). A schema that brings dozens splits by name prefix instead — audit_*, invoice_* — with the tail kept inside that schema, never a nameless "other". Each withheld table belongs to exactly one bundle, anchored on the drawn table closest to the focus.
  • Growing the view. Clicking a bundle draws its members; the counts and remaining bundles adjust, so the diagram grows only where you asked. reset on the focus chip undoes it. The details panel lists the same relationships grouped by schema and collapsed, with draw on any row or draw all on any group.
  • Bus routing. A table with five or more drawn wires no longer fans them out: they leave the card, join one vertical trunk and branch off horizontally at each target's row, so twelve relationships read as one line with twelve branches.

So a 100-relationship table is 12 cards plus about 6 bundles — and the same rule holds at 1000.

Hubs — in most schemas a handful of tables carry the bulk of the relationships, and they produce most of the spaghetti. Tables at or above the hub threshold (20 relationships, adjustable) are marked with an amber badge, and the hubs toggle folds their wires into chips. The layout still knows about those relations, so folding the wires away never moves a table.

Also on by default above 60 tables: constraint labels off, tables with no relationships hidden, and a cap of 22 rows per card (+ N more opens the rest). The minimap (bottom left) shows where you are.

The diagram

  • Notation — the child (many) end carries the crow's foot; a nullable foreign key gets the "zero or many" circle. The parent (one) end carries the double tick. Hovering an edge shows the constraint, ON DELETE and ON UPDATE.
  • Cards — the badge counts relationships (amber = hub); an amber dot flags a table with no primary key. enum columns are underlined (hover for the values); the chevron collapses a table to its header.
  • Navigate — drag the background to pan, wheel to zoom (cursor-anchored), drag a card to move it, click to select, double-click to focus. Selecting dims everything not directly related. Positions are remembered per database, scope and detail level, so a diagram you have tidied up stays tidy; arrange re-runs the layout and reset saved layout (in view) forgets them.
  • Layout — force-directed per connected component inside each schema (springs along foreign keys, repulsion over a spatial hash, then rectangle separation), schemas shelf-packed with labelled frames. Deterministic: the same schema always draws the same picture.
  • Keys/ search, f fit, e focus the selection, +/- zoom, Esc clear focus then selection.
  • view menu — short type names (varchar_255) vs. the engine's own (character varying(255)), include views, show unrelated tables, minimap, panels, neighbours drawn, hub threshold, row cap, reset layout.
  • Themedark / light in the top bar, remembered across sessions.

The same schema in the dark theme

Credentials

Passwords never reach the browser and are never written to disk. Anything discovery finds (container env, DATABASE_URL) stays in the API process, keyed by candidate id; the UI connects by that id and afterwards holds only an opaque connection id. Passwords you type are used for that connection and kept in memory until you disconnect or the API stops. Discovery probes ports and reads container metadata — it never guesses passwords.

Only localStorage state is cosmetic: diagram positions and view preferences.

API

Route Purpose
GET /api/discover?ports=5455,15432 candidates + notes (e.g. "docker daemon unreachable")
POST /api/connect {candidateId?, engine, host, port, socketPath?, user, password?, database?} → connection + databases
GET /api/connections/:id/databases re-list databases
GET /api/connections/:id/schema?database=x&schemas=public,app normalized schema
DELETE /api/connections/:id close the connection and its pools

The schema payload is engine-independent:

{
  "engine": "postgres", "serverVersion": "12.5", "database": "app",
  "schemas": ["public"],
  "tables": [{
    "id": "public.posts", "schema": "public", "name": "posts", "kind": "table",
    "rowEstimate": 1240, "sizeBytes": 81920, "comment": null,
    "columns": [{ "name": "user_id", "type": "bigint", "nullable": false, "default": null,
                  "autoIncrement": false, "enumValues": null, "isPrimary": false, "isUnique": false,
                  "foreignKeys": [{ "constraint": "posts_user_id_fkey", "table": "public.users", "column": "id" }] }],
    "indexes": [{ "name": "posts_pkey", "unique": true, "primary": true, "columns": ["id"] }],
    "primaryKey": ["id"]
  }],
  "relations": [{ "id": "public.posts.posts_user_id_fkey", "name": "posts_user_id_fkey",
                  "from": { "table": "public.posts", "columns": ["user_id"] },
                  "to": { "table": "public.users", "columns": ["id"] },
                  "onDelete": "CASCADE", "onUpdate": "NO ACTION", "optional": false, "selfReference": false }]
}

PostgreSQL is read from pg_catalog (types via format_type, comments, enum labels, index definitions, row estimates from reltuples); MySQL/MariaDB from information_schema. Adding an engine means one module in server/src/engines/ exporting createPool, serverVersion, listDatabases, introspect and bootstrapDatabases — nothing in the UI changes.

Requires PostgreSQL 10+ (uses attidentity) or MySQL 5.7+/MariaDB 10.2+, and Node 20+.

Credits

Inspired by Truss, which renders a Laravel project's migrations as an ERD. dbui takes the same reading experience — cards, crow's feet, constraint labels — and points it at live servers instead, so it works on any PostgreSQL or MySQL/MariaDB database on the machine, Laravel or not.

Built with Claude Code.

Licence

MIT.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages