From 2c7f2e2c7c45abf787bde3655bee3c64aea7fda7 Mon Sep 17 00:00:00 2001 From: j Date: Mon, 9 Mar 2026 18:39:54 +1300 Subject: [PATCH] Stream real-time section progress during single-host refresh --- app/__pycache__/app.cpython-312.pyc | Bin 0 -> 37341 bytes app/app.py | 122 ++++++++++++++++++++++++++-- 2 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 app/__pycache__/app.cpython-312.pyc diff --git a/app/__pycache__/app.cpython-312.pyc b/app/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..165684498cdd997abbd6fbaecae74fc10e1677e7 GIT binary patch literal 37341 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 a4V8zsgKtj!s!tjVIt$f5E7W#Y>i>TMGmOXp literal 0 HcmV?d00001 diff --git a/app/app.py b/app/app.py index a8e977c..83dc115 100644 --- a/app/app.py +++ b/app/app.py @@ -116,7 +116,7 @@ def load_ssh_key(): raise RuntimeError(f"Could not load SSH key from {SSH_KEY_PATH}") -def collect_one(entry, ssh_key): +def collect_one(entry, ssh_key, progress_cb=None): """SSH into a single server, run the gather script, return parsed data.""" try: ssh = paramiko.SSHClient() @@ -136,7 +136,21 @@ def collect_one(entry, ssh_key): stdin, stdout, stderr = ssh.exec_command('bash -s', timeout=60) stdin.write(script) stdin.channel.shutdown_write() - output = stdout.read().decode('utf-8', errors='replace') + + if progress_cb: + # Stream line-by-line and report section headers as progress + output_lines = [] + for raw_line in stdout: + line = raw_line.rstrip('\n') + output_lines.append(line) + stripped = line.strip() + if stripped.startswith('[') and stripped.endswith(']') and stripped != '[end]': + section = stripped[1:-1].split(':')[0] + progress_cb(section) + output = '\n'.join(output_lines) + else: + output = stdout.read().decode('utf-8', errors='replace') + ssh.close() data = parse_gather_output(output) @@ -479,9 +493,65 @@ def api_refresh_one_stream(server_id): yield f"data: [DONE]\n\n" return + # Helper to collect one server with streaming progress + def collect_with_progress(srv_entry, srv_id): + progress_msgs = [] + last_section = [None] + def on_progress(section): + if section != last_section[0]: + last_section[0] = section + progress_msgs.append(section) + result = collect_one(srv_entry, ssh_key, progress_cb=on_progress) + with app.app_context(): + srv = Server.query.get(srv_id) + _update_server_from_result(srv, srv_entry, result) + db.session.commit() + return result, progress_msgs + # Collect the host - yield f"data: Connecting to {hostname}...\n\n" - result = collect_one(entry, ssh_key) + import queue as _queue + progress_q = _queue.Queue() + host_done = threading.Event() + + def _collect_host(): + last_reported = [None] + def on_progress(section): + if section != last_reported[0]: + last_reported[0] = section + progress_q.put(('progress', f"{hostname}: {section}")) + try: + result = collect_one(entry, ssh_key, progress_cb=on_progress) + progress_q.put(('result', result)) + except Exception as e: + progress_q.put(('result', {'is_online': False, 'error': str(e)})) + host_done.set() + + t = threading.Thread(target=_collect_host) + t.start() + + # Stream progress while collecting + while not host_done.is_set(): + try: + kind, val = progress_q.get(timeout=0.3) + if kind == 'progress': + yield f"data: {val}\n\n" + elif kind == 'result': + break + except _queue.Empty: + continue + + t.join() + # Drain remaining messages + result = None + while not progress_q.empty(): + kind, val = progress_q.get_nowait() + if kind == 'progress': + yield f"data: {val}\n\n" + elif kind == 'result': + result = val + + if result is None: + result = {'is_online': False, 'error': 'unknown'} with app.app_context(): server = Server.query.get(server_id) @@ -500,11 +570,49 @@ def api_refresh_one_stream(server_id): else: yield f"data: {hostname} - offline: {result.get('error', 'unknown')}\n\n" - # Collect child VMs + # Collect child VMs with progress for child_entry in child_entries: child_host = child_entry['hostname'] - yield f"data: Connecting to {child_host}...\n\n" - child_result = collect_one(child_entry, ssh_key) + child_q = _queue.Queue() + child_done = threading.Event() + + def _collect_child(ce=child_entry, cq=child_q, cd=child_done): + last_reported = [None] + def on_progress(section): + if section != last_reported[0]: + last_reported[0] = section + cq.put(('progress', f"{ce['hostname']}: {section}")) + try: + r = collect_one(ce, ssh_key, progress_cb=on_progress) + cq.put(('result', r)) + except Exception as e: + cq.put(('result', {'is_online': False, 'error': str(e)})) + cd.set() + + ct = threading.Thread(target=_collect_child) + ct.start() + + while not child_done.is_set(): + try: + kind, val = child_q.get(timeout=0.3) + if kind == 'progress': + yield f"data: {val}\n\n" + elif kind == 'result': + break + except _queue.Empty: + continue + + ct.join() + child_result = None + while not child_q.empty(): + kind, val = child_q.get_nowait() + if kind == 'progress': + yield f"data: {val}\n\n" + elif kind == 'result': + child_result = val + + if child_result is None: + child_result = {'is_online': False, 'error': 'unknown'} with app.app_context(): child_server = Server.query.get(child_entry['id'])