Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pyfuse3 = ["pyfuse3 >= 3.1.1"]
nofuse = []
s3 = ["borgstore[s3] ~= 0.3.0"]
sftp = ["borgstore[sftp] ~= 0.3.0"]
cockpit = ["textual>=6.8.0"] # might also work with older versions, untested

[project.urls]
"Homepage" = "https://borgbackup.org/"
Expand Down
18 changes: 18 additions & 0 deletions src/borg/archiver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ def build_parser(self):
parser.add_argument(
"-V", "--version", action="version", version="%(prog)s " + __version__, help="show version number and exit"
)
parser.add_argument("--cockpit", dest="cockpit", action="store_true", help="Start the Borg TUI")
parser.common_options.add_common_group(parser, "_maincommand", provide_defaults=True)

common_parser = argparse.ArgumentParser(add_help=False, prog=self.prog)
Expand Down Expand Up @@ -646,6 +647,23 @@ def main(): # pragma: no cover
print(msg, file=sys.stderr)
print(tb, file=sys.stderr)
sys.exit(EXIT_ERROR)

if args.cockpit:
# Cockpit TUI operation
try:
from ..cockpit.app import BorgCockpitApp
except ImportError as err:
print(f"ImportError: {err}", file=sys.stderr)
print("The Borg Cockpit feature has some additional requirements.", file=sys.stderr)
print("Please install them using: pip install 'borgbackup[cockpit]'", file=sys.stderr)
sys.exit(EXIT_ERROR)

app = BorgCockpitApp()
app.borg_args = [arg for arg in sys.argv[1:] if arg != "--cockpit"]
app.run()
sys.exit(EXIT_SUCCESS) # borg subprocess RC was already shown on the TUI

# normal borg CLI operation
try:
with sig_int:
exit_code = archiver.run(args)
Expand Down
5 changes: 5 additions & 0 deletions src/borg/cockpit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
Borg Cockpit - Terminal User Interface for BorgBackup.

This module contains the TUI implementation using Textual.
"""
124 changes: 124 additions & 0 deletions src/borg/cockpit/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""
Borg Cockpit - Application Entry Point.
"""

import asyncio
import time

from textual.app import App, ComposeResult
from textual.widgets import Header, Footer
from textual.containers import Horizontal, Container

from .theme import theme


class BorgCockpitApp(App):
"""The main TUI Application class for Borg Cockpit."""

from .. import __version__ as BORG_VERSION

TITLE = f"Cockpit for BorgBackup {BORG_VERSION}"
CSS_PATH = "cockpit.tcss"
BINDINGS = [("q", "quit", "Quit"), ("ctrl+c", "quit", "Quit"), ("t", "toggle_translator", "Toggle Translator")]

def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
from .widgets import LogoPanel, StatusPanel, StandardLog

yield Header(show_clock=True)

with Container(id="main-grid"):
with Horizontal(id="top-row"):
yield LogoPanel(id="logopanel")
yield StatusPanel(id="status")

yield StandardLog(id="standard-log")

yield Footer()

def get_theme_variable_defaults(self):
# make these variables available to ALL themes
return {
"pulsar-color": "#ffffff",
"pulsar-dim-color": "#000000",
"star-color": "#888888",
"star-bright-color": "#ffffff",
"logo-color": "#00dd00",
}

def on_load(self) -> None:
"""Initialize theme before UI."""
self.register_theme(theme)
self.theme = theme.name

def on_mount(self) -> None:
"""Initialize components."""
from .runner import BorgRunner

self.query_one("#logo").styles.animate("opacity", 1, duration=1)
self.query_one("#slogan").styles.animate("opacity", 1, duration=1)

self.start_time = time.monotonic()
self.process_running = True
args = getattr(self, "borg_args", ["--version"]) # Default to safe command if none passed
self.runner = BorgRunner(args, self.handle_log_event)
self.runner_task = asyncio.create_task(self.runner.start())

# Speed tracking
self.total_lines_processed = 0
self.last_lines_processed = 0
self.speed_timer = self.set_interval(1.0, self.compute_speed)

def compute_speed(self) -> None:
"""Calculate and update speed (lines per second)."""
current_lines = self.total_lines_processed
lines_per_second = float(current_lines - self.last_lines_processed)
self.last_lines_processed = current_lines

status_panel = self.query_one("#status")
status_panel.update_speed(lines_per_second / 1000)
if self.process_running:
status_panel.elapsed_time = time.monotonic() - self.start_time

async def on_unmount(self) -> None:
"""Cleanup resources on app shutdown."""
if hasattr(self, "runner"):
await self.runner.stop()

