cockpit: use the JSON API, with per-command screens and docs; JSON API additions and fixes, #9454 - #10353
Draft
ThomasWaldmann wants to merge 25 commits into
Draft
cockpit: use the JSON API, with per-command screens and docs; JSON API additions and fixes, #9454#10353ThomasWaldmann wants to merge 25 commits into
ThomasWaldmann wants to merge 25 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #10353 +/- ##
==========================================
+ Coverage 88.25% 88.69% +0.44%
==========================================
Files 103 107 +4
Lines 18898 19619 +721
Branches 2938 3028 +90
==========================================
+ Hits 16678 17401 +723
- Misses 1544 1549 +5
+ Partials 676 669 -7 ☔ View full report in Codecov by Harness. |
ThomasWaldmann
marked this pull request as draft
September 10, 2026 09:05
ThomasWaldmann
force-pushed
the
cockpit-json-9454
branch
from
September 18, 2026 18:16
edf5519 to
23adc5e
Compare
…es, borgbackup#9454 The cockpit now runs borg with --log-json --progress injected and feeds the JSON objects it gets on stderr (log_message, progress_percent/message, archive_progress, file_status, question_*) as typed events into a Session model, which the widgets render from on a timer. - events.py: JSON line -> typed event, tolerant to unknown types and keys. - session.py: item counts, phases, rates and a bounded log buffer. The --list lines are the preferred source of the counts (exact and complete), archive_progress is the fallback (rate limited, its final object carries no statistics) and the source of the sizes. - runner.py: stdin pipe for answering prompts, stdout/stderr readers that also pass on unterminated lines (prompts) after an idle time; on POSIX, borg runs in a new session, so it has no controlling terminal. - prompt.py: modal dialog for question_prompt, the answer goes to stdin. - widgets.py: the status panel is updated from the session and also shows the original/deduplicated size and the current progress message; the log panel styles lines by status/level and is bounded. - tests: the event parser, the session and the runner are tested without Textual; the app is tested with a fake runner on all platforms. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The screen is selected by the borg subcommand: - CreateScreen (create, import-tar, recreate, transfer): the archive statistics, with the deduplication ratio, the throughput and the number of warnings. For create and import-tar, --json is added to the command line and the final statistics are taken from the JSON on stdout: the panel shows the exact numbers and the archive name and duration at the end, the log shows the statistics like --stats would. - ExtractScreen (extract, export-tar): a progress bar with percentage and ETA over the bytes to extract, the throughput and the --list counts. - GenericScreen (everything else): the phases borg reports progress for, with a bar per phase, plus elapsed time, warnings and the exit code. The status panels share a base class that only updates the lines whose text changed; the top row is as high as the panel needs. The session model gained the stdout capture, per-phase rates and the warning counts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The page describes how to run a command in the cockpit, how the cockpit works (subprocess with --log-json --progress, the JSON API), what the display shows per kind of command, how prompts and passphrases are handled and the keys. It is linked from the usage toctree and the installation docs. Also: the cockpit exits with the exit code of the borg command (it was always 0 before), and the --cockpit help text says what the cockpit is. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The getpass fallback warning text and the hint about setting the passphrase environment variables were assigned to names containing "PASSPHRASE", which bandit (B105) reports as hardcoded passwords, failing the security CI job. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…SON objects, borgbackup#9454 With --log-json, the --list output of extract and export-tar consists of file_status objects now (status "+" or "-"), like for create, instead of log_message objects carrying the text lines. The text listing of export-tar gets the "+" prefix that extract's listing already has. prune --list / --list-kept / --list-pruned output one archive_status object per listed archive with --log-json: the keys of the archive objects of its --json output (name, id, time, group, kept, keep_rule, ...) plus the text line as "message". The frontends docs describe both, with examples. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The list lines of extract, export-tar and prune arrive as file_status and archive_status objects now, so the shim recognising them in log messages of the borg.output.list logger is gone. The generic screen shows the numbers of kept and pruned archives for prune. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s key, borgbackup#9454 With --log-json, delete --list and undelete --list output one archive_status object per archive (status "deleted" / "undeleted"), like prune does for the archives it keeps or prunes. All archive_status objects carry a "status" key now (kept, pruned, deleted, undeleted; with --dry-run: what would be done), so a frontend can tell them apart without knowing the command. The commands share Archiver.print_archive_status(), the counterpart of print_file_status(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The archive_status objects are counted by their status key; the generic screen shows e.g. "Archives: 3 kept, 2 pruned" or "Archives: 2 deleted". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paths, archive names and messages were shown via markup strings. rich's escape() does not protect textual's markup parser: a text containing "[/" without a closing bracket raised a MarkupError in the refresh timer, which ended the app and thus terminated the borg run. Also, a trailing backslash was shown doubled and "\[" was shown as "[". The status panel lines do not interpret markup anymore, the phases and the log lines are rich Text objects with a style. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The test failed on Windows, where the output of the borg.exe binary has CRLF line endings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
borg's stdin is a pipe from the cockpit (for the answers to prompts) that stays open while borg runs, so "create NAME -", --paths-from-stdin, "import-tar NAME -", "key import -" / --paper and serve waited forever. The terminal belongs to the TUI, so there is no way to give them their input: borg --cockpit now refuses them with an error message. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With redirected output (or when run by cron), the TUI wrote its escape sequences to the file or pipe and never ended, because there was no way to press the key that quits it. borg --cockpit now refuses to start if stdin, stdout or stderr is not a terminal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The runner collected an unterminated line without a limit and re-split the whole buffer for each chunk read: 200 MB of stdout data without a line end (extract --stdout, export-tar to "-") took 141 s and 3.6 GB of memory. Of a line longer than 1 MiB, only the start is passed on now and the rest is dropped (200 MB: 0.2 s, no memory growth). extract --stdout and export-tar to "-" can not work in the cockpit (it owns the terminal and borg's stdout), so borg --cockpit refuses them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The cockpit removes --cockpit from the command line of the borg it runs, but the option can also be enabled by BORG_COCKPIT=true or by "cockpit: true" in borg's config file, which applied to that borg also. The runner now sets BORG_COCKPIT=false for it (the environment overrides the config file). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The signal handlers installed by borg's main() raise an exception. Raised somewhere inside the event loop of the TUI, it ended the cockpit with a traceback and exit code 1, without terminating and waiting for borg (after a SIGHUP, that took 15 s). While the app runs, the event loop's signal handlers replace them: the cockpit terminates borg, waits for it and exits with borg's exit code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A path, an archive name or a message can contain control characters, e.g. an ESC starting a terminal escape sequence. They reached the terminal as they were, so a file name could clear the screen or otherwise corrupt the TUI. The status panels, the log and the prompt dialog now show U+FFFD instead of C0, DEL and C1 control characters (the log and the prompt dialog keep linefeeds and tabs). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Quitting the cockpit terminates borg, and q (or Ctrl-C) did that at once: an accidental key press ended a running backup. If borg still runs, the cockpit now shows a dialog first. The button that has the focus (Enter), n and Escape continue, y terminates borg and quits. When borg has finished, q quits right away, as before. A signal (SIGTERM, SIGHUP, SIGINT) still ends the cockpit without asking. The dialogs have a translucent background now, so the cockpit stays visible behind them. Tests: they send the keys via App.simulate_key(), pilot.press() waits for the end of all animations and the pulsar and slogan never stop theirs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…--list lines
As soon as there was a --list line, the create panel counted the list lines
instead of using archive_progress. With --filter, the list only has some of
the items: "create --list --filter=AME" of 120012 files with 3 modified ones
showed "Files: 3, Unchanged: 0" for the whole run (archive_progress said
23223 files, 23220 unchanged after 3 s). Also, the number of files included
the directories, and excluded items ("-") were counted as unchanged.
The number of files and the counts by status now only come from the final
--json statistics or else from archive_progress, so they do not depend on
the unrelated --list option anymore. What borg does not report is shown as
"-" instead of a misleading 0: the counts by status for import-tar, all
numbers of a dry-run until it has finished, and everything for recreate and
transfer (they output their progress as text, even with --log-json).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ress
ArchiveRecreater created the target archive without log_json, so its
statistics printed the progress as text lines ("1.02 kB O 0 B U 1 N path"),
even with --log-json. docs/internals/frontends.rst documents archive_progress
objects for recreate, and a frontend reading the JSON stream got lines it can
not parse.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ress transfer created the archive in the destination repository without log_json, so its statistics printed the progress as text lines, even with --log-json, like recreate did. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Only create filled Statistics.files_stats, so for import-tar and recreate the counts were always 0: the "Added files" line of import-tar --stats, and files_stats in the archive_progress objects and in the --json output of import-tar (a frontend could not show how many files were added). Both now count each item where they print its --list status: import-tar the members of the tarball, recreate the items that go into the new archive (not the excluded ones). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The progress output is rate limited and the final archive_progress object (finished: true) had no statistics, so nothing reported what was processed after the previous object, or anything at all for a short operation: a transfer of 10000 files ended at "nfiles": 5653, a recreate of 500 files at 0. create and import-tar have the final --json output for that, recreate and transfer have nothing else. The final object had lost its statistics because they were all 0 for create, see borgbackup#6570: create and import-tar reported the statistics of the archive before adding the ones of the object processors to them. They add them first now. The cockpit uses the statistics of the final object. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ThomasWaldmann
force-pushed
the
cockpit-json-9454
branch
from
September 20, 2026 18:00
5a250b4 to
d6a38a8
Compare
A dry-run counted the files and their size, but never reported them before it had finished: --progress showed nothing, and with --log-json there were no archive_progress objects, so a frontend had nothing to show. It now reports its statistics like a real run does (rate limited, plus a final object). The deduplicated size is unknown for a dry-run (it does not read the files): it is absent from the JSON objects, the text progress shows 0 B. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Like create --dry-run did, a dry-run of recreate reported nothing but a final archive_progress object with all statistics at 0. It now counts the files that would be in the new archive and their size and reports them like a real run does. The deduplicated size is unknown for a dry-run: it is absent from the JSON objects, the text progress shows 0 B. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With --dry-run, prune, delete and undelete output the same status values (pruned, deleted, undeleted) as when they really do it, only the message text said "Would prune: ...". A frontend had to know the command line to tell whether an archive is gone. Each archive_status object now has a boolean dry_run key. The cockpit marks the archive counts of a dry-run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebuilds the cockpit TUI (
borg --cockpit) on top of the documented JSON API, adds the JSON objects that were still missing for it and fixes the gaps in borg's JSON output that showed up while doing so. Fixes #9454.The cockpit
The cockpit runs the borg command as a subprocess with
--log-json --progressinjected and builds its display from the JSON objects borg emits for frontends (docs/internals/frontends.rst), like Vorta does. It no longer classifies text lines by their first character.Architecture (
src/borg/cockpit/):runner.pyruns borg with the options injected in front of the subcommand, reads stdout/stderr as pipes and turns the stderr lines into typed events. stdin is a pipe, so prompts can be answered. On POSIX, borg runs in a new session: it has no controlling terminal and cannot paint over the TUI. Unterminated lines (prompts) are passed on after 1 s of idle time; of a line longer than 1 MiB only the start is passed on, so the buffer is bounded. The borg it runs getsBORG_COCKPIT=false, so a cockpit enabled via the environment ordefault.yamldoes not start another one.events.py:parse_json_line()givesLogMessage,ProgressPercent,ProgressMessage,ArchiveProgress,FileStatus,ArchiveStatus,QuestionorUnknownJson; the runner addsRawLineandProcessFinished. Tolerant to unknown types and missing keys.session.py: the state of one run, without any Textual dependency: the statistics, archive counts, phases (by operation id / msgid), the current progress text, rates, warnings, and a bounded line buffer that the UI drains at 5 Hz, so a--listflood costs little more than the JSON parsing.screens.pyandwidgets.py: the screen is selected by the subcommand. CreateScreen (create, import-tar, recreate, transfer): files, sizes with the deduplication ratio, the counts by status, warnings, the current path, files/s and MB/s with a sparkline. ExtractScreen (extract, export-tar): a progress bar with percentage and ETA, MB/s, the--listcounts. GenericScreen (everything else): elapsed time, warnings, the exit code, the archives listed by prune / delete / undelete (marked as dry-run if nothing was changed), and the phases borg reports progress for, with a bar per phase.prompt.py: a dialog forquestion_prompt(YES / NO buttons and a free-text field, the answer goes to borg's stdin) and the quit confirmation.Where the numbers come from: only from borg's statistics, the final
--jsonoutput (added to the command line for create and import-tar) or elsearchive_progress. They do not depend on--list/--filter, which only determine what the log shows. What borg does not report is shown as-(the counts by status for transfer and for a dry-run).Robustness:
[/used to crash the app and thus end the backup), and control characters are shown as U+FFFD, so a file name cannot send escape sequences to the terminal.borg --cockpitrefuses, with an error message, what cannot work in it: commands reading from stdin (create NAME -,--paths-from-stdin,import-tar NAME -,key import -/--paper,serve), commands writing their data to stdout (extract --stdout,export-tar NAME -), and running without a terminal (it used to write escape sequences into the redirected output and never end).q(or Ctrl-C) asks for confirmation while borg still runs, because quitting terminates borg.Behaviour:
--listis only passed through when given. The cockpit exits with the exit code of the borg command (it was always 0 before).ttoggles the translator.Docs: a usage page (
docs/usage/cockpit.rst), linked from the usage toctree and the installation docs.JSON API: additions
file_statusobjects are now also output byextract --listandexport-tar --listwith--log-json(status+/-), as create does. The text listing of export-tar gets the+prefix extract's listing already had.archive_statusobjects, one per listed archive, forprune --list/--list-kept/--list-pruned,delete --listandundelete --listwith--log-json:status(kept,pruned,deleted,undeleted),dry_run(the status is what would be done),name/archive,id,timeandmessage(the text line). Prune additionally gives the keys of the archive objects of its--jsonoutput. The commands shareArchiver.print_archive_status(), the counterpart ofprint_file_status().Compatibility: with
--log-json, these--listlines used to belog_messageobjects (loggerborg.output.list) carrying the text line. A frontend that only showslog_messageobjects does not show these listings anymore until it handlesfile_status/archive_status.JSON API: fixes
recreateandtransferoutputarchive_progressobjects with--log-json --progress, as documented. They printed text progress lines, because their archive objects were created withoutlog_json.archive_progressobject (finished: true) has the final statistics. The progress is rate limited, so nothing reported what was processed after the previous object, or anything at all for a short operation (a transfer of 10000 files ended atnfiles: 5653). It had lost its statistics because they were all 0 for create, see borg create: Everything 0 archive_progress at end of backup #6570: create and import-tar reported the archive's statistics before adding the ones of the item processors. They add them first now.import-tarandrecreatecount the items by their status (files_stats), like create does. The counts were always 0, also "Added files" ofimport-tar --stats.create --dry-runandrecreate --dry-runreport their progress (--progressshowed nothing, there were noarchive_progressobjects). The deduplicated size is unknown for a dry-run: absent from the JSON objects, 0 B in the text progress.All of this is documented in the frontends docs, with examples.
Verified
--list,--filter,--dry-run, a rerun on the same series; 120000 files), extract, recreate, transfer, import-tar,check --repairanswering NO and YES, compact, prune, an encrypted repo without a passphrase, quitting with and without confirmation, SIGTERM / SIGHUP.--list19.5-20.0 s, in the cockpit with--list20.1-20.8 s.Known limitations
--listlines, and nothing is printed to the terminal after the cockpit has ended.setsid) and signals are not handled; untested there beyond CI.Follow-ups, not in this PR
A warnings view and a summary after exit, a JSON type for passphrase prompts, time-based throttling of
progress_percent. Found on the way, not touched: recreate counts the item metadata into the archive's original size (shown byborg infoafterwards), andrecreate --statsprints no statistics.Commits
🤖 Generated with Claude Code