v1.5.0 — declared, but not in force
CLI database ops, as buttons. Local-only, no AI — psql operations turned into clicks.
Two constraints that are enforced with a hole in them. Both are read from the catalog, and both are shown where you would actually notice them — a database, not one table at a time.
🕳 A composite UNIQUE the NULLs walk through
UNIQUE (email, tenant_id) does not stop two rows with the same email when tenant_id is NULL in both. A NULL is not equal to another NULL, so the index sees two different keys and lets them in. The constraint is enforced. It has a hole the shape of its nullable columns — and you find out when the duplicates show up.
The Health panel now lists every composite UNIQUE with a nullable key column, and says which column is the hole. That part is catalog-only: no table is read.
Counting what actually slipped through is a separate button, because it reads the table. The trick is one line of SQL semantics: GROUP BY treats NULLs as the same group, which is exactly what the unique index refuses to do. That difference is the duplicates.
PostgreSQL 15's NULLS NOT DISTINCT closes the hole, so indexes built that way are not listed. The column that says so only exists from 15 — on 14 the query would fail to parse — so the query is picked by server version rather than written once and hoped for.
Partial and expression indexes are left out on purpose: a WHERE-qualified unique constraint declares uniqueness for those rows, so duplicates outside the predicate are the specification, not a slip.
🧟 Invalid indexes, listed for the whole database
An index left indisvalid = false by a failed CREATE INDEX CONCURRENTLY is ignored by the planner outright: it costs disk and write time while answering nothing. The badge already existed on the table detail — but nobody opens 200 tables one by one to find it.
The catch is that a build still running looks exactly the same in the catalog. Reporting it as wreckage would send you to drop an index that is about to become useful, so pg_stat_progress_create_index is read and building is shown as its own state. Not filtered out: "I cannot tell yet" is a different answer from "it failed".
A failed concurrent drop (indislive = false) gets its own state as well — unusable for queries, still maintained on every write, which is the worst of both.
Notes
- Both are declared not-applicable on MySQL rather than answered with an empty card. An empty card reads as "no problem here", and that is a claim this cannot make.
- Everything above is read-only. Counting runs under a statement timeout; nothing is validated, changed, or written.
docker compose up -dpullsjiniie/cli2ui:latest(now 1.5.0). Pin withCLI2UI_IMAGEin.env; build from source withcp docker-compose.override.yml.example docker-compose.override.yml.
Full detail in CHANGELOG.md.
Full changelog: v1.4.0...v1.5.0