Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0a20f1a
feat(config): load multiple databases
omercnet Jun 25, 2026
d8eecbf
feat(db): add database pool registry
omercnet Jun 25, 2026
82fff1c
refactor(server): split route helpers
omercnet Jun 25, 2026
d0f4537
feat(server): route catalog by database
omercnet Jun 25, 2026
4e3490d
feat(server): route queries by database
omercnet Jun 25, 2026
7162d69
refactor(server): split saved query handlers
omercnet Jun 25, 2026
fc92733
feat(server): add database selection
omercnet Jun 25, 2026
a51e7d7
feat(main): wire database registry
omercnet Jun 25, 2026
356dabc
feat(web): add database URL state
omercnet Jun 25, 2026
855f6f0
refactor(web): split app modules
omercnet Jun 25, 2026
628b096
feat(web): add database switcher
omercnet Jun 25, 2026
8996209
docs: document multi-database setup
omercnet Jun 25, 2026
1209c09
build(deps): update go toolchain deps
omercnet Jun 25, 2026
7798793
build: align module imports
omercnet Jun 25, 2026
649c3ed
fix: align module path after rebase
omercnet Jun 25, 2026
7af54e7
fix(web): restore jsdom dependency
omercnet Jun 25, 2026
86534ce
fix(deps): override vulnerable esbuild
omercnet Jun 25, 2026
30a7f68
test(guard): cover restricted relations
omercnet Jun 25, 2026
6308f9a
fix(web): preserve CodeMirror 6 editor
omercnet Jun 25, 2026
bbc8cf1
test(web): restore coverage thresholds
omercnet Jun 25, 2026
2247c6b
fix(web): prevent URL filter pollution
omercnet Jun 25, 2026
b79a484
chore: exclude unrelated infra changes
omercnet Jun 25, 2026
81f4176
fix(web): store URL filters as arrays
omercnet Jun 25, 2026
418b904
fix: address PR review feedback
omercnet Jun 25, 2026
b5b8464
fix(web): address follow-up review
omercnet Jun 25, 2026
650614a
docs: add multi-database screenshot
omercnet Jun 26, 2026
5c5a684
chore: exclude unrelated workflow changes
omercnet Jun 26, 2026
f95b0cc
test(web): split database URL tests
omercnet Jun 26, 2026
f0e4308
fix(web): remove unused test import
omercnet Jun 26, 2026
79433f9
fix(config): reject mixed database sources
omercnet Jun 26, 2026
57d582a
test(server): add multi-database integration
omercnet Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 94 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,23 @@ browser ── HTTP ──> pgpeek (Go, single static binary)

## Configuration (env vars)

Everything is configured via the environment. Any value can also be supplied
from a **mounted file** by setting `<VAR>_FILE` to a path (Docker secrets / k8s
projected volumes); the file's trimmed contents become the value. This is wired
for the secret-bearing `DATABASE_URL` (use `DATABASE_URL_FILE`).
Everything is configured via the environment. Single-database deployments can
keep using `DATABASE_URL`; multi-database deployments can use a URL list,
Comment thread
omercnet marked this conversation as resolved.
numbered env vars, or a mounted JSON config file. Secret-bearing URLs can be
supplied from mounted files so they do not live in manifests.

| Variable | Default | Notes |
| ---------------------------- | -------------------- | --------------------------------------------------------------------- |
| `DATABASE_URL` | _(required)_ | Postgres DSN. Use the read-only role. **Never logged.** Aurora: include `?sslmode=require`. |
| `DATABASE_URL` | single-DB required | Postgres DSN for single-database installs. Use the read-only role. **Never logged.** Aurora: include `?sslmode=require`. |
| `DATABASE_URL_FILE` | — | Path to a file holding the DSN (mounted-secret alternative). |
| `PGPEEK_DATABASE_URLS` | — | Comma- or semicolon-separated DSNs for multiple databases. Quoted CSV values are supported. |
| `PGPEEK_DATABASE_IDS` | `db1`, `db2`, … | Optional comma/semicolon IDs matching `PGPEEK_DATABASE_URLS`; URL-safe (`A-Z`, `a-z`, `0-9`, `_`, `-`, `.`). |
| `PGPEEK_DATABASE_NAMES` | `Database N` | Optional display names matching `PGPEEK_DATABASE_URLS`. |
| `PGPEEK_DATABASE_URL_1` | — | Numbered DSN form. Continue with `_2`, `_3`, …; each also supports `_FILE`. |
| `PGPEEK_DATABASE_ID_1` | `db1` | Optional ID for numbered database 1. |
| `PGPEEK_DATABASE_NAME_1` | `Database 1` | Optional display name for numbered database 1. |
| `PGPEEK_DATABASES_FILE` | — | Path to a mounted JSON config file with database entries. |
| `PGPEEK_DEFAULT_DATABASE` | first configured DB | Default database ID when the URL has no `db=` parameter. |
| `PGPEEK_LISTEN` | `:8080` | Listen address. |
| `PGPEEK_ROW_CAP` | `1000` | Max rows returned/exported per query. |
| `PGPEEK_STATEMENT_TIMEOUT` | `30s` | Per-query DB statement timeout. |
Expand All @@ -95,6 +103,79 @@ for the secret-bearing `DATABASE_URL` (use `DATABASE_URL_FILE`).
| `PGPEEK_DB_IAM_AUTH` | `false` | Use RDS/Aurora IAM auth instead of a password (see below). |
| `PGPEEK_AWS_REGION` | `$AWS_REGION` | AWS region for IAM token signing (required when IAM auth is on). |

### Multiple databases / clusters

The UI shows a database selector. The selected ID is kept in the URL as
`?db=<id>` alongside table, tab, filter, sort, and pagination state, so links are
bookmarkable and shareable.

Same-env list form:

```bash
export PGPEEK_DATABASE_URLS='postgres://reader:PASSWORD@prod:5432/app?sslmode=require;postgres://reader:PASSWORD@analytics:5432/warehouse?sslmode=require'
export PGPEEK_DATABASE_IDS='prod;analytics'
export PGPEEK_DATABASE_NAMES='Production;Analytics'
export PGPEEK_DEFAULT_DATABASE=prod
```

Numbered env var form:

```bash
export PGPEEK_DATABASE_URL_1_FILE=/run/secrets/prod-url
export PGPEEK_DATABASE_ID_1=prod
export PGPEEK_DATABASE_NAME_1=Production
export PGPEEK_DATABASE_URL_2_FILE=/run/secrets/analytics-url
export PGPEEK_DATABASE_ID_2=analytics
export PGPEEK_DATABASE_NAME_2=Analytics
```

Mounted config file form (`PGPEEK_DATABASES_FILE=/config/pgpeek/databases.json`):

```json
{
"default": "prod",
"databases": [
{ "id": "prod", "name": "Production", "urlFile": "/secrets/prod-url" },
{ "id": "analytics", "name": "Analytics", "urlFile": "/secrets/analytics-url" }
]
}
```

Kubernetes example (ConfigMap-mounted config + Secret-mounted DSNs; illustrative
only, not an extra manifest to commit):

```yaml
env:
- name: PGPEEK_DATABASES_FILE
value: /config/pgpeek/databases.json
volumeMounts:
- name: pgpeek-db-config
mountPath: /config/pgpeek
readOnly: true
- name: pgpeek-db-urls
mountPath: /secrets
readOnly: true
volumes:
- name: pgpeek-db-config
configMap:
name: pgpeek-db-config
- name: pgpeek-db-urls
secret:
secretName: pgpeek-db-urls
```

Docker Compose example (volume-mounted JSON + secret files; illustrative only):

```yaml
services:
pgpeek:
environment:
PGPEEK_DATABASES_FILE: /config/pgpeek/databases.json
volumes:
- ./pgpeek-config:/config/pgpeek:ro
- ./pgpeek-secrets:/secrets:ro
```

### RDS / Aurora IAM authentication

Set `PGPEEK_DB_IAM_AUTH=true` and `PGPEEK_AWS_REGION`. The `DATABASE_URL` then
Expand Down Expand Up @@ -218,13 +299,14 @@ Two ways:

