更新履歴は、英語で書かれています。
cli2ui のリリース履歴
All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project follows Semantic Versioning.
Versioning convention for this project:
0.x— deepening the PostgreSQL ops console. The0.xminor number tracks maturity, not API stability; PostgreSQL features land here (0.8.xpatches,0.9= feature-frozen / MySQL in progress).1.0— multi-database support (MySQL) lands. This is the first stable release: cli2ui becomes a multi-DB ops console.1.x/2.0— post-1.0, additive features bump the minor; only an actual backward-incompatible change bumps the major.
1.12.0 - 2026-10-08
Added
- Activity: which session is keeping VACUUM from cleaning up. PostgreSQL
keeps the old version of an updated or deleted row until nothing can still be
looking at it; one session left holding an old view stops VACUUM from
reclaiming everything changed since, and the table keeps growing. In the state
column that session is just another "idle in transaction". The Activity panel
now reads
age(backend_xmin)— how many transactions ago the view a session holds was taken — into a new "xmin age" column, and a card above the table names the oldest one. A session that is not running a query but still holds a view is marked, with a note that Cancel does nothing there and ending the session is what lets VACUUM clean up. (Postgres only; MySQL has no equivalent.) - Replication slots: how much WAL each one is holding. A new "Retained WAL"
column shows the distance from the slot's restart LSN to the current WAL
position, so a slot that has quietly been pinning gigabytes shows up from its
first byte —
wal_statusonly changes once the slot is already in trouble. An inactive slot is highlighted; a slot with no restart LSN shows "—", not 0. It is the usual measure, so a brand-new slot shows what was written since the last checkpoint (WAL the server keeps for crash recovery anyway). Postgres only. - Saved connection passwords are now encrypted at rest — always. The password of a saved
connection was kept in plain text in the management database; it is now encrypted (Fernet)
when it is written and decrypted only when a connection is opened. The key comes from
CLI2UI_SECRET_KEYSif set (several keys, newest first, are how a key is rotated), otherwise from a key file,secret.key, created next to the database the first time it is needed and readable by its owner only (in Docker it lands in the/datavolume). Upgrading: migration 0009 encrypts the passwords you already have and creates that file — back it up apart from the database, because without it the saved passwords cannot be read and the connections have to be entered again. A missing key file is never silently replaced while encrypted passwords depend on it. A password found stored in plain text is refused when used (saving the connection, ormanage.py encrypt_secrets, writes it back encrypted). New commands:generate_secret_key,encrypt_secrets(--checkchanges nothing and fails on plain text). Hosted mode warns while the key is a file next to the database. An app can replace the encryption with its own. - A saved connection's password can be a reference instead of the password. Type
secret://<scheme>/<name>; it is looked up when the connection is used, by a resolver an app registers for that scheme, so the database holds no password at all. References are stored as typed, never resolved when saving, and one that cannot be resolved is never handed out as the password. Which schemes exist is up to the app that registers them. - Hosted mode: hooks for an app that supplies the login and per-connection permissions.
CLI2UI_HOSTED_AUTH=extensionhands every request to a login function an installed app registers (nothing registered: the server refuses to start); an authorizer can narrow what a caller may do to a saved connection, and a scope narrows which saved connections they see. They can only narrow what the deployment allows. Outside hosted mode none of it applies.
1.11.0 - 2026-10-01
Added
Hosted mode: a guard for the day cli2ui is reached from outside your own machine.
CLI2UI_HOSTED=1switches it on; unset, none of it runs and local use is exactly as before. It is a guard against misconfiguration, not a promise that cli2ui is safe on the public internet — there are still no user accounts. Four parts, described in README.HOSTED.md (日本語: README.HOSTED.ja.md):- It refuses to start on an unsafe setup —
DEBUGon, the built-inSECRET_KEY,ALLOWED_HOSTS=*, no publichttps://CSRF origin, or no declaration of how access is protected (CLI2UI_HOSTED_AUTH=proxy, orbasicfor a built-in HTTP Basic login).python manage.py check_hostedlists every problem without starting the server;runserver,manage.py checkand wsgi all stop on the same errors. - Dangerous operations are off until you name them. Write-mode SQL, DDL,
role and database administration,
ALTER SYSTEM, cancelling/killing sessions, import/dump/export and adding or deleting connections answer403and their buttons are removed from the page.CLI2UI_HOSTED_ALLOW=write_sql,ddlturns each back on by name. Reads always work. - A target-database allowlist.
CLI2UI_HOSTED_TARGETSlists thehost:portpairs cli2ui may connect to (empty refuses everything), and what a name resolves to must be a public address — loopback, link-local (cloud metadata) and private ranges are refused unless listed inCLI2UI_HOSTED_PRIVATE_NETS. The connection then goes to the vetted IP rather than a second DNS lookup, so DNS cannot re-point an allowed name; this covers the drivers and thepg_dump/psql/mysqldump/mysqlchild processes alike. - Per-IP rate limits — 120 requests a minute overall, 30 for non-GET, 20
for the SQL runner and EXPLAIN — counted before authentication so password
guessing is throttled too.
X-Forwarded-Foris believed only fromCLI2UI_HOSTED_TRUSTED_PROXIESand read from the right, so entries a client invents cannot dodge the limit. The counters are per process; with several workers the limit is per worker.
CLI2UI_ALLOWED_HOSTSandCLI2UI_SECURE_COOKIESare new env vars alongside it (unset,ALLOWED_HOSTSstays permissive as before).scripts/verify_hosted.shchecks all of the above end to end against a real server;scripts/run_hosted.shstarts a hosted instance and opens the browser.- It refuses to start on an unsafe setup —
Extra apps: a generic way to plug a Django app into cli2ui.
CLI2UI_EXTRA_APPS(comma-separated module names) is appended toINSTALLED_APPS, and each app'surlsmodule, if it has one, is included after the core routes — so an extra app can add routes but never shadow a core one. A name that is malformed or cannot be imported stops startup rather than being skipped, and there is no automatic discovery: installing a package does not enable it.core.features.require(key)guards a view with a feature (404 unless the current edition allows it). In hosted mode, a route of an extra app that changes state is refused unless it declared what it needs withhosted.declare_capability(...)(see README.HOSTED.md); naming a capability inCLI2UI_HOSTED_ALLOWnever opens a route that declared nothing.
1.10.0 - 2026-09-21
Added
- The sample connection prefill and the CSRF trusted origins are now
overridable via env vars. Both were hardcoded to the bundled
docker compose upsetup (sampledb/shop/demoandlocalhost:8000/127.0.0.1:8000only), which is what a self-hoster or a hosted read-only demo actually needs to change.CLI2UI_SAMPLE_NAME/_HOST/_PORT/_DBNAME/_USER/_PASSWORDoverride the prefilled form;CLI2UI_EXTRA_CSRF_ORIGINS(comma-separated) adds origins on top of the always-on localhost pair. Unset, both behave exactly as before. - A live, read-only demo, linked from the README and the site — no
install, no signup. Writes are rejected at the database level (a
SELECT-only role), not just by the UI's own write-mode toggle.
1.9.0 - 2026-09-16
Added
A light theme, and a custom one. The header carries four choices — 自動 / 明 / 暗 / 自作. 自動 follows the operating system; 自作 asks for three colours (ground, text, accent) and derives the other fourteen. The choice is kept in
localStorageand applied in<head>before the first paint, so switching does not flash the old theme. It is never sent to the server — no cookie, no request. cli2ui runs on your machine and the theme belongs to the browser looking at it.Two things the custom theme does on purpose. Faint text is clamped: it is made by pulling the text colour toward the ground, and on a theme where those two are already close (Solarized) that sank to 2.0 : 1 — it now stops at the last point that still holds 3.0 : 1. And when the two seed colours themselves are closer than 3.0 : 1, the panel says so rather than quietly drawing a screen nobody can read. The threshold is 3.0 and not 4.5 because 4.5 fired on Solarized Light (4.1 : 1), a theme people really use — and a warning that cries wolf stops being read.
Changed
Colour is a role now, not a shade. Templates no longer name palette colours (
text-zinc-400,bg-emerald-500); they name what the colour is for (text-muted,bg-accent) and the theme decides the shade. There were 1,459 such names across 989 lines, which is why the app could only ever be dark. There are now 17 roles and no direct colours in any of the 38 templates. STYLE.md is rewritten around them.The screenshots in this README are light-mode, and six panels that had never been shown are now in
docs/screenshots/.
Fixed
The primary button's label used the page's ground colour instead of the colour computed to sit on the accent. In the two presets those are the same value, so it looked correct; in a custom theme with a dark ground and a dark accent the label fell to 2.4 : 1. It now uses
on-accent— 7.6 : 1 on the same theme.Filled warning buttons ("Run write", "Preview index") used that same on-accent colour, which is derived from the accent and says nothing about amber. Seed a dark ground with a dark accent and the label went white on amber: 1.9 : 1. They use the ground colour now, which is the readable side of a fixed intent colour by construction: 11.1 : 1.
The table list could hide entire objects, or blur two different ones together. It read
pg_classfiltered torelkind IN ('r', 'p')— ordinary and partitioned tables only, so materialized views and foreign tables never showed up at all. Partition children did show up, but alphabetized flat alongside everything else: a table with fifty monthly partitions buried every unrelated table in between them. And anUNLOGGEDtable looked identical to a regular one, with nothing on screen to say a crash empties it. One query now covers materialized views and foreign tables too, nests each partition under its own parent instead of the alphabet, and flagsUNLOGGED.
1.8.0 - 2026-09-04
Click a table and you see what is in it — a thousand rows at a time, with counts that no longer say 0 when they mean "not counted".
Changed
Clicking a table now shows its rows, not its column list. You wrote the migration; you know what the columns are. What you clicked the table to see is what is in it. Columns and Indexes are one click away, and the tab you are on now survives an edit — changing a column used to throw you back to the first tab every time the panel re-rendered.
The row grid is paged: 1,000 rows at a time, and you can change that (100 / 500 / 1,000 / 5,000). It used to show the first 50 rows and stop, with no way to see row 51.
Pages are ordered by the primary key.
LIMIT/OFFSETwithout anORDER BYis faster, but PostgreSQL does not promise an order, so paging through would show some rows twice and never show others — a pager that lies. Measured on the bundled Airlines demo (2,360,335 rows): the first page takes 7 ms ordered, the last 878 ms; unordered it is 50 ms and wrong. On a table with no primary key there is nothing to order by, and the pager says so rather than pretending.The total is the statistics estimate, labelled as one —
count(*)would scan the whole table every time you opened it. Whether a next page exists is decided by how many rows actually came back, not by the estimate: stale statistics make a "next" that lands on nothing, which is worse than a total that is slightly off.
Fixed
The table list showed 0 rows for tables that were not empty. The bundled Airlines demo has 2,360,335 rows in
ticket_flights; the sidebar said0.Row counts came from
pg_stat_user_tables.n_live_tup, which counts writes this server has observed since it started — so a database restored from a dump reports zero for everything, forever, until something writes to it. The planner statistics were right there and correct the whole time.The list now reads
reltuples, like the TRUNCATE/DROP confirmations already did. And it distinguishes "no rows" from "not counted yet": a table that has never been analyzed says unknown, not0. That rule was already in place before a destructive button — writing0reads as "empty, safe to drop" — but the table list had been saying0all along. Same rule, both places now.The workspace total no longer folds unknown tables in as zeroes either; summing what you do not know produces a total that looks authoritative and is not.
MySQL gets the same treatment:
TABLE_ROWSis NULL until InnoDB has statistics, and that NULL is now carried through instead of being flattened to0.
Changed
Counts are written with thousand separators.
2,360,335rather than2360335: past six digits, an unseparated number has to be counted rather than read, and 2.3 million and 230 thousand look alike at a glance. This covers the table list, the row counts in the TRUNCATE and DROP confirmations, query and filter results, the orphan-row and NULL-slip checks, and the overview tiles.Values read out of your database are still printed exactly as they came. A
bigintcolumn holding1048576shows as1048576, because rewriting it would be the same thing the timezone fix above just stopped doing. Numbers this tool counted are formatted; numbers your database holds are not.
1.7.0 - 2026-09-04
Try the ALTER before you run it — and stop the screen from arguing with your database.
Fixed
The screen was rewriting the timestamps your database returned. A
timestamptzholding2026-09-04 00:10 UTCwas displayed as2026-09-03 19:10. The value in the database was correct; the page was not.Two things stacked up.
TIME_ZONEwas never set, so Django's default —America/Chicago— silently applied to every aware datetime the templates rendered, including rows read out of your database. On top of that,LocaleMiddlewarepicked upAccept-Language: jaand formatted the result in Japanese, so the same row read differently depending on the browser.For a tool whose entire job is to show what a database actually holds, a display that quietly disagrees with it is the worst thing it can do — so the fix is in two parts:
TIME_ZONEis now explicit, defaulting to UTC rather than a city nobody chose.docker composepasses the host'sTZthrough, soTZ=Asia/Tokyo docker compose upshows the app's own timestamps (backups, snapshots) in local time.- Values read out of your database are no longer converted or reformatted at
all. They render exactly as the database returned them, offset included —
2026-09-04 00:10:00+00:00, the same stringpsqlprints. The display no longer changes when you switch the interface language: the UI is translated, your data is not.
Added
Rehearse an
ALTER, then throw it away. v1.6.0 showed you the statement before you pressed. This runs it — for real, against your table, inside a transaction that is always rolled back — and tells you what happened.ALTERis the only destructive operation that can be rehearsed at all: PostgreSQL can roll back DDL.DROPcannot be tried and undone, which is why everything else in this family stops at showing you what it would take with it.Three answers come back, and all three are answers:
- It fits — with the measured time the statement held ACCESS EXCLUSIVE. Measured, not estimated: the real button sends the statement as a single autocommit statement, so the statement's duration is the hold.
- It does not fit — the budget ran out while it was still working. This is not an error. "It does not finish in 3 seconds on this table" is what you came to find out, and the timeout is treated as the result rather than as a failure. When the bound that stopped it was the lock wait rather than the work, it says so: somebody else is holding the table, which is exactly what the real change would have hit.
- It would not run at all — existing NULLs, a column that is not there. Found in a second, at no cost, instead of in production.
What it costs is said out loud, before you press: the rehearsal takes a real ACCESS EXCLUSIVE lock for as long as it runs, so readers really do queue behind it. Briefly, but for real. A simulation would have cost nothing and told you nothing.
The rehearsal composes the statement with the same builder the real change uses, so it cannot drift into measuring something the button would not send — the same rule the v1.6.0 preview follows.
SET NOT NULLonly, for now.ALTER COLUMN TYPErewrites the table, and a rewrite burns WAL and temporary disk for real even when the transaction is rolled back; that needs a size gatekeeper first.PostgreSQL only. MySQL commits DDL implicitly, so there is nothing to roll back and no rehearsal to offer.
1.6.1 - 2026-09-01
Two data-loss fixes: one introduced in 1.6.0, and one that has been there since the backup feature first shipped (before 0.8.0) and had never once run in a test on the author's machine. Both were found by turning CI on. Upgrade before opening the Objects panel again, and see below for what to check if you already have.
Fixed
Opening the Objects panel dropped databases. Not on a click, not on a confirmation — on rendering the panel.
1.6.0 added "the SQL a button is about to run, shown in its confirmation". It works by calling the same method the button would call, inside
engine.preview(), and capturing what the executor composed instead of sending it. The Objects panel does that for every row it lists, so it can show each row'sDROPbefore you press.The capture was implemented in
_execute. Database-level statements do not go through_execute— they need a connection to a different database, so they take a second path,_execute_admin, and that path had no capture. Previewing aDROP DATABASEtherefore ran it.The blast radius was every database on the server that the connection was allowed to drop: the connected database refuses to drop itself, and the maintenance database (
postgres) fails because it is in use, so those two survived. Everything else went, silently, at render time.PostgreSQL only. MySQL's
drop_databasegoes through_executeand was never affected. Schemas and roles were never affected either, for the same reason.If you ran 1.6.0 against a server with databases you care about, check whether they are still there before doing anything else. They were dropped without
WITH (FORCE), so a database with an open connection at that moment survived; anything idle did not. cli2ui's own auto-backup does not cover this path — the drop came from the preview, which takes no snapshot.Both execution paths are now guarded, and the tests cover every drop the panel previews rather than one representative of them. The 1.6.0 test suite did contain the failing end-to-end test that would have caught this; there was no CI running it.
Restoring a table snapshot threw away what it had just recovered. The automatic safety snapshot taken before a destructive table operation is a single-table dump. A table's foreign keys point at tables such a dump has no room for, so restoring it always hit "relation does not exist" — and the restore was strict, so one unapplicable constraint failed the whole operation and the database, already filled with the rows the snapshot existed to protect, was dropped again.
Most tables have a foreign key. This was the common case, not the corner one: the safety net worked right up until you needed it.
Restoring into a brand-new database is now lenient — it applies everything it can, which also recovers the primary key and indexes that sat after the failing constraint in the archive — and then asks the database what landed rather than reading the tool's message, which the server writes in its own language. An empty database still means failure and is still dropped. A database with tables in it is kept, with a warning saying which parts could not be applied. Restoring into an existing database is unchanged and still all-or-nothing.
This was covered by a test the whole time. It never ran on the author's machine: the round trip is skipped when the local
pg_dumpis newer than the server, which it always was. CI, on a version-matched pair, ran it on the first try.
1.6.0 - 2026-08-28
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.
Added
The SQL a button is about to run, shown in its confirmation. Not a second rendering of what we think it will do:
engine.preview()runs 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_timeoutthat guards every destructive DDL is shown too. It has always been there — bounding the wait so one clicked button cannot stall a table until the connection pool runs dry — but nothing in the UI ever said so.What a TRUNCATE or DROP would take with it. How many rows, and who points at this table.
The row count comes from the planner's statistics, not
count(*): opening a confirmation dialog must not scan a billion-row table. It is labelled an estimate, with the date the statistics were last refreshed. A table that has never been analyzed reports unknown, not zero — "0 rows" would read as "empty, safe to drop".Foreign keys pointing at the table are listed, because a TRUNCATE fails without CASCADE while one exists, and finding that out after pressing is late.
What
DROP … CASCADEwould take with it. Views and materialized views, by name.DROP TABLEdefaults to RESTRICT and simply fails while something depends on it — that is the safe outcome. The dangerous one is reaching for CASCADE without knowing what is on the other end.Implicit dependencies (indexes, constraints, types) are left out: they go with the table by definition, and listing them buries the view you actually needed to see. Foreign keys are left out here too — they are counted in the line above, and the same object in two places reads as two casualties.
Previews that cannot be produced are not rendered at all. An empty SQL box or a "0 rows" line would state something untrue.
1.5.0 - 2026-08-28
Declared, but not in force. Two constraints that are enforced with a hole in them, both read from the catalog and both shown where you would actually notice them — a database, not one table at a time.
Added
Invalid indexes, listed DB-wide. An index left
indisvalid = falseby a failedCREATE INDEX CONCURRENTLYis 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.A build still running looks exactly the same in the catalog, so
pg_stat_progress_create_indexis read and "building" is reported as its own state rather than filtered out — dropping that one throws away work that is about to finish. A failed concurrent drop (indislive = false) gets its own state too: unusable for queries, still maintained on every write.Composite UNIQUE with a NULL hole.
UNIQUE (email, tenant_id)does not stop two rows with the same email whentenant_idis NULL in both — NULLs are not equal to each other, so the index sees two different keys. The constraint is enforced; it has a hole the shape of its nullable columns.The list is catalog-only. Counting what actually slipped through is a separate, on-demand read:
GROUP BYtreats NULLs as the same group, which is precisely what the unique index does not do, and that difference is the duplicates. PostgreSQL 15'sNULLS NOT DISTINCTcloses the hole, so those indexes are not listed; the column itself only exists from 15, so the query is chosen by server version rather than referencing a column that would fail to parse on 14.Partial and expression indexes are excluded: a
WHERE-qualified unique constraint declares uniqueness for those rows, so duplicates outside the predicate are the specification, not a slip.
Both are declared UNSUPPORTED on MySQL rather than answered with an empty card — an empty card reads as "no problem here".
1.4.0 - 2026-08-23
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 takeACCESS 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 — verified under a heldLOCK TABLES … WRITE, where a plainSELECTblocked and the size query returned in 8ms.) - "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.
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.- Upgrading an existing checkout moves the management database. Dropping the
.:/appmount from the default path meant saved connections and auto-backups would have lived inside the container and vanished on the nextup, soCLI2UI_DB_PATHnow points at/dataon a named volume. A checkout that had connections saved in the project root will therefore start empty: either copy the existingdb.sqlite3into the volume, or use the override, which puts the file back in the project root where the host can inspect or delete it.
1.3.0 - 2026-07-31
Added
Replication: replay lag in time, not just bytes — the Standbys table now shows each replica's
replay_lagalongside the byte lag: how long a commit on the primary takes to become visible on that standby (ms/s/min). Byte lag tells you how far a replica trails; replay lag tells you how long — which is the number that matters when you read your own writes from a replica. It's exactly the wait PostgreSQL 19'sWAIT FOR LSNwould incur for a read there. Read frompg_stat_replication.replay_lag(alsowrite_lag/flush_lagin the panel's "open in SQL");NULLuntil Postgres has a round-trip sample, shown as—.Locks: head-blocker chains — the Locks panel now folds the wait-for graph into a tree instead of a flat "who's blocking me" list. Each tree is rooted at the head blocker — the session holding a lock and waiting on nothing — with the blocked sessions nested beneath it, deeper for each step down the chain, so the highest-leverage action (cancel/kill the head to free everything under it) is at the top, labelled with how many it's blocking. Multi-level chains (A waits on B waits on C) are traced to their root and blocking cycles are detected and flagged. Engine-agnostic — the same folding runs for PostgreSQL and MySQL.
Health: orphan rows (unenforced references) — a new card surfacing the two places a referenced row can go missing: foreign keys left
NOT VALID(their pre-existing rows were never checked) and<base>_idcolumns with no foreign key that name-match a table's single-column primary key. A validated FK can't have orphans, so it's never listed. Each row offers an on-demand "count orphans" that runs a read-onlyLEFT JOINanti-join under a statement timeout — it counts, it never validates or changes anything. A fact, not a rule. PostgreSQL; MySQL shows "not applicable" (InnoDB has noNOT VALIDclause, so a declared FK is always enforced).Extensions panel — a read-only catalog view of what's installed in this database and what the server could install (the Web equivalent of
\dxunioned withpg_available_extensions), with an update-available badge when an installed version lags the server's default.CREATE EXTENSIONstays in the SQL runner. MySQL shows "not applicable" (plugins/components are a different, server-level concept).JSON shape on
json/jsonbcolumns — an on-demand "shape" view in the table detail's Columns tab: sample up to 500 non-null values (read-only, statement-timeout capped) and show the observed top-level keys with value types and occurrence counts, root type mix, nesting depth, and whether a GIN index covers the column. Describes the rows examined, not every row — a fact, not a rule. PostgreSQL; MySQL shows "not applicable".Opt-in Airlines demo database —
seed/demo/fetch.shdownloads the Postgres Pro "Airlines" dataset (~60MB; never committed) anddocker compose --profile demo up -d airlinesserves it on port 5434, loaded once into a named volume. Realistic volume (65k flights, 593k bookings, 829k tickets,jsonbcolumns) for the health, activity, and sampling features.
Changed
Structural changes now give up instead of piling up — every
ALTER/DROP/TRUNCATE/ rename the UI issues runs with a shortlock_timeout(2s;lock_wait_timeouton MySQL, whose own default is a full year). A DDL statement needs an exclusive lock, so it waits behind any open transaction on the table — and every statement that arrives after it, plainSELECTs included, then waits behind the DDL. That is how one clicked button used to be able to stall a whole table until the connection pool ran dry. Now the wait is bounded: the change fails in seconds with a message saying nothing was changed and pointing at the Locks panel, and no queue ever forms. It bounds the wait, not the hold — a statement that rewrites the table (ALTER COLUMN … TYPE) still holds its lock for as long as the rewrite runs.CREATE INDEX CONCURRENTLY/DROP INDEX CONCURRENTLYare deliberately exempt: they block nobody, and timing them out is what leaves an invalid index behind.Write mode offers the same lock guard — hand-written DDL can park a table exactly like the buttons can, so write mode in the SQL runner runs under the same 2s
lock_timeout, shown in the header line and turned off with a checkbox. Opt-out rather than mandatory: this is SQL you wrote yourself, and sometimes waiting for the lock is the point. Read-only runs are untouched — a waitingSELECTholds no lock, so nothing can queue behind it, andstatement_timeoutalready bounds it.
Fixed
Write mode never reached the server — the write-mode and (new) lock-guard toggles sit outside the runner's
<form>, and htmx only serializes a form's own descendants, sowrite=1was silently dropped from every request. The panel showed write mode as armed while the server ran the statement read-only: writes came back as "cannot execute … in a read-only transaction" and the safety snapshot that precedes a write never ran. The form now pulls the toggles in explicitly (hx-include), and a test asserts it keeps doing so.Health: unindexed foreign keys no longer counts a partial index (its predicate can't be proven for the FK's own lookup) or an invalid index (left behind by a failed
CREATE INDEX CONCURRENTLY) as covering a foreign key — both previously produced a false "indexed" verdict.
1.2.0 - 2026-07-05
Added
- Health: unindexed foreign keys & redundant indexes — two new read-only, catalog-fact cards on the Health panel. "Unindexed foreign keys" flags FK columns that aren't the leading key of any index (PostgreSQL doesn't auto-create one, so FK checks / cascade deletes / joins to the parent seq-scan; MySQL/InnoDB auto-indexes FK columns, so the card shows "not applicable"). "Redundant indexes" flags a non-unique index whose columns are a leading prefix of, or identical to, another index on the same table. Neither advises — they only surface the fact. PostgreSQL and MySQL.
1.1.0 - 2026-07-04
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 safe 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).
1.0.0 - 2026-06-20
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 — where a feature has no MySQL equivalent the panel degrades to a clear "not applicable" rather than a misleading empty card.
Added
- MySQL support (phase 1) — a
MysqlEngine(PyMySQL) wired into the engine factory, so a connection of kind "mysql" now works for: connecting, the table list / column detail / row preview, the read-only ad-hoc query runner (the server enforces read-only viaSTART TRANSACTION READ ONLY) with write mode, the filter builder, CSV import, streamed CSV/JSON exports,EXPLAIN(FORMAT=JSONparsed into the shared plan tree, so snapshots/diffs work), the session/process list (SHOW PROCESSLIST) with cancel/kill, index list + create/drop, table rename/truncate/drop, column add/rename/drop/retype/ nullability/default, database create/drop, the user list, and table sizes. The connection form defaults the port to 3306 for MySQL and the detail panel's starter query uses backtick quoting. cryptographydependency — PyMySQL needs it for MySQL 8's defaultcaching_sha2_passwordauth over a non-TLS connection.- MySQL support (phase 2) — the locks panel now shows a real lock-wait graph for
MySQL (
performance_schema.data_lock_waits, MySQL 8.0+), and the health panel lists unused indexes (sys.schema_unused_indexes). When the server can't answer "is anything blocked?" —performance_schemais off, or the server is older than 8.0 — the panel raises a clear message instead of reporting a false "nothing blocked". Engine.supports()/UNSUPPORTED— engines can declare a feature conceptually absent (e.g. InnoDB has no vacuum or bloat model, and MySQL has no schema separate from a database). Panels then show "not applicable to this engine" rather than an empty card, so a structural absence is never confused with "no data".- MySQL support (phase 3) — the three remaining ops surfaces now work for MySQL:
- Backup via
mysqldump/mysql(fixed argv, no shell, password through theMYSQL_PWDenvironment, never the command line). This also fixes a latent crash: destructive operations snapshot first via_auto_backup, which had no MySQL dump to call and 500'd before the operation ran. - Settings editor reads
performance_schema.global_variablesand persists changes withSET PERSIST(writesmysqld-auto.cnf, surviving a restart — the closest match to Postgres'ALTER SYSTEM). Variable names are whitelisted against the server catalog before use; values are bound. - Replication shows the binlog/GTID posture (role,
log_bin,server_id,gtid_mode, binlog position, and replica thread/lag health), connected replicas, and a copy-paste recipe to attach one (GRANT REPLICATION SLAVE→CHANGE REPLICATION SOURCE TO … SOURCE_AUTO_POSITION=1→START REPLICA). MySQL has no replication slots, so that part is flagged not-applicable.
- Backup via
Notes
- MySQL has no schema-vs-database split, so the engine reports the connection's
database as each table's schema and scopes catalog queries to it. The remaining
capabilities with no MySQL equivalent — role mutations, the planner what-if lab
(MySQL DDL commits implicitly, so it can't be rolled back), replication slots,
and vacuum/bloat/schema health — raise a clear message or are flagged "not
applicable", so panels degrade rather than break. The distinction is
deliberate: a feature that could report a problem but can't right now (lock
waits with
performance_schemaoff) raises, while one that is conceptually absent returns empty and is flagged — a safety signal like "is anything blocked?" must never degrade to a false negative.
0.9.0 - 2026-06-17
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-only
SELECT *, 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-only
SELECT * … 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.
0.8.0 - 2026-06-17
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).