new: create mux-0kmtui tool
This commit is contained in:
64
mux-0kmtui/README.md
Normal file
64
mux-0kmtui/README.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# mux-0kmtui
|
||||||
|
|
||||||
|
TUI for running commands on multiple VPS via `0km vps-mux`.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Server Selection**: Multi-select servers from your configured hosts
|
||||||
|
- **Multi-line Commands**: Write complex bash scripts directly in the editor
|
||||||
|
- **Script Import**: Load .sh files to execute on remote servers
|
||||||
|
- **Command Execution**: Run commands on all selected servers simultaneously
|
||||||
|
- **Live Output**: View real-time output from each server in dedicated tabs
|
||||||
|
- **Closable Tabs**: Close individual server log tabs when no longer needed
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mux-0kmtui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
1. Select servers from the left panel (Space to toggle, or use All/None buttons)
|
||||||
|
2. Type a command in the text area at the bottom (multi-line supported)
|
||||||
|
- Or click "Load .sh" to import a script file
|
||||||
|
3. Press `Ctrl+R` or click "Run"
|
||||||
|
4. View output from each server in dedicated tabs
|
||||||
|
5. Close tabs with the X button when done
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `Space` | Toggle server selection |
|
||||||
|
| `Ctrl+A` | Select all servers |
|
||||||
|
| `Ctrl+D` | Deselect all servers |
|
||||||
|
| `Ctrl+R` | Run command |
|
||||||
|
| `Ctrl+O` | Load .sh file |
|
||||||
|
| `Ctrl+L` | Clear all log tabs |
|
||||||
|
| `Escape` | Focus command input |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Uses the same configuration as other 0kmtui tools:
|
||||||
|
|
||||||
|
1. `~/.config/0kmtui/config.yml`
|
||||||
|
2. `~/.config/0km/config.yml`
|
||||||
|
3. `/etc/0km/config.yml`
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- `0km` command installed and in PATH
|
||||||
|
- SSH access to VPS hosts
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
35
mux-0kmtui/pyproject.toml
Normal file
35
mux-0kmtui/pyproject.toml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "mux-0kmtui"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "TUI for running commands on multiple VPS via 0km vps-mux"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = "MIT"
|
||||||
|
authors = [
|
||||||
|
{ name = "Stéphan Sainléger", email = "stephan@sainleger.fr" }
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"textual>=0.47.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0",
|
||||||
|
"textual-dev>=1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mux-0kmtui = "mux_0kmtui.app:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/mux_0kmtui"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
include = [
|
||||||
|
"/src",
|
||||||
|
]
|
||||||
3
mux-0kmtui/src/mux_0kmtui/__init__.py
Normal file
3
mux-0kmtui/src/mux_0kmtui/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""mux-0kmtui - TUI for running commands on multiple VPS."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
459
mux-0kmtui/src/mux_0kmtui/app.py
Normal file
459
mux-0kmtui/src/mux_0kmtui/app.py
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
"""Main application for mux-0kmtui."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Horizontal, Vertical, Center, VerticalScroll
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Footer, Header, Button, Static, TextArea, Input, Markdown
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .widgets import ServerSelector, LogTabs
|
||||||
|
|
||||||
|
|
||||||
|
HELP_TEXT_TEMPLATE = """\
|
||||||
|
# mux-0kmtui - Aide
|
||||||
|
|
||||||
|
## Global
|
||||||
|
|
||||||
|
| Raccourci | Action |
|
||||||
|
|-----------|--------|
|
||||||
|
| `Ctrl+R` | Exécuter la commande |
|
||||||
|
| `Ctrl+O` | Charger un fichier .sh |
|
||||||
|
| `Ctrl+L` | Fermer tous les onglets de logs |
|
||||||
|
| `F1` | Afficher cette aide |
|
||||||
|
| `Escape` | Focus sur la zone de commande |
|
||||||
|
| `Ctrl+Q` | Quitter |
|
||||||
|
|
||||||
|
## Sélection des serveurs
|
||||||
|
|
||||||
|
| Raccourci | Action |
|
||||||
|
|-----------|--------|
|
||||||
|
| `Space` | Sélectionner/désélectionner le serveur |
|
||||||
|
| `Ctrl+A` | Sélectionner tous les serveurs visibles |
|
||||||
|
| `Ctrl+D` | Désélectionner tous les serveurs visibles |
|
||||||
|
| `Up/Down` | Naviguer dans la liste |
|
||||||
|
|
||||||
|
### Filtrage
|
||||||
|
|
||||||
|
Tapez dans la barre de recherche pour filtrer par :
|
||||||
|
- **Nom du serveur** : ex. `elabore` → serveurs contenant "elabore"
|
||||||
|
- **Tag** : ex. `prod` → serveurs avec le tag "prod"
|
||||||
|
|
||||||
|
Les serveurs sélectionnés restent sélectionnés même s'ils sont filtrés.
|
||||||
|
|
||||||
|
**Tags disponibles** : {tags}
|
||||||
|
|
||||||
|
## Zone de commande
|
||||||
|
|
||||||
|
| Raccourci | Action |
|
||||||
|
|-----------|--------|
|
||||||
|
| `Ctrl+R` | Exécuter la commande |
|
||||||
|
| Multi-ligne | Supporté (écrire un script complet) |
|
||||||
|
|
||||||
|
## Onglets de logs
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Clic sur onglet | Afficher les logs du serveur |
|
||||||
|
| Bouton `X` | Fermer l'onglet du serveur |
|
||||||
|
| Scroll | Naviguer dans les logs |
|
||||||
|
|
||||||
|
---
|
||||||
|
*Appuyez sur Escape ou q pour fermer*
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class HelpScreen(ModalScreen):
|
||||||
|
"""Modal screen displaying help information."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "dismiss", "Fermer"),
|
||||||
|
Binding("q", "dismiss", "Fermer"),
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
HelpScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
HelpScreen > Vertical {
|
||||||
|
width: 70;
|
||||||
|
height: 80%;
|
||||||
|
border: thick $primary;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
HelpScreen Markdown {
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tags: list[str] | None = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._tags = tags or []
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
tags_str = (
|
||||||
|
", ".join(f"`{t}`" for t in sorted(self._tags)) if self._tags else "*aucun*"
|
||||||
|
)
|
||||||
|
help_text = HELP_TEXT_TEMPLATE.format(tags=tags_str)
|
||||||
|
with Vertical():
|
||||||
|
yield VerticalScroll(Markdown(help_text))
|
||||||
|
|
||||||
|
|
||||||
|
class MuxApp(App):
|
||||||
|
"""TUI for running commands on multiple VPS via 0km vps-mux."""
|
||||||
|
|
||||||
|
TITLE = "mux-0kmtui"
|
||||||
|
SUB_TITLE = "Multi-VPS Command Runner"
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
Screen {
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-container {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#left-panel {
|
||||||
|
width: 40;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#right-panel {
|
||||||
|
width: 1fr;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command-area {
|
||||||
|
height: auto;
|
||||||
|
min-height: 10;
|
||||||
|
max-height: 18;
|
||||||
|
dock: bottom;
|
||||||
|
padding: 0 1;
|
||||||
|
background: $panel;
|
||||||
|
border-top: solid $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command-header {
|
||||||
|
height: 1;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command-header > Static {
|
||||||
|
width: 1fr;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command-header > Button {
|
||||||
|
min-width: 12;
|
||||||
|
margin-left: 1;
|
||||||
|
border: none;
|
||||||
|
height: 1;
|
||||||
|
background: $primary;
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command-input {
|
||||||
|
height: 1fr;
|
||||||
|
min-height: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-input-area {
|
||||||
|
height: 3;
|
||||||
|
display: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-input-area.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-path-input {
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-actions {
|
||||||
|
height: 3;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-actions Button {
|
||||||
|
margin-right: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Header {
|
||||||
|
dock: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
Footer {
|
||||||
|
dock: bottom;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("ctrl+q", "quit", "Quit"),
|
||||||
|
Binding("ctrl+a", "select_all", "Select All", show=False),
|
||||||
|
Binding("ctrl+d", "deselect_all", "Deselect All", show=False),
|
||||||
|
Binding("ctrl+l", "clear_logs", "Clear Logs"),
|
||||||
|
Binding("ctrl+r", "run_command", "Run", show=True),
|
||||||
|
Binding("ctrl+o", "load_file", "Load .sh"),
|
||||||
|
Binding("escape", "focus_command", "Focus Command", show=False),
|
||||||
|
Binding("f1", "show_help", "Help"),
|
||||||
|
Binding("question_mark", "show_help", "Help", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.config = Config.load()
|
||||||
|
self._running_process: asyncio.subprocess.Process | None = None
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
|
||||||
|
with Horizontal(id="main-container"):
|
||||||
|
with Vertical(id="left-panel"):
|
||||||
|
yield ServerSelector(self.config.hosts, id="server-selector")
|
||||||
|
with Horizontal(id="selection-actions"):
|
||||||
|
yield Button("All", id="btn-all", variant="default")
|
||||||
|
yield Button("None", id="btn-none", variant="default")
|
||||||
|
|
||||||
|
with Vertical(id="right-panel"):
|
||||||
|
yield LogTabs(id="log-tabs")
|
||||||
|
|
||||||
|
with Vertical(id="command-area"):
|
||||||
|
with Horizontal(id="command-header"):
|
||||||
|
yield Static("Command (multi-line supported, Ctrl+R to run)")
|
||||||
|
yield Button("Load .sh", id="btn-load", variant="default")
|
||||||
|
yield Button("Run", id="run-btn", variant="primary")
|
||||||
|
|
||||||
|
yield TextArea(id="command-input")
|
||||||
|
|
||||||
|
with Horizontal(id="file-input-area"):
|
||||||
|
yield Input(
|
||||||
|
placeholder="Enter path to .sh file...",
|
||||||
|
id="file-path-input",
|
||||||
|
)
|
||||||
|
yield Button("OK", id="btn-load-ok", variant="primary")
|
||||||
|
yield Button("Cancel", id="btn-load-cancel", variant="default")
|
||||||
|
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
"""Handle button presses."""
|
||||||
|
if event.button.id == "run-btn":
|
||||||
|
self.action_run_command()
|
||||||
|
elif event.button.id == "btn-all":
|
||||||
|
self.action_select_all()
|
||||||
|
elif event.button.id == "btn-none":
|
||||||
|
self.action_deselect_all()
|
||||||
|
elif event.button.id == "btn-load":
|
||||||
|
self.action_load_file()
|
||||||
|
elif event.button.id == "btn-load-ok":
|
||||||
|
self._do_load_file()
|
||||||
|
elif event.button.id == "btn-load-cancel":
|
||||||
|
self._hide_file_input()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
"""Handle Enter in file path input."""
|
||||||
|
if event.input.id == "file-path-input":
|
||||||
|
self._do_load_file()
|
||||||
|
|
||||||
|
def action_select_all(self) -> None:
|
||||||
|
"""Select all servers."""
|
||||||
|
self.query_one("#server-selector", ServerSelector).select_all()
|
||||||
|
|
||||||
|
def action_deselect_all(self) -> None:
|
||||||
|
"""Deselect all servers."""
|
||||||
|
self.query_one("#server-selector", ServerSelector).deselect_all()
|
||||||
|
|
||||||
|
def action_clear_logs(self) -> None:
|
||||||
|
"""Clear all log tabs."""
|
||||||
|
self.query_one("#log-tabs", LogTabs).clear_all()
|
||||||
|
|
||||||
|
def action_focus_command(self) -> None:
|
||||||
|
"""Focus the command input."""
|
||||||
|
self.query_one("#command-input", TextArea).focus()
|
||||||
|
|
||||||
|
def action_show_help(self) -> None:
|
||||||
|
"""Show help screen."""
|
||||||
|
# Collect all unique tags from hosts
|
||||||
|
tags = set()
|
||||||
|
for host in self.config.hosts:
|
||||||
|
tags.update(host.tags)
|
||||||
|
self.push_screen(HelpScreen(tags=list(tags)))
|
||||||
|
|
||||||
|
def action_load_file(self) -> None:
|
||||||
|
"""Show file input area."""
|
||||||
|
file_area = self.query_one("#file-input-area")
|
||||||
|
file_area.add_class("visible")
|
||||||
|
file_input = self.query_one("#file-path-input", Input)
|
||||||
|
file_input.value = ""
|
||||||
|
file_input.focus()
|
||||||
|
|
||||||
|
def _hide_file_input(self) -> None:
|
||||||
|
"""Hide file input area."""
|
||||||
|
file_area = self.query_one("#file-input-area")
|
||||||
|
file_area.remove_class("visible")
|
||||||
|
self.query_one("#command-input", TextArea).focus()
|
||||||
|
|
||||||
|
def _do_load_file(self) -> None:
|
||||||
|
"""Load the specified file."""
|
||||||
|
file_input = self.query_one("#file-path-input", Input)
|
||||||
|
file_path = file_input.value.strip()
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
self.notify("Please enter a file path", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Expand ~ and resolve path
|
||||||
|
path = Path(file_path).expanduser().resolve()
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
self.notify(f"File not found: {path}", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not path.is_file():
|
||||||
|
self.notify(f"Not a file: {path}", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
text_area = self.query_one("#command-input", TextArea)
|
||||||
|
text_area.load_text(content)
|
||||||
|
self.notify(f"Loaded: {path.name}", severity="information")
|
||||||
|
self._hide_file_input()
|
||||||
|
except Exception as e:
|
||||||
|
self.notify(f"Error reading file: {e}", severity="error")
|
||||||
|
|
||||||
|
def action_run_command(self) -> None:
|
||||||
|
"""Run command on selected servers."""
|
||||||
|
text_area = self.query_one("#command-input", TextArea)
|
||||||
|
command = text_area.text.strip()
|
||||||
|
if not command:
|
||||||
|
self.notify("Please enter a command", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
selector = self.query_one("#server-selector", ServerSelector)
|
||||||
|
selected = selector.get_selected()
|
||||||
|
if not selected:
|
||||||
|
self.notify("Please select at least one server", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start async execution
|
||||||
|
asyncio.create_task(self._run_mux_command(command, [h.name for h in selected]))
|
||||||
|
|
||||||
|
async def _run_mux_command(self, command: str, servers: list[str]) -> None:
|
||||||
|
"""Execute 0km vps-mux command and stream output."""
|
||||||
|
log_tabs = self.query_one("#log-tabs", LogTabs)
|
||||||
|
|
||||||
|
# Show first lines of command in panel header
|
||||||
|
first_line = command.split("\n")[0][:50]
|
||||||
|
if len(command.split("\n")) > 1 or len(command) > 50:
|
||||||
|
first_line += "..."
|
||||||
|
|
||||||
|
# Create tabs for each server
|
||||||
|
for server in servers:
|
||||||
|
panel = log_tabs.add_server(server)
|
||||||
|
panel.clear()
|
||||||
|
panel.write(f"[cyan]Running: {first_line}[/cyan]")
|
||||||
|
|
||||||
|
# Build the command
|
||||||
|
# 0km vps-mux reads from stdin
|
||||||
|
cmd = ["0km", "vps-mux"] + servers
|
||||||
|
|
||||||
|
self.notify(f"Running on {len(servers)} server(s)...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
self._running_process = proc
|
||||||
|
|
||||||
|
# Send command to stdin (can be multi-line)
|
||||||
|
if proc.stdin:
|
||||||
|
proc.stdin.write(command.encode("utf-8"))
|
||||||
|
if not command.endswith("\n"):
|
||||||
|
proc.stdin.write(b"\n")
|
||||||
|
await proc.stdin.drain()
|
||||||
|
proc.stdin.close()
|
||||||
|
await proc.stdin.wait_closed()
|
||||||
|
|
||||||
|
# Parse output lines
|
||||||
|
# Format: "servername | output" for stdout
|
||||||
|
# Format: "servername ! output" for stderr
|
||||||
|
# The server name is padded to max length
|
||||||
|
line_pattern = re.compile(r"^(.+?)\s+([|!])\s*(.*)$")
|
||||||
|
|
||||||
|
if proc.stdout:
|
||||||
|
async for line in proc.stdout:
|
||||||
|
decoded = line.decode("utf-8", errors="replace").rstrip("\n")
|
||||||
|
|
||||||
|
# Strip ANSI color codes for parsing
|
||||||
|
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", decoded)
|
||||||
|
match = line_pattern.match(clean_line)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
server_name = match.group(1).strip()
|
||||||
|
is_stderr = match.group(2) == "!"
|
||||||
|
content = match.group(3)
|
||||||
|
|
||||||
|
panel = log_tabs.get_panel(server_name)
|
||||||
|
if panel:
|
||||||
|
if is_stderr:
|
||||||
|
panel.write_stderr(content)
|
||||||
|
else:
|
||||||
|
panel.write_stdout(content)
|
||||||
|
else:
|
||||||
|
# Line doesn't match pattern, write to all panels
|
||||||
|
for server in servers:
|
||||||
|
panel = log_tabs.get_panel(server)
|
||||||
|
if panel:
|
||||||
|
panel.write(decoded)
|
||||||
|
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
# Write completion message
|
||||||
|
for server in servers:
|
||||||
|
panel = log_tabs.get_panel(server)
|
||||||
|
if panel:
|
||||||
|
panel.write("[dim]--- Command completed ---[/dim]")
|
||||||
|
|
||||||
|
self.notify("Command completed", severity="information")
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.notify("Error: 0km command not found", severity="error")
|
||||||
|
for server in servers:
|
||||||
|
panel = log_tabs.get_panel(server)
|
||||||
|
if panel:
|
||||||
|
panel.write_stderr("Error: 0km command not found in PATH")
|
||||||
|
except Exception as e:
|
||||||
|
self.notify(f"Error: {e}", severity="error")
|
||||||
|
for server in servers:
|
||||||
|
panel = log_tabs.get_panel(server)
|
||||||
|
if panel:
|
||||||
|
panel.write_stderr(f"Error: {e}")
|
||||||
|
finally:
|
||||||
|
self._running_process = None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Entry point for the application."""
|
||||||
|
app = MuxApp()
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
167
mux-0kmtui/src/mux_0kmtui/config.py
Normal file
167
mux-0kmtui/src/mux_0kmtui/config.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Configuration management for 0kmtui."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VPSHost:
|
||||||
|
"""Represents a VPS host configuration."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
host: str
|
||||||
|
user: str = "root"
|
||||||
|
port: int = 22
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, name: str, data: dict[str, Any] | str) -> "VPSHost":
|
||||||
|
"""Create VPSHost from dict or string."""
|
||||||
|
if isinstance(data, str):
|
||||||
|
return cls(name=name, host=data)
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
host=data.get("host", name),
|
||||||
|
user=data.get("user", "root"),
|
||||||
|
port=data.get("port", 22),
|
||||||
|
tags=data.get("tags", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
"""Application configuration."""
|
||||||
|
|
||||||
|
hosts: list[VPSHost] = field(default_factory=list)
|
||||||
|
ssh_public_keys: list[str] = field(default_factory=list)
|
||||||
|
default_backup_server: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_config_paths(cls) -> list[Path]:
|
||||||
|
"""Return list of possible config file paths (first found takes priority for hosts)."""
|
||||||
|
return [
|
||||||
|
Path.home()
|
||||||
|
/ ".config"
|
||||||
|
/ "0kmtui"
|
||||||
|
/ "config.yml", # App-specific config first
|
||||||
|
Path.home() / ".config" / "0km" / "config.yml", # Shared 0km config
|
||||||
|
Path("/etc/0km/config.yml"), # System-wide config
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path | None = None) -> "Config":
|
||||||
|
"""Load configuration from YAML files, merging all found configs."""
|
||||||
|
if path is not None:
|
||||||
|
paths = [path]
|
||||||
|
else:
|
||||||
|
paths = cls.get_config_paths()
|
||||||
|
|
||||||
|
# Merge all config files found
|
||||||
|
merged_config = cls()
|
||||||
|
for config_path in paths:
|
||||||
|
if config_path.exists():
|
||||||
|
file_config = cls._load_from_file(config_path)
|
||||||
|
# Merge hosts (add new ones, don't overwrite existing)
|
||||||
|
existing_names = {h.name for h in merged_config.hosts}
|
||||||
|
for host in file_config.hosts:
|
||||||
|
if host.name not in existing_names:
|
||||||
|
merged_config.hosts.append(host)
|
||||||
|
# Merge SSH keys
|
||||||
|
for key in file_config.ssh_public_keys:
|
||||||
|
if key not in merged_config.ssh_public_keys:
|
||||||
|
merged_config.ssh_public_keys.append(key)
|
||||||
|
# Use first found backup server
|
||||||
|
if (
|
||||||
|
not merged_config.default_backup_server
|
||||||
|
and file_config.default_backup_server
|
||||||
|
):
|
||||||
|
merged_config.default_backup_server = (
|
||||||
|
file_config.default_backup_server
|
||||||
|
)
|
||||||
|
|
||||||
|
return merged_config
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _load_from_file(cls, path: Path) -> "Config":
|
||||||
|
"""Load config from a specific file."""
|
||||||
|
with open(path) as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
hosts = []
|
||||||
|
|
||||||
|
# Parse hosts section
|
||||||
|
hosts_data = data.get("hosts", {})
|
||||||
|
if isinstance(hosts_data, dict):
|
||||||
|
for name, host_data in hosts_data.items():
|
||||||
|
hosts.append(VPSHost.from_dict(name, host_data))
|
||||||
|
elif isinstance(hosts_data, list):
|
||||||
|
for item in hosts_data:
|
||||||
|
if isinstance(item, str):
|
||||||
|
hosts.append(VPSHost(name=item, host=item))
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
for name, host_data in item.items():
|
||||||
|
hosts.append(VPSHost.from_dict(name, host_data))
|
||||||
|
|
||||||
|
# Parse SSH keys
|
||||||
|
ssh_keys = []
|
||||||
|
ssh_access = data.get("ssh-access", {})
|
||||||
|
if isinstance(ssh_access, dict):
|
||||||
|
ssh_keys = ssh_access.get("public-keys", [])
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
hosts=hosts,
|
||||||
|
ssh_public_keys=ssh_keys,
|
||||||
|
default_backup_server=data.get("default-backup-server"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, path: Path | None = None) -> None:
|
||||||
|
"""Save configuration to YAML file."""
|
||||||
|
if path is None:
|
||||||
|
path = Path.home() / ".config" / "0kmtui" / "config.yml"
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if self.hosts:
|
||||||
|
data["hosts"] = {}
|
||||||
|
for host in self.hosts:
|
||||||
|
host_data: dict[str, Any] = {"host": host.host}
|
||||||
|
if host.user != "root":
|
||||||
|
host_data["user"] = host.user
|
||||||
|
if host.port != 22:
|
||||||
|
host_data["port"] = host.port
|
||||||
|
if host.tags:
|
||||||
|
host_data["tags"] = host.tags
|
||||||
|
data["hosts"][host.name] = host_data
|
||||||
|
|
||||||
|
if self.ssh_public_keys:
|
||||||
|
data["ssh-access"] = {"public-keys": self.ssh_public_keys}
|
||||||
|
|
||||||
|
if self.default_backup_server:
|
||||||
|
data["default-backup-server"] = self.default_backup_server
|
||||||
|
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
|
||||||
|
def add_host(self, host: VPSHost) -> None:
|
||||||
|
"""Add a new host to configuration."""
|
||||||
|
# Remove existing host with same name
|
||||||
|
self.hosts = [h for h in self.hosts if h.name != host.name]
|
||||||
|
self.hosts.append(host)
|
||||||
|
|
||||||
|
def remove_host(self, name: str) -> bool:
|
||||||
|
"""Remove a host by name. Returns True if found and removed."""
|
||||||
|
initial_len = len(self.hosts)
|
||||||
|
self.hosts = [h for h in self.hosts if h.name != name]
|
||||||
|
return len(self.hosts) < initial_len
|
||||||
|
|
||||||
|
def get_host(self, name: str) -> VPSHost | None:
|
||||||
|
"""Get a host by name."""
|
||||||
|
for host in self.hosts:
|
||||||
|
if host.name == name:
|
||||||
|
return host
|
||||||
|
return None
|
||||||
6
mux-0kmtui/src/mux_0kmtui/widgets/__init__.py
Normal file
6
mux-0kmtui/src/mux_0kmtui/widgets/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Widgets for mux-0kmtui."""
|
||||||
|
|
||||||
|
from .server_selector import ServerSelector
|
||||||
|
from .log_panel import LogPanel, LogTabs
|
||||||
|
|
||||||
|
__all__ = ["ServerSelector", "LogPanel", "LogTabs"]
|
||||||
219
mux-0kmtui/src/mux_0kmtui/widgets/log_panel.py
Normal file
219
mux-0kmtui/src/mux_0kmtui/widgets/log_panel.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""Log panel widgets for displaying server output."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
from textual.message import Message
|
||||||
|
from textual.widgets import Static, RichLog, Button, TabbedContent, TabPane, Label
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_id(name: str) -> str:
|
||||||
|
"""Convert a server name to a valid Textual ID."""
|
||||||
|
# Replace dots and other invalid chars with hyphens
|
||||||
|
return re.sub(r"[^a-zA-Z0-9_-]", "-", name)
|
||||||
|
|
||||||
|
|
||||||
|
class LogPanel(Vertical):
|
||||||
|
"""A single server's log output panel."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
LogPanel {
|
||||||
|
height: 100%;
|
||||||
|
border: solid $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogPanel .log-header {
|
||||||
|
height: 1;
|
||||||
|
background: $primary;
|
||||||
|
color: $text;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogPanel .log-header Label {
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogPanel .log-header Button {
|
||||||
|
min-width: 3;
|
||||||
|
height: 1;
|
||||||
|
border: none;
|
||||||
|
background: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogPanel RichLog {
|
||||||
|
height: 1fr;
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
class CloseRequested(Message):
|
||||||
|
"""Message sent when close is requested."""
|
||||||
|
|
||||||
|
def __init__(self, server_name: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.server_name = server_name
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server_name: str,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
id: str | None = None,
|
||||||
|
classes: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(name=name, id=id, classes=classes)
|
||||||
|
self.server_name = server_name
|
||||||
|
self._log: RichLog | None = None
|
||||||
|
self._pending_writes: list[tuple[str, str | None]] = []
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Horizontal(classes="log-header"):
|
||||||
|
yield Label(self.server_name)
|
||||||
|
yield Button("X", id="close-btn", variant="error")
|
||||||
|
yield RichLog(id="log", highlight=True, markup=True, wrap=True)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Cache the log widget and flush pending writes."""
|
||||||
|
self._log = self.query_one("#log", RichLog)
|
||||||
|
# Flush any pending writes
|
||||||
|
for text, style in self._pending_writes:
|
||||||
|
self._do_write(text, style)
|
||||||
|
self._pending_writes.clear()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
"""Handle close button."""
|
||||||
|
if event.button.id == "close-btn":
|
||||||
|
self.post_message(self.CloseRequested(self.server_name))
|
||||||
|
|
||||||
|
def _do_write(self, text: str, style: str | None = None) -> None:
|
||||||
|
"""Actually write to the log."""
|
||||||
|
if self._log:
|
||||||
|
if style:
|
||||||
|
self._log.write(f"[{style}]{text}[/{style}]")
|
||||||
|
else:
|
||||||
|
self._log.write(text)
|
||||||
|
|
||||||
|
def write(self, text: str, style: str | None = None) -> None:
|
||||||
|
"""Write text to the log."""
|
||||||
|
if self._log:
|
||||||
|
self._do_write(text, style)
|
||||||
|
else:
|
||||||
|
# Queue for later if not mounted yet
|
||||||
|
self._pending_writes.append((text, style))
|
||||||
|
|
||||||
|
def write_stdout(self, text: str) -> None:
|
||||||
|
"""Write stdout text."""
|
||||||
|
self.write(text)
|
||||||
|
|
||||||
|
def write_stderr(self, text: str) -> None:
|
||||||
|
"""Write stderr text (in red)."""
|
||||||
|
self.write(text, "red")
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear the log."""
|
||||||
|
self._pending_writes.clear()
|
||||||
|
if self._log:
|
||||||
|
self._log.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class LogTabs(Vertical):
|
||||||
|
"""Container with closable tabs for multiple server logs."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
LogTabs {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogTabs .no-logs {
|
||||||
|
height: 100%;
|
||||||
|
content-align: center middle;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogTabs TabbedContent {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogTabs TabPane {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogTabs TabPane > LogPanel {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogTabs ContentSwitcher {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
id: str | None = None,
|
||||||
|
classes: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(name=name, id=id, classes=classes)
|
||||||
|
self._panels: dict[str, LogPanel] = {}
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static(
|
||||||
|
"Run a command to see server logs here", classes="no-logs", id="placeholder"
|
||||||
|
)
|
||||||
|
yield TabbedContent(id="log-tabs")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Hide tabs initially."""
|
||||||
|
self.query_one("#log-tabs", TabbedContent).display = False
|
||||||
|
|
||||||
|
def add_server(self, server_name: str) -> LogPanel:
|
||||||
|
"""Add a new server log tab."""
|
||||||
|
if server_name in self._panels:
|
||||||
|
return self._panels[server_name]
|
||||||
|
|
||||||
|
# Hide placeholder, show tabs
|
||||||
|
self.query_one("#placeholder").display = False
|
||||||
|
tabs = self.query_one("#log-tabs", TabbedContent)
|
||||||
|
tabs.display = True
|
||||||
|
|
||||||
|
# Create panel and tab with sanitized IDs
|
||||||
|
safe_id = _sanitize_id(server_name)
|
||||||
|
panel = LogPanel(server_name, id=f"panel-{safe_id}")
|
||||||
|
self._panels[server_name] = panel
|
||||||
|
|
||||||
|
# Add tab pane
|
||||||
|
tab_pane = TabPane(server_name, panel, id=f"tab-{safe_id}")
|
||||||
|
tabs.add_pane(tab_pane)
|
||||||
|
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def remove_server(self, server_name: str) -> None:
|
||||||
|
"""Remove a server log tab."""
|
||||||
|
if server_name not in self._panels:
|
||||||
|
return
|
||||||
|
|
||||||
|
safe_id = _sanitize_id(server_name)
|
||||||
|
tabs = self.query_one("#log-tabs", TabbedContent)
|
||||||
|
tabs.remove_pane(f"tab-{safe_id}")
|
||||||
|
del self._panels[server_name]
|
||||||
|
|
||||||
|
# Show placeholder if no tabs left
|
||||||
|
if not self._panels:
|
||||||
|
tabs.display = False
|
||||||
|
self.query_one("#placeholder").display = True
|
||||||
|
|
||||||
|
def get_panel(self, server_name: str) -> LogPanel | None:
|
||||||
|
"""Get panel for a server."""
|
||||||
|
return self._panels.get(server_name)
|
||||||
|
|
||||||
|
def clear_all(self) -> None:
|
||||||
|
"""Remove all server tabs."""
|
||||||
|
for name in list(self._panels.keys()):
|
||||||
|
self.remove_server(name)
|
||||||
|
|
||||||
|
def on_log_panel_close_requested(self, event: LogPanel.CloseRequested) -> None:
|
||||||
|
"""Handle panel close request."""
|
||||||
|
self.remove_server(event.server_name)
|
||||||
179
mux-0kmtui/src/mux_0kmtui/widgets/server_selector.py
Normal file
179
mux-0kmtui/src/mux_0kmtui/widgets/server_selector.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""Server selection widget with multi-select support."""
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Vertical
|
||||||
|
from textual.message import Message
|
||||||
|
from textual.widgets import Static, SelectionList, Input
|
||||||
|
from textual.widgets.selection_list import Selection
|
||||||
|
|
||||||
|
from ..config import VPSHost
|
||||||
|
|
||||||
|
|
||||||
|
class ServerSelector(Vertical):
|
||||||
|
"""Widget for selecting multiple VPS servers with search/filter support."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
ServerSelector {
|
||||||
|
border: solid $primary;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerSelector .selector-header {
|
||||||
|
height: 1;
|
||||||
|
background: $primary;
|
||||||
|
color: $text;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerSelector #search-input {
|
||||||
|
height: 1;
|
||||||
|
border: none;
|
||||||
|
padding: 0 1;
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerSelector #search-input:focus {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerSelector .selector-info {
|
||||||
|
height: 1;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerSelector SelectionList {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
class SelectionChanged(Message):
|
||||||
|
"""Message sent when selection changes."""
|
||||||
|
|
||||||
|
def __init__(self, selected: list[VPSHost]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.selected = selected
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hosts: list[VPSHost],
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
id: str | None = None,
|
||||||
|
classes: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(name=name, id=id, classes=classes)
|
||||||
|
self._hosts = {h.name: h for h in hosts}
|
||||||
|
self._selected_names: set[str] = set()
|
||||||
|
self._current_filter: str = ""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static("Servers (Space=toggle)", classes="selector-header")
|
||||||
|
yield Input(placeholder="Filter by name or tag...", id="search-input")
|
||||||
|
yield Static("0 selected", id="selection-info", classes="selector-info")
|
||||||
|
yield SelectionList[str](
|
||||||
|
*self._build_selections(),
|
||||||
|
id="server-list",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_selections(self) -> list[Selection[str]]:
|
||||||
|
"""Build selection items, filtered by current search."""
|
||||||
|
selections = []
|
||||||
|
filter_lower = self._current_filter.lower()
|
||||||
|
|
||||||
|
for host in self._hosts.values():
|
||||||
|
# Check if host matches filter (name or any tag)
|
||||||
|
if filter_lower:
|
||||||
|
name_match = filter_lower in host.name.lower()
|
||||||
|
tag_match = any(filter_lower in tag.lower() for tag in host.tags)
|
||||||
|
if not (name_match or tag_match):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build display label with tags
|
||||||
|
if host.tags:
|
||||||
|
tags_str = " [" + ", ".join(host.tags) + "]"
|
||||||
|
label = f"{host.name}{tags_str}"
|
||||||
|
else:
|
||||||
|
label = host.name
|
||||||
|
|
||||||
|
is_selected = host.name in self._selected_names
|
||||||
|
selections.append(Selection(label, host.name, is_selected))
|
||||||
|
|
||||||
|
return selections
|
||||||
|
|
||||||
|
def _rebuild_list(self) -> None:
|
||||||
|
"""Rebuild the selection list with current filter."""
|
||||||
|
selection_list = self.query_one("#server-list", SelectionList)
|
||||||
|
selection_list.clear_options()
|
||||||
|
for selection in self._build_selections():
|
||||||
|
selection_list.add_option(selection)
|
||||||
|
self._update_info()
|
||||||
|
|
||||||
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
|
"""Handle search input changes."""
|
||||||
|
if event.input.id == "search-input":
|
||||||
|
self._current_filter = event.value
|
||||||
|
self._rebuild_list()
|
||||||
|
|
||||||
|
def on_selection_list_selected_changed(
|
||||||
|
self, event: SelectionList.SelectedChanged
|
||||||
|
) -> None:
|
||||||
|
"""Handle selection changes."""
|
||||||
|
# Update internal tracking of selected names
|
||||||
|
visible_selected = set(event.selection_list.selected)
|
||||||
|
|
||||||
|
# For visible items, sync with the selection list
|
||||||
|
for option in event.selection_list._options:
|
||||||
|
name = option.value
|
||||||
|
if name in visible_selected:
|
||||||
|
self._selected_names.add(name)
|
||||||
|
elif name in self._selected_names:
|
||||||
|
# Only remove if it's visible and was deselected
|
||||||
|
self._selected_names.discard(name)
|
||||||
|
|
||||||
|
self._update_info()
|
||||||
|
self.post_message(self.SelectionChanged(self.get_selected()))
|
||||||
|
|
||||||
|
def _update_info(self) -> None:
|
||||||
|
"""Update the selection info label."""
|
||||||
|
info = self.query_one("#selection-info", Static)
|
||||||
|
count = len(self._selected_names)
|
||||||
|
visible_count = len(self.query_one("#server-list", SelectionList)._options)
|
||||||
|
total_count = len(self._hosts)
|
||||||
|
|
||||||
|
if self._current_filter:
|
||||||
|
info.update(f"{count} selected ({visible_count}/{total_count} shown)")
|
||||||
|
else:
|
||||||
|
info.update(f"{count} selected" if count != 1 else "1 selected")
|
||||||
|
|
||||||
|
def get_selected(self) -> list[VPSHost]:
|
||||||
|
"""Get currently selected hosts (including filtered-out ones)."""
|
||||||
|
return [
|
||||||
|
self._hosts[name] for name in self._selected_names if name in self._hosts
|
||||||
|
]
|
||||||
|
|
||||||
|
def select_all(self) -> None:
|
||||||
|
"""Select all visible servers."""
|
||||||
|
selection_list = self.query_one("#server-list", SelectionList)
|
||||||
|
selection_list.select_all()
|
||||||
|
# Also add to internal tracking
|
||||||
|
for option in selection_list._options:
|
||||||
|
self._selected_names.add(option.value)
|
||||||
|
self._update_info()
|
||||||
|
|
||||||
|
def deselect_all(self) -> None:
|
||||||
|
"""Deselect all visible servers."""
|
||||||
|
selection_list = self.query_one("#server-list", SelectionList)
|
||||||
|
# Remove visible items from internal tracking
|
||||||
|
for option in selection_list._options:
|
||||||
|
self._selected_names.discard(option.value)
|
||||||
|
selection_list.deselect_all()
|
||||||
|
self._update_info()
|
||||||
|
|
||||||
|
def get_all_tags(self) -> set[str]:
|
||||||
|
"""Get all unique tags from all hosts."""
|
||||||
|
tags = set()
|
||||||
|
for host in self._hosts.values():
|
||||||
|
tags.update(host.tags)
|
||||||
|
return tags
|
||||||
Reference in New Issue
Block a user