| Method & path | Purpose |
| --------------------------------------------- | ---------------------------------------------- |
| `POST /api/query` | Run a query → JSON `{columns, rows, …}`. |
| `POST /api/export` | Run a query → CSV download. |
| `GET /api/meta` | Server limits the UI needs (`{rowCap}`). |
| `GET /api/tables` | List browsable tables/views (+ row estimate). |
| `GET /api/tables/{schema}/{table}/columns` | Column structure (name, type, nullable, default). |
| `GET /api/tables/{schema}/{table}/fks` | Single-column foreign keys (for click-through). |
| `GET /api/tables/{schema}/{table}/data` | Paged rows; `?limit=&offset=&search=&sort=&dir=&f=col:op:val` (`&format=csv`). |
| `GET /api/databases` | List configured databases → `{defaultId, databases:[{id,name}]}`. |
| `POST /api/query?db=<id>` | Run a query → JSON `{columns, rows, …}`. |
| `POST /api/export?db=<id>` | Run a query → CSV download. |
| `GET /api/meta?db=<id>` | Server limits the UI needs (`{rowCap}`). |
| `GET /api/tables?db=<id>` | List browsable tables/views (+ row estimate). |
| `GET /api/tables/{schema}/{table}/columns?db=<id>` | Column structure (name, type, nullable, default). |
| `GET /api/tables/{schema}/{table}/fks?db=<id>` | Single-column foreign keys (for click-through). |
| `GET /api/tables/{schema}/{table}/data?db=<id>` | Paged rows; `&limit=&offset=&search=&sort=&dir=&f=col:op:val` (`&format=csv`). |
| `GET /api/queries` | List saved/preset queries. |
| `POST /api/queries` | Create a saved query. |
| `PUT /api/queries/{id}` | Update a saved query. |
Expand Down
Binary file added docs/assets/img/multi-database.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
57 changes: 44 additions & 13 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,27 @@ <h3 class="feature__title">Foreign keys you can click through</h3>
</div>
</article>

<!-- Structure -->
<article class="feature reveal">
<div class="feature__figure">
<figure class="frame">
<div class="frame__toolbar"><span class="frame__dots"><span class="frame__dot frame__dot--red"></span><span class="frame__dot frame__dot--yellow"></span><span class="frame__dot frame__dot--green"></span></span><span class="frame__url">?db=analytics&amp;schema=public&amp;table=users</span></div>
<img src="assets/img/multi-database.png" width="1440" height="900" loading="lazy" decoding="async"
alt="pgpeek with the database selector set to Analytics cluster while browsing public.users, with the selected database encoded in the URL." />
</figure>
</div>
<div class="feature__text">
<span class="feature__num">03</span>
<h3 class="feature__title">Switch databases without losing the link</h3>
<ul class="feature__bullets">
<li>Pick from named databases or clusters in the header.</li>
<li>The selected database is encoded as <code>db=...</code> in the URL.</li>
<li>Table, tab, search, filter, sort, and pagination state remain bookmarkable.</li>
</ul>
</div>
</article>

<!-- Structure -->
<article class="feature feature--flip reveal">
<div class="feature__figure">
<figure class="frame">
<div class="frame__toolbar"><span class="frame__dots"><span class="frame__dot frame__dot--red"></span><span class="frame__dot frame__dot--yellow"></span><span class="frame__dot frame__dot--green"></span></span><span class="frame__url">Structure</span></div>
Expand All @@ -226,7 +245,7 @@ <h3 class="feature__title">Foreign keys you can click through</h3>
</figure>
</div>
<div class="feature__text">
<span class="feature__num">03</span>
<span class="feature__num">04</span>
<h3 class="feature__title">Structure at a glance</h3>
<ul class="feature__bullets">
<li>Every column: name, type, nullable, default.</li>
Expand All @@ -237,7 +256,7 @@ <h3 class="feature__title">Structure at a glance</h3>
</article>

<!-- SQL -->
<article class="feature feature--flip reveal">
<article class="feature reveal">
<div class="feature__figure">
<figure class="frame">
<div class="frame__toolbar"><span class="frame__dots"><span class="frame__dot frame__dot--red"></span><span class="frame__dot frame__dot--yellow"></span><span class="frame__dot frame__dot--green"></span></span><span class="frame__url">SQL</span></div>
Expand All @@ -246,7 +265,7 @@ <h3 class="feature__title">Structure at a glance</h3>
</figure>
</div>
<div class="feature__text">
<span class="feature__num">04</span>
<span class="feature__num">05</span>
<h3 class="feature__title">A SQL scratchpad with memory</h3>
<ul class="feature__bullets">
<li>CodeMirror editor (pgsql mode), gracefully degrades to a textarea.</li>
Expand Down Expand Up @@ -524,14 +543,18 @@ <h2 class="section-title">Running in under a minute</h2>
<div class="section-header reveal">
Comment thread
omercnet marked this conversation as resolved.
<span class="section-label">Configuration</span>
<h2 class="section-title">Everything is an environment variable</h2>
<p class="section-desc">Any value can also come from a mounted file via <code>&lt;VAR&gt;_FILE</code> — Docker / Kubernetes secrets friendly.</p>
<p class="section-desc">Single-database installs keep using <code>DATABASE_URL</code>. Multi-database installs can use URL lists, numbered env vars, or a mounted JSON config file.</p>
</div>

<div class="table-wrap reveal">
<table class="data-table">
<thead><tr><th>Variable</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="td-path">DATABASE_URL</td><td><span class="pill-required">required</span></td><td>Postgres DSN. Use the read-only role. Never logged. (<code>DATABASE_URL_FILE</code> reads it from a mounted secret.)</td></tr>
<tr><td class="td-path">DATABASE_URL</td><td><span class="pill-required">single-DB required</span></td><td>Postgres DSN for single-database installs. Use the read-only role. Never logged. (<code>DATABASE_URL_FILE</code> reads it from a mounted secret.)</td></tr>
<tr><td class="td-path">PGPEEK_DATABASE_URLS</td><td><span class="pill-default">—</span></td><td>Comma/semicolon-separated DSNs for multiple databases. Pair with <code>PGPEEK_DATABASE_IDS</code> and <code>PGPEEK_DATABASE_NAMES</code>.</td></tr>
<tr><td class="td-path">PGPEEK_DATABASE_URL_1</td><td><span class="pill-default">—</span></td><td>Numbered multi-DB form; continue with <code>_2</code>, <code>_3</code>, … and use <code>_FILE</code> for mounted secrets.</td></tr>
<tr><td class="td-path">PGPEEK_DATABASES_FILE</td><td><span class="pill-default">—</span></td><td>Mounted JSON config file with <code>{default, databases:[{id,name,urlFile}]}</code>.</td></tr>
<tr><td class="td-path">PGPEEK_DEFAULT_DATABASE</td><td><span class="pill-default">first DB</span></td><td>Default database ID when the URL has no <code>db=</code>.</td></tr>
<tr><td class="td-path">PGPEEK_LISTEN</td><td><span class="pill-default">:8080</span></td><td>Listen address.</td></tr>
<tr><td class="td-path">PGPEEK_ROW_CAP</td><td><span class="pill-default">1000</span></td><td>Max rows returned/exported per query.</td></tr>
<tr><td class="td-path">PGPEEK_STATEMENT_TIMEOUT</td><td><span class="pill-default">30s</span></td><td>Per-query DB statement timeout.</td></tr>
Expand All @@ -556,6 +579,13 @@ <h2 class="section-title">Everything is an environment variable</h2>
<strong>RDS / Aurora IAM auth.</strong> Set <code>PGPEEK_DB_IAM_AUTH=true</code> and a region, drop the password from the DSN, and pgpeek mints a short-lived IAM token from the default AWS credential chain (env / web-identity / IRSA / instance role) before every new connection — no static DB password stored anywhere.
</div>
</div>

