fix: stop and start an instance from the instance's own menu

1.3.0 put suspend and resume in the main menu and on the command line, which
meant remembering a separate command and picking the instance separately for
each of the two. They belong where you are already looking at the instance.

The menu of one instance now ends with a single line above "Back", worded for the
state that instance is in when the menu is drawn:

  Instance 2 - supa2.didgibot.com - running
    ...
    Suspend it (stop it, keep its data)

  Instance 2 - supa2.didgibot.com - suspended
    ...
    Resume it (start it again with its data)

The state is read again every time the menu is drawn, so right after suspending
an instance the same line reads "Resume it". The instance's state is in the menu
title, and the instance picker names it next to every instance, so you can see
which ones are awake before choosing one.

- The main menu's two entries "Suspend instance" and "Resume instance" are gone.
  "Show instance" is now "Show instance (and suspend or resume it)": one way in.
- Asking for the tunnel command of a sleeping instance says it is suspended and
  to resume it from this menu, instead of printing a command that cannot work.
- The line printed after suspending, and the one `supaspin list` prints under a
  sleeping instance, point at the menu line rather than at a command.

`supaspin suspend N` and `supaspin resume N` still work for scripting.

Verified on instance 2 (supa2.didgibot.com): suspended from the instance menu,
the menu came back reading "Resume it" and the picker showed "(suspended)",
resumed from the same line, 12/12 pods with 0 restarts and its 55 tables and
11 MB of data intact.

Tests: 136 pass (6 new). Version 1.3.0 -> 1.3.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #8.
This commit is contained in:
Igor
2026-09-22 22:32:33 +03:00
co-authored by Claude Opus 5
parent 2e4e46c878
commit 4bcdf6907c
4 changed files with 129 additions and 40 deletions
+75 -1
View File
@@ -1273,5 +1273,79 @@ class InstanceState(unittest.TestCase):
sp.print_list(CFG)
text = out.getvalue()
self.assertIn("suspended", text)
self.assertIn("supaspin resume 1", text)
self.assertIn("Resume it", text) # the menu line, not a command to remember
self.assertNotIn("ssh -N -L", text)
class InstanceMenu(unittest.TestCase):
"""Stopping and starting an instance lives inside the instance you are looking at: one line,
worded for the state the instance is in, so there is no second command to remember."""
def run_menu(self, state, choose):
"""Draw the menu for an instance in `state`, pick the item whose text `choose` matches
(None: leave at once). Returns (the item texts, what was called)."""
seen, called = [], []
def menu(title, items):
seen.append((title, list(items)))
if choose is None:
return None
for i, text in enumerate(items):
if choose in text:
return i
raise AssertionError(f"no item matching {choose!r} in {items}")
calls = {"suspend": lambda cfg, n, log: called.append(("suspend", n)),
"resume": lambda cfg, n, log: called.append(("resume", n))}
out = io.StringIO()
with mock.patch.object(sp, "menu", menu), \
mock.patch.object(sp, "instance_state", lambda n: state), \
mock.patch.object(sp, "pause", lambda: (_ for _ in ()).throw(StopIteration)), \
mock.patch.object(sp, "suspend_instance", calls["suspend"]), \
mock.patch.object(sp, "resume_instance", calls["resume"]), \
mock.patch.object(sp, "read_env", lambda cfg, n: "A=b\n"), \
contextlib.redirect_stdout(out):
try:
sp.show_menu(CFG, 2, lambda *a, **k: None)
except StopIteration:
pass # the menu would loop for ever; stop at the first "press Enter"
return seen[0][1], called, out.getvalue(), seen[0][0]
def test_a_running_instance_offers_to_suspend_it(self):
items, called, _, title = self.run_menu("running", None)
self.assertTrue(any("Suspend it" in t for t in items), items)
self.assertFalse(any("Resume" in t for t in items), items)
self.assertIn("running", title)
def test_a_suspended_instance_offers_to_resume_it(self):
items, called, _, title = self.run_menu("suspended", None)
self.assertTrue(any("Resume it" in t for t in items), items)
self.assertFalse(any("Suspend" in t for t in items), items)
self.assertIn("suspended", title)
def test_choosing_it_suspends_this_instance(self):
_, called, _, _ = self.run_menu("running", "Suspend it")
self.assertEqual(called, [("suspend", 2)])
def test_choosing_it_resumes_this_instance(self):
_, called, _, _ = self.run_menu("suspended", "Resume it")
self.assertEqual(called, [("resume", 2)])
def test_the_tunnel_of_a_sleeping_instance_says_why_it_is_pointless(self):
_, _, text, _ = self.run_menu("suspended", "Tunnel")
self.assertIn("suspended", text)
self.assertNotIn("ssh -N -L", text)
def test_the_picker_names_the_state_of_every_instance(self):
seen = []
def menu(title, items):
seen.append(list(items))
return None
with mock.patch.object(sp, "menu", menu), \
mock.patch.object(sp, "existing_numbers", lambda cfg: {1, 2}), \
mock.patch.object(sp, "instance_state", lambda n: "suspended" if n == 2 else "running"):
sp.pick_instance(CFG, "Which instance?")
self.assertEqual(seen[0], ["1 supa1.example.com (running)",
"2 supa2.example.com (suspended)"])