feat: add spinner

This commit is contained in:
eric
2026-03-18 13:28:51 +01:00
parent f150afec0a
commit f558ab4ba9
12 changed files with 287 additions and 15 deletions

View File

@@ -11,9 +11,11 @@ import os
import re
import shlex
import shutil
import select
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
@@ -22,6 +24,7 @@ SUPPORTED_CONFIG_MARKER = "Generated by nodeiwest host init."
SUPPORTED_DISKO_MARKER = "Generated by nodeiwest host init."
DEFAULT_STATE_VERSION = "25.05"
BOOT_MODE_CHOICES = ("uefi", "bios")
ACTIVITY_FRAMES = (0, 1, 2, 3, 2, 1)
class NodeiwestError(RuntimeError):
@@ -110,7 +113,7 @@ def build_parser() -> argparse.ArgumentParser:
init_parser.add_argument("--user", default="root", help="SSH user. Default: root.")
init_parser.add_argument("--disk", help="Override the probed disk device, e.g. /dev/sda.")
init_parser.add_argument("--boot-mode", choices=BOOT_MODE_CHOICES, help="Override the probed boot mode.")
init_parser.add_argument("--swap-size", help="Swap partition size. Default: 4GiB.")
init_parser.add_argument("--swap-size", help="Swap partition size. Default: 4G.")
init_parser.add_argument("--timezone", help="Time zone. Default for new hosts: UTC.")
init_parser.add_argument(
"--tailscale-openbao",
@@ -267,7 +270,7 @@ def cmd_host_init(args: argparse.Namespace) -> int:
file=sys.stderr,
)
swap_size = args.swap_size or (existing_disko.swap_size if existing_disko else "4GiB")
swap_size = normalize_swap_size(args.swap_size or (existing_disko.swap_size if existing_disko else "4G"))
timezone = args.timezone or (existing_config.timezone if existing_config else "UTC")
tailscale_openbao = parse_on_off(args.tailscale_openbao, existing_config.tailscale_openbao if existing_config else True)
state_version = existing_config.state_version if existing_config else repo_defaults.state_version
@@ -466,6 +469,7 @@ def cmd_install_run(args: argparse.Namespace) -> int:
install_context["command"],
cwd=repo_root,
next_fix="Recover via provider console or public SSH, then re-check the generated host files and bootstrap material.",
activity_label="Executing install",
)
print("")
@@ -705,6 +709,21 @@ def parse_swaps(output: str) -> list[str]:
return [line.split()[0] for line in lines[1:]]
def normalize_swap_size(value: str) -> str:
normalized = value.strip()
replacements = {
"KiB": "K",
"MiB": "M",
"GiB": "G",
"TiB": "T",
"PiB": "P",
}
for suffix, replacement in replacements.items():
if normalized.endswith(suffix):
return normalized[: -len(suffix)] + replacement
return normalized
def parse_existing_configuration(path: Path) -> ExistingConfiguration:
text = path.read_text()
if "./disko.nix" not in text or "./hardware-configuration.nix" not in text:
@@ -1309,15 +1328,54 @@ def stream_command(
cwd: Path | None = None,
env: dict[str, str] | None = None,
next_fix: str | None = None,
activity_label: str | None = None,
) -> None:
merged_env = os.environ.copy()
if env:
merged_env.update(env)
indicator = BottomActivityIndicator(activity_label) if activity_label else None
process = subprocess.Popen(
command,
cwd=str(cwd) if cwd else None,
env=merged_env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
if process.stdout is None:
raise NodeiwestError(f"Failed to open output stream for command: {shlex.join(command)}")
if indicator is not None:
indicator.start()
stdout_fd = process.stdout.fileno()
os.set_blocking(stdout_fd, False)
try:
while True:
if indicator is not None:
indicator.render()
ready, _, _ = select.select([stdout_fd], [], [], 0.1)
if ready:
chunk = read_process_chunk(stdout_fd)
if chunk:
write_output_chunk(chunk)
if indicator is not None:
indicator.render(force=True)
continue
break
if process.poll() is not None:
chunk = read_process_chunk(stdout_fd)
if chunk:
write_output_chunk(chunk)
continue
break
finally:
process.stdout.close()
if indicator is not None:
indicator.stop()
return_code = process.wait()
if return_code != 0:
raise NodeiwestError(
@@ -1326,6 +1384,96 @@ def stream_command(
)
class BottomActivityIndicator:
def __init__(self, label: str, stream: Any | None = None) -> None:
self.label = label
self.stream = stream or choose_activity_stream()
self.enabled = bool(self.stream and supports_ansi_status(self.stream))
self.rows = 0
self.frame_index = 0
self.last_render_at = 0.0
def start(self) -> None:
if not self.enabled:
return
self.rows = shutil.get_terminal_size(fallback=(80, 24)).lines
if self.rows < 2:
self.enabled = False
return
self.stream.write("\033[?25l")
self.stream.write(f"\033[1;{self.rows - 1}r")
self.stream.flush()
self.render(force=True)
def render(self, *, force: bool = False) -> None:
if not self.enabled:
return
now = time.monotonic()
if not force and (now - self.last_render_at) < 0.12:
return
rows = shutil.get_terminal_size(fallback=(80, 24)).lines
if rows != self.rows and rows >= 2:
self.rows = rows
self.stream.write(f"\033[1;{self.rows - 1}r")
frame = format_activity_frame(self.label, ACTIVITY_FRAMES[self.frame_index])
self.frame_index = (self.frame_index + 1) % len(ACTIVITY_FRAMES)
self.stream.write("\0337")
self.stream.write(f"\033[{self.rows};1H\033[2K{frame}")
self.stream.write("\0338")
self.stream.flush()
self.last_render_at = now
def stop(self) -> None:
if not self.enabled:
return
self.stream.write("\0337")
self.stream.write(f"\033[{self.rows};1H\033[2K")
self.stream.write("\0338")
self.stream.write("\033[r")
self.stream.write("\033[?25h")
self.stream.flush()
def choose_activity_stream() -> Any | None:
if getattr(sys.stderr, "isatty", lambda: False)():
return sys.stderr
if getattr(sys.stdout, "isatty", lambda: False)():
return sys.stdout
return None
def supports_ansi_status(stream: Any) -> bool:
return bool(getattr(stream, "isatty", lambda: False)() and os.environ.get("TERM", "") not in {"", "dumb"})
def format_activity_frame(label: str, active_index: int) -> str:
blocks = []
for index in range(4):
if index == active_index:
blocks.append("\033[38;5;220m█\033[0m")
else:
blocks.append("\033[38;5;208m█\033[0m")
return f"{''.join(blocks)} \033[1;37m{label}\033[0m"
def read_process_chunk(fd: int) -> bytes:
try:
return os.read(fd, 4096)
except BlockingIOError:
return b""
def write_output_chunk(chunk: bytes) -> None:
if hasattr(sys.stdout, "buffer"):
sys.stdout.buffer.write(chunk)
sys.stdout.buffer.flush()
return
sys.stdout.write(chunk.decode(errors="replace"))
sys.stdout.flush()
def format_command_failure(
command: list[str],
result: subprocess.CompletedProcess[str],