Releases: MR-TABATA/cli2ui
Release list
v1.6.0 — the button says what it is about to do
CLI database ops, as buttons. Local-only, no AI — psql operations turned into clicks.
Press it and find out. That was the whole confirmation dialog. Now the button says what it is about to do, before you press it.
Drop table "public.orders"? This cannot be undone.
This runs:
SET lock_timeout = '3s';
DROP TABLE "public"."orders";
Rows: about 1,203,000 (estimated from statistics, last analyzed 2026-08-27)
Referenced by:
public.order_items (order_items_order_id_fkey)
A DROP … CASCADE would also drop:
view public.orders_v
materialized view public.daily_sales
📋 The SQL, composed by the thing that will run it
Not a second rendering of what we think the button does: engine.preview() calls the same method with the same arguments and captures what the executor composed, without sending it. A preview written separately from the executor drifts from it — and the first time you notice is when the button does something the preview did not show.
The SET lock_timeout shows up too. It has always been there, bounding the wait so one clicked button cannot queue behind an open transaction and stall a table until the connection pool runs dry. Nothing in the UI ever said so.
🔢 How many rows, and who points at this table
The count comes from the planner's statistics, not count(*): opening a confirmation dialog must not scan a billion-row table. So it says about, with the date the statistics were last refreshed.
A table that has never been analyzed reports unknown, not zero. "0 rows" reads as "empty, safe to drop", and that is a sentence this cannot honestly say.
Foreign keys pointing at the table are listed, because a TRUNCATE fails without CASCADE while one exists — and finding that out after pressing the button is late.
💥 What CASCADE would take with it
Views and materialized views, by name.
DROP TABLE defaults to RESTRICT: while something depends on the table, the drop simply fails. That is the safe outcome. The dangerous one is reaching for CASCADE after seeing the failure, without knowing what is on the other end.
Implicit dependencies — indexes, constraints, types — are deliberately not listed: they go with the table by definition, and listing them buries the view you actually needed to see. Foreign keys are not repeated here either; they are already on the line above, and the same object in two places reads as two casualties.
Notes
- A preview that cannot be produced is not rendered at all. An empty SQL box, or a "0 rows" line, would state something untrue.
- Everything above is catalog reads. Nothing is executed, validated or written to answer these questions.
docker compose up -dpullsjiniie/cli2ui:latest(now 1.6.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.5.0...v1.6.0
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
v1.4.0 — starting the app is now a pull, not a build
docker compose up no longer builds the image on your machine, and two failures that showed up exactly when you needed the tool are fixed.
Important
Upgrading an existing checkout moves the management database. The default compose path no longer mounts .:/app, so CLI2UI_DB_PATH now points at /data on a named volume. A checkout that had connections saved in the project root will start empty — nothing is deleted, the app is looking somewhere else. Either copy the existing db.sqlite3 into the volume, or copy docker-compose.override.yml.example, which puts the file back in the project root where you can inspect it.
Changed
docker compose upnow pulls a published image instead of building one — the first screen used to be preceded by apt and pip on a laptop that has no reason to compile anything. Compose points atjiniie/cli2ui:latest(amd64 and arm64, pushed onv*tags), so starting the app is a pull;CLI2UI_IMAGEin.envpins a version. Building from source moves todocker-compose.override.yml.example— copy it and you getbuild: .plus the working-tree mount, i.e. the previous behaviour with live reload.- The management database moved to a named volume — see the upgrade note above.
Fixed
- The workspace no longer hangs while a table is locked —
pg_total_relation_size()andpg_table_size()open the relation they measure, so the size and bloat probes tookACCESS SHAREon every table and queued behind anyACCESS EXCLUSIVEholder. Both probes feed the overview and the Health panel, the pages on the way to the Locks panel, so anALTER TABLEwaiting on an idle transaction made the workspace unreachable at exactly the moment you needed it to explain the jam. Measured before the fix:/locksanswered in 0.02s while the connection page never returned. Both probes now run under a 1slock_timeoutand report what happened; each degrades its own card, so Health keeps the cards a lock can't touch. (MySQL is unaffected: its sizes come frominformation_schema.TABLES, which reads the data dictionary.) - "Cancel" is no longer offered where it does nothing — cancel stops a running query, so on a session that isn't running one it reports success and releases nothing. That session is the usual head blocker (
idle in transaction), so the button appeared exactly where it couldn't help. Cancel now renders disabled there, with a note that only kill ends the transaction; sessions waiting on a lock keep it, since a lock wait means a statement is in flight.
Full changelog: https://github.com/MR-TABATA/cli2ui/blob/main/CHANGELOG.md
v1.3.0 — DDL that gives up instead of piling up
A safety release. Structural changes now bound how long they wait for a lock, and write mode in the SQL runner works from the UI for the first time.
Changed
- DDL gives up instead of piling up — every
ALTER/DROP/TRUNCATE/ rename on a table, column, schema or role runs under a shortlock_timeout(2s;lock_wait_timeouton MySQL, whose own default is a full year). A DDL statement waiting for its exclusive lock parks every later query behind it, plainSELECTs included, until the connection pool runs dry — so the wait is now bounded, and a change that can't get its lock fails in seconds saying nothing was changed. It bounds the wait, not the hold: a rewrite still keeps its lock while it runs.CREATE/DROP INDEX CONCURRENTLYare deliberately exempt — they block nobody, and timing them out is what leaves an invalid index behind. - Write mode offers the same guard — hand-written DDL can park a table exactly like the buttons can, so write mode runs under the same
lock_timeout, shown in the header and turned off with a checkbox when you mean to wait.
Fixed
- Write mode never reached the server — the runner's mode toggles sit outside its
<form>, and htmx only serializes a form's own descendants, sowrite=1was dropped from every request: the panel showed write mode armed while the server ran the statement read-only, and the safety snapshot that precedes a write never ran. The form now includes the toggles explicitly. - Unindexed foreign keys no longer counts a partial or invalid index as covering a foreign key.
Added
- Locks: head-blocker chains — the wait-for graph folded into trees rooted at the session that is holding a lock and waiting on nothing, so the highest-leverage cancel is at the top. Cycles are detected and flagged. Engine-agnostic.
- Health: orphan rows — foreign keys left
NOT VALIDand*_idcolumns with no FK that name-match a primary key, each with an on-demand read-only anti-join count that never validates or changes anything. - Extensions panel — what's installed and what the server could install (
\dxunioned withpg_available_extensions), with an update-available badge. - JSON shape — an on-demand sampled view of a
json/jsonbcolumn's top-level keys, value types, nesting depth and GIN coverage. - Replication: replay lag in time, not just bytes.
- Opt-in Airlines demo database for realistic data volume.
Full changelog: https://github.com/MR-TABATA/cli2ui/blob/main/CHANGELOG.md
v1.2.0 — Health: unindexed FK & redundant index detection
Two new read-only, catalog-fact cards on the Health panel — siblings of the unused-index card and the v1.1.0 foreign-key dependency graph.
Added
- Unindexed foreign keys — FK columns that aren't the leading key of any index. PostgreSQL doesn't auto-create one, so FK checks, cascade deletes and joins back to the parent do a sequential scan. (MySQL/InnoDB auto-indexes FK columns, so the card shows "not applicable".)
- Redundant indexes — a non-unique index whose key columns are a leading prefix of (or identical to) another index on the same table + access method. Unique indexes are never reported (they enforce uniqueness); exact-duplicate pairs are listed once.
Neither advises — they only surface the fact. PostgreSQL (pg_index / pg_constraint) and MySQL (information_schema.STATISTICS). Verified live against PostgreSQL 17 and MariaDB 11.8.
Full changelog: https://github.com/MR-TABATA/cli2ui/blob/main/CHANGELOG.md
v1.1.0 — Dependencies panel (FK dependency graph)
Additive release on top of the 1.0 multi-DB launch.
Added
- Dependencies panel — the foreign-key graph of the current database, topologically sorted (
graphlib.TopologicalSorter) into a safe TRUNCATE/DELETE order and its reverse (the INSERT/load order). Foreign-key cycles are detected and named (no valid order exists), self-referential keys are flagged, and the edges are listed for inspection. - Read-only — nothing is truncated; the order is only computed and shown.
- Works for both PostgreSQL (
pg_constraint) and MySQL (information_schema.KEY_COLUMN_USAGE).
Full changelog: https://github.com/MR-TABATA/cli2ui/blob/main/CHANGELOG.md
v1.0.0 — multi-DB ops console (PostgreSQL + MySQL)
First stable release. cli2ui becomes a multi-database ops console: a full MySQL engine lands alongside the mature PostgreSQL one, behind the same safety-first, local-only UI.
MySQL support (8.0+)
The same console over a MySQL connection: table/column browse, read-only + write query runner (the server enforces read-only via START TRANSACTION READ ONLY), filter builder, CSV import, streamed CSV/JSON export, EXPLAIN parsed into the shared plan tree (so snapshots & diff work), index + table + column DDL, database create/drop, activity (SHOW PROCESSLIST) with cancel/kill, locks (performance_schema.data_lock_waits), health (unused indexes / sizes), backup & restore (mysqldump/mysql), a settings editor (SET PERSIST), and replication status with an attach recipe.
Degrade, don't lie
Engine.supports() / UNSUPPORTED: features with no MySQL equivalent (vacuum/bloat, schemas separate from databases, the planner what-if lab, replication slots) show as "not applicable" rather than an empty card. A safety signal like "is anything blocked?" raises a clear message instead of degrading to a false negative.
Verified live against MySQL 8.0 (engine sweep + browser); full test suite green (179 tests).
Full changelog: https://github.com/MR-TABATA/cli2ui/blob/v1.0.0/CHANGELOG.md
v0.9.0 — PostgreSQL feature-freeze
PostgreSQL feature-freeze milestone: the explicit PostgreSQL backlog is now
complete. Next stop is 1.0 with multi-database (MySQL) support.
Added
- Table-level CSV/JSON export: a per-table "export" control on the table detail
panel streams every row (read-onlySELECT *, full table — not the preview's
row cap) as a download, reusing the query exporter's streaming path. - Filter builder on the table Data tab: stack column / operator (=, ≠, <, ≤, >,
≥, contains, starts with, is null, is not null) / value rows, ANDed, run as a
read-onlySELECT * … WHERE …(columns validated, values bound — never
interpolated) and rendered in place. - CSV import: append rows from an uploaded CSV into an existing table via
COPY,
matched by header name, in one all-or-nothing transaction (a bad row rolls the
whole import back), with an automatic safety snapshot taken first. CLI2UI_DB_PATHenvironment variable to relocate the management SQLite file
(e.g. onto a named Docker volume) — see README.NETWORKING.md.
v0.8.0 — first versioned release
First versioned release. The project was already public at
cli2ui.com; this tags the current, mature PostgreSQL-only
state as the baseline (0.8 reflecting how much is already built, with 1.0
reserved for multi-DB support).
Added
- Read-only and write-mode SQL runner, with query result export to CSV/JSON
(streamed, full result set). - Object browser: databases, schemas, tables, columns, indexes, constraints.
- Table operations: create/rename/drop, column add/rename/type/null/default
changes, with automatic snapshot before destructive changes. - Database operations: create, clone, rename, drop, and ALTER schema/role.
- Backup & restore via
pg_dump/psqlwith streaming restore, restore into a
new or existing database, and an auto-backup total-size retention cap. - Activity, Locks/blocking, Health (sizes, unused indexes, dead rows, bloat
estimate), and Replication (readiness, WAL position, slots, standby setup
recipe) panels. - Command history.
- Optional
planner_labapp (scale simulation + index lab), decoupled behind a
feature flag. - Internationalisation (English / Japanese).
- Workspace overview dashboard and unified UI (design system).