async def action_quit(self) -> None:
"""Handle quit action."""
if hasattr(self, "speed_timer"):
self.speed_timer.stop()
if hasattr(self, "runner"):
await self.runner.stop()
if hasattr(self, "runner_task"):
await self.runner_task
self.query_one("#logo").styles.animate("opacity", 0, duration=2)
self.query_one("#slogan").styles.animate("opacity", 0, duration=2)
await asyncio.sleep(2) # give the user a chance the see the borg RC
self.exit()

def action_toggle_translator(self) -> None:
"""Toggle the universal translator."""
from .translator import TRANSLATOR

TRANSLATOR.toggle()
# Refresh dynamic UI elements
self.query_one("#status").refresh_ui_labels()
self.query_one("#standard-log").update_title()
self.query_one("#slogan").update_slogan()

def handle_log_event(self, data: dict):
"""Process a event from BorgRunner."""
msg_type = data.get("type", "log")

if msg_type == "stream_line":
self.total_lines_processed += 1
line = data.get("line", "")
widget = self.query_one("#standard-log")
widget.add_line(line)

elif msg_type == "process_finished":
self.process_running = False
rc = data.get("rc", 0)
self.query_one("#status").rc = rc
201 changes: 201 additions & 0 deletions src/borg/cockpit/cockpit.tcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/* Borg Cockpit Stylesheet */

Screen {
background: $surface;
}

Header {
dock: top;
background: $primary;
color: $secondary;
text-style: bold;
}

Header * {
background: $primary;
color: $secondary;
text-style: bold;
}

.header--clock, .header--title, .header--icon {
background: $primary;
color: $secondary;
text-style: bold;
}

.header--clock {
dock: right;
}

Footer {
background: $background;
color: $primary;
dock: bottom;
}

.footer--key {
background: $background;
color: $primary;
text-style: bold;
}

.footer--description {
background: $background;
color: $primary;
text-style: bold;
}

.footer--highlight {
background: $primary;
color: $secondary;
}

#standard-log-content {
scrollbar-background: $background;
scrollbar-color: $primary;
/* Hide horizontal scrollbar and clip long lines at the right */
overflow-x: hidden;
text-wrap: nowrap;
}

#standard-log {
border: double $primary;
}

#main-grid {
/* Simple vertical stack: top row content-sized, log fills remaining space */
layout: vertical;
/* Fill available area between header and footer */
height: 1fr;
/* Allow shrinking when space is tight */
min-height: 0;
margin: 0 1;
}

#top-row {
border: double $primary;
/* If content grows too large, scroll rather than pushing the log off-screen */
overflow-y: auto;
/* Adjust this if status or logo panel shall get more/less height. */
height: 16;
}

#logopanel {
width: 50%;
/* Stretch to the full height of the top row so the separator spans fully */
height: 100%;
border-right: double $primary;
text-align: center;
layers: base overlay;
/* Make logo panel not influence row height beyond status; clip overflow */
overflow: hidden;
}

Starfield {
layer: base;
width: 100%;
/* Size to content and get clipped by the panel */
height: 100%;
min-height: 0;
}

Pulsar {
layer: overlay;
width: 3;
height: 3;
content-align: center middle;
color: $pulsar-color;
transition: color 4s linear;
}

Slogan {
layer: overlay;
width: auto;
height: 1;
content-align: center middle;
color: #00ff00;
transition: color 1s linear;
opacity: 0;
max-height: 100%;
overflow: hidden;
}

Logo {
layer: overlay;
width: auto;
/* Size to its intrinsic content, clipped by the panel */
height: auto;
opacity: 0;
max-height: 100%;
overflow: hidden;
}

Slogan.dim {
color: #005500;
}

Pulsar.dim {
color: $pulsar-dim-color;
}

#status {
width: 50%;
/* Let height be determined by content so the row can size to content */
height: auto;
/* Prevent internal content from forcing excessive height; allow scrolling */
overflow-y: auto;
}

/* Ensure the log always keeps at least 5 rows visible */
#standard-log {
min-height: 5;
/* Explicitly claim the remaining space in the grid */
height: 1fr;
}

/* Within the log panel (a Vertical container), keep the title to 1 line and let content fill the rest */
#standard-log-title {
height: 1;
}

#standard-log-content {
/* Allow the RichLog to expand within the log panel */
height: 1fr;
}

.panel-title {
background: $primary;
color: $secondary;
padding: 0 1;
text-style: bold;
}

#speed-sparkline {
width: 100%;
height: 4;
margin-bottom: 1;
}

.status {
color: $primary;
}

.errors-ok {
color: $success;
}

.errors-warning {
color: $warning;
}

.rc-ok {
color: $success;
}

.rc-warning {
color: $warning;
}

.rc-error {
color: $error;
}
Loading
Loading