Skip to content

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
borgbackup:masterfrom
ThomasWaldmann:cockpit-json-9454
Draft

ThomasWaldmann wants to merge 25 commits into
borgbackup:masterfrom
ThomasWaldmann:cockpit-json-9454

Conversation

@ThomasWaldmann

@ThomasWaldmann ThomasWaldmann commented Sep 10, 2026

Copy link
Copy Markdown
Member

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 --progress injected 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.py runs 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 gets BORG_COCKPIT=false, so a cockpit enabled via the environment or default.yaml does not start another one.
  • events.py: parse_json_line() gives LogMessage, ProgressPercent, ProgressMessage, ArchiveProgress, FileStatus, ArchiveStatus, Question or UnknownJson; the runner adds RawLine and ProcessFinished. 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 --list flood costs little more than the JSON parsing.
  • screens.py and widgets.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 --list counts. 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 for question_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 --json output (added to the command line for create and import-tar) or else archive_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:

  • Text from borg (paths, archive names, messages) is never interpreted as markup (a path containing [/ 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 --cockpit refuses, 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).
  • SIGTERM, SIGHUP and SIGINT end it in an orderly way: borg is terminated and waited for, the cockpit exits with borg's exit code (it was a traceback and rc 1).
  • q (or Ctrl-C) asks for confirmation while borg still runs, because quitting terminates borg.

Behaviour: --list is only passed through when given. The cockpit exits with the exit code of the borg command (it was always 0 before). t toggles the translator.

Docs: a usage page (docs/usage/cockpit.rst), linked from the usage toctree and the installation docs.

JSON API: additions

  • file_status objects are now also output by extract --list and export-tar --list with --log-json (status + / -), as create does. The text listing of export-tar gets the + prefix extract's listing already had.
  • New archive_status objects, one per listed archive, for prune --list / --list-kept / --list-pruned, delete --list and undelete --list with --log-json: status (kept, pruned, deleted, undeleted), dry_run (the status is what would be done), name / archive, id, time and message (the text line). Prune additionally gives the keys of the archive objects of its --json output. The commands share Archiver.print_archive_status(), the counterpart of print_file_status().

Compatibility: with --log-json, these --list lines used to be log_message objects (logger borg.output.list) carrying the text line. A frontend that only shows log_message objects does not show these listings anymore until it handles file_status / archive_status.

JSON API: fixes

  • recreate and transfer output archive_progress objects with --log-json --progress, as documented. They printed text progress lines, because their archive objects were created without log_json.
  • The final archive_progress object (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 at nfiles: 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-tar and recreate count the items by their status (files_stats), like create does. The counts were always 0, also "Added files" of import-tar --stats.
  • create --dry-run and recreate --dry-run report their progress (--progress showed nothing, there were no archive_progress objects). 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

  • Unit tests: the event parser, the session and the runner (with a fake borg script, including a prompt round trip via stdin) run without Textual; the app tests drive each screen with a fake runner and cover the layout, the dialogs, markup-like and control characters, signals and quitting. They also pass with Textual 6.8.0, the declared minimum.
  • Archiver tests for the JSON objects of create, extract, export-tar, import-tar, recreate, transfer, prune, delete and undelete, including the final statistics and the dry-runs.
  • With real borg on macOS, driving the TUI headless or in a pty: create (with --list, --filter, --dry-run, a rerun on the same series; 120000 files), extract, recreate, transfer, import-tar, check --repair answering NO and YES, compact, prune, an encrypted repo without a passphrase, quitting with and without confirmation, SIGTERM / SIGHUP.
  • Overhead, 120000 files: plain create 18.1 s, with text --list 19.5-20.0 s, in the cockpit with --list 20.1-20.8 s.

Known limitations

  • Passphrases must come from the environment; the cockpit shows a hint if borg asks for one (it recognizes the text of Python's getpass fallback).
  • Warnings share the bounded line buffer with the --list lines, and nothing is printed to the terminal after the cockpit has ended.
  • The only test running real borg through the real app is the slow smoke test (FreeBSD / Windows only).
  • On Windows, the child is not detached from the console (no 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 by borg info afterwards), and recreate --stats prints no statistics.

Commits

  1. cockpit: process borg's --log-json output instead of parsing text lines
  2. cockpit: per-command screens and the final statistics
  3. docs: add a usage page for the cockpit TUI
  4. cockpit: rename two constants bandit mistakes for hardcoded passwords
  5. extract/export-tar: file_status JSON objects, prune: archive_status JSON objects
  6. cockpit: use the file_status and archive_status objects
  7. delete/undelete: archive_status JSON objects for --list, with a status key
  8. cockpit: show the archives delete and undelete list
  9. cockpit: show text from borg as it is, never as markup
  10. tests: export-tar --list: do not depend on the line ending
  11. cockpit: refuse commands that read from stdin
  12. cockpit: do not start without a terminal
  13. cockpit: bound the line buffer, refuse commands writing data to stdout
  14. cockpit: the borg it runs must not start a cockpit again
  15. cockpit: end in an orderly way on SIGTERM, SIGHUP and SIGINT
  16. cockpit: do not show control characters
  17. cockpit: ask for confirmation before quitting while borg runs
  18. cockpit: the statistics are borg's statistics, not the counts of the --list lines
  19. recreate: output archive_progress JSON objects with --log-json --progress
  20. transfer: output archive_progress JSON objects with --log-json --progress
  21. import-tar/recreate: count the items by their status, like create does
  22. archive_progress: the final object has the final statistics
  23. create --dry-run --progress: show the progress
  24. recreate --dry-run --progress: show the progress
  25. archive_status: add a dry_run key

🤖 Generated with Claude Code

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.78017% with 67 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.69%. Comparing base (219d145) to head (7007944).
⚠️ Report is 1 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/borg/cockpit/widgets.py 90.86% 17 Missing and 4 partials ⚠️
src/borg/cockpit/app.py 80.85% 12 Missing and 6 partials ⚠️
src/borg/cockpit/runner.py 81.05% 13 Missing and 5 partials ⚠️
src/borg/cockpit/session.py 98.48% 1 Missing and 3 partials ⚠️
src/borg/cockpit/screens.py 92.68% 3 Missing ⚠️
src/borg/archiver/create_cmd.py 86.66% 2 Missing ⚠️
src/borg/cockpit/prompt.py 96.87% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann ThomasWaldmann changed the title cockpit: process borg's --log-json output instead of parsing text lines, #9454 cockpit: use the JSON API, with per-command screens and docs; file_status / archive_status JSON objects for more commands, #9454 Sep 10, 2026
@ThomasWaldmann
ThomasWaldmann marked this pull request as draft September 10, 2026 09:05
@ThomasWaldmann ThomasWaldmann added this to the 2.0.0b25 milestone Sep 17, 2026
@ThomasWaldmann ThomasWaldmann self-assigned this Sep 17, 2026
ThomasWaldmann and others added 22 commits September 20, 2026 19:41
…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>
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>
ThomasWaldmann and others added 2 commits September 20, 2026 22:25
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>
@ThomasWaldmann ThomasWaldmann changed the title cockpit: use the JSON API, with per-command screens and docs; file_status / archive_status JSON objects for more commands, #9454 cockpit: use the JSON API, with per-command screens and docs; JSON API additions and fixes, #9454 Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

--cockpit needs to use json

1 participant