mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
feat(operations): jobs page
This commit is contained in:
parent
9c7943a0c8
commit
0fb0eb2bac
13 changed files with 564 additions and 2 deletions
|
|
@ -36,6 +36,7 @@ class DetailOfUnreachableHostTests(TestCase):
|
|||
self.assertIn("far-vm", messages[0])
|
||||
self.assertNotIn("far-host", messages[0])
|
||||
self.assertNotIn("10.0.0.9", messages[0])
|
||||
self.assertIn("Jobs", messages[0])
|
||||
|
||||
def test_superuser_sees_the_host_and_its_error(self):
|
||||
admin = get_user_model().objects.create_superuser("far-admin", password="pw", email="far@example.com")
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ def instance(request, pk):
|
|||
}
|
||||
else:
|
||||
msg = _("The host of '%(name)s' cannot be reached right now.") % {"name": instance.name}
|
||||
messages.error(request, msg)
|
||||
messages.error(request, msg + _(" Jobs on it can be followed under Jobs in the menu."))
|
||||
return redirect(reverse("instances:index"))
|
||||
# The page only reads the VM, and its getters read the same XML some 30
|
||||
# times: libvirt is asked once per kind of XML.
|
||||
|
|
|
|||
99
operations/templates/operations/job.html
Normal file
99
operations/templates/operations/job.html
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap_icons %}
|
||||
{% load operations %}
|
||||
|
||||
{% block title %}{{ job.label }}{% endblock %}
|
||||
|
||||
{% block page_heading %}{{ job.label }}{% endblock page_heading %}
|
||||
|
||||
{% block page_heading_extra %}
|
||||
<div class="d-flex gap-2">
|
||||
{% if job.cancellable %}
|
||||
<form method="post" action="{% url 'operations:cancel' job.id %}" aria-label="{% trans 'Cancel job form' %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-warning" type="submit">{% trans "Cancel" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a class="btn btn-outline-secondary" href="{% url 'operations:jobs' %}">{% trans "Jobs" %}</a>
|
||||
</div>
|
||||
{% endblock page_heading_extra %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
{% if op.error %}
|
||||
<div class="alert alert-{{ op.state|state_color }}" role="alert">
|
||||
{% bs_icon 'exclamation-triangle' %} {{ op.error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<dl class="row">
|
||||
<dt class="col-sm-3">{% trans "Resource" %}</dt><dd class="col-sm-9 text-break">{{ job.resource_label }}</dd>
|
||||
<dt class="col-sm-3">{% trans "User" %}</dt><dd class="col-sm-9">{{ job.username }}</dd>
|
||||
<dt class="col-sm-3">{% trans "State" %}</dt>
|
||||
<dd class="col-sm-9">{% include "operations/state_badge.html" with state=op.state %}</dd>
|
||||
{% if op.phase %}<dt class="col-sm-3">{% trans "Phase" %}</dt><dd class="col-sm-9">{% translate op.phase %}</dd>{% endif %}
|
||||
{% if op.progress_total %}
|
||||
{% widthratio op.progress_done|default:0 op.progress_total 100 as percent %}
|
||||
<dt class="col-sm-3">{% trans "Progress" %}</dt>
|
||||
<dd class="col-sm-9">
|
||||
<div class="progress mb-1" role="progressbar" aria-label="{% trans 'Progress' %}" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar" style="width: {{ percent }}%">{{ percent }}%</div>
|
||||
</div>
|
||||
<span class="small text-body-secondary">
|
||||
{% if progress_bytes %}{{ op.progress_done|default:0|filesizeformat }} / {{ op.progress_total|filesizeformat }}{% else %}{{ op.progress_done|default:0 }} / {{ op.progress_total }}{% endif %}
|
||||
</span>
|
||||
</dd>
|
||||
{% endif %}
|
||||
<dt class="col-sm-3">{% trans "Created" %}</dt><dd class="col-sm-9">{{ op.created_at|date:"M d H:i:s" }}</dd>
|
||||
{% if op.started_at %}<dt class="col-sm-3">{% trans "Started" %}</dt><dd class="col-sm-9">{{ op.started_at|date:"M d H:i:s" }}</dd>{% endif %}
|
||||
{% if op.finished_at %}<dt class="col-sm-3">{% trans "Finished" %}</dt><dd class="col-sm-9">{{ op.finished_at|date:"M d H:i:s" }}</dd>{% endif %}
|
||||
{% if job.result.left_behind %}
|
||||
<dt class="col-sm-3">{% trans "Left behind" %}</dt>
|
||||
<dd class="col-sm-9">
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for item in job.result.left_behind %}<li class="text-break"><code>{{ item }}</code></li>{% endfor %}
|
||||
</ul>
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if job.result.links %}
|
||||
<dt class="col-sm-3">{% trans "Results" %}</dt>
|
||||
<dd class="col-sm-9">
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for link in job.result.links %}<li><a href="{{ link.url }}">{{ link.label }}</a></li>{% endfor %}
|
||||
</ul>
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if job.resolution %}
|
||||
<dt class="col-sm-3">{% trans "Resolution" %}</dt>
|
||||
<dd class="col-sm-9">{{ job.resolution|state_label }}: {{ job.resolution_note }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{% if request.user.is_superuser and op.state == "uncertain" %}
|
||||
<h5 class="mt-4">{% trans "Resolve" %}</h5>
|
||||
<form method="post" action="{% url 'operations:resolve' job.id %}" aria-label="{% trans 'Resolve job form' %}">
|
||||
{% csrf_token %}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label" for="resolve-outcome">{% trans "Outcome" %}</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-select" name="outcome" id="resolve-outcome">
|
||||
<option value="succeeded">{% trans "Succeeded" %}</option>
|
||||
<option value="failed">{% trans "Failed" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label" for="resolve-note">{% trans "Note" %}</label>
|
||||
<div class="col-sm-9">
|
||||
<textarea class="form-control" name="note" id="resolve-note" rows="3" required placeholder="{% trans 'What was checked' %}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<button class="btn btn-primary" type="submit">{% trans "Resolve" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
94
operations/templates/operations/jobs.html
Normal file
94
operations/templates/operations/jobs.html
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load django_bootstrap5 %}
|
||||
{% load bootstrap_icons %}
|
||||
{% load operations %}
|
||||
|
||||
{% block title %}{% trans "Jobs" %}{% endblock %}
|
||||
|
||||
{% block page_heading %}{% trans "Jobs" %}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
{% if worker_unavailable %}
|
||||
<div class="alert alert-warning shadow-sm">
|
||||
{% bs_icon 'exclamation-triangle' %} {% trans "Worker unavailable, last seen" %}
|
||||
{% if worker_last_seen %}{{ worker_last_seen|date:"M d H:i:s" }}{% else %}{% trans "never" %}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<form method="get" class="row g-2 mb-3" role="search" aria-label="{% trans 'Filter jobs' %}">
|
||||
<div class="col-6 col-md">
|
||||
<input class="form-control" name="compute" placeholder="{% trans 'Host' %}" aria-label="{% trans 'Host' %}" value="{{ filters.compute }}">
|
||||
</div>
|
||||
<div class="col-6 col-md">
|
||||
<input class="form-control" name="username" placeholder="{% trans 'User' %}" aria-label="{% trans 'User' %}" value="{{ filters.username }}">
|
||||
</div>
|
||||
<div class="col-6 col-md">
|
||||
<select class="form-select" name="kind" aria-label="{% trans 'Kind' %}">
|
||||
<option value="">{% trans "All kinds" %}</option>
|
||||
{% for name, label in kinds %}
|
||||
<option value="{{ name }}"{% if filters.kind == name %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md">
|
||||
<select class="form-select" name="state" aria-label="{% trans 'State' %}">
|
||||
<option value="">{% trans "All states" %}</option>
|
||||
{% for state in states %}
|
||||
<option value="{{ state }}"{% if filters.state == state %} selected{% endif %}>{{ state|state_label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-auto d-flex gap-2 justify-content-end">
|
||||
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
<a class="btn btn-outline-secondary" href="{% url 'operations:jobs' %}">{% trans "Reset" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if not rows %}
|
||||
<div class="alert alert-warning shadow-sm">
|
||||
{% bs_icon 'exclamation-triangle' %} {% trans "No jobs" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="d-none d-md-table-cell">{% trans "Created" %}</th>
|
||||
<th scope="col">{% trans "Job" %}</th>
|
||||
<th scope="col">{% trans "State" %}</th>
|
||||
<th scope="col" class="d-none d-md-table-cell">{% trans "Resource" %}</th>
|
||||
{% if request.user.is_superuser %}<th scope="col" class="d-none d-md-table-cell">{% trans "User" %}</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for op, row in rows %}
|
||||
<tr>
|
||||
<td class="text-nowrap d-none d-md-table-cell">{{ op.created_at|date:"M d H:i:s" }}</td>
|
||||
<td style="min-width: 10rem;">
|
||||
<a href="{% url 'operations:job' row.id %}">{{ row.label }}</a>
|
||||
{% if op.state in active_states and op.phase %}
|
||||
<div class="small text-body-secondary">{% translate op.phase %}</div>
|
||||
{% elif op.state == "failed" or op.state == "uncertain" %}
|
||||
<div class="small text-body-secondary" title="{{ op.error }}">{{ op.error|truncatechars:80 }}</div>
|
||||
{% endif %}
|
||||
{# below md the hidden columns show here #}
|
||||
<div class="d-md-none small text-body-secondary">{{ row.resource_label|wrap_at_slash }}<br>{{ op.created_at|date:"M d H:i:s" }}{% if request.user.is_superuser %} · {{ row.username }}{% endif %}</div>
|
||||
</td>
|
||||
<td>{% include "operations/state_badge.html" with state=op.state %}</td>
|
||||
<td class="d-none d-md-table-cell">{{ row.resource_label|wrap_at_slash }}</td>
|
||||
{% if request.user.is_superuser %}<td class="text-nowrap d-none d-md-table-cell">{{ row.username }}</td>{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if page.has_other_pages %}
|
||||
{% bootstrap_pagination page extra=page_query %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
1
operations/templates/operations/state_badge.html
Normal file
1
operations/templates/operations/state_badge.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
{% load operations %}{% with color=state|state_color %}<span class="badge bg-{{ color }}{% if color == "warning" %} text-dark{% endif %}">{{ state|state_label }}</span>{% endwith %}
|
||||
|
|
@ -1,12 +1,55 @@
|
|||
import uuid
|
||||
|
||||
from django import template
|
||||
from django.utils.html import format_html
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from operations.models import Operation
|
||||
|
||||
register = template.Library()
|
||||
|
||||
# The Bootstrap colour of each job state, for badges and alerts; job_panel.js has the same map
|
||||
STATE_COLORS = {
|
||||
Operation.QUEUED: "secondary",
|
||||
Operation.RUNNING: "primary",
|
||||
Operation.CANCELLING: "info",
|
||||
Operation.SUCCEEDED: "success",
|
||||
Operation.FAILED: "danger",
|
||||
Operation.CANCELLED: "secondary",
|
||||
Operation.UNCERTAIN: "warning",
|
||||
}
|
||||
|
||||
|
||||
# What a job's state is called on the pages; the stored state stays the code
|
||||
STATE_LABELS = {
|
||||
Operation.QUEUED: pgettext_lazy("job state", "queued"),
|
||||
Operation.RUNNING: pgettext_lazy("job state", "running"),
|
||||
Operation.CANCELLING: pgettext_lazy("job state", "cancelling"),
|
||||
Operation.SUCCEEDED: pgettext_lazy("job state", "succeeded"),
|
||||
Operation.FAILED: pgettext_lazy("job state", "failed"),
|
||||
Operation.CANCELLED: pgettext_lazy("job state", "cancelled"),
|
||||
Operation.UNCERTAIN: pgettext_lazy("job state", "uncertain"),
|
||||
}
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def submission_key():
|
||||
"""A new key per rendered form: a resubmitted form returns its job."""
|
||||
return format_html('<input type="hidden" name="submission_key" value="{}">', uuid.uuid4().hex)
|
||||
|
||||
|
||||
@register.filter
|
||||
def state_color(state):
|
||||
return STATE_COLORS[state]
|
||||
|
||||
|
||||
@register.filter
|
||||
def state_label(state):
|
||||
return STATE_LABELS[state]
|
||||
|
||||
|
||||
@register.filter
|
||||
def wrap_at_slash(label):
|
||||
"""A "<compute>/<vm>" label that wraps after its slash only, never inside a name."""
|
||||
return format_html_join(mark_safe("/<wbr>"), '<span class="text-nowrap">{}</span>', ((part,) for part in label.split("/")))
|
||||
|
|
|
|||
212
operations/tests/test_views.py
Normal file
212
operations/tests/test_views.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import SimpleTestCase, TransactionTestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone as django_timezone
|
||||
from django.utils import translation
|
||||
|
||||
from operations import jobs
|
||||
from operations.jobs import Outcome
|
||||
from operations.models import Claim, Operation, WorkerLease
|
||||
from operations.templatetags.operations import STATE_COLORS, STATE_LABELS, state_color, state_label
|
||||
from operations.tests import fakes
|
||||
|
||||
|
||||
class StateColorsTestCase(SimpleTestCase):
|
||||
def test_every_state_has_its_colour(self):
|
||||
# job_panel.test.js checks the same map in job_panel.js
|
||||
self.assertEqual(STATE_COLORS, {
|
||||
"queued": "secondary", "running": "primary", "cancelling": "info", "succeeded": "success",
|
||||
"failed": "danger", "cancelled": "secondary", "uncertain": "warning",
|
||||
})
|
||||
self.assertEqual(set(STATE_COLORS), {state for state, _label in Operation.STATES})
|
||||
self.assertEqual(state_color("failed"), "danger")
|
||||
|
||||
def test_every_state_has_its_label(self):
|
||||
self.assertEqual({state: str(label) for state, label in STATE_LABELS.items()},
|
||||
{state: state for state, _label in Operation.STATES})
|
||||
self.assertEqual(state_label("uncertain"), "uncertain")
|
||||
|
||||
|
||||
class JobsPageTestCase(TransactionTestCase):
|
||||
def setUp(self):
|
||||
fakes.reset()
|
||||
U = get_user_model()
|
||||
self.user = U.objects.create_user("page_user", "p@example.com", "pw")
|
||||
self.admin = U.objects.create_superuser("page_admin", "a@example.com", "pw")
|
||||
self.op = jobs.submit("test.noop", self.user, {}, "key-00000001")
|
||||
|
||||
def test_a_user_sees_own_jobs_only(self):
|
||||
other = get_user_model().objects.create_user("page_other", "o@example.com", "pw")
|
||||
self.client.force_login(other)
|
||||
self.assertNotContains(self.client.get(reverse("operations:jobs")), "No-op")
|
||||
self.assertEqual(self.client.get(reverse("operations:job", args=[self.op.pk])).status_code, 404)
|
||||
self.client.force_login(self.user)
|
||||
self.assertContains(self.client.get(reverse("operations:jobs")), "No-op")
|
||||
|
||||
def test_superusers_filter_by_compute_and_state(self):
|
||||
self.client.force_login(self.admin)
|
||||
link = reverse("operations:job", args=[self.op.pk])
|
||||
self.assertContains(self.client.get(reverse("operations:jobs"), {"compute": "c"}), link)
|
||||
self.assertNotContains(self.client.get(reverse("operations:jobs"), {"state": "failed"}), link)
|
||||
|
||||
def test_resolve_needs_a_superuser_an_outcome_and_a_note(self):
|
||||
jobs.finish(self.op, Outcome(Operation.UNCERTAIN, error="check"))
|
||||
self.client.force_login(self.user)
|
||||
self.assertEqual(self.client.post(reverse("operations:resolve", args=[self.op.pk]),
|
||||
{"outcome": "failed", "note": "x"}).status_code, 403)
|
||||
self.client.force_login(self.admin)
|
||||
self.client.post(reverse("operations:resolve", args=[self.op.pk]), {"outcome": "failed", "note": ""})
|
||||
self.assertTrue(Claim.objects.filter(operation=self.op).exists())
|
||||
self.client.post(reverse("operations:resolve", args=[self.op.pk]), {"outcome": "failed", "note": "checked"})
|
||||
self.assertFalse(Claim.objects.filter(operation=self.op).exists())
|
||||
|
||||
def test_the_detail_shows_what_recovery_saw_escaped(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(error="<script>x</script>", phase="Waiting for recovery",
|
||||
state=Operation.UNCERTAIN)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("operations:job", args=[self.op.pk]))
|
||||
self.assertNotContains(response, "<script>x</script>")
|
||||
self.assertContains(response, "Waiting for recovery")
|
||||
|
||||
def test_pagination_keeps_the_filters(self):
|
||||
for i in range(51):
|
||||
Operation.objects.create(kind="test.noop", user=self.user, username="page_user",
|
||||
submission_key=f"page-{i:08d}", resource="r", resource_label="c/vm")
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(reverse("operations:jobs"), {"compute": "c"})
|
||||
self.assertContains(response, "compute=c")
|
||||
self.assertContains(response, "page=2")
|
||||
|
||||
def _pages_seen_by(self, user):
|
||||
Operation.objects.filter(pk=self.op.pk).update(resource_label="wvc-test-host/wvc-test-vm")
|
||||
self.client.force_login(user)
|
||||
return [self.client.get(reverse("operations:jobs")), self.client.get(reverse("operations:job", args=[self.op.pk]))]
|
||||
|
||||
def test_a_plain_owner_sees_only_the_vm_name(self):
|
||||
for response in self._pages_seen_by(self.user):
|
||||
self.assertContains(response, "wvc-test-vm")
|
||||
self.assertNotContains(response, "wvc-test-host")
|
||||
|
||||
def test_users_who_may_see_hosts_see_the_compute_name(self):
|
||||
self.user.user_permissions.add(Permission.objects.get(codename="view_instances",
|
||||
content_type__app_label="instances"))
|
||||
for response in self._pages_seen_by(self.user) + self._pages_seen_by(self.admin):
|
||||
self.assertContains(response, "wvc-test-host")
|
||||
self.assertContains(response, "wvc-test-vm")
|
||||
|
||||
def test_the_list_wraps_a_resource_after_its_slash_only(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(resource_label="wvc-test-host/wvc-test-<vm>")
|
||||
self.client.force_login(self.admin)
|
||||
self.assertContains(self.client.get(reverse("operations:jobs")),
|
||||
'<span class="text-nowrap">wvc-test-host</span>/<wbr>'
|
||||
'<span class="text-nowrap">wvc-test-<vm></span>')
|
||||
|
||||
@override_settings(TIME_ZONE="Europe/Istanbul")
|
||||
def test_dates_show_in_the_app_format_and_time_zone(self):
|
||||
at = datetime(2026, 10, 13, 20, 4, 0, 801375, tzinfo=timezone.utc)
|
||||
Operation.objects.filter(pk=self.op.pk).update(created_at=at, started_at=at, finished_at=at,
|
||||
state=Operation.SUCCEEDED)
|
||||
self.client.force_login(self.admin)
|
||||
for response in (self.client.get(reverse("operations:jobs")),
|
||||
self.client.get(reverse("operations:job", args=[self.op.pk]))):
|
||||
self.assertContains(response, "Oct 13 23:04:00")
|
||||
self.assertNotContains(response, "2026-10-13T20:04")
|
||||
|
||||
def test_list_and_detail_show_a_state_badge(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.FAILED, error="disk busy")
|
||||
self.client.force_login(self.admin)
|
||||
for response in (self.client.get(reverse("operations:jobs")),
|
||||
self.client.get(reverse("operations:job", args=[self.op.pk]))):
|
||||
self.assertContains(response, '<span class="badge bg-danger">failed</span>')
|
||||
detail = self.client.get(reverse("operations:job", args=[self.op.pk]))
|
||||
self.assertContains(detail, 'class="alert alert-danger"')
|
||||
|
||||
def test_the_list_shows_the_phase_while_active_and_the_error_after_a_failure(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING, phase="Copying", error="")
|
||||
self.client.force_login(self.admin)
|
||||
self.assertContains(self.client.get(reverse("operations:jobs")), "Copying")
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.SUCCEEDED)
|
||||
self.assertNotContains(self.client.get(reverse("operations:jobs")), "Copying")
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.FAILED, error="x" * 200)
|
||||
response = self.client.get(reverse("operations:jobs"))
|
||||
self.assertNotContains(response, "Copying")
|
||||
self.assertContains(response, "x" * 79 + "\u2026")
|
||||
|
||||
def test_the_filters_offer_states_and_kinds_and_a_reset(self):
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(reverse("operations:jobs"), {"state": "queued", "kind": "test.noop"})
|
||||
self.assertContains(response, '<option value="queued" selected>queued</option>')
|
||||
self.assertContains(response, '<option value="test.noop" selected>No-op</option>')
|
||||
self.assertContains(response, f'href="{reverse("operations:jobs")}"')
|
||||
self.assertNotContains(response, 'class="pagination')
|
||||
|
||||
def test_the_worker_warning_shows_when_a_visible_job_waits_for_a_stale_worker(self):
|
||||
# the panel uses the same rule (job_panel.test.js)
|
||||
stale = django_timezone.now() - timedelta(minutes=2)
|
||||
WorkerLease.objects.create(name="worker", holder="h:1:a", host="h", heartbeat_at=stale)
|
||||
self.client.force_login(self.user)
|
||||
for state, warned in ((Operation.QUEUED, True), (Operation.RUNNING, True), (Operation.CANCELLING, True),
|
||||
(Operation.UNCERTAIN, False), (Operation.SUCCEEDED, False)):
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=state)
|
||||
response = self.client.get(reverse("operations:jobs"))
|
||||
(self.assertContains if warned else self.assertNotContains)(response, "Worker unavailable", msg_prefix=state)
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING)
|
||||
WorkerLease.objects.update(heartbeat_at=django_timezone.now())
|
||||
self.assertNotContains(self.client.get(reverse("operations:jobs")), "Worker unavailable")
|
||||
# another user's running job is not this user's concern
|
||||
other = get_user_model().objects.create_user("page_other2", "o2@example.com", "pw")
|
||||
WorkerLease.objects.update(heartbeat_at=stale)
|
||||
self.client.force_login(other)
|
||||
self.assertNotContains(self.client.get(reverse("operations:jobs")), "Worker unavailable")
|
||||
|
||||
def test_an_uncertain_badge_has_dark_text(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.UNCERTAIN)
|
||||
self.client.force_login(self.admin)
|
||||
# the app's theme draws white text on bg-warning
|
||||
self.assertContains(self.client.get(reverse("operations:jobs")),
|
||||
'<span class="badge bg-warning text-dark">uncertain</span>')
|
||||
|
||||
def test_a_narrow_list_shows_resource_date_and_user_under_the_job(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(resource_label="wvc-test-host/wvc-test-vm")
|
||||
self.client.force_login(self.admin)
|
||||
html = self.client.get(reverse("operations:jobs")).content.decode()
|
||||
# below md only Job and State are columns
|
||||
for header in ("Created", "Resource", "User"):
|
||||
self.assertIn(f'<th scope="col" class="d-none d-md-table-cell">{header}</th>', html)
|
||||
narrow = re.search(r'<div class="d-md-none small text-body-secondary">(.*?)</div>', html, re.S).group(1)
|
||||
for text in ('<span class="text-nowrap">wvc-test-vm</span>', "page_user"):
|
||||
self.assertIn(text, narrow)
|
||||
self.assertRegex(narrow, r"[A-Z][a-z]{2} \d{2} \d{2}:\d{2}:\d{2}")
|
||||
# a plain user's narrow line has no user
|
||||
self.client.force_login(self.user)
|
||||
html = self.client.get(reverse("operations:jobs")).content.decode()
|
||||
narrow = re.search(r'<div class="d-md-none small text-body-secondary">(.*?)</div>', html, re.S).group(1)
|
||||
self.assertNotIn("page_user", narrow)
|
||||
|
||||
def test_states_and_phases_show_translated(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING, phase="Merging the snapshot")
|
||||
self.client.force_login(self.admin)
|
||||
fake = lambda *args: f"T[{args[-1]}]" # noqa: E731
|
||||
with patch.object(translation._trans, "gettext", side_effect=fake), \
|
||||
patch.object(translation._trans, "pgettext", side_effect=fake):
|
||||
listed = self.client.get(reverse("operations:jobs"))
|
||||
detail = self.client.get(reverse("operations:job", args=[self.op.pk]))
|
||||
for response in (listed, detail):
|
||||
self.assertContains(response, '<span class="badge bg-primary">T[running]</span>')
|
||||
self.assertContains(response, "T[Merging the snapshot]")
|
||||
self.assertContains(listed, '<option value="failed">T[failed]</option>')
|
||||
|
||||
def test_the_detail_shows_progress_in_bytes_for_kinds_that_count_bytes(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(progress_done=3 * 1024 ** 3, progress_total=6 * 1024 ** 3)
|
||||
self.client.force_login(self.admin)
|
||||
url = reverse("operations:job", args=[self.op.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, 'aria-valuenow="50"')
|
||||
self.assertContains(response, str(3 * 1024 ** 3))
|
||||
with patch.object(fakes.NoopKind, "progress_bytes", True):
|
||||
self.assertContains(self.client.get(url), "3.0\xa0GB / 6.0\xa0GB")
|
||||
11
operations/urls.py
Normal file
11
operations/urls.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "operations"
|
||||
urlpatterns = [
|
||||
path("", views.jobs, name="jobs"),
|
||||
path("<uuid:pk>/", views.job, name="job"),
|
||||
path("<uuid:pk>/cancel/", views.cancel, name="cancel"),
|
||||
path("<uuid:pk>/resolve/", views.resolve, name="resolve"),
|
||||
]
|
||||
80
operations/views.py
Normal file
80
operations/views.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from datetime import timedelta
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.paginator import Paginator
|
||||
from django.http import Http404
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.http import require_POST
|
||||
from vrtManager.util import OperationError
|
||||
|
||||
from . import jobs as jobs_module
|
||||
from .api.serializers import OperationSerializer
|
||||
from .models import Operation, last_seen
|
||||
|
||||
|
||||
def _visible(user):
|
||||
return Operation.objects.all() if user.is_superuser else Operation.objects.filter(user=user)
|
||||
|
||||
|
||||
def _get(user, pk):
|
||||
try:
|
||||
return _visible(user).get(pk=pk)
|
||||
except Operation.DoesNotExist:
|
||||
raise Http404 from None
|
||||
|
||||
|
||||
def jobs(request):
|
||||
qs = _visible(request.user)
|
||||
if request.user.is_superuser:
|
||||
if compute := request.GET.get("compute"):
|
||||
qs = qs.filter(resource_label__startswith=f"{compute}/")
|
||||
for field in ("state", "kind", "username"):
|
||||
if value := request.GET.get(field):
|
||||
qs = qs.filter(**{field: value})
|
||||
page = Paginator(qs, 50).get_page(request.GET.get("page"))
|
||||
# the model for its datetimes, the serialized job for its labels
|
||||
rows = [(op, OperationSerializer(op, context={"request": request}).data) for op in page.object_list]
|
||||
seen = last_seen()
|
||||
# a job the worker has to start, run or stop (job_panel.js uses the same rule)
|
||||
unavailable = ((seen is None or timezone.now() - seen > timedelta(seconds=60))
|
||||
and _visible(request.user).filter(state__in=Operation.ACTIVE).exists())
|
||||
return render(request, "operations/jobs.html",
|
||||
{"page": page, "rows": rows, "worker_last_seen": seen, "worker_unavailable": unavailable,
|
||||
"filters": request.GET, "active_states": Operation.ACTIVE,
|
||||
"states": Operation.ACTIVE + Operation.TERMINAL,
|
||||
"kinds": sorted((name, _(kind.label)) for name, kind in jobs_module.KINDS.items()),
|
||||
"page_query": urlencode({k: v for k, v in request.GET.items() if k != "page"})})
|
||||
|
||||
|
||||
def job(request, pk):
|
||||
op = _get(request.user, pk)
|
||||
kind = jobs_module.KINDS.get(op.kind)
|
||||
return render(request, "operations/job.html",
|
||||
{"job": OperationSerializer(op, context={"request": request}).data, "op": op,
|
||||
"progress_bytes": kind is not None and kind.progress_bytes})
|
||||
|
||||
|
||||
@require_POST
|
||||
def cancel(request, pk):
|
||||
op = _get(request.user, pk)
|
||||
try:
|
||||
jobs_module.cancel(op.pk, request.user)
|
||||
except OperationError as err:
|
||||
messages.error(request, str(err))
|
||||
return redirect("operations:job", pk=op.pk)
|
||||
|
||||
|
||||
@require_POST
|
||||
def resolve(request, pk):
|
||||
if not request.user.is_superuser:
|
||||
raise PermissionDenied
|
||||
op = _get(request.user, pk)
|
||||
try:
|
||||
jobs_module.resolve(op.pk, request.user, request.POST.get("outcome", ""), request.POST.get("note", ""))
|
||||
except OperationError as err:
|
||||
messages.error(request, str(err))
|
||||
return redirect("operations:job", pk=op.pk)
|
||||
14
static/icon_cache/list-task_None_None_None.svg
Normal file
14
static/icon_cache/list-task_None_None_None.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-list-task" viewBox="0 0 16 16">
|
||||
|
||||
|
||||
<path fill-rule="evenodd" d="M2 2.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5zM3 3H2v1h1z"/>
|
||||
|
||||
|
||||
<path d="M5 3.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5M5.5 7a.5.5 0 0 0 0 1h9a.5.5 0 0 0 0-1zm0 4a.5.5 0 0 0 0 1h9a.5.5 0 0 0 0-1z"/>
|
||||
|
||||
|
||||
<path fill-rule="evenodd" d="M1.5 7a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5zM2 7h1v1H2zm0 3.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zm1 .5H2v1h1z"/>
|
||||
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 666 B |
|
|
@ -20,6 +20,9 @@
|
|||
{% endif %}
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item {% class_active request '^/jobs' %}">
|
||||
<a class="nav-link" href="{% url 'operations:jobs' %}" aria-label="{% trans 'Jobs' %}">{% bs_icon 'list-task' %}</a>
|
||||
</li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="nav-item dropdown {% app_active request 'admin' %}">
|
||||
<a class="nav-link" id="administration" href="#" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ ALLOWED_FOR_ANY_USER = {
|
|||
"instance-flavor-detail": "API: flavor catalogue",
|
||||
"operation-list": "API: filtered to the user's own jobs",
|
||||
"operation-resource": "API: checks per resource who may see it",
|
||||
"operations:jobs": "lists only the user's own jobs",
|
||||
"schema": "API docs: any authenticated user (SERVE_PERMISSIONS)",
|
||||
"schema-json": "API docs: any authenticated user (SERVE_PERMISSIONS)",
|
||||
"schema-redoc": "API docs: any authenticated user (SERVE_PERMISSIONS)",
|
||||
|
|
@ -167,6 +168,8 @@ class AuthorizationMatrixTestCase(TestCase):
|
|||
for name, converter in params.items():
|
||||
if name in ("compute_id", "compute_pk"):
|
||||
kwargs[name] = self.compute.pk
|
||||
elif converter is not None and type(converter).__name__ == "UUIDConverter":
|
||||
kwargs[name] = "00000000-0000-0000-0000-000000000000"
|
||||
elif name in ("pk", "instance_id"):
|
||||
kwargs[name] = self.instance.pk if converter else str(self.instance.pk)
|
||||
elif name == "vname":
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ urlpatterns = [
|
|||
path("instances/", include("instances.urls")),
|
||||
path("i18n/", include("django.conf.urls.i18n")),
|
||||
path("logs/", include("logs.urls")),
|
||||
path("jobs/", include("operations.urls")),
|
||||
path('api/v1/', include("webvirtcloud.urls-api")),
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
re_path(r"^swagger\.(?P<format>json|yaml)$", SpectacularAPIView.as_view(), name="schema-json"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue