1
0
Fork 0
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:
catborise 2026-11-02 23:33:34 +03:00
parent 9d57adc097
commit 0eab09e19d
13 changed files with 178 additions and 42 deletions

Binary file not shown.

View file

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

View file

@ -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 "当前无法连接此虚拟机所在的主机。"

View file

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

View file

@ -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];

View file

@ -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);
})();

View file

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

View file

@ -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 %}&middot;{% endif %}
{% if row.phase %}&middot;{% 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>

View file

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

View file

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

View file

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

View file

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