mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
feat(operations): the jobs list as a card with progress, the job page as one record
The Jobs list sits in a card: the count and the filters in its header, the job with its phase, its progress and its date in one cell, the state at the row's end. The job page is one card: resource, user and state on top, then the phase, Cancel and the progress, the error and the results, and a timeline that says how long the job waited and ran. Resolving an uncertain job folds into its own card.
This commit is contained in:
parent
3c6de221ca
commit
3d1a7190ed
8 changed files with 241 additions and 148 deletions
|
|
@ -2,105 +2,122 @@
|
|||
{% load i18n %}
|
||||
{% load bootstrap_icons %}
|
||||
{% load operations %}
|
||||
{% load tz %}
|
||||
|
||||
{% 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>
|
||||
<a class="btn btn-outline-secondary" href="{% url 'operations:jobs' %}">{% trans "Jobs" %}</a>
|
||||
{% 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 %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="fw-semibold text-break me-auto">{{ job.resource_label }}</span>
|
||||
{% include "operations/state_badge.html" with state=op.state %}
|
||||
</div>
|
||||
<div class="small text-body-secondary mt-1">{% trans "User" %}: {{ job.username }}</div>
|
||||
</div>
|
||||
{% if op.phase or op.progress_total or job.cancellable or op.error or job.result.links or job.result.warning or job.result.left_behind or job.resolution %}
|
||||
<div class="card-body pb-0">
|
||||
{% if op.phase or job.cancellable %}
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
|
||||
<span class="me-auto">{% if op.phase %}{% translate op.phase %}{% endif %}</span>
|
||||
{% if job.cancellable %}
|
||||
<form method="post" action="{% url 'operations:cancel' job.id %}" aria-label="{% trans 'Cancel job form' %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm btn-outline-warning" type="submit">{% trans "Cancel" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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">
|
||||
<div class="d-flex flex-wrap justify-content-between gap-2 small mb-2">
|
||||
<span class="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>
|
||||
<strong>{{ percent }}%</strong>
|
||||
</div>
|
||||
<div class="progress mb-3" style="height: .5rem;" role="progressbar" aria-label="{% trans 'Progress' %}" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar" style="width: {{ percent }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% get_current_timezone as TIME_ZONE %}
|
||||
<dt class="col-sm-3">{% blocktrans %}Created ({{ TIME_ZONE }}){% endblocktrans %}</dt><dd class="col-sm-9">{{ op.created_at|date:"Y-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:"Y-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:"Y-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.warning %}
|
||||
<dt class="col-sm-3">{% trans "Warning" %}</dt><dd class="col-sm-9 text-break">{{ job.result.warning }}</dd>
|
||||
{% if op.error %}
|
||||
<div class="alert alert-{{ op.state|state_color }}" role="alert">
|
||||
{% bs_icon 'exclamation-triangle' %} {{ op.error }}
|
||||
</div>
|
||||
{% 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>
|
||||
<h2 class="h6">{% trans "Results" %}</h2>
|
||||
<ul class="list-unstyled mb-3">
|
||||
{% for link in job.result.links %}<li><a href="{{ link.url }}">{{ link.label }}</a></li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if job.result.warning %}
|
||||
<div class="alert alert-warning text-break" role="alert">{{ job.result.warning }}</div>
|
||||
{% endif %}
|
||||
{% if job.result.left_behind %}
|
||||
<h2 class="h6">{% trans "Left behind" %}</h2>
|
||||
<ul class="list-unstyled mb-3">
|
||||
{% for item in job.result.left_behind %}<li class="text-break"><code>{{ item }}</code></li>{% endfor %}
|
||||
</ul>
|
||||
{% 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>
|
||||
<h2 class="h6">{% trans "Resolution" %}</h2>
|
||||
<p class="mb-3">{{ job.resolution|state_label }}: {{ job.resolution_note }}</p>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
<ol class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex flex-wrap justify-content-between gap-1 small">
|
||||
<span>{% bs_icon 'plus-circle' %} {% trans "Created" %}</span>
|
||||
<span class="text-body-secondary">{{ op.created_at|date:"Y-m-d H:i:s T" }}</span>
|
||||
</li>
|
||||
{% if op.started_at %}
|
||||
<li class="list-group-item d-flex flex-wrap justify-content-between gap-1 small">
|
||||
<span>{% bs_icon 'play-circle' %} {% trans "Started" %} · {% blocktrans with time=op.created_at|duration:op.started_at %}queued for {{ time }}{% endblocktrans %}</span>
|
||||
<span class="text-body-secondary">{{ op.started_at|date:"Y-m-d H:i:s T" }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if op.finished_at %}
|
||||
<li class="list-group-item d-flex flex-wrap justify-content-between gap-1 small">
|
||||
<span>{% bs_icon 'stop-circle' %} {% trans "Finished" %}{% if op.started_at %} · {% blocktrans with time=op.started_at|duration:op.finished_at %}ran for {{ time }}{% endblocktrans %}{% endif %}</span>
|
||||
<span class="text-body-secondary">{{ op.finished_at|date:"Y-m-d H:i:s T" }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{% if request.user.is_superuser and op.state == "uncertain" %}
|
||||
<h5 class="mt-4">{% trans "Resolve" %}</h5>
|
||||
{% if request.user.is_superuser and op.state == "uncertain" %}
|
||||
<details class="card border-warning">
|
||||
<summary class="card-header">
|
||||
<span class="fw-semibold">{% trans "Resolve" %}</span>
|
||||
<span class="d-block small text-body-secondary mt-1">{% trans "Record what you checked; the job is marked succeeded or failed." %}</span>
|
||||
</summary>
|
||||
<div class="card-body">
|
||||
<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 class="mb-3">
|
||||
<label class="form-label" for="resolve-outcome">{% trans "Outcome" %}</label>
|
||||
<select class="form-select" name="outcome" id="resolve-outcome">
|
||||
<option value="succeeded">{% trans "Succeeded" %}</option>
|
||||
<option value="failed">{% trans "Failed" %}</option>
|
||||
</select>
|
||||
</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 class="mb-3">
|
||||
<label class="form-label" for="resolve-note">{% trans "What you checked" %}</label>
|
||||
<textarea class="form-control" name="note" id="resolve-note" rows="3" required></textarea>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<button class="btn btn-primary" type="submit">{% trans "Resolve" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
{% if follow %}{% include "operations/follow_active.html" %}{% endif %}
|
||||
|
|
|
|||
|
|
@ -3,96 +3,106 @@
|
|||
{% load django_bootstrap5 %}
|
||||
{% load bootstrap_icons %}
|
||||
{% load operations %}
|
||||
{% load tz %}
|
||||
|
||||
{% 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:"Y-m-d H:i:s" }}{% else %}{% trans "never" %}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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:"Y-m-d H:i:s T" }}{% else %}{% trans "never" %}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="me-auto">{% trans "Jobs" %} <span class="badge text-bg-light border">{{ page.paginator.count }}</span></span>
|
||||
{% 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 }}">
|
||||
<form method="get" class="row g-2 col-12 col-xl-10" role="search" aria-label="{% trans 'Filter jobs' %}">
|
||||
<div class="col-6 col-lg">
|
||||
<input class="form-control form-control-sm" 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 class="col-6 col-lg">
|
||||
<input class="form-control form-control-sm" 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' %}">
|
||||
<div class="col-6 col-lg">
|
||||
<select class="form-select form-select-sm" 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' %}">
|
||||
<div class="col-6 col-lg">
|
||||
<select class="form-select form-select-sm" 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 class="col-12 col-lg-auto d-flex gap-2 justify-content-end">
|
||||
<button class="btn btn-sm btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
<a class="btn btn-sm 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>
|
||||
{% get_current_timezone as TIME_ZONE %}
|
||||
<th scope="col" class="d-none d-md-table-cell">{% blocktrans %}Created ({{ TIME_ZONE }}){% endblocktrans %}</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:"Y-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:"Y-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>
|
||||
{% if not rows %}
|
||||
<div class="card-body text-center text-body-secondary">{% trans "No jobs" %}</div>
|
||||
{% else %}
|
||||
<div class="table-responsive position-relative">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Job" %}</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 %}
|
||||
<th scope="col" class="text-end">{% trans "State" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for op, row in rows %}
|
||||
<tr>
|
||||
<td style="min-width: 12rem;">
|
||||
<a href="{% url 'operations:job' row.id %}">{{ row.label }}</a>
|
||||
{% if op.state in active_states %}
|
||||
{% if op.phase or op.progress_total %}
|
||||
<div class="small text-body-secondary">
|
||||
{% if op.phase %}{% translate op.phase %}{% endif %}
|
||||
{% if op.progress_total %}
|
||||
{% if op.phase %}·{% endif %}
|
||||
{% if op.kind in bytes_kinds %}{{ op.progress_done|default:0|filesizeformat }} / {{ op.progress_total|filesizeformat }}{% else %}{{ op.progress_done|default:0 }} / {{ op.progress_total }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if op.progress_total %}
|
||||
{% widthratio op.progress_done|default:0 op.progress_total 100 as percent %}
|
||||
<div class="progress my-1" style="height: .25rem;" role="progressbar" aria-label="{% trans 'Progress' %}" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar" style="width: {{ percent }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif op.state == "failed" or op.state == "uncertain" %}
|
||||
<div class="small text-body-secondary" title="{{ op.error }}">{{ op.error|truncatechars:80 }}</div>
|
||||
{% endif %}
|
||||
<div class="small text-body-secondary">{{ op.created_at|date:"Y-m-d H:i:s T" }}{% if request.user.is_superuser %}<span class="d-md-none"> · {{ row.username }}</span>{% endif %}</div>
|
||||
{# below md the hidden columns show here #}
|
||||
<div class="d-md-none small">{{ row.resource_label|wrap_at_slash }}</div>
|
||||
</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 %}
|
||||
<td class="text-end">{% include "operations/state_badge.html" with state=op.state %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if page.has_other_pages %}
|
||||
{% bootstrap_pagination page extra=page_query %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
{% if follow %}{% include "operations/follow_active.html" %}{% endif %}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import uuid
|
|||
from django import template
|
||||
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 django.utils.timesince import timesince
|
||||
from django.utils.translation import ngettext, pgettext_lazy
|
||||
|
||||
from operations.jobs import take_remembered
|
||||
from operations.models import Operation
|
||||
|
|
@ -51,6 +52,16 @@ def state_label(state):
|
|||
return STATE_LABELS[state]
|
||||
|
||||
|
||||
@register.filter
|
||||
def duration(start, end):
|
||||
"""The time from start to end, in seconds under a minute: timesince counts minutes.
|
||||
Never negative: the worker and the panel may run on hosts whose clocks differ."""
|
||||
seconds = max(0, int((end - start).total_seconds()))
|
||||
if seconds < 60:
|
||||
return ngettext("%(count)d second", "%(count)d seconds", seconds) % {"count": seconds}
|
||||
return timesince(start, end)
|
||||
|
||||
|
||||
@register.filter
|
||||
def wrap_at_slash(label):
|
||||
"""A "<compute>/<vm>" label that wraps after its slash only, never inside a name."""
|
||||
|
|
|
|||
|
|
@ -128,10 +128,25 @@ class JobsPageTestCase(TransactionTestCase):
|
|||
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, "2026-10-13 23:04:00")
|
||||
self.assertContains(response, f"Created ({django_timezone.get_current_timezone_name()})")
|
||||
self.assertContains(response, "2026-10-13 23:04:00 +03")
|
||||
self.assertNotContains(response, "2026-10-13T20:04")
|
||||
|
||||
def test_the_job_page_shows_how_long_it_waited_and_ran(self):
|
||||
at = datetime(2026, 10, 13, 20, 4, 0, tzinfo=timezone.utc)
|
||||
Operation.objects.filter(pk=self.op.pk).update(created_at=at, started_at=at + timedelta(seconds=3),
|
||||
finished_at=at + timedelta(minutes=4, seconds=3),
|
||||
state=Operation.SUCCEEDED)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("operations:job", args=[self.op.pk]))
|
||||
self.assertContains(response, "queued for 3 seconds")
|
||||
self.assertContains(response, "ran for 4\xa0minutes")
|
||||
# cancelled while queued: it never started
|
||||
Operation.objects.filter(pk=self.op.pk).update(started_at=None, state=Operation.CANCELLED)
|
||||
response = self.client.get(reverse("operations:job", args=[self.op.pk]))
|
||||
self.assertNotContains(response, "queued for")
|
||||
self.assertNotContains(response, "ran for")
|
||||
self.assertContains(response, "Finished")
|
||||
|
||||
def test_list_and_detail_refresh_while_a_job_is_active(self):
|
||||
self.client.force_login(self.user)
|
||||
pages = (reverse("operations:jobs"), reverse("operations:job", args=[self.op.pk]))
|
||||
|
|
@ -199,22 +214,28 @@ class JobsPageTestCase(TransactionTestCase):
|
|||
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):
|
||||
def test_a_narrow_list_shows_resource_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 (f"Created ({django_timezone.get_current_timezone_name()})", "Resource", "User"):
|
||||
for header in ("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"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")
|
||||
# a plain user's narrow line has no user
|
||||
self.assertIn('<span class="d-md-none"> · page_user</span>', html)
|
||||
narrow = re.search(r'<div class="d-md-none small">(.*?)</div>', html, re.S).group(1)
|
||||
self.assertIn('<span class="text-nowrap">wvc-test-vm</span>', narrow)
|
||||
# a plain user's list 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)
|
||||
self.assertNotContains(self.client.get(reverse("operations:jobs")), "· page_user")
|
||||
|
||||
def test_the_list_shows_an_active_jobs_progress(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING, progress_done=3, progress_total=10)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("operations:jobs"))
|
||||
self.assertContains(response, "3 / 10")
|
||||
self.assertContains(response, 'aria-valuenow="30"')
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.SUCCEEDED)
|
||||
self.assertNotContains(self.client.get(reverse("operations:jobs")), 'role="progressbar"')
|
||||
|
||||
def test_states_and_phases_show_translated(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING, phase="Merging the snapshot")
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ def jobs(request):
|
|||
"follow": any(op.state in Operation.ACTIVE for op in page.object_list),
|
||||
"states": Operation.ACTIVE + Operation.TERMINAL,
|
||||
"kinds": sorted((name, _(kind.label)) for name, kind in jobs_module.KINDS.items()),
|
||||
"bytes_kinds": {name for name, kind in jobs_module.KINDS.items() if kind.progress_bytes},
|
||||
"page_query": urlencode({k: v for k, v in request.GET.items() if k != "page"})})
|
||||
|
||||
|
||||
|
|
|
|||
11
static/icon_cache/play-circle_None_None_None.svg
Normal file
11
static/icon_cache/play-circle_None_None_None.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-play-circle" viewBox="0 0 16 16">
|
||||
|
||||
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
|
||||
|
||||
<path d="M6.271 5.055a.5.5 0 0 1 .52.038l3.5 2.5a.5.5 0 0 1 0 .814l-3.5 2.5A.5.5 0 0 1 6 10.5v-5a.5.5 0 0 1 .271-.445"/>
|
||||
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
11
static/icon_cache/plus-circle_None_None_None.svg
Normal file
11
static/icon_cache/plus-circle_None_None_None.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-circle" viewBox="0 0 16 16">
|
||||
|
||||
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
|
||||
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/>
|
||||
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 368 B |
11
static/icon_cache/stop-circle_None_None_None.svg
Normal file
11
static/icon_cache/stop-circle_None_None_None.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-stop-circle" viewBox="0 0 16 16">
|
||||
|
||||
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
|
||||
|
||||
<path d="M5 6.5A1.5 1.5 0 0 1 6.5 5h3A1.5 1.5 0 0 1 11 6.5v3A1.5 1.5 0 0 1 9.5 11h-3A1.5 1.5 0 0 1 5 9.5z"/>
|
||||
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
Loading…
Add table
Add a link
Reference in a new issue