Skip to content

Commit 94a85b0

Browse files
authored
Merge pull request #130 from quadsproject/development
fix: add proper awareness for awaiting_move
2 parents 05af0cf + f7d0900 commit 94a85b0

6 files changed

Lines changed: 246 additions & 37 deletions

File tree

src/quads_client/commands/track.py

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,13 @@ def _track_single(self, api, console, hostname):
4949
data = api.get_move_status(hostname)
5050

5151
if not data:
52-
self._show_pending_moves(api, hostname=hostname)
53-
return
52+
pending = self._get_pending_moves(api, hostname=hostname)
53+
if not pending:
54+
self.shell.rich_console.print_info(f"No active or scheduled moves for {hostname}")
55+
return
56+
data = self._wait_for_active_single(api, console, hostname, pending)
57+
if not data:
58+
return
5459

5560
try:
5661
with Live(self._build_single_table(data), console=console, refresh_per_second=0.2) as live:
@@ -72,8 +77,14 @@ def _track_all(self, api, console, cloud=None):
7277
moves = api.get_all_move_status(cloud=cloud)
7378

7479
if not moves:
75-
self._show_pending_moves(api, cloud)
76-
return
80+
pending = self._get_pending_moves(api, cloud=cloud)
81+
if not pending:
82+
label = f" for {cloud}" if cloud else ""
83+
self.shell.rich_console.print_info(f"No active or scheduled moves{label}")
84+
return
85+
moves = self._wait_for_active_all(api, console, cloud, pending)
86+
if not moves:
87+
return
7788

7889
try:
7990
with Live(self._build_all_table(moves), console=console, refresh_per_second=0.2) as live:
@@ -89,7 +100,7 @@ def _track_all(self, api, console, cloud=None):
89100
count = len(moves) if moves else 0
90101
console.print(f"[dim]Stopped tracking. {count} move(s) active.[/dim]")
91102

92-
def _show_pending_moves(self, api, cloud=None, hostname=None):
103+
def _get_pending_moves(self, api, cloud=None, hostname=None):
93104
try:
94105
pending = api.get_moves()
95106
except Exception:
@@ -99,18 +110,45 @@ def _show_pending_moves(self, api, cloud=None, hostname=None):
99110
pending = [m for m in pending if m.get("new") == cloud]
100111
if hostname:
101112
pending = [m for m in pending if m.get("host") == hostname]
113+
return pending
102114

103-
if not pending:
104-
if hostname:
105-
self.shell.rich_console.print_info(f"No active or scheduled moves for {hostname}")
106-
elif cloud:
107-
self.shell.rich_console.print_info(f"No active or scheduled moves for {cloud}")
108-
else:
109-
self.shell.rich_console.print_info("No active or scheduled moves")
110-
return
115+
def _wait_for_active_all(self, api, console, cloud, pending):
116+
try:
117+
with Live(self._build_pending_table(pending), console=console, refresh_per_second=0.2) as live:
118+
while True:
119+
time.sleep(10)
120+
active = api.get_all_move_status(cloud=cloud)
121+
if active:
122+
return active
123+
pending = self._get_pending_moves(api, cloud=cloud)
124+
if not pending:
125+
return []
126+
live.update(self._build_pending_table(pending))
127+
except KeyboardInterrupt:
128+
console.print("[dim]Stopped tracking.[/dim]")
129+
return None
111130

131+
def _wait_for_active_single(self, api, console, hostname, pending):
132+
try:
133+
with Live(self._build_pending_table(pending), console=console, refresh_per_second=0.2) as live:
134+
while True:
135+
time.sleep(10)
136+
data = api.get_move_status(hostname)
137+
if data:
138+
return data
139+
pending = self._get_pending_moves(api, hostname=hostname)
140+
if not pending:
141+
return None
142+
live.update(self._build_pending_table(pending))
143+
except KeyboardInterrupt:
144+
console.print("[dim]Stopped tracking.[/dim]")
145+
return None
146+
147+
def _build_pending_table(self, pending):
148+
now = datetime.now().strftime("%H:%M:%S")
112149
table = Table(
113150
title="Scheduled Moves (awaiting next move cycle)",
151+
caption=f"Last check: {now} | Refresh: 10s | Ctrl+C to stop",
114152
show_header=True,
115153
header_style="bold yellow",
116154
)
@@ -127,8 +165,7 @@ def _show_pending_moves(self, api, cloud=None, hostname=None):
127165
"Scheduled",
128166
style="yellow",
129167
)
130-
131-
self.shell.rich_console.console.print(table)
168+
return table
132169

