mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
feat(operations): the job panel and job pages speak the page's language
The job panel on VM and storage pages wrote its buttons and notes in English (Cancel, Open, Refresh, Dismiss, Finishing..., Files not removed, Worker unavailable, Cancel failed) and its state badge as the state's code, and the jobs API gave the phase untranslated. The panel now takes its texts and the state labels from the page's settings, and the API gives the phase in the viewer's language, as it gives the label; the job pages show that phase. A job that cannot be cancelled and ran past its time limit had "(over its time limit)" written into its phase, which then matched no translation. The phase stays as the job sets it, and the pages say the job is past its limit.
This commit is contained in:
parent
9d57adc097
commit
0eab09e19d
13 changed files with 178 additions and 42 deletions
Binary file not shown.
|
|
@ -11,7 +11,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-11-02 20:06+0000\n"
|
||||
"POT-Creation-Date: 2026-11-02 20:32+0000\n"
|
||||
"PO-Revision-Date: 2020-11-04 12:00+0000\n"
|
||||
"Last-Translator: catborise <muhammetalisag@gmail.com>, 2020\n"
|
||||
"Language-Team: Turkish (https://www.transifex.com/catborise/teams/110663/"
|
||||
|
|
@ -103,6 +103,7 @@ msgstr "bir yıl içinde"
|
|||
#: admin/templates/admin/user_form.html:16
|
||||
#: admin/templates/admin/user_list.html:58
|
||||
#: operations/templates/operations/jobs.html:15
|
||||
#: operations/templatetags/operations.py:91
|
||||
msgid "never"
|
||||
msgstr "hiçbir zaman"
|
||||
|
||||
|
|
@ -290,6 +291,7 @@ msgstr "Parola Değiştir"
|
|||
#: nwfilters/templates/create_nwfilter_block.html:33
|
||||
#: nwfilters/templates/nwfilter.html:178 nwfilters/templates/nwfilters.html:93
|
||||
#: operations/templates/operations/job.html:31
|
||||
#: operations/templatetags/operations.py:83
|
||||
#: storages/templates/create_stg_block.html:183
|
||||
#: storages/templates/create_stg_vol_block.html:35
|
||||
#: storages/templates/create_stg_vol_block.html:68
|
||||
|
|
@ -3596,6 +3598,7 @@ msgid "OS details"
|
|||
msgstr "İşletim sistemi ayrıntıları"
|
||||
|
||||
#: instances/templates/instance.html:39
|
||||
#: operations/templatetags/operations.py:85
|
||||
msgid "Refresh"
|
||||
msgstr "Yenile"
|
||||
|
||||
|
|
@ -7435,6 +7438,11 @@ msgstr ""
|
|||
"Harfler, rakamlar, nokta, tire ve alt çizgi. Kopya, filtrenin kurallarını ve "
|
||||
"başvurularını içerir."
|
||||
|
||||
#: operations/api/serializers.py:35
|
||||
#, python-format
|
||||
msgid "%(phase)s (over its time limit)"
|
||||
msgstr "%(phase)s (süre sınırını aştı)"
|
||||
|
||||
#: operations/templates/operations/job.html:11
|
||||
#: operations/templates/operations/jobs.html:7
|
||||
#: operations/templates/operations/jobs.html:9
|
||||
|
|
@ -7509,6 +7517,7 @@ msgid "What you checked"
|
|||
msgstr "Neyi kontrol ettiniz"
|
||||
|
||||
#: operations/templates/operations/jobs.html:14
|
||||
#: operations/templatetags/operations.py:90
|
||||
msgid "Worker unavailable, last seen"
|
||||
msgstr "İşçi kullanılamıyor, son görülme"
|
||||
|
||||
|
|
@ -7586,6 +7595,36 @@ msgid_plural "%(count)d seconds"
|
|||
msgstr[0] "%(count)d saniye"
|
||||
msgstr[1] "%(count)d saniye"
|
||||
|
||||
#: operations/templatetags/operations.py:84
|
||||
msgctxt "verb"
|
||||
msgid "Open"
|
||||
msgstr "Aç"
|
||||
|
||||
#: operations/templatetags/operations.py:86
|
||||
msgid "Dismiss"
|
||||
msgstr "Kapat"
|
||||
|
||||
#: operations/templatetags/operations.py:87
|
||||
msgid "Finishing…"
|
||||
msgstr "Bitiriliyor…"
|
||||
|
||||
#: operations/templatetags/operations.py:88
|
||||
msgid "Files not removed:"
|
||||
msgstr "Silinmeyen dosyalar:"
|
||||
|
||||
#: operations/templatetags/operations.py:89
|
||||
msgid "A short operation is in progress."
|
||||
msgstr "Kısa bir işlem sürüyor."
|
||||
|
||||
#: operations/templatetags/operations.py:92
|
||||
#, python-format
|
||||
msgid "Cancel failed (HTTP %(status)s)."
|
||||
msgstr "İptal edilemedi (HTTP %(status)s)."
|
||||
|
||||
#: operations/templatetags/operations.py:93
|
||||
msgid "Cancel failed: the server could not be reached."
|
||||
msgstr "İptal edilemedi: sunucuya ulaşılamadı."
|
||||
|
||||
#: operations/utils.py:52
|
||||
msgid "The host of this virtual machine cannot be reached right now."
|
||||
msgstr "Bu sanal makinenin sunucusuna şu anda ulaşılamıyor."
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -7,7 +7,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-11-02 20:06+0000\n"
|
||||
"POT-Creation-Date: 2026-11-02 20:32+0000\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
|
|
@ -99,6 +99,7 @@ msgstr "一年后"
|
|||
#: admin/templates/admin/user_form.html:16
|
||||
#: admin/templates/admin/user_list.html:58
|
||||
#: operations/templates/operations/jobs.html:15
|
||||
#: operations/templatetags/operations.py:91
|
||||
msgid "never"
|
||||
msgstr "永不"
|
||||
|
||||
|
|
@ -286,6 +287,7 @@ msgstr "修改密码"
|
|||
#: nwfilters/templates/create_nwfilter_block.html:33
|
||||
#: nwfilters/templates/nwfilter.html:178 nwfilters/templates/nwfilters.html:93
|
||||
#: operations/templates/operations/job.html:31
|
||||
#: operations/templatetags/operations.py:83
|
||||
#: storages/templates/create_stg_block.html:183
|
||||
#: storages/templates/create_stg_vol_block.html:35
|
||||
#: storages/templates/create_stg_vol_block.html:68
|
||||
|
|
@ -3498,6 +3500,7 @@ msgid "OS details"
|
|||
msgstr "操作系统详情"
|
||||
|
||||
#: instances/templates/instance.html:39
|
||||
#: operations/templatetags/operations.py:85
|
||||
msgid "Refresh"
|
||||
msgstr "刷新"
|
||||
|
||||
|
|
@ -7120,6 +7123,11 @@ msgid ""
|
|||
"rules and references."
|
||||
msgstr "字母、数字、点、短横线和下划线。副本包含该过滤器的规则和引用。"
|
||||
|
||||
#: operations/api/serializers.py:35
|
||||
#, python-format
|
||||
msgid "%(phase)s (over its time limit)"
|
||||
msgstr "%(phase)s(已超出时间限制)"
|
||||
|
||||
#: operations/templates/operations/job.html:11
|
||||
#: operations/templates/operations/jobs.html:7
|
||||
#: operations/templates/operations/jobs.html:9
|
||||
|
|
@ -7192,6 +7200,7 @@ msgid "What you checked"
|
|||
msgstr "检查内容"
|
||||
|
||||
#: operations/templates/operations/jobs.html:14
|
||||
#: operations/templatetags/operations.py:90
|
||||
msgid "Worker unavailable, last seen"
|
||||
msgstr "工作进程不可用,最后在线时间"
|
||||
|
||||
|
|
@ -7268,6 +7277,36 @@ msgid "%(count)d second"
|
|||
msgid_plural "%(count)d seconds"
|
||||
msgstr[0] "%(count)d 秒"
|
||||
|
||||
#: operations/templatetags/operations.py:84
|
||||
msgctxt "verb"
|
||||
msgid "Open"
|
||||
msgstr "打开"
|
||||
|
||||
#: operations/templatetags/operations.py:86
|
||||
msgid "Dismiss"
|
||||
msgstr "关闭"
|
||||
|
||||
#: operations/templatetags/operations.py:87
|
||||
msgid "Finishing…"
|
||||
msgstr "即将完成…"
|
||||
|
||||
#: operations/templatetags/operations.py:88
|
||||
msgid "Files not removed:"
|
||||
msgstr "未删除的文件:"
|
||||
|
||||
#: operations/templatetags/operations.py:89
|
||||
msgid "A short operation is in progress."
|
||||
msgstr "有一个短操作正在进行。"
|
||||
|
||||
#: operations/templatetags/operations.py:92
|
||||
#, python-format
|
||||
msgid "Cancel failed (HTTP %(status)s)."
|
||||
msgstr "取消失败(HTTP %(status)s)。"
|
||||
|
||||
#: operations/templatetags/operations.py:93
|
||||
msgid "Cancel failed: the server could not be reached."
|
||||
msgstr "取消失败:无法连接到服务器。"
|
||||
|
||||
#: operations/utils.py:52
|
||||
msgid "The host of this virtual machine cannot be reached right now."
|
||||
msgstr "当前无法连接此虚拟机所在的主机。"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import serializers
|
||||
|
||||
|
|
@ -8,6 +11,7 @@ from operations.utils import shown_label
|
|||
|
||||
class OperationSerializer(serializers.ModelSerializer):
|
||||
label = serializers.SerializerMethodField()
|
||||
phase = serializers.SerializerMethodField()
|
||||
resource_label = serializers.SerializerMethodField()
|
||||
cancellable = serializers.SerializerMethodField()
|
||||
in_request = serializers.SerializerMethodField()
|
||||
|
|
@ -22,6 +26,15 @@ class OperationSerializer(serializers.ModelSerializer):
|
|||
def get_label(self, op) -> str:
|
||||
return str(_(label_of(op.kind)))
|
||||
|
||||
def get_phase(self, op) -> str:
|
||||
"""The phase in the viewer's language; a job running past its time
|
||||
limit says so (one that can be cancelled is cancelled instead)."""
|
||||
phase = _(op.phase) if op.phase else ""
|
||||
if op.state == Operation.RUNNING and op.time_limit and op.started_at \
|
||||
and timezone.now() - op.started_at > timedelta(seconds=op.time_limit):
|
||||
return (_("%(phase)s (over its time limit)") % {"phase": phase}).strip()
|
||||
return phase
|
||||
|
||||
def get_resource_label(self, op) -> str:
|
||||
return shown_label(op, self.context["request"].user)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ var JOB_STATE_COLORS = {
|
|||
failed: "danger", cancelled: "secondary", uncertain: "warning",
|
||||
};
|
||||
|
||||
function jobPanel(element, urls, csrfToken, initialIds) {
|
||||
// states: the label of each state; text: the panel's own texts, both in the
|
||||
// page's language (job_panel_script)
|
||||
function jobPanel(element, urls, csrfToken, initialIds, states, text) {
|
||||
var shown = {}; // id -> job in the last active list
|
||||
var pending = {}; // id -> true: left the list (or just submitted), outcome not fetched yet
|
||||
var inflight = {}; // id -> true: its outcome is being fetched
|
||||
|
|
@ -41,7 +43,7 @@ function jobPanel(element, urls, csrfToken, initialIds) {
|
|||
return '<div class="progress mt-2" role="progressbar" aria-valuenow="' + (finishing ? 100 : pct)
|
||||
+ '" aria-valuemin="0" aria-valuemax="100"><div class="progress-bar'
|
||||
+ (finishing ? " progress-bar-striped progress-bar-animated" : "") + '" style="width:'
|
||||
+ (finishing ? 100 : pct) + '%">' + (finishing ? "Finishing\u2026" : pct + "%") + "</div></div>";
|
||||
+ (finishing ? 100 : pct) + '%">' + (finishing ? esc(text.finishing) : pct + "%") + "</div></div>";
|
||||
}
|
||||
|
||||
// label, state badge and muted phase on one line, the buttons on its right
|
||||
|
|
@ -49,7 +51,7 @@ function jobPanel(element, urls, csrfToken, initialIds) {
|
|||
return '<div class="d-flex flex-wrap align-items-center gap-2"><strong>' + esc(label) + "</strong>"
|
||||
+ '<span class="badge bg-' + JOB_STATE_COLORS[state]
|
||||
// the theme draws white text on a warning badge
|
||||
+ (JOB_STATE_COLORS[state] === "warning" ? " text-dark" : "") + '">' + esc(state) + "</span>"
|
||||
+ (JOB_STATE_COLORS[state] === "warning" ? " text-dark" : "") + '">' + esc(states[state] || state) + "</span>"
|
||||
+ (phase ? '<span class="small text-body-secondary">' + esc(phase) + "</span>" : "")
|
||||
+ (buttons ? '<div class="ms-auto d-flex gap-2">' + buttons + "</div>" : "") + "</div>";
|
||||
}
|
||||
|
|
@ -64,7 +66,7 @@ function jobPanel(element, urls, csrfToken, initialIds) {
|
|||
|
||||
function jobHtml(job) {
|
||||
var cancel = job.cancellable ? '<button type="button" class="btn btn-sm btn-outline-danger" data-cancel="'
|
||||
+ esc(job.id) + '"' + (cancelling[job.id] ? " disabled" : "") + ">Cancel</button>" : "";
|
||||
+ esc(job.id) + '"' + (cancelling[job.id] ? " disabled" : "") + ">" + esc(text.cancel) + "</button>" : "";
|
||||
// an uncertain job keeps its claims, so it stays in the list with its error
|
||||
return alertHtml(job.state, header(job.label, job.state, job.phase, cancel) + errorHtml(job) + bar(job));
|
||||
}
|
||||
|
|
@ -72,26 +74,26 @@ function jobPanel(element, urls, csrfToken, initialIds) {
|
|||
function outcomeHtml(job) {
|
||||
var left = (job.result && job.result.left_behind) || [];
|
||||
var note = job.result && job.result.warning ? '<div class="small mt-2">' + esc(job.result.warning) + "</div>" : "";
|
||||
var warn = note + (left.length ? '<div class="small mt-2">Files not removed:</div><ul class="list-unstyled small mb-0">'
|
||||
var warn = note + (left.length ? '<div class="small mt-2">' + esc(text.not_removed) + '</div><ul class="list-unstyled small mb-0">'
|
||||
+ left.map(function (path) { return '<li class="text-break"><code>' + esc(path) + "</code></li>"; }).join("") + "</ul>" : "");
|
||||
var buttons = (opens(job) ? '<a class="btn btn-sm btn-primary" href="' + esc(job.result.open) + '">Open</a>' : "")
|
||||
+ '<button type="button" class="btn btn-sm btn-primary" data-refresh="1">Refresh</button>'
|
||||
var buttons = (opens(job) ? '<a class="btn btn-sm btn-primary" href="' + esc(job.result.open) + '">' + esc(text.open) + "</a>" : "")
|
||||
+ '<button type="button" class="btn btn-sm btn-primary" data-refresh="1">' + esc(text.refresh) + "</button>"
|
||||
+ '<button type="button" class="btn btn-sm btn-outline-secondary" data-dismiss="' + esc(job.id)
|
||||
+ '">Dismiss</button>';
|
||||
+ '">' + esc(text.dismiss) + "</button>";
|
||||
return alertHtml(job.state, header(job.label, job.state, "", buttons) + errorHtml(job) + warn);
|
||||
}
|
||||
|
||||
function render() {
|
||||
var html = notice ? alertHtml("failed", '<div class="d-flex flex-wrap align-items-center gap-2"><span>'
|
||||
+ esc(notice) + '</span><div class="ms-auto"><button type="button" class="btn btn-sm btn-outline-secondary"'
|
||||
+ ' data-dismiss-error="1">Dismiss</button></div></div>') : "";
|
||||
+ ' data-dismiss-error="1">' + esc(text.dismiss) + "</button></div></div>") : "";
|
||||
last.jobs.forEach(function (job) { html += jobHtml(job); });
|
||||
Object.keys(outcomes).forEach(function (id) { html += outcomeHtml(outcomes[id]); });
|
||||
last.others.forEach(function (o) {
|
||||
html += '<div class="alert alert-secondary mb-3" role="status">' + header(o.label, o.state, "", "") + "</div>";
|
||||
});
|
||||
if (last.short_operation) {
|
||||
html += '<div class="alert alert-secondary mb-3" role="status">A short operation is in progress.</div>';
|
||||
html += '<div class="alert alert-secondary mb-3" role="status">' + esc(text.short_operation) + "</div>";
|
||||
}
|
||||
// a job the worker has to start, run or stop, not one a request runs (the Jobs page uses the same rule)
|
||||
var waiting = last.jobs.some(function (j) {
|
||||
|
|
@ -99,8 +101,8 @@ function jobPanel(element, urls, csrfToken, initialIds) {
|
|||
});
|
||||
var seen = last.worker_last_seen ? new Date(last.worker_last_seen) : null;
|
||||
if (waiting && (!seen || Date.now() - seen.getTime() > 60000)) {
|
||||
html += '<div class="alert alert-warning mb-3" role="status">Worker unavailable, last seen '
|
||||
+ esc(seen ? seen.toLocaleString() : "never") + ".</div>";
|
||||
html += '<div class="alert alert-warning mb-3" role="status">' + esc(text.worker_unavailable) + " "
|
||||
+ esc(seen ? seen.toLocaleString() : text.never) + ".</div>";
|
||||
}
|
||||
if (html !== rendered) {
|
||||
element.innerHTML = html;
|
||||
|
|
@ -189,7 +191,7 @@ function jobPanel(element, urls, csrfToken, initialIds) {
|
|||
.fail(function (xhr) {
|
||||
// e.g. a queued job started meanwhile and cannot be stopped any more
|
||||
notice = (xhr.responseJSON && xhr.responseJSON.detail)
|
||||
|| (xhr.status ? "Cancel failed (HTTP " + xhr.status + ")." : "Cancel failed: the server could not be reached.");
|
||||
|| (xhr.status ? text.cancel_failed.replace("%(status)s", xhr.status) : text.cancel_unreachable);
|
||||
})
|
||||
.always(function () {
|
||||
delete cancelling[id];
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@
|
|||
resource: settings.resource,
|
||||
job: function (id) { return settings.jobs + id + "/"; },
|
||||
cancel: function (id) { return settings.jobs + id + "/cancel/"; },
|
||||
}, settings.csrf_token, settings.remembered);
|
||||
}, settings.csrf_token, settings.remembered, settings.states, settings.text);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@
|
|||
</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 %}
|
||||
{% if job.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 %}
|
||||
{% if job.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>
|
||||
<span class="me-auto">{{ job.phase }}</span>
|
||||
{% if job.cancellable %}
|
||||
<form method="post" action="{% url 'operations:cancel' job.id %}" aria-label="{% trans 'Cancel job form' %}">
|
||||
{% csrf_token %}
|
||||
|
|
|
|||
|
|
@ -68,11 +68,11 @@
|
|||
<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 %}
|
||||
{% if row.phase or op.progress_total %}
|
||||
<div class="small text-body-secondary">
|
||||
{% if op.phase %}{% translate op.phase %}{% endif %}
|
||||
{{ row.phase }}
|
||||
{% if op.progress_total %}
|
||||
{% if op.phase %}·{% endif %}
|
||||
{% if row.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>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from django.urls import reverse
|
|||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timesince import timesince
|
||||
from django.utils.translation import ngettext, pgettext_lazy
|
||||
from django.utils.translation import gettext, ngettext, pgettext, pgettext_lazy
|
||||
|
||||
from operations.jobs import take_remembered
|
||||
from operations.models import Operation
|
||||
|
|
@ -78,4 +78,18 @@ def job_panel_script(context, resource):
|
|||
"jobs": reverse("operation-list"),
|
||||
"csrf_token": get_token(context["request"]),
|
||||
"remembered": take_remembered(context["request"], resource),
|
||||
"states": {state: str(label) for state, label in STATE_LABELS.items()},
|
||||
"text": {
|
||||
"cancel": gettext("Cancel"),
|
||||
"open": pgettext("verb", "Open"),
|
||||
"refresh": gettext("Refresh"),
|
||||
"dismiss": gettext("Dismiss"),
|
||||
"finishing": gettext("Finishing…"),
|
||||
"not_removed": gettext("Files not removed:"),
|
||||
"short_operation": gettext("A short operation is in progress."),
|
||||
"worker_unavailable": gettext("Worker unavailable, last seen"),
|
||||
"never": gettext("never"),
|
||||
"cancel_failed": gettext("Cancel failed (HTTP %(status)s)."),
|
||||
"cancel_unreachable": gettext("Cancel failed: the server could not be reached."),
|
||||
},
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -50,12 +50,20 @@ eval(fs.readFileSync(process.argv[2], "utf8"));
|
|||
eval(fs.readFileSync(process.argv[3], "utf8"));
|
||||
|
||||
const urls = { resource: "/r", job: (id) => "/j/" + id, cancel: (id) => "/c/" + id };
|
||||
// the labels and texts the page gives (job_panel_script), here as in English
|
||||
const STATES = { queued: "queued", running: "running", cancelling: "cancelling", succeeded: "succeeded",
|
||||
failed: "failed", cancelled: "cancelled", uncertain: "uncertain" };
|
||||
const TEXT = { cancel: "Cancel", open: "Open", refresh: "Refresh", dismiss: "Dismiss", finishing: "Finishing\u2026",
|
||||
not_removed: "Files not removed:", short_operation: "A short operation is in progress.",
|
||||
worker_unavailable: "Worker unavailable, last seen", never: "never",
|
||||
cancel_failed: "Cancel failed (HTTP %(status)s).",
|
||||
cancel_unreachable: "Cancel failed: the server could not be reached." };
|
||||
const seen = new Date(1700000000000).toISOString();
|
||||
const running = { id: "a", label: "Delete snapshot", state: "running", phase: "Merging", progress_done: 1,
|
||||
progress_total: 4, cancellable: false, result: {}, error: "" };
|
||||
const empty = { jobs: [], others: [], short_operation: false, worker_last_seen: seen };
|
||||
|
||||
jobPanel(el, urls, "tok");
|
||||
jobPanel(el, urls, "tok", undefined, STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", { ...empty, jobs: [running] });
|
||||
assert.ok(el.innerHTML.includes("Delete snapshot") && el.innerHTML.includes("25%"), "shows the job and real progress");
|
||||
|
|
@ -108,7 +116,7 @@ assert.ok(!window.reloaded);
|
|||
// a job that ended before the first poll: shown from the submitted ids
|
||||
open = []; timers = [];
|
||||
const el2 = { innerHTML: "", addEventListener() {} };
|
||||
jobPanel(el2, urls, "tok", ["b"]);
|
||||
jobPanel(el2, urls, "tok", ["b"], STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", empty);
|
||||
answer("/j/b", { ...running, id: "b", state: "failed", error: "refused" });
|
||||
|
|
@ -118,7 +126,7 @@ assert.ok(el2.innerHTML.includes("refused") && el2.innerHTML.includes('class="al
|
|||
// a late answer for a dismissed job does not bring it back
|
||||
const el3 = { innerHTML: "", addEventListener(ev, f) { listeners3 = f; } };
|
||||
let listeners3;
|
||||
jobPanel(el3, urls, "tok", ["c"]);
|
||||
jobPanel(el3, urls, "tok", ["c"], STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", empty);
|
||||
listeners3({ target: { dataset: { dismiss: "c" } } });
|
||||
|
|
@ -130,7 +138,7 @@ open = []; timers = [];
|
|||
let writes = 0, html4 = "";
|
||||
const el4 = { addEventListener() {},
|
||||
get innerHTML() { return html4; }, set innerHTML(v) { writes++; html4 = v; } };
|
||||
jobPanel(el4, urls, "tok");
|
||||
jobPanel(el4, urls, "tok", undefined, STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", { ...empty, jobs: [{ ...running, state: "uncertain", error: "check the disks" }] });
|
||||
assert.ok(el4.innerHTML.includes('<span class="badge bg-warning text-dark">uncertain</span>'), "dark text on the yellow badge");
|
||||
|
|
@ -153,7 +161,7 @@ assert.strictEqual(window.backgroundJobRunning, true, "a foreground operation ke
|
|||
open = []; timers = [];
|
||||
const el6 = { innerHTML: "", addEventListener() {} };
|
||||
window.backgroundJobRunning = false;
|
||||
jobPanel(el6, urls, "tok");
|
||||
jobPanel(el6, urls, "tok", undefined, STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", { ...empty, jobs: [{ ...running, id: "d" }] });
|
||||
assert.strictEqual(window.backgroundJobRunning, true);
|
||||
|
|
@ -169,7 +177,7 @@ assert.ok(!open.some((r) => r.url === "/j/d" && !r.settled), "a missing job is n
|
|||
open = []; timers = [];
|
||||
window.backgroundJobRunning = false;
|
||||
const el7 = { innerHTML: "", addEventListener() {} };
|
||||
jobPanel(el7, urls, "tok", ["e"]);
|
||||
jobPanel(el7, urls, "tok", ["e"], STATES, TEXT);
|
||||
assert.strictEqual(window.backgroundJobRunning, true, "set at init, not after the first poll");
|
||||
advance(0);
|
||||
answer("/r", empty, false); // a failed first poll keeps it
|
||||
|
|
@ -183,7 +191,7 @@ assert.strictEqual(window.backgroundJobRunning, false, "released once the rememb
|
|||
open = []; timers = [];
|
||||
const el8 = { innerHTML: "", addEventListener() {} };
|
||||
const stale = new Date(Date.now() - 120000).toISOString();
|
||||
jobPanel(el8, urls, "tok");
|
||||
jobPanel(el8, urls, "tok", undefined, STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", { ...empty, worker_last_seen: stale, jobs: [running] });
|
||||
assert.ok(el8.innerHTML.includes("Worker unavailable"), "a running job with a stale lease warns");
|
||||
|
|
@ -209,7 +217,7 @@ open = []; timers = [];
|
|||
let click9;
|
||||
const el9 = { innerHTML: "", addEventListener(ev, f) { click9 = f; } };
|
||||
const queued = { ...running, id: "q", state: "queued", cancellable: true, progress_total: null };
|
||||
jobPanel(el9, urls, "tok");
|
||||
jobPanel(el9, urls, "tok", undefined, STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", { ...empty, jobs: [queued] });
|
||||
assert.ok(el9.innerHTML.includes('data-cancel="q">Cancel</button>'), "an enabled cancel button");
|
||||
|
|
@ -234,7 +242,7 @@ assert.ok(el9.innerHTML.includes("could not be reached"), "a network error shows
|
|||
open = []; timers = [];
|
||||
window.backgroundJobRunning = false;
|
||||
const el10 = { innerHTML: "", addEventListener() {} };
|
||||
const panel = jobPanel(el10, urls, "tok");
|
||||
const panel = jobPanel(el10, urls, "tok", undefined, STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", empty);
|
||||
assert.strictEqual(window.backgroundJobRunning, false);
|
||||
|
|
@ -249,7 +257,7 @@ assert.ok(el10.innerHTML.includes("Upload") && el10.innerHTML.includes('<span cl
|
|||
// a list answer older than the job, then its detail still active: the panel keeps following it
|
||||
open = []; timers = [];
|
||||
const el11 = { innerHTML: "", addEventListener() {} };
|
||||
const panel11 = jobPanel(el11, urls, "tok");
|
||||
const panel11 = jobPanel(el11, urls, "tok", undefined, STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", empty);
|
||||
panel11.track("u");
|
||||
|
|
@ -268,7 +276,7 @@ assert.ok(!el11.innerHTML.includes('<span class="badge bg-success">succeeded</sp
|
|||
// a job that made a VM opens its page; a warning keeps the outcome, with Open; only a path of this site
|
||||
open = []; timers = []; window.reloaded = false; window.opened = null;
|
||||
const elv = { innerHTML: "", addEventListener() {} };
|
||||
jobPanel(elv, urls, "tok", ["v"]);
|
||||
jobPanel(elv, urls, "tok", ["v"], STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", empty);
|
||||
answer("/j/v", { ...running, id: "v", label: "Clone VM", state: "succeeded", result: { open: "/instances/9/" } });
|
||||
|
|
@ -277,7 +285,7 @@ for (const [result, shown] of [[{ open: "/instances/9/", warning: "not started"
|
|||
[{ open: "//evil.example.com/" }, false], [{ open: "javascript:alert(1)" }, false]]) {
|
||||
open = []; timers = []; window.reloaded = false; window.opened = null;
|
||||
const elw = { innerHTML: "", addEventListener() {} };
|
||||
jobPanel(elw, urls, "tok", ["w"]);
|
||||
jobPanel(elw, urls, "tok", ["w"], STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", empty);
|
||||
answer("/j/w", { ...running, id: "w", state: "succeeded", result });
|
||||
|
|
@ -291,7 +299,7 @@ for (const [label, doc] of [["dialog", { querySelector: (s) => s === ".modal.sho
|
|||
open = []; timers = []; window.reloaded = false;
|
||||
Object.assign(document, { activeElement: null, querySelector: () => null }, doc);
|
||||
const elm = { innerHTML: "", addEventListener() {} };
|
||||
jobPanel(elm, urls, "tok", ["m"]);
|
||||
jobPanel(elm, urls, "tok", ["m"], STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", empty);
|
||||
answer("/j/m", { ...running, id: "m", label: "Clone", state: "succeeded" });
|
||||
|
|
@ -304,7 +312,7 @@ Object.assign(document, { activeElement: null, querySelector: () => null });
|
|||
open = []; timers = []; window.reloaded = false;
|
||||
window.xmlEditUnsaved = () => true;
|
||||
const elx = { innerHTML: "", addEventListener() {} };
|
||||
jobPanel(elx, urls, "tok", ["x"]);
|
||||
jobPanel(elx, urls, "tok", ["x"], STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", empty);
|
||||
answer("/j/x", { ...running, id: "x", state: "succeeded" });
|
||||
|
|
@ -314,7 +322,7 @@ delete window.xmlEditUnsaved;
|
|||
// another job's outcome shown or still awaited: a quiet success does not wipe it
|
||||
open = []; timers = []; window.reloaded = false;
|
||||
const elo = { innerHTML: "", addEventListener() {} };
|
||||
jobPanel(elo, urls, "tok", ["f", "s", "w"]);
|
||||
jobPanel(elo, urls, "tok", ["f", "s", "w"], STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", empty);
|
||||
answer("/j/f", { ...running, id: "f", label: "Migrate", state: "failed", error: "no route" });
|
||||
|
|
@ -325,7 +333,7 @@ answer("/j/w", { ...running, id: "w", label: "Snapshot", state: "succeeded" });
|
|||
assert.ok(!window.reloaded, "still not while an outcome is shown");
|
||||
open = []; timers = []; window.reloaded = false;
|
||||
const elp = { innerHTML: "", addEventListener() {} };
|
||||
jobPanel(elp, urls, "tok", ["s2", "f2"]);
|
||||
jobPanel(elp, urls, "tok", ["s2", "f2"], STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", empty);
|
||||
answer("/j/s2", { ...running, id: "s2", state: "succeeded" });
|
||||
|
|
@ -335,10 +343,21 @@ assert.ok(elp.innerHTML.includes("refused"), "which then shows");
|
|||
// another job still running: its outcome would be lost to a reload
|
||||
open = []; timers = []; window.reloaded = false;
|
||||
const elr = { innerHTML: "", addEventListener() {} };
|
||||
jobPanel(elr, urls, "tok", ["s3"]);
|
||||
jobPanel(elr, urls, "tok", ["s3"], STATES, TEXT);
|
||||
advance(0);
|
||||
answer("/r", { ...empty, jobs: [{ ...running, id: "r3" }] });
|
||||
answer("/j/s3", { ...running, id: "s3", state: "succeeded" });
|
||||
assert.ok(!window.reloaded, "not while another job runs");
|
||||
Date.now = realNow;
|
||||
console.log("ok");
|
||||
|
||||
// the page's language: the badge, the buttons and the panel's texts come from its settings
|
||||
{
|
||||
const elt = { innerHTML: "", addEventListener() {} };
|
||||
const states = { ...STATES, running: "çalışıyor" };
|
||||
jobPanel(elt, urls, "tok", undefined, states, { ...TEXT, cancel: "İptal" });
|
||||
advance(0);
|
||||
answer("/r", { ...empty, jobs: [{ ...running, id: "t", cancellable: true }] });
|
||||
assert.ok(elt.innerHTML.includes('<span class="badge bg-primary">çalışıyor</span>')
|
||||
&& elt.innerHTML.includes(">İptal</button>"), "labels and texts of the page's language");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TransactionTestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
|
|
@ -24,6 +27,14 @@ class OperationsApiTestCase(TransactionTestCase):
|
|||
UserInstance.objects.create(user=self.owner, instance=self.vm2)
|
||||
self.op = jobs.submit("test.noop", self.owner, {"uuid": "u1"}, "key-00000001")
|
||||
|
||||
def test_the_phase_comes_in_the_users_language_and_says_when_it_is_past_its_limit(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING, phase="Merging the snapshot")
|
||||
self.client.force_login(self.owner)
|
||||
url = f"/api/v1/operations/{self.op.pk}/"
|
||||
self.assertEqual(self.client.get(url, HTTP_ACCEPT_LANGUAGE="tr").json()["phase"], "Anlık görüntü birleştiriliyor")
|
||||
Operation.objects.filter(pk=self.op.pk).update(time_limit=60, started_at=timezone.now() - timedelta(seconds=61))
|
||||
self.assertEqual(self.client.get(url).json()["phase"], "Merging the snapshot (over its time limit)")
|
||||
|
||||
def test_users_list_only_their_own_jobs(self):
|
||||
self.client.force_login(self.other)
|
||||
self.assertEqual(self.client.get("/api/v1/operations/").json()["count"], 0)
|
||||
|
|
|
|||
|
|
@ -229,10 +229,9 @@ class Worker:
|
|||
now = timezone.now()
|
||||
for op in Operation.objects.filter(pk__in=list(self.jobs), time_limit__isnull=False):
|
||||
if op.started_at and now - op.started_at > timedelta(seconds=op.time_limit):
|
||||
# one that cannot be cancelled runs on; its page says it is over its limit
|
||||
if jobs.get_kind(op.kind).cancellable and not op.cancel_requested:
|
||||
self._fenced_update([op.pk], state=Operation.CANCELLING, cancel_requested=True)
|
||||
elif "(over its time limit)" not in op.phase:
|
||||
self._fenced_update([op.pk], phase=f"{op.phase} (over its time limit)"[:255])
|
||||
|
||||
def _progress(self):
|
||||
"""At most one query per job and none waiting for a thread, so queries
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue