mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 03:36:48 +00:00
feat(instances): the Power tab shows the VM's state and where each action takes it
The tab put Power Off and Suspend, then Power Cycle and Force Off, in joined button groups with a paragraph under each, so which sentence told which button was left to the reader, and the two power cuts looked like one control as large as the rest. Now the tab shows the three states a VM moves between, Active, Suspended and Off, the current one marked, and under each the action that takes the VM there with one line on what it does. The power cuts are small outlined buttons on a line of their own with one warning; their confirmations stay. A state outside the three (shutting down, crashed) is named above them. The test of the tab now finds the outlined power-cut buttons, which its class match missed.
This commit is contained in:
parent
4da64c46cd
commit
c247a7a3f0
9 changed files with 694 additions and 759 deletions
|
|
@ -19,6 +19,8 @@ STATES = {
|
|||
UNKNOWN = (_("Unknown"), "secondary") # the VM cannot be read
|
||||
|
||||
RUNNING = {"suspend", "poweroff", "powercycle", "force_off", "console"}
|
||||
# the states the power tab shows as its strip: active, blocked, suspended, off, PM suspended
|
||||
POWER_STATES = (1, 2, 3, 5, 7)
|
||||
STATE_ACTIONS = {
|
||||
1: RUNNING,
|
||||
2: RUNNING,
|
||||
|
|
|
|||
|
|
@ -4,54 +4,75 @@
|
|||
{% if not can_power %}
|
||||
<p>{% trans "Only the owners of this instance and administrators can change its power state." %}</p>
|
||||
{% else %}
|
||||
{# the actions the VM's state allows this user (states.allowed_actions): those that keep the guest's data, and those that cut the power #}
|
||||
{# the three states a VM moves between, the current one marked; under each, the action that takes the VM there #}
|
||||
{% vm_state instance.status as state %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="small text-body-secondary mb-2">{% trans "Power" %} · {{ state.0 }}</p>
|
||||
{% if "poweroff" in power_actions or "suspend" in power_actions or "resume" in power_actions or "poweron" in power_actions or "force_off" in power_actions or "powercycle" in power_actions %}
|
||||
<div class="row g-3">
|
||||
{% if "poweroff" in power_actions or "suspend" in power_actions or "resume" in power_actions or "poweron" in power_actions %}
|
||||
<form class="col-12 col-md-6" method="post"{% if "poweron" in power_actions %} action="{% url 'instances:poweron' instance.id %}" data-please-wait{% endif %}>{% csrf_token %}
|
||||
<div class="btn-group mb-2" role="group" aria-label="{% trans "Power actions that keep the guest's data" %}">
|
||||
{% if "poweron" in power_actions %}<button type="submit" class="btn btn-primary" name="poweron">{% trans "Power On" %}</button>{% endif %}
|
||||
{% if "poweroff" in power_actions %}<button type="submit" class="btn btn-primary" name="poweroff" formaction="{% url 'instances:poweroff' instance.id %}">{% trans "Power Off" %}</button>{% endif %}
|
||||
{% if "resume" in power_actions %}<button type="submit" class="btn btn-primary" name="resume" formaction="{% url 'instances:resume' instance.id %}">{% trans "Resume" %}</button>{% endif %}
|
||||
{% if "suspend" in power_actions %}<button type="submit" class="btn btn-secondary" name="suspend" formaction="{% url 'instances:suspend' instance.id %}">{% trans "Suspend" %}</button>{% endif %}
|
||||
</div>
|
||||
<p class="small text-body-secondary mb-0">
|
||||
{% if "poweron" in power_actions %}{% trans "Power On starts the VM." %}{% endif %}
|
||||
{% if "poweroff" in power_actions %}{% trans "Power Off asks the guest to shut down; the guest decides." %}{% endif %}
|
||||
{% if "resume" in power_actions %}{% trans "Resume lets the paused VM run on." %}{% endif %}
|
||||
{% if "suspend" in power_actions %}{% trans "Suspend pauses its vCPUs; the VM stays in memory." %}{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if "force_off" in power_actions or "powercycle" in power_actions %}
|
||||
<form class="col-12 col-md-6" method="post">{% csrf_token %}
|
||||
<div class="btn-group mb-2" role="group" aria-label="{% trans "Power actions that cut the power" %}">
|
||||
{% if "powercycle" in power_actions %}
|
||||
<button type="submit" class="btn btn-danger" name="powercycle" formaction="{% url 'instances:powercycle' instance.id %}"
|
||||
data-confirm="{% blocktrans with name=instance.name %}Power cycle {{ name }}? It stops at once, as after a power cut, and starts again; what the guest has not written to disk is lost.{% endblocktrans %}">{% trans "Power Cycle" %}</button>
|
||||
{% endif %}
|
||||
{% if "force_off" in power_actions %}
|
||||
<button type="submit" class="btn btn-danger" name="powerforce" formaction="{% url 'instances:force_off' instance.id %}"
|
||||
data-confirm="{% blocktrans with name=instance.name %}Force off {{ name }}? It stops at once, as after a power cut; what the guest has not written to disk is lost.{% endblocktrans %}">{% trans "Force Off" %}</button>
|
||||
{% if instance.status not in power_states %}
|
||||
<p class="mb-3">{% blocktrans with state=state.0 %}The VM's state: {{ state }}{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<form method="post"{% if "poweron" in power_actions %} action="{% url 'instances:poweron' instance.id %}" data-please-wait{% endif %}>{% csrf_token %}
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
{% if instance.status == 1 or instance.status == 2 %}
|
||||
<span class="power-dot bg-success" aria-hidden="true"></span><span class="fw-semibold">{% trans "Active" %}</span><span class="small text-body-secondary">{% trans "now" %}</span>
|
||||
{% else %}
|
||||
<span class="power-dot" aria-hidden="true"></span><span class="text-body-secondary">{% trans "Active" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="small text-body-secondary mb-0">
|
||||
{% trans "These cut the power: what the guest has not written to disk is lost." %}
|
||||
{% if "powercycle" in power_actions %}{% trans "Power Cycle then starts the VM again, with the settings pending its next start." %}{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if "poweron" in power_actions %}
|
||||
<button type="submit" class="btn btn-primary" name="poweron">{% trans "Power On" %}</button>
|
||||
<p class="small text-body-secondary mt-1 mb-0">{% trans "Starts the VM." %}</p>
|
||||
{% elif "resume" in power_actions %}
|
||||
<button type="submit" class="btn btn-primary" name="resume" formaction="{% url 'instances:resume' instance.id %}">{% trans "Resume" %}</button>
|
||||
<p class="small text-body-secondary mt-1 mb-0">{% trans "Lets it run on." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
{% if instance.status == 3 or instance.status == 7 %}
|
||||
<span class="power-dot bg-warning" aria-hidden="true"></span><span class="fw-semibold">{% trans "Suspended" %}</span><span class="small text-body-secondary">{% trans "now" %}</span>
|
||||
{% else %}
|
||||
<span class="power-dot" aria-hidden="true"></span><span class="text-body-secondary">{% trans "Suspended" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if "suspend" in power_actions %}
|
||||
<button type="submit" class="btn btn-outline-primary" name="suspend" formaction="{% url 'instances:suspend' instance.id %}">{% trans "Suspend" %}</button>
|
||||
<p class="small text-body-secondary mt-1 mb-0">{% trans "Pauses it; it stays in memory." %}</p>
|
||||
{% elif instance.status == 3 and not power_actions %}
|
||||
<p class="small text-body-secondary mb-0">{% trans "An administrator can resume it." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
{% if instance.status == 5 %}
|
||||
<span class="power-dot bg-secondary" aria-hidden="true"></span><span class="fw-semibold">{% trans "Off" %}</span><span class="small text-body-secondary">{% trans "now" %}</span>
|
||||
{% else %}
|
||||
<span class="power-dot" aria-hidden="true"></span><span class="text-body-secondary">{% trans "Off" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if "poweroff" in power_actions %}
|
||||
<button type="submit" class="btn btn-outline-primary" name="poweroff" formaction="{% url 'instances:poweroff' instance.id %}">{% trans "Power Off" %}</button>
|
||||
<p class="small text-body-secondary mt-1 mb-0">{% trans "Asks the guest to shut down." %}</p>
|
||||
{% elif instance.status == 5 and instance.is_template %}
|
||||
<p class="small text-body-secondary mb-0">{% trans "A template is not started: clone it to make a VM." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif instance.status == 3 %}
|
||||
<p class="mb-0">{% trans "The VM is paused. An administrator can resume it." %}</p>
|
||||
{% elif instance.status == 5 and instance.is_template %}
|
||||
<p class="mb-0">{% trans "A template is not started: clone it to make a VM." %}</p>
|
||||
{% else %}
|
||||
<p class="mb-0">{% trans "There is no power action for the VM in this state." %}</p>
|
||||
</form>
|
||||
{% if "force_off" in power_actions or "powercycle" in power_actions %}
|
||||
<form method="post" class="d-flex flex-wrap align-items-center gap-2 border-top mt-4 pt-3">{% csrf_token %}
|
||||
<span class="small text-danger me-auto">{% trans "A power cut: what the guest has not written to disk is lost." %}</span>
|
||||
{% if "force_off" in power_actions %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" name="powerforce" formaction="{% url 'instances:force_off' instance.id %}"
|
||||
data-confirm="{% blocktrans with name=instance.name %}Force off {{ name }}? It stops at once, as after a power cut; what the guest has not written to disk is lost.{% endblocktrans %}">{% trans "Force Off" %}</button>
|
||||
{% endif %}
|
||||
{% if "powercycle" in power_actions %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" name="powercycle" formaction="{% url 'instances:powercycle' instance.id %}"
|
||||
data-confirm="{% blocktrans with name=instance.name %}Power cycle {{ name }}? It stops at once, as after a power cut, and starts again; what the guest has not written to disk is lost.{% endblocktrans %}">{% trans "Power Cycle" %}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -208,19 +208,32 @@ class PageControlsTestCase(PageControls, TestCase):
|
|||
def test_the_power_tab_offers_the_state_s_actions_and_confirms_a_power_cut(self):
|
||||
admin = get_user_model().objects.create_superuser("power_admin", "pa@example.com", "x")
|
||||
owner = self.user("power_owner", owner={})
|
||||
for user, status, names in ((admin, 1, ["poweroff", "suspend", "powercycle", "powerforce"]),
|
||||
# each action under the state it leads to (active, suspended, off), the power cut ones after them
|
||||
for user, status, names in ((admin, 1, ["suspend", "poweroff", "powerforce", "powercycle"]),
|
||||
(admin, 3, ["resume", "powerforce"]), (owner, 3, []), (admin, 5, ["poweron"])):
|
||||
with self.subTest(user=user.username, status=status):
|
||||
self.controls(user, status=status)
|
||||
(tab,) = lxml_html.fromstring(self.last_page).xpath("//div[@id='power']")
|
||||
self.assertEqual(tab.xpath(".//button/@name"), names)
|
||||
# the power-cut actions ask first
|
||||
self.assertTrue(all(b.get("data-confirm") for b in tab.xpath(".//button[contains(@class, 'btn-danger')]")))
|
||||
danger = tab.xpath(".//button[contains(@class, 'danger')]")
|
||||
self.assertEqual(len(danger), len({"powerforce", "powercycle"} & set(names)))
|
||||
self.assertTrue(all(b.get("data-confirm") for b in danger))
|
||||
if user == owner:
|
||||
self.assertIn("The VM is paused. An administrator can resume it.", tab.text_content())
|
||||
self.assertIn("An administrator can resume it.", tab.text_content())
|
||||
if status == 5:
|
||||
self.assertEqual(tab.xpath(".//form/@data-please-wait"), [""])
|
||||
|
||||
def test_the_power_tab_marks_the_current_state_or_names_another(self):
|
||||
admin = get_user_model().objects.create_superuser("power_strip_admin", "ps@example.com", "x")
|
||||
for status, now in ((1, "Active"), (3, "Suspended"), (5, "Off"), (6, None)):
|
||||
with self.subTest(status=status):
|
||||
self.controls(admin, status=status)
|
||||
(tab,) = lxml_html.fromstring(self.last_page).xpath("//div[@id='power']")
|
||||
marked = [s.text_content() for s in tab.xpath(".//span[@class='fw-semibold']")]
|
||||
self.assertEqual(marked, [now] if now else [])
|
||||
self.assertEqual("The VM's state: Crashed" in tab.text_content(), now is None)
|
||||
|
||||
def test_a_running_paused_or_shut_off_vm_can_be_cloned(self):
|
||||
changer = self.user("vm_cloner", perms=("clone_instances",), owner={"is_change": True})
|
||||
for status, enabled in ((1, True), (3, True), (5, True), (4, False)):
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ from . import addresses, report, utils
|
|||
from .api.serializers import DestroyOptionsSerializer
|
||||
from .forms import ConsoleForm, FlavorForm, NewVMForm
|
||||
from .models import Flavor
|
||||
from .states import STATES, UNKNOWN, allowed_actions, state_label
|
||||
from .states import POWER_STATES, STATES, UNKNOWN, allowed_actions, state_label
|
||||
|
||||
# the VM page waits this long (seconds) for the compute's drbdadm status
|
||||
DRBD_STATUS_TIMEOUT = 10
|
||||
|
|
@ -295,6 +295,7 @@ def _instance_page(request, pk, instance, compute):
|
|||
can_open_console = utils.can_open_console(request.user, instance)
|
||||
can_power = request.user.is_superuser or userinstance is not None
|
||||
power_actions = allowed_actions(request.user, instance, instance.status)
|
||||
power_states = POWER_STATES # the power tab names any other state above its strip
|
||||
# a remote viewer on the user's computer reaches a VNC or SPICE console that listens beyond the host's loopback
|
||||
vdi_reachable = instance.console_type in ("vnc", "spice") and not util.is_loopback(instance.console_listener_address)
|
||||
# root's password and keys change in a shut-off VM's disk (gstfsd on the host), or through a running VM's agent
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -121,6 +121,16 @@ th[aria-sort="ascending"]:not([data-sort-method="none"])::after {
|
|||
}
|
||||
|
||||
/* the boot order editor numbers its checked devices, top to bottom */
|
||||
/* the power tab's states: the current one filled, the others a ring */
|
||||
.power-dot {
|
||||
width: .8rem;
|
||||
height: .8rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--bs-border-color);
|
||||
}
|
||||
.power-dot.bg-success, .power-dot.bg-warning, .power-dot.bg-secondary { border-color: transparent; }
|
||||
|
||||
.boot-sequence {
|
||||
counter-reset: boot;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue