Connects to a PostgreSQL instance, reads what the server already knows about itself, and turns it into a health picture: cache hit ratio, slow queries, dead tuples, unused indexes, blocking locks, partition layout, connection pressure.
Every query it runs targets pg_catalog and the pg_stat_* views. It never
reads your application tables, and it never writes anything. Point it at a
read-only role and it still works fully.
pg_stat_statements and friends already contain the answer to "why is this
database slow". The problem is that reading them means remembering a dozen
catalogue queries and doing the ratio maths by hand, per database, every time.
This runs the catalogue queries, does the arithmetic, and ranks the findings so
the worst thing is at the top.
Unit tests cannot tell you a query returns the right answer -- valid SQL can
pass every test and still be wrong. So the queries run against a live
PostgreSQL with a deliberately awkward fixture: a table named "Weird.Name",
an index nothing uses, a table with no primary key, and real sessions made to
block each other.
docker compose -f docker-compose.test.yml up -d
node server/scripts/verify-queries.js--- blocking detection, with real locks ------------------------------
179 blocks 180 (ROOT CAUSE, waited 1.5s)
180 blocks 181 (also blocked, waited 1.5s)
PASS blocking is detected at all
PASS exactly one session is identified as the root cause
PASS and it is the session actually holding the lock
10 queries executed, 0 checks failed
flowchart LR
UI[React UI<br/>localhost:3001]
API[Express server<br/>localhost:3002]
subgraph queries [server/utils/queries.js]
Q1[pg_stat_database<br/>cache hit ratio]
Q2[pg_stat_user_tables<br/>dead tuples, seq scans]
Q3[pg_stat_statements<br/>slow queries]
Q4[pg_stat_user_indexes<br/>unused indexes]
end
PG[(PostgreSQL<br/>catalogue and stat views only)]
HELP[src/utils/helpers.js<br/>score, grade, recommend]
UI --> API --> Q1 & Q2 & Q3 & Q4 --> PG
PG --> HELP --> UI
Read-only by construction: every statement targets pg_catalog or a
pg_stat_* view, so a read-only role loses no functionality.
Two processes: an Express API that talks to Postgres, and a React UI.
npm install
cd server && npm install && cd ..
cp .env.example server/.env # then edit it
cd server && npm start # API on :3002
npm start # UI on :3001Connections come from .env. The UI selects between the ones loaded at
startup; it does not register new ones at runtime. There is a
DatabaseConnection component that accepts host and credentials, but this build
does not mount it, and the analysis endpoint keys off connectionId and ignores
those fields — so an earlier version of this README describing runtime
connection entry was describing something that does not happen.
Passwords are never returned by the API either way.
| Area | Signal |
|---|---|
| Cache | Buffer cache hit ratio, and whether it is below the healthy band |
| Queries | Slowest statements by total execution time, via pg_stat_statements |
| Tables | Dead tuple ratio, sequential scans on large tables, bloat indicators |
| Indexes | Never-used and rarely-used indexes, with the cost of keeping them |
| Locks | Currently blocked queries and what is blocking them |
| Connections | Active, idle, and idle-in-transaction counts against max_connections |
| Partitions | Inheritance and partition layout per parent table |
Findings are grouped into immediate, this-week and this-month actions, because "you have 40 problems" is not a plan.
pg_stat_statements is optional. The tool detects whether the extension is
installed and degrades to what it can see without it, rather than failing.
server/routes/claude.js and src/components/claude/ predate any LLM
integration and the name stuck. There is no Anthropic dependency and no API
call to any model. The analysis is rule-based, in plain JavaScript, and you
can read every threshold.
What the /analyze-for-claude endpoint does is assemble the full diagnostic
payload as JSON so you can hand it to an LLM yourself if you want a second
opinion. The tool reaches its own conclusions without one.
-
.envis gitignored. Keep it that way; the file holds database passwords. -
Use a role with
pg_monitor. PlainSELECTon the catalogues is not enough, and the failure is silent rather than loud.Measured on PostgreSQL 16, with another session running
SELECT pg_sleep(20):Role Rows visible in pg_stat_activityordinary role with SELECT1 — its own session only same role after GRANT pg_monitor3 — every session, with query text An ordinary role does not see other sessions at all. Nothing errors: the blocking panel simply comes back empty, which reads as "nothing is blocking" on a database that may be badly stuck. That is the wrong way round for a diagnostic tool, so grant
pg_monitor(orpg_read_all_stats).Nothing here needs write access, and nothing here reads your table data — only the catalogues and the statistics views.
-
The API never returns a password.
/api/database/connection-configreportshasPassword: true|falseso the UI can show whether one is configured. -
The API has no authentication by default. It is built to run on
localhostalongside the UI. Anything it can read from your database — includingpg_stat_statementsquery text, connected users and client addresses — is readable by anything that can reach the port.Three defaults exist to keep that contained, and they are defaults rather than guarantees:
Setting Default Why HOST127.0.0.1Binding 0.0.0.0put an unauthenticated tool that holds database credentials on every interface, reachable from the whole local networkALLOWED_ORIGINShttp://localhost:3001Open CORS meant any page in your browser could drive this server and read the results API_TOKENunset Set it and every /apiroute requiresx-api-token. Required if you changeHOST; the server warns at startup if you did notError responses no longer include the server's own connection settings.
DEBUG_CONNECTION_DETAILS=trueputs them back when you are debugging a connection failure and can see who is asking. -
rejectUnauthorized: falseis set on SSL connections, which accepts self-signed certificates. Convenient against a managed instance behind a bastion, wrong for anything reachable from the open internet. Change it if your threat model is not "inside the VPC".
Unit tests check the code around the queries. They cannot tell you whether a query returns the right answer, because a query can be valid SQL, pass every test, and still be wrong.
So there is a second check that runs the real queries against a real server:
docker compose -f docker-compose.test.yml up -d
node server/scripts/verify-queries.jsIt builds a deliberately awkward fixture — a table named "Weird.Name", an
index nothing uses, a table with no primary key — then opens real sessions and
makes them block each other. Output from an actual run:
PostgreSQL 16.15 at 127.0.0.1:55432
--- every query, against a live server -------------------------------
PASS databaseInfo 1 rows
PASS cacheHitRatio 1 rows
PASS tableStats 3 rows
PASS slowQueries 0 rows
PASS partitioningInfo 0 rows
PASS blockingQueries 0 rows
PASS indexUsage 3 rows
PASS connectionStats 1 rows
PASS lockStats 2 rows
PASS userStats 2 rows
--- do the numbers match the fixture? --------------------------------
PASS orders reports 20000 live tuples
PASS a table with a dot in its name is listed
PASS "Weird.Name" is correctly reported as having no primary key
PASS orders is correctly reported as having a primary key
PASS the deliberately unused index shows 0 scans
PASS one idle client connection is counted
PASS total equals the states beneath it
--- blocking detection, with real locks ------------------------------
187 blocks 185 (ROOT CAUSE, waited 1.5s)
185 blocks 186 (also blocked, waited 1.5s)
PASS blocking is detected at all
PASS exactly one session is identified as the root cause
PASS and it is the session actually holding the lock
10 queries executed, 0 checks failed
Session ids and some row counts differ per run -- slowQueries in particular
depends on what pg_stat_statements has observed. The PASS lines are the
assertions; the counts beside them are incidental.
Every figure above comes from a throwaway container full of generated rows. None of it is production data, and the numbers describe the fixture rather than any real system.
Running this is how three defects were found, all of them after the unit suite was green:
tableStatscrashed on any database containing a table with a dot in its name. It builtschemaname||'.'||relnameunquoted, so PostgreSQL read"Weird.Name"as a cross-database reference and rejected the query. One awkwardly named table broke the panel for every table. It now usesrelid.connectionStatscounted PostgreSQL's own background workers. Checkpointer, walwriter and the autovacuum launcher appear inpg_stat_activitywith a NULL state, so an idle server reported five connections of which none were active and none idle — a total that matched nothing beneath it. "Stale" and "hung" were also keyed offbackend_start, the age of the connection, rather than how long it had been idle or how long its transaction had been open.blockingQueriesnamed a stuck session as the one to kill. Waiters queue, each holding a transient tuple lock, so the second waiter appeared to be blocked by the first. It now usespg_blocking_pids()and flags whether each blocker is itself blocked, so a root cause is distinguishable from a link in the chain.
React, Express, node-postgres.
- Slow-query analysis needs
pg_stat_statements. Without that extension loaded, that panel is empty. Nothing else depends on it. - Statistics are cumulative since the last reset. "Unused index" means
unused since the counters were last cleared, so an index serving a monthly
job looks dead for twenty-nine days. Check
stats_resetbefore dropping anything. - A snapshot, not a time series. It reads current catalogue state. For trends over time, feed a metrics pipeline instead.
- No query plans. It reports which statements are expensive, not why. The
EXPLAINstep is still yours. - Single instance. No replica awareness, no connection pooler visibility, no cross-shard view.
- The recommendations are starting points. An index suggestion names a placeholder column, because the tool sees statistics rather than your query shapes.
Bug reports and pull requests are welcome. CONTRIBUTING.md covers the setup and the gate that must be green before a PR. Everyone taking part is expected to follow the Code of Conduct.
For a security problem, do not open an issue: see SECURITY.md.
MIT. See LICENSE.