feat: add spinner
This commit is contained in:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user