1
0
Fork 0
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:
catborise 2026-10-13 20:26:07 +03:00
parent 9c7943a0c8
commit 0fb0eb2bac
13 changed files with 564 additions and 2 deletions

View file

@ -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")

View file

@ -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.

View 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 %}

View 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 %} &middot; {{ 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 %}

View 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 %}

View file

@ -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("/")))

View 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-&lt;vm&gt;</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
View 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
View 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)

View 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

View file

@ -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">

View file

@ -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":

View file

@ -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"),