133170
def _build_all_table(self, moves):
134171
now = datetime.now().strftime("%H:%M:%S")

src/quads_client/commands/user.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -802,13 +802,14 @@ def cmd_my_hosts(self, args):
802802
status = "Active"
803803
progress = format_progress_str("completed")
804804
else:
805-
status = "Provisioning"
806-
progress = "N/A"
807805
move_data = move_status_map.get(host_name)
808806
if move_data:
809807
move_st = move_data.get("status", "pending")
810808
progress = format_progress_str(move_st)
811809
status = move_st.replace("_", " ").title()
810+
else:
811+
status = "Scheduled"
812+
progress = "Awaiting move"
812813

813814
unique_hosts[host_name] = {
814815
"status": status,

src/quads_client/gui/views/moves.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def _create_ui(self):
5252

5353
self.tree.tree.tag_configure("failed", foreground="red")
5454
self.tree.tree.tag_configure("completed", foreground="green")
55+
self.tree.tree.tag_configure("scheduled", foreground="goldenrod")
5556

5657
self.create_status_label()
5758
self._load_progress()
@@ -65,16 +66,40 @@ def _load_progress(self):
6566
self._loading = True
6667

6768
def _fetch():
68-
return self.shell.connection.api.get_all_move_status()
69-
70-
def _on_loaded(moves):
69+
api = self.shell.connection.api
70+
active = api.get_all_move_status()
71+
if active:
72+
return ("active", active)
73+
try:
74+
pending = api.get_moves()
75+
except Exception:
76+
pending = []
77+
return ("pending", pending) if pending else ("active", [])
78+
79+
def _on_loaded(result):
7180
self._loading = False
7281
if not self.winfo_exists():
7382
return
7483
self.tree.clear()
84+
source, moves = result
7585
if not moves:
7686
self.update_status("No active moves")
7787
return
88+
89+
if source == "pending":
90+
for move in moves:
91+
values = (
92+
move.get("host", "?"),
93+
move.get("current", ""),
94+
move.get("new", ""),
95+
"",
96+
"Scheduled",
97+
"Awaiting next move cycle",
98+
)
99+
self.tree.tree.insert("", tk.END, values=values, tags=("scheduled",))
100+
self.update_status(f"{len(moves)} scheduled move(s) (awaiting next move cycle)")
101+
return
102+
78103
for move in moves:
79104
status = move.get("status", "pending")
80105
tag = ""

src/quads_client/gui/views/my_hosts.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -234,17 +234,18 @@ def _fetch_assignments(self):
234234
if isinstance(hostname, dict):
235235
hostname = hostname.get("name", "")
236236

237-
status = "active" if is_validated else "provisioning"
238-
239-
if status == "active":
237+
if is_validated:
238+
status = "active"
240239
progress = format_progress_str("completed")
241240
else:
242-
progress = "N/A"
243241
move_data = move_status_map.get(str(hostname))
244242
if move_data:
245243
move_status = move_data.get("status", "pending")
246244
progress = format_progress_str(move_status)
247245
status = move_status
246+
else:
247+
status = "scheduled"
248+
progress = "Awaiting move"
248249

249250
hosts.append({"name": str(hostname), "status": status, "progress": progress})
250251

@@ -335,6 +336,9 @@ def _create_assignment_panel(self, assignment):
335336
elif host["status"] == "failed":
336337
tree.item(item_id, tags=("failed",))
337338
tree.tag_configure("failed", foreground=self.shell.gui_app.theme_manager.get_color("error"))
339+
elif host["status"] == "scheduled":
340+
tree.item(item_id, tags=("scheduled",))
341+
tree.tag_configure("scheduled", foreground=self.shell.gui_app.theme_manager.get_color("warning"))
338342
else:
339343
tree.item(item_id, tags=("provisioning",))
340344
tree.tag_configure(
@@ -353,10 +357,10 @@ def _create_assignment_panel(self, assignment):
353357
).pack(side=tk.LEFT)
354358

355359
def _get_status_icon(self, status):
356-
"""Get status icon for host"""
357360
icons = {
358361
"active": "✓",
359362
"failed": "✗",
363+
"scheduled": "○",
360364
}
361365
return icons.get(status, "⏳")
362366

tests/test_commands_track.py

Lines changed: 127 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,52 @@ def test_track_no_active_moves_no_pending(mock_shell):
6060
mock_shell.rich_console.print_info.assert_called_once_with("No active or scheduled moves")
6161

6262

63-
def test_track_no_active_moves_with_pending(mock_shell):
64-
mock_shell.connection.api.get_all_move_status.return_value = []
65-
mock_shell.connection.api.get_moves.return_value = [
66-
{"host": "host1.example.com", "current": "cloud01", "new": "cloud02"},
63+
@patch("quads_client.commands.track.Live")
64+
@patch("quads_client.commands.track.time")
65+
def test_track_no_active_moves_with_pending_transitions(mock_time, mock_live_cls, mock_shell):
66+
"""Pending moves poll at 10s until active moves appear, then transition to live tracking"""
67+
pending = [{"host": "host1.example.com", "current": "cloud01", "new": "cloud02"}]
68+
active = [
69+
{
70+
"host": "host1.example.com",
71+
"source_cloud": "cloud01",
72+
"target_cloud": "cloud02",
73+
"status": "pending",
74+
}
75+
]
76+
mock_shell.connection.api.get_all_move_status.side_effect = [
77+
[],
78+
active,
79+
active,
80+
[],
6781
]
82+
mock_shell.connection.api.get_moves.return_value = pending
83+
mock_live_instance = MagicMock()
84+
mock_live_cls.return_value.__enter__ = MagicMock(return_value=mock_live_instance)
85+
mock_live_cls.return_value.__exit__ = MagicMock(return_value=False)
6886

6987
cmd = TrackCommands(mock_shell)
7088
cmd.cmd_track("")
7189

72-
mock_shell.rich_console.console.print.assert_called_once()
73-
table = mock_shell.rich_console.console.print.call_args[0][0]
74-
assert table.title == "Scheduled Moves (awaiting next move cycle)"
90+
assert mock_live_cls.call_count == 2
91+
92+
93+
@patch("quads_client.commands.track.Live")
94+
@patch("quads_client.commands.track.time")
95+
def test_track_pending_ctrl_c_exits(mock_time, mock_live_cls, mock_shell):
96+
"""Ctrl+C during pending polling exits cleanly"""
97+
pending = [{"host": "host1.example.com", "current": "cloud01", "new": "cloud02"}]
98+
mock_shell.connection.api.get_all_move_status.return_value = []
99+
mock_shell.connection.api.get_moves.return_value = pending
100+
mock_live_instance = MagicMock()
101+
mock_live_cls.return_value.__enter__ = MagicMock(return_value=mock_live_instance)
102+
mock_live_cls.return_value.__exit__ = MagicMock(return_value=False)
103+
mock_time.sleep.side_effect = KeyboardInterrupt
104+
105+
cmd = TrackCommands(mock_shell)
106+
cmd.cmd_track("")
107+
108+
mock_shell.rich_console.console.print.assert_called()
75109

76110

77111
def test_track_no_active_move_single(mock_shell):
@@ -84,16 +118,85 @@ def test_track_no_active_move_single(mock_shell):
84118
mock_shell.rich_console.print_info.assert_called_once_with("No active or scheduled moves for host1")
85119

86120

87-
def test_track_no_active_single_with_pending(mock_shell):
88-
mock_shell.connection.api.get_move_status.return_value = None
89-
mock_shell.connection.api.get_moves.return_value = [
90-
{"host": "host1", "current": "cloud01", "new": "cloud02"},
121+
@patch("quads_client.commands.track.Live")
122+
@patch("quads_client.commands.track.time")
123+
def test_track_single_pending_transitions(mock_time, mock_live_cls, mock_shell):
124+
"""Single-host pending polling transitions to live tracking when move starts"""
125+
pending = [{"host": "host1", "current": "cloud01", "new": "cloud02"}]
126+
active = {
127+
"host": "host1",
128+
"source_cloud": "cloud01",
129+
"target_cloud": "cloud02",
130+
"status": "completed",
131+
}
132+
mock_shell.connection.api.get_move_status.side_effect = [
133+
None,
134+
active,
135+
active,
91136
]
137+
mock_shell.connection.api.get_moves.return_value = pending
138+
mock_live_instance = MagicMock()
139+
mock_live_cls.return_value.__enter__ = MagicMock(return_value=mock_live_instance)
140+
mock_live_cls.return_value.__exit__ = MagicMock(return_value=False)
92141

93142
cmd = TrackCommands(mock_shell)
94143
cmd.cmd_track("host1")
95144

96-
mock_shell.rich_console.console.print.assert_called_once()
145+
assert mock_live_cls.call_count == 2
146+
147+
148+
@patch("quads_client.commands.track.Live")
149+
@patch("quads_client.commands.track.time")
150+
def test_track_pending_all_disappears(mock_time, mock_live_cls, mock_shell):
151+
"""Pending moves disappear after one refresh cycle"""
152+
pending = [{"host": "host1.example.com", "current": "cloud01", "new": "cloud02"}]
153+
mock_shell.connection.api.get_all_move_status.return_value = []
154+
mock_shell.connection.api.get_moves.side_effect = [pending, pending, []]
155+
mock_live_instance = MagicMock()
156+
mock_live_cls.return_value.__enter__ = MagicMock(return_value=mock_live_instance)
157+
mock_live_cls.return_value.__exit__ = MagicMock(return_value=False)
158+
159+
cmd = TrackCommands(mock_shell)
160+
cmd.cmd_track("")
161+
162+
mock_live_cls.assert_called_once()
163+
mock_live_instance.update.assert_called_once()
164+
165+
166+
@patch("quads_client.commands.track.Live")
167+
@patch("quads_client.commands.track.time")
168+
def test_track_pending_single_disappears(mock_time, mock_live_cls, mock_shell):
169+
"""Single-host pending move disappears after one refresh cycle"""
170+
pending = [{"host": "host1", "current": "cloud01", "new": "cloud02"}]
171+
mock_shell.connection.api.get_move_status.side_effect = [None, None, None]
172+
mock_shell.connection.api.get_moves.side_effect = [pending, pending, []]
173+
mock_live_instance = MagicMock()
174+
mock_live_cls.return_value.__enter__ = MagicMock(return_value=mock_live_instance)
175+
mock_live_cls.return_value.__exit__ = MagicMock(return_value=False)
176+
177+
cmd = TrackCommands(mock_shell)
178+
cmd.cmd_track("host1")
179+
180+
mock_live_cls.assert_called_once()
181+
mock_live_instance.update.assert_called_once()
182+
183+
184+
@patch("quads_client.commands.track.Live")
185+
@patch("quads_client.commands.track.time")
186+
def test_track_pending_single_ctrl_c(mock_time, mock_live_cls, mock_shell):
187+
"""Ctrl+C during single-host pending polling exits cleanly"""
188+
pending = [{"host": "host1", "current": "cloud01", "new": "cloud02"}]
189+
mock_shell.connection.api.get_move_status.return_value = None
190+
mock_shell.connection.api.get_moves.return_value = pending
191+
mock_live_instance = MagicMock()
192+
mock_live_cls.return_value.__enter__ = MagicMock(return_value=mock_live_instance)
193+
mock_live_cls.return_value.__exit__ = MagicMock(return_value=False)
194+
mock_time.sleep.side_effect = KeyboardInterrupt
195+
196+
cmd = TrackCommands(mock_shell)
197+
cmd.cmd_track("host1")
198+
199+
mock_shell.rich_console.console.print.assert_called()
97200

98201

99202
def test_track_cloud_filter(mock_shell):
@@ -221,3 +324,15 @@ def test_build_single_table_with_error(mock_shell):
221324
}
222325
table = cmd._build_single_table(data)
223326
assert table.row_count >= 6
327+
328+
329+
def test_build_pending_table(mock_shell):
330+
cmd = TrackCommands(mock_shell)
331+
pending = [
332+
{"host": "host1.example.com", "current": "cloud01", "new": "cloud02"},
333+
{"host": "host2.example.com", "current": "cloud01", "new": "cloud03"},
334+
]
335+
table = cmd._build_pending_table(pending)
336+
assert table.title == "Scheduled Moves (awaiting next move cycle)"
337+
assert "10s" in table.caption
338+
assert table.row_count == 2

0 commit comments

Comments
 (0)