Compare commits

...

3 Commits

Author SHA1 Message Date
Bas Nijholt
aabdd550ba feat(cli): Add progress bar to ssh status host connectivity check (#31)
Use run_parallel_with_progress for visual feedback during host checks.
Results are now sorted alphabetically for consistent output.

Also adds code style rule to CLAUDE.md about keeping imports at top level.
2025-12-18 12:21:47 -08:00
Bas Nijholt
8ff60a1e3e refactor(ssh): Unify ssh_status to use run_command like check command (#29) 2025-12-18 12:17:47 -08:00
Bas Nijholt
2497bd727a feat(web): Navigate to dashboard for Apply/Refresh from command palette (#28)
When triggering Apply or Refresh from the command palette on a non-dashboard
page, navigate to the dashboard first and then execute the action, opening
the terminal output.
2025-12-18 12:12:50 -08:00
4 changed files with 72 additions and 46 deletions

View File

@@ -43,6 +43,10 @@ Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templat
7. **State tracking**: Tracks where services are deployed for auto-migration
8. **Pre-flight checks**: Verifies NFS mounts and Docker networks exist before starting/migrating
## Code Style
- **Imports at top level**: Never add imports inside functions unless they are explicitly marked with `# noqa: PLC0415` and a comment explaining it speeds up CLI startup. Heavy modules like `pydantic`, `yaml`, and `rich.table` are lazily imported to keep `cf --help` fast.
## Communication Notes
- Clarify ambiguous wording (e.g., homophones like "right"/"write", "their"/"there").

View File

@@ -149,8 +149,14 @@ def _check_ssh_connectivity(cfg: Config) -> list[str]:
async def check_host(host_name: str) -> tuple[str, bool]:
host = cfg.hosts[host_name]
result = await run_command(host, "echo ok", host_name, stream=False)
return host_name, result.success
try:
result = await asyncio.wait_for(
run_command(host, "echo ok", host_name, stream=False),
timeout=5.0,
)
return host_name, result.success
except TimeoutError:
return host_name, False
results = run_parallel_with_progress(
"Checking SSH connectivity",

View File

@@ -2,14 +2,20 @@
from __future__ import annotations
import asyncio
import subprocess
from typing import Annotated
from typing import TYPE_CHECKING, Annotated
import typer
from compose_farm.cli.app import app
from compose_farm.cli.common import ConfigOption, load_config_or_exit
from compose_farm.cli.common import ConfigOption, load_config_or_exit, run_parallel_with_progress
from compose_farm.console import console, err_console
from compose_farm.executor import run_command
if TYPE_CHECKING:
from compose_farm.config import Host
from compose_farm.ssh_keys import (
SSH_KEY_PATH,
SSH_PUBKEY_PATH,
@@ -192,7 +198,7 @@ def ssh_setup(
@ssh_app.command("status")
def ssh_status( # noqa: PLR0912 - branches are clear and readable
def ssh_status(
config: ConfigOption = None,
) -> None:
"""Show SSH key status and host connectivity."""
@@ -232,50 +238,42 @@ def ssh_status( # noqa: PLR0912 - branches are clear and readable
console.print(" [dim]No remote hosts configured[/]")
return
async def check_host(item: tuple[str, Host]) -> tuple[str, str, str]:
"""Check connectivity to a single host."""
host_name, host = item
target = f"{host.user}@{host.address}"
if host.port != _DEFAULT_SSH_PORT:
target += f":{host.port}"
try:
result = await asyncio.wait_for(
run_command(host, "echo ok", host_name, stream=False),
timeout=5.0,
)
status = "[green]OK[/]" if result.success else "[red]Auth failed[/]"
except TimeoutError:
status = "[red]Timeout (5s)[/]"
except Exception as e:
status = f"[red]Error: {e}[/]"
return host_name, target, status
# Check connectivity in parallel with progress bar
results = run_parallel_with_progress(
"Checking hosts",
list(remote_hosts.items()),
check_host,
)
# Build table from results
table = Table(show_header=True, header_style="bold")
table.add_column("Host")
table.add_column("Address")
table.add_column("Status")
for host_name, host in remote_hosts.items():
target = f"{host.user}@{host.address}"
if host.port != _DEFAULT_SSH_PORT:
target += f":{host.port}"
# Test connectivity with a simple command
cmd = [
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"BatchMode=yes", # Fail immediately if password required
"-o",
"ConnectTimeout=5",
]
# Add key file if it exists
if key_exists():
cmd.extend(["-i", str(SSH_KEY_PATH)])
if host.port != _DEFAULT_SSH_PORT:
cmd.extend(["-p", str(host.port)])
cmd.extend([f"{host.user}@{host.address}", "echo ok"])
try:
result = subprocess.run(
cmd, check=False, capture_output=True, timeout=10, env=get_ssh_env()
)
if result.returncode == 0:
table.add_row(host_name, target, "[green]OK[/]")
else:
table.add_row(host_name, target, "[red]Auth failed[/]")
except subprocess.TimeoutExpired:
table.add_row(host_name, target, "[red]Timeout[/]")
except Exception as e:
table.add_row(host_name, target, f"[red]Error: {e}[/]")
# Sort by host name for consistent order
for host_name, target, status in sorted(results, key=lambda r: r[0]):
table.add_row(host_name, target, status)
console.print(table)

View File

@@ -411,6 +411,16 @@ function initPage() {
document.addEventListener('DOMContentLoaded', function() {
initPage();
initKeyboardShortcuts();
// Handle ?action= parameter (from command palette navigation)
const params = new URLSearchParams(window.location.search);
const action = params.get('action');
if (action && window.location.pathname === '/') {
// Clear the URL parameter
history.replaceState({}, '', '/');
// Trigger the action
htmx.ajax('POST', `/api/${action}`, {swap: 'none'});
}
});
// Re-initialize after HTMX swaps main content
@@ -498,12 +508,20 @@ document.body.addEventListener('htmx:afterRequest', function(evt) {
const post = (url) => () => htmx.ajax('POST', url, {swap: 'none'});
const nav = (url) => () => window.location.href = url;
// Navigate to dashboard and trigger action (or just POST if already on dashboard)
const dashboardAction = (endpoint) => () => {
if (window.location.pathname === '/') {
htmx.ajax('POST', `/api/${endpoint}`, {swap: 'none'});
} else {
window.location.href = `/?action=${endpoint}`;
}
};
const cmd = (type, name, desc, action, icon = null) => ({ type, name, desc, action, icon });
function buildCommands() {
const actions = [
cmd('action', 'Apply', 'Make reality match config', post('/api/apply'), icons.check),
cmd('action', 'Refresh', 'Update state from reality', post('/api/refresh'), icons.refresh_cw),
cmd('action', 'Apply', 'Make reality match config', dashboardAction('apply'), icons.check),
cmd('action', 'Refresh', 'Update state from reality', dashboardAction('refresh'), icons.refresh_cw),
cmd('nav', 'Dashboard', 'Go to dashboard', nav('/'), icons.home),
cmd('nav', 'Console', 'Go to console', nav('/console'), icons.terminal),
];