1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00

feat(instances): a VM's power actions at a glance, a power cut asked first

The Power tab hid each action behind a sub-tab of one sentence and one
button. The owner found the first rendered layouts crowded and chose a
compact one: the VM's state, then the actions its state allows in two
groups, those that keep the guest's data and the red ones that cut the
power, each group with one short caption.

Codex reviewed the power actions; these are fixed:

- Power Cycle and Force Off ask first, naming the VM and what is lost,
  as the VM lists already did.
- The words say what happens: Power Off asks the guest to shut down;
  Power Cycle cuts the power and starts the VM again, with its pending
  settings; Force Off cuts the power.
- The tab follows states.allowed_actions, as the lists do; a running
  VM's actions now include Force Off there too.
- A running template is not offered Power Cycle, which would start it
  again; a shut-off template says to clone it instead of showing a
  Power On button that only looked disabled.
- A paused VM tells its owner that an administrator can resume it; it
  said an administrator had blocked it.
- Power On's please-wait dialog opens on the form's submit, from the
  keyboard too.
This commit is contained in:
catborise 2026-10-30 19:21:30 +03:00
parent f8e30d7668
commit efb77dd866
5 changed files with 87 additions and 153 deletions

View file

@ -18,7 +18,7 @@ STATES = {
}
UNKNOWN = (_("Unknown"), "secondary") # the VM cannot be read
RUNNING = {"suspend", "poweroff", "powercycle", "console"}
RUNNING = {"suspend", "poweroff", "powercycle", "force_off", "console"}
STATE_ACTIONS = {
1: RUNNING,
2: RUNNING,
@ -49,15 +49,16 @@ def allowed_actions(user, instance, state):
The power actions the state allows and the user may use: superusers all,
a VM's owners all but suspend and resume, nobody else any (the
view_instances permission is read-only). A VM suspended by an
administrator offers its owner nothing. A shut-off template is not
started but cloned, by anyone with the clone permission who sees it.
administrator offers its owner nothing. A template is not started (a
power cycle starts it again) but cloned, by anyone with the clone
permission who sees it.
"""
if state == 5 and instance.is_template:
return {"clone"} if user.has_perm("instances.clone_instances") else set()
if user.is_superuser:
return STATE_ACTIONS.get(state, set())
if not manages(user, instance):
actions = STATE_ACTIONS.get(state, set())
elif not manages(user, instance) or state == 3:
return set()
if state == 3:
return set()
return STATE_ACTIONS.get(state, set()) - SUPERUSER_ONLY
else:
actions = STATE_ACTIONS.get(state, set()) - SUPERUSER_ONLY
return actions - {"powercycle"} if instance.is_template else actions

View file

@ -1,155 +1,61 @@
{% load i18n %}
{% load vm_states %}
<div class="tab-pane active mt-3" id="power" role="tabpanel">
{% if not can_power %}
<p>{% trans "Only the owners of this instance and administrators can change its power state." %}</p>
{% else %}
<!-- Nav tabs -->
{# 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 #}
{% vm_state instance.status as state %}
<div class="card">
<div class="card-header bg-transparent pb-0">
<ul class="nav nav-underline small" role="tablist" aria-label="Instance power actions">
{% if instance.status == 1 or instance.status == 2 %}
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#poweroff" type="button" role="tab" aria-controls="poweroff" aria-selected="true">
{% trans "Power Off" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#powercycle" type="button" role="tab" aria-controls="powercycle" aria-selected="false">
{% trans "Power Cycle" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#powerforce" type="button" role="tab" aria-controls="powerforce" aria-selected="false">
{% trans "Force Off" %}
</button>
</li>
{% if request.user.is_superuser %}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#suspend" type="button" role="tab" aria-controls="suspend" aria-selected="false">
{% trans "Suspend" %}
</button>
</li>
{% endif %}
{% endif %}
{% if instance.status == 3 %}
{% if request.user.is_superuser %}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#resume" type="button" role="tab" aria-controls="resume" aria-selected="false">
{% trans "Resume" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#powerforce" type="button" role="tab" aria-controls="powerforce" aria-selected="false">
{% trans "Force Off" %}
</button>
</li>
{% endif %}
{% endif %}
{% if instance.status == 4 or instance.status == 6 or instance.status == 7 %}
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#powerforce" type="button" role="tab" aria-controls="powerforce" aria-selected="true">
{% trans "Force Off" %}
</button>
</li>
{% endif %}
{% if instance.status == 5 %}
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#boot" type="button" role="tab" aria-controls="boot" aria-selected="true">
{% trans "Power On" %}
</button>
</li>
{% endif %}
</ul>
</div>
<!-- Tab panes -->
<div class="tab-content card-body">
{% if instance.status == 1 or instance.status == 2 %}
<div class="tab-pane active" id="poweroff" role="tabpanel" aria-labelledby="poweroff-tab">
<p>{% trans "This action sends an ACPI shutdown signal to the instance." %}</p>
<form action="{% url 'instances:poweroff' instance.id %}" method="post" role="form" aria-label="Power off instance form">
{% csrf_token %}
<input type="submit" name="poweroff" class="btn btn-primary float-end" value="{% trans "Power Off" %}">
<div class="clearfix"></div>
<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 %}" onsubmit="showPleaseWaitDialog()"{% 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>
</div>
<div class="tab-pane" id="powercycle" role="tabpanel" aria-labelledby="powercycle-tab">
<p>{% trans "This action forcibly powers off and start the instance and may cause data corruption." %}</p>
<form action="{% url 'instances:powercycle' instance.id %}" method="post" role="form" aria-label="Power cycle instance form">{% csrf_token %}
<input type="submit" name="powercycle" class="btn btn-danger float-end" value="{% trans "Power Cycle" %}">
<div class="clearfix"></div>
{% 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 %}"
onclick="return confirm(this.dataset.confirm)">{% 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 %}"
onclick="return confirm(this.dataset.confirm)">{% trans "Force Off" %}</button>
{% 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 %}
</div>
<div class="tab-pane" id="powerforce" role="tabpanel" aria-labelledby="powerforce-tab">
<p>{% trans "This action forcibly powers off the instance and may cause data corruption." %}</p>
<form action="{% url 'instances:force_off' instance.id %}" method="post" role="form" aria-label="Force to shutdown instance form">
{% csrf_token %}
<input type="submit" name="powerforce" class="btn btn-danger float-end" value="{% trans "Force Off" %}">
<div class="clearfix"></div>
</form>
</div>
{% if request.user.is_superuser %}
<div class="tab-pane" id="suspend" role="tabpanel" aria-labelledby="suspend-tab">
<p>{% trans "This action suspends the instance." %}</p>
<form action="{% url 'instances:suspend' instance.id %}" method="post" role="form" aria-label="Suspend instance form">{% csrf_token %}
<input type="submit" name="suspend" class="btn btn-primary float-end" value="{% trans "Suspend" %}">
<div class="clearfix"></div>
</form>
</div>
{% endif %}
{% endif %}
{% if instance.status == 3 %}
{% if request.user.is_superuser %}
<div class="tab-pane active" id="resume" role="tabpanel" aria-labelledby="resume-tab">
<p>{% trans "This action restore the instance after suspend." %}</p>
<form action="{% url 'instances:resume' instance.id %}" method="post" role="form" aria-label="Resume instance from suspension form">{% csrf_token %}
<input type="submit" name="resume" class="btn btn-primary float-end" value="{% trans "Resume" %}">
<div class="clearfix"></div>
</form>
</div>
<div class="tab-pane" id="powerforce" role="tabpanel" aria-labelledby="powerforce-tab">
<p>{% trans "This action forcibly powers off the instance and may cause data corruption." %}</p>
<form action="{% url 'instances:force_off' instance.id %}" method="post" role="form" aria-label="Force to shutdown form">{% csrf_token %}
<input type="submit" name="powerforce" class="btn btn-danger float-end" value="{% trans "Force Off" %}">
<div class="clearfix"></div>
</form>
</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 %}
<div class="tab-pane active" id="resume" role="tabpanel" aria-labelledby="resume-tab">
<p>{% trans "Administrator blocked your instance." %}</p>
<form action="{% url 'instances:resume' instance.id %}" method="post" role="form" aria-label="Resume instance form">{% csrf_token %}
<button class="btn btn-primary disabled float-end">{% trans "Resume" %}</button>
<div class="clearfix"></div>
</form>
</div>
<p class="mb-0">{% trans "There is no power action for the VM in this state." %}</p>
{% endif %}
{% endif %}
{% if instance.status == 4 or instance.status == 6 or instance.status == 7 %}
{# shutting down, crashed or suspended by the guest: a forced power off ends it, then it can be started #}
<div class="tab-pane active" id="powerforce" role="tabpanel" aria-labelledby="powerforce-tab">
<p>{% trans "This action forcibly powers off the instance and may cause data corruption." %}</p>
<form action="{% url 'instances:force_off' instance.id %}" method="post" role="form" aria-label="Force to shutdown instance form">
{% csrf_token %}
<input type="submit" name="powerforce" class="btn btn-danger float-end" value="{% trans "Force Off" %}">
<div class="clearfix"></div>
</form>
</div>
{% endif %}
{% if instance.status == 5 %}
<div class="tab-pane active" id="boot" role="tabpanel" aria-labelledby="boot-tab">
<p>{% trans "Click on Power On button to start this instance." %}</p>
<form action="{% url 'instances:poweron' instance.id %}" method="post" role="form" aria-label="Start instance form">
{% csrf_token %}
{% if instance.is_template %}
<p>{% trans "Template instance cannot be started." %}</p>
<input type="submit" name="poweron" class="btn btn-primary float-end disabled" value="{% trans "Power On" %}">
{% else %}
<input type="submit" name="poweron" class="btn btn-primary float-end" onclick="showPleaseWaitDialog()" value="{% trans "Power On" %}">
{% endif %}
<div class="clearfix"></div>
</form>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>

View file

@ -88,7 +88,7 @@ class PageControls:
"console": bool(doc.xpath("//button[@id='console_button']")),
"vv file": bool(doc.xpath("//a[@href=$a]", a=reverse("instances:getvvfile", args=[pk]))),
"vdi": bool(doc.xpath("//*[@id='vdiconsole']")),
"power": form("poweroff") or form("poweron"),
"power": form("poweron") or bool(doc.xpath("//button[@formaction=$a]", a=reverse("instances:poweroff", args=[pk]))),
"root password": form("rootpasswd"),
"ssh key": form("add_public_key"),
# a running VM with hotplug shows per-vCPU buttons instead of the CPU form
@ -197,6 +197,22 @@ class PageControlsTestCase(PageControls, TestCase):
)
self.assertEqual(bool(buttons), enabled)
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"]),
(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')]")))
if user == owner:
self.assertIn("The VM is paused. An administrator can resume it.", tab.text_content())
if status == 5:
self.assertEqual(tab.xpath(".//form/@onsubmit"), ["showPleaseWaitDialog()"])
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)):

View file

@ -19,8 +19,8 @@ LABELS = {0: "No state", 1: "Active", 2: "Blocked", 3: "Suspended", 4: "Shutting
# actions offered per state, for a superuser
ACTIONS = {
0: set(),
1: {"poweroff", "powercycle", "suspend"},
2: {"poweroff", "powercycle", "suspend"},
1: {"poweroff", "powercycle", "force_off", "suspend"},
2: {"poweroff", "powercycle", "force_off", "suspend"},
3: {"resume", "force_off"},
4: {"force_off"},
5: {"poweron"},
@ -248,6 +248,16 @@ class AllowedActionsTests(TestCase):
self.assertEqual(allowed_actions(other, vm, state), set())
class TemplatePowerTests(TestCase):
"""A running template is not power cycled: that would start it again."""
def test_a_running_template_has_no_power_cycle(self):
admin = get_user_model().objects.create_superuser("tp-admin", "tp@example.com", "pw")
compute = Compute.objects.create(name="tp", hostname="10.0.0.5", login="u", password="p", type=1)
vm = Instance.objects.create(compute=compute, name="tp-vm", uuid="dddddddd-0000-0000-0000-000000000000", is_template=True)
self.assertEqual(allowed_actions(admin, vm, 1) - {"console"}, ACTIONS[1] - {"powercycle"})
class TemplateCloneTests(TestCase):
"""A shut-off template offers Clone to whoever may clone it, owner or not."""

View file

@ -61,7 +61,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, state_label
from .states import STATES, UNKNOWN, allowed_actions, state_label
# the VM page waits this long (seconds) for the compute's drbdadm status
DRBD_STATUS_TIMEOUT = 10
@ -264,6 +264,7 @@ def _instance_page(request, pk, instance, compute):
# view_instances permission are read-only.
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)
# get_instance(perm_type="delete"): a template only for superusers and staff owners
can_delete = request.user.is_superuser or bool(
userinstance and userinstance.is_delete and (request.user.is_staff or not instance.is_template)