<div class="callout callout--success reveal">
<div class="callout__icon" aria-hidden="true">db</div>
<div class="callout__body">
<strong>Multiple clusters are bookmarkable.</strong> The UI writes the selected database into the URL as <code>?db=prod</code>, alongside table, tab, filter, sort, and pagination params. For Kubernetes, mount a ConfigMap at <code>/config/pgpeek/databases.json</code> and Secret files at <code>/secrets/*</code>; for Compose, mount local directories with <code>./pgpeek-config:/config/pgpeek:ro</code> and <code>./pgpeek-secrets:/secrets:ro</code>. These are examples only — no extra deploy files are required.
</div>
</div>
</div>
</section>

Expand All @@ -572,13 +602,14 @@ <h2 class="section-title">A small, predictable surface</h2>
<table class="data-table">
<thead><tr><th>Method &amp; path</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="td-path"><span class="method-badge method-post">POST</span> /api/query</td><td>Run a query → JSON <code>{columns, rows, …}</code>.</td></tr>
<tr><td class="td-path"><span class="method-badge method-post">POST</span> /api/export</td><td>Run a query → CSV download.</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/meta</td><td>Server limits the UI needs (<code>{rowCap}</code>).</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/tables</td><td>List browsable tables/views (+ row estimate).</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/tables/{schema}/{table}/columns</td><td>Column structure.</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/tables/{schema}/{table}/fks</td><td>Single-column foreign keys (for click-through).</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/tables/{schema}/{table}/data</td><td>Paged rows: <code>?limit=&amp;offset=&amp;search=&amp;sort=&amp;dir=&amp;f=col:op:val</code> (<code>&amp;format=csv</code>).</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/databases</td><td>List configured databases → <code>{defaultId, databases:[{id,name}]}</code>.</td></tr>
<tr><td class="td-path"><span class="method-badge method-post">POST</span> /api/query?db=&lt;id&gt;</td><td>Run a query → JSON <code>{columns, rows, …}</code>.</td></tr>
<tr><td class="td-path"><span class="method-badge method-post">POST</span> /api/export?db=&lt;id&gt;</td><td>Run a query → CSV download.</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/meta?db=&lt;id&gt;</td><td>Server limits the UI needs (<code>{rowCap}</code>).</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/tables?db=&lt;id&gt;</td><td>List browsable tables/views (+ row estimate).</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/tables/{schema}/{table}/columns?db=&lt;id&gt;</td><td>Column structure.</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/tables/{schema}/{table}/fks?db=&lt;id&gt;</td><td>Single-column foreign keys (for click-through).</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/tables/{schema}/{table}/data?db=&lt;id&gt;</td><td>Paged rows: <code>&amp;limit=&amp;offset=&amp;search=&amp;sort=&amp;dir=&amp;f=col:op:val</code> (<code>&amp;format=csv</code>).</td></tr>
<tr><td class="td-path"><span class="method-badge method-get">GET</span> /api/queries</td><td>List saved/preset queries.</td></tr>
<tr><td class="td-path"><span class="method-badge method-post">POST</span> /api/queries</td><td>Create a saved query.</td></tr>
<tr><td class="td-path"><span class="method-badge method-put">PUT</span> /api/queries/{id}</td><td>Update a saved query.</td></tr>
Expand Down
38 changes: 23 additions & 15 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import (

// Config is the fully-resolved application configuration.
type Config struct {
Server Server
DB DB
StorePath string
RowCap int
Server Server
DB DB
Databases []DatabaseEntry
DefaultDatabaseID string
StorePath string
RowCap int
}

// Server holds HTTP server settings.
Expand Down Expand Up @@ -51,15 +53,13 @@ type DB struct {

// Load reads and validates configuration from the environment.
func Load() (*Config, error) {
dsn, err := envOrFile("DATABASE_URL")
stmtTimeout := envDur("PGPEEK_STATEMENT_TIMEOUT", 30*time.Second)
iamAuth := envBool("PGPEEK_DB_IAM_AUTH", false)
region := env("PGPEEK_AWS_REGION", os.Getenv("AWS_REGION"))
databases, defaultDatabaseID, err := loadDatabases(iamAuth, region)
if err != nil {
return nil, err
}
if dsn == "" {
return nil, errors.New("DATABASE_URL (or DATABASE_URL_FILE) is required")
}

stmtTimeout := envDur("PGPEEK_STATEMENT_TIMEOUT", 30*time.Second)

c := &Config{
Server: Server{
Expand All @@ -72,15 +72,20 @@ func Load() (*Config, error) {
TLSKeyFile: os.Getenv("PGPEEK_TLS_KEY_FILE"),
},
DB: DB{
DSN: dsn,
DSN: "",
MaxConns: int32(envInt("PGPEEK_MAX_CONNS", 8)),
StatementTimeout: stmtTimeout,
IdleTxTimeout: envDur("PGPEEK_IDLE_TX_TIMEOUT", 30*time.Second),
IAMAuth: envBool("PGPEEK_DB_IAM_AUTH", false),
Region: env("PGPEEK_AWS_REGION", os.Getenv("AWS_REGION")),
IAMAuth: iamAuth,
Region: region,
},
StorePath: env("PGPEEK_STORE_PATH", "/data/pgpeek.db"),
RowCap: envInt("PGPEEK_ROW_CAP", 1000),
Databases: databases,
DefaultDatabaseID: defaultDatabaseID,
StorePath: env("PGPEEK_STORE_PATH", "/data/pgpeek.db"),
RowCap: envInt("PGPEEK_ROW_CAP", 1000),
}
if err := applyDefaultDatabase(c); err != nil {
return nil, err
}
if err := c.validate(); err != nil {
return nil, err
Expand All @@ -98,6 +103,9 @@ func (c *Config) validate() error {
if c.DB.IAMAuth && c.DB.Region == "" {
return errors.New("PGPEEK_DB_IAM_AUTH requires PGPEEK_AWS_REGION (or AWS_REGION)")
}
if err := validateDatabases(c.Databases); err != nil {
return err
}
if (c.Server.TLSCertFile == "") != (c.Server.TLSKeyFile == "") {
return errors.New("PGPEEK_TLS_CERT_FILE and PGPEEK_TLS_KEY_FILE must be set together")
}
Expand Down
Loading