diff --git a/kolibri/core/auth/test/sync_utils.py b/kolibri/core/auth/test/sync_utils.py
index 958aab0b904..7a015cf23ea 100644
--- a/kolibri/core/auth/test/sync_utils.py
+++ b/kolibri/core/auth/test/sync_utils.py
@@ -56,6 +56,7 @@ def __init__(
self.port = get_free_tcp_port()
self.baseurl = "http://127.0.0.1:{}/".format(self.port)
self.enable_automatic_download = enable_automatic_download
+ self._instance = None
if seeded_kolibri_home is not None:
shutil.rmtree(self.env["KOLIBRI_HOME"])
shutil.copytree(seeded_kolibri_home, self.env["KOLIBRI_HOME"])
@@ -132,7 +133,8 @@ def _wait_for_server_start(self, timeout=20):
def kill(self):
try:
subprocess.Popen("kolibri stop", env=self.env, shell=True)
- self._instance.kill()
+ if self._instance is not None:
+ self._instance.kill()
shutil.rmtree(self.env["KOLIBRI_HOME"])
except OSError:
pass
@@ -188,6 +190,7 @@ def generate_base_data(self):
class multiple_kolibri_servers:
def __init__(self, count=2, **server_kwargs):
self.server_count = count
+ self.servers = []
self.server_kwargs = [
{
key: value[i] if isinstance(value, (list, tuple)) else value
@@ -197,6 +200,19 @@ def __init__(self, count=2, **server_kwargs):
]
def __enter__(self):
+ try:
+ self._start_servers()
+ except BaseException:
+ # Servers left running hold connections that stop every later test
+ # creating its own database. BaseException: Django exits rather than
+ # raises when it cannot clobber one.
+ self.__exit__(None, None, None)
+ raise
+ return self.servers
+
+ def _start_servers(self):
+ # the same instance is reused for every invocation, so start from scratch
+ self.servers = []
# spin up the servers
if "sqlite" in connection.vendor:
tempserver = KolibriServer(
@@ -208,12 +224,15 @@ def __enter__(self):
tempserver.delete_model(DatabaseIDModel)
preseeded_home = tempserver.env["KOLIBRI_HOME"]
- self.servers = [
- KolibriServer(
- seeded_kolibri_home=preseeded_home, **self.server_kwargs[i]
+ for i in range(self.server_count):
+ # track before starting, so a failed start is still shut down
+ server = KolibriServer(
+ autostart=False,
+ seeded_kolibri_home=preseeded_home,
+ **self.server_kwargs[i],
)
- for i in range(self.server_count)
- ]
+ self.servers.append(server)
+ server.start()
# calculate the DATABASE settings
for server in self.servers:
@@ -259,17 +278,22 @@ def __enter__(self):
server_conn.close()
server.start()
- return self.servers
-
def __exit__(self, typ, val, traceback):
- # make sure all the servers are shut down
+ # kill every server before touching any database, so that a database that
+ # refuses to drop cannot abort the loop and leave later servers running
for server in self.servers:
server.kill()
+ for server in self.servers:
+ # a server abandoned before its alias was registered has no database
+ if server.db_alias not in connections.databases:
+ continue
# destroy the test databases
server_conn = connections[server.db_alias]
try:
server_conn.creation.destroy_test_db()
- except OSError:
+ except Exception:
+ # Nothing narrower will do: Django surfaces a missing database
+ # as a RuntimeError from _nodb_cursor.
pass
server_conn.close()
# Remove the database alias from settings to prevent subsequent tests
diff --git a/kolibri/core/auth/test/test_auth_tasks.py b/kolibri/core/auth/test/test_auth_tasks.py
index 4d734eb108d..bc7f6494580 100644
--- a/kolibri/core/auth/test/test_auth_tasks.py
+++ b/kolibri/core/auth/test/test_auth_tasks.py
@@ -74,6 +74,8 @@ class dummy_orm_job_data:
interval = 8600
retry_interval = 5
max_retries = 3
+ last_finished_state = None
+ last_finished_time = None
@patch("kolibri.core.tasks.viewsets.tasks.job_storage")
diff --git a/kolibri/core/tasks/job.py b/kolibri/core/tasks/job.py
index 78d5eb6cf8c..d139bed6bb4 100644
--- a/kolibri/core/tasks/job.py
+++ b/kolibri/core/tasks/job.py
@@ -86,6 +86,12 @@ class State:
CANCELING,
}
+ FINISHED_STATES = {
+ FAILED,
+ CANCELED,
+ COMPLETED,
+ }
+
JobStatus = namedtuple("Status", ("title", "text"))
@@ -263,6 +269,18 @@ def __init__(
self._supervisor_id = NO_VALUE
self.func = callable_to_import_path(func)
+ def reset_for_new_run(self):
+ """
+ Clear the fields describing a single run, so a repeating job does not
+ inherit the previous run's progress or outcome. extra_metadata is kept:
+ it is what the task manager renders for the run that just finished.
+ """
+ self.exception = None
+ self.traceback = ""
+ self.progress = 0
+ self.total_progress = 0
+ self.result = None
+
def _check_storage_attached(self):
if self._storage is None:
raise ReferenceError(
diff --git a/kolibri/core/tasks/migrations/0005_add_last_finished_fields.py b/kolibri/core/tasks/migrations/0005_add_last_finished_fields.py
new file mode 100644
index 00000000000..7e04e86f4ec
--- /dev/null
+++ b/kolibri/core/tasks/migrations/0005_add_last_finished_fields.py
@@ -0,0 +1,21 @@
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("kolibritasks", "0004_add_supervisor_registry"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="job",
+ name="last_finished_state",
+ field=models.CharField(blank=True, max_length=20, null=True),
+ ),
+ migrations.AddField(
+ model_name="job",
+ name="last_finished_time",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/kolibri/core/tasks/models.py b/kolibri/core/tasks/models.py
index ea4949e0e9f..36d0de6bb6f 100644
--- a/kolibri/core/tasks/models.py
+++ b/kolibri/core/tasks/models.py
@@ -55,6 +55,11 @@ class Job(models.Model):
# Maximum number of retries allowed for the job
max_retries = models.IntegerField(null=True, blank=True)
+ # The outcome of the last finished run, which a repeating job's own state
+ # stops describing as soon as the job is re-queued.
+ last_finished_state = models.CharField(max_length=20, null=True, blank=True)
+ last_finished_time = models.DateTimeField(null=True, blank=True)
+
# References the supervisor currently responsible for this job.
# No FK constraint to avoid complexity with supervisor cleanup.
supervisor_id = UUIDField(null=True, blank=True)
diff --git a/kolibri/core/tasks/storage.py b/kolibri/core/tasks/storage.py
index 66b05d85795..ae74e16e1eb 100644
--- a/kolibri/core/tasks/storage.py
+++ b/kolibri/core/tasks/storage.py
@@ -475,11 +475,7 @@ def clear(self, queue=None, job_id=None, force=False):
# filter only by the finished jobs, if we are not specified to force
if not force:
- queryset = queryset.filter(
- Q(state=State.COMPLETED)
- | Q(state=State.FAILED)
- | Q(state=State.CANCELED)
- )
+ queryset = queryset.filter(state__in=State.FINISHED_STATES)
if self._hooks:
for orm_job in queryset:
@@ -673,7 +669,7 @@ def reschedule_finished_job_if_needed( # noqa: C901
orm_job = self.get_orm_job(job_id)
# Only allow this function to be run on a job that is in a finished state.
- if orm_job.state not in {State.COMPLETED, State.FAILED, State.CANCELED}:
+ if orm_job.state not in State.FINISHED_STATES:
raise JobNotRestartable(
"Cannot reschedule job with state={}".format(orm_job.state)
)
@@ -712,10 +708,7 @@ def reschedule_finished_job_if_needed( # noqa: C901
current_retries = orm_job.retries if orm_job.retries is not None else 0
kwargs["retries"] = current_retries + 1
- elif (
- orm_job.state in {State.COMPLETED, State.FAILED, State.CANCELED}
- and kwargs["repeat"] != 0
- ):
+ elif orm_job.state in State.FINISHED_STATES and kwargs["repeat"] != 0:
# Otherwise, if we are in a finished state and repeat is not 0, then we can reschedule, either because
# repeat is None, or because repeat is not None and is greater than 0.
if kwargs["repeat"] is not None:
@@ -847,6 +840,9 @@ def _write_job_update(
self, job, orm_job, state, supervisor_id, kwargs, repeat=NO_VALUE
):
if state is not None:
+ # orm_job.state is the state being left, until the assignment below.
+ if state == State.RUNNING and orm_job.state != State.RUNNING:
+ job.reset_for_new_run()
orm_job.state = job.state = state
# Ownership exists only in supervised states; a bare re-mark
# preserves the owner, a terminal state clears it.
@@ -855,6 +851,9 @@ def _write_job_update(
orm_job.supervisor_id = supervisor_id
else:
orm_job.supervisor_id = None
+ if state in State.FINISHED_STATES:
+ orm_job.last_finished_state = state
+ orm_job.last_finished_time = self._now()
# repeat is nullable, so None is a real value; NO_VALUE means "leave it".
if repeat is not NO_VALUE:
orm_job.repeat = repeat
diff --git a/kolibri/core/tasks/test/taskrunner/test_storage.py b/kolibri/core/tasks/test/taskrunner/test_storage.py
index bf3f56c834b..e1df2978b2e 100644
--- a/kolibri/core/tasks/test/taskrunner/test_storage.py
+++ b/kolibri/core/tasks/test/taskrunner/test_storage.py
@@ -484,6 +484,106 @@ def test_reschedule_finished_job_failed(self, defaultbackend, simplejob):
assert requeued_job.state == State.QUEUED
assert requeued_orm_job.scheduled_time > previous_scheduled_time
+ def test_reschedule_recurring_job_keeps_last_finished(
+ self, defaultbackend, simplejob
+ ):
+ job_id = defaultbackend.enqueue_at(
+ local_now(), simplejob, QUEUE, interval=10, repeat=None
+ )
+ assert defaultbackend.get_orm_job(job_id).last_finished_state is None
+ defaultbackend.complete_job(job_id)
+
+ defaultbackend.reschedule_finished_job_if_needed(job_id)
+
+ requeued_orm_job = defaultbackend.get_orm_job(job_id)
+
+ assert requeued_orm_job.state == State.QUEUED
+ assert requeued_orm_job.last_finished_state == State.COMPLETED
+ assert requeued_orm_job.last_finished_time is not None
+
+ def test_reschedule_with_delay_keeps_last_finished(self, defaultbackend, simplejob):
+ job_id = defaultbackend.enqueue_job(simplejob, QUEUE)
+ defaultbackend.complete_job(job_id)
+
+ defaultbackend.reschedule_finished_job_if_needed(
+ job_id, delay=datetime.timedelta(seconds=5)
+ )
+
+ requeued_orm_job = defaultbackend.get_orm_job(job_id)
+
+ assert requeued_orm_job.state == State.QUEUED
+ assert requeued_orm_job.last_finished_state == State.COMPLETED
+ assert requeued_orm_job.last_finished_time is not None
+
+ def test_last_finished_describes_the_most_recent_run(
+ self, defaultbackend, simplejob
+ ):
+ exception = ValueError("Error")
+ job_id = defaultbackend.enqueue_job(
+ simplejob, QUEUE, retry_interval=5, max_retries=3
+ )
+ defaultbackend.mark_job_as_failed(job_id, exception, "Traceback")
+ defaultbackend.reschedule_finished_job_if_needed(job_id, exception=exception)
+ assert defaultbackend.get_orm_job(job_id).last_finished_state == State.FAILED
+ defaultbackend.mark_job_as_running(job_id)
+
+ defaultbackend.complete_job(job_id)
+
+ assert defaultbackend.get_orm_job(job_id).last_finished_state == State.COMPLETED
+
+ def test_clear_leaves_rescheduled_recurring_job(self, defaultbackend, simplejob):
+ job_id = defaultbackend.enqueue_at(
+ local_now(), simplejob, QUEUE, interval=10, repeat=None
+ )
+ defaultbackend.complete_job(job_id)
+ defaultbackend.reschedule_finished_job_if_needed(job_id)
+
+ defaultbackend.clear(force=False)
+
+ assert defaultbackend.get_orm_job(job_id).state == State.QUEUED
+
+ def test_marking_running_clears_completed_run_fields(
+ self, defaultbackend, simplejob
+ ):
+ job_id = defaultbackend.enqueue_job(simplejob, QUEUE)
+ defaultbackend.mark_job_as_running(job_id)
+ defaultbackend.update_job_progress(job_id, 5, 10)
+ defaultbackend.complete_job(job_id, result="run one")
+
+ defaultbackend.mark_job_as_running(job_id)
+
+ job = defaultbackend.get_job(job_id)
+
+ assert job.progress == 0
+ assert job.total_progress == 0
+ assert job.result is None
+
+ def test_marking_running_clears_failed_run_fields(self, defaultbackend, simplejob):
+ job_id = defaultbackend.enqueue_job(simplejob, QUEUE)
+ defaultbackend.mark_job_as_running(job_id)
+ defaultbackend.mark_job_as_failed(job_id, ValueError("Error"), "Traceback")
+
+ defaultbackend.mark_job_as_running(job_id)
+
+ job = defaultbackend.get_job(job_id)
+
+ assert job.exception is None
+ assert job.traceback == ""
+
+ def test_marking_running_keeps_last_finished(self, defaultbackend, simplejob):
+ job_id = defaultbackend.enqueue_at(
+ local_now(), simplejob, QUEUE, interval=10, repeat=None
+ )
+ defaultbackend.complete_job(job_id)
+ defaultbackend.reschedule_finished_job_if_needed(job_id)
+
+ defaultbackend.mark_job_as_running(job_id)
+
+ orm_job = defaultbackend.get_orm_job(job_id)
+
+ assert orm_job.last_finished_state == State.COMPLETED
+ assert orm_job.last_finished_time is not None
+
def test_reschedule_finished_job_invalid_state_queued(
self, defaultbackend, simplejob
):
diff --git a/kolibri/core/tasks/test/test_api.py b/kolibri/core/tasks/test/test_api.py
index 6f3b44cc545..64f5f3f5a30 100644
--- a/kolibri/core/tasks/test/test_api.py
+++ b/kolibri/core/tasks/test/test_api.py
@@ -20,11 +20,13 @@
from kolibri.core.tasks.exceptions import JobRunning
from kolibri.core.tasks.job import Job
from kolibri.core.tasks.job import State
+from kolibri.core.tasks.main import job_storage
from kolibri.core.tasks.permissions import CanManageContent
from kolibri.core.tasks.permissions import IsSuperAdmin
from kolibri.core.tasks.registry import RegisteredTask
from kolibri.core.tasks.registry import TaskRegistry
from kolibri.core.tasks.validation import JobValidator
+from kolibri.utils.time_utils import local_now
DUMMY_PASSWORD = "password"
@@ -55,6 +57,8 @@ class dummy_orm_job_data:
interval = 8600
retry_interval = 5
max_retries = 3
+ last_finished_state = None
+ last_finished_time = None
class BaseAPITestCase(APITestCase):
@@ -280,6 +284,8 @@ def add(x, y):
"extra_metadata": {},
"facility_id": None,
"scheduled_datetime": dummy_orm_job_data.scheduled_time.isoformat(),
+ "last_finished_status": None,
+ "last_finished_datetime": None,
"repeat": dummy_orm_job_data.repeat,
"repeat_interval": dummy_orm_job_data.interval,
"retry_interval": dummy_orm_job_data.retry_interval,
@@ -340,6 +346,8 @@ def add(**kwargs):
"extra_metadata": {},
"facility_id": None,
"scheduled_datetime": dummy_orm_job_data.scheduled_time.isoformat(),
+ "last_finished_status": None,
+ "last_finished_datetime": None,
"repeat": dummy_orm_job_data.repeat,
"repeat_interval": dummy_orm_job_data.interval,
"retry_interval": dummy_orm_job_data.retry_interval,
@@ -359,6 +367,8 @@ def add(**kwargs):
"extra_metadata": {},
"facility_id": None,
"scheduled_datetime": dummy_orm_job_data.scheduled_time.isoformat(),
+ "last_finished_status": None,
+ "last_finished_datetime": None,
"repeat": dummy_orm_job_data.repeat,
"repeat_interval": dummy_orm_job_data.interval,
"retry_interval": dummy_orm_job_data.retry_interval,
@@ -444,6 +454,8 @@ def add(**kwargs):
"facility": "kolibri HQ",
},
"scheduled_datetime": dummy_orm_job_data.scheduled_time.isoformat(),
+ "last_finished_status": None,
+ "last_finished_datetime": None,
"repeat": dummy_orm_job_data.repeat,
"repeat_interval": dummy_orm_job_data.interval,
"retry_interval": dummy_orm_job_data.retry_interval,
@@ -537,6 +549,8 @@ def add(x, y):
"facility": "kolibri HQ",
},
"scheduled_datetime": dummy_orm_job_data.scheduled_time.isoformat(),
+ "last_finished_status": None,
+ "last_finished_datetime": None,
"repeat": dummy_orm_job_data.repeat,
"repeat_interval": dummy_orm_job_data.interval,
"retry_interval": dummy_orm_job_data.retry_interval,
@@ -558,6 +572,8 @@ def add(x, y):
"facility": "kolibri HQ",
},
"scheduled_datetime": dummy_orm_job_data.scheduled_time.isoformat(),
+ "last_finished_status": None,
+ "last_finished_datetime": None,
"repeat": dummy_orm_job_data.repeat,
"repeat_interval": dummy_orm_job_data.interval,
"retry_interval": dummy_orm_job_data.retry_interval,
@@ -1221,6 +1237,8 @@ def subtract(x, y):
"kwargs": {},
"extra_metadata": {},
"scheduled_datetime": dummy_orm_job_data.scheduled_time.isoformat(),
+ "last_finished_status": None,
+ "last_finished_datetime": None,
"repeat": dummy_orm_job_data.repeat,
"repeat_interval": dummy_orm_job_data.interval,
"retry_interval": dummy_orm_job_data.retry_interval,
@@ -1240,6 +1258,8 @@ def subtract(x, y):
"kwargs": {},
"extra_metadata": {},
"scheduled_datetime": dummy_orm_job_data.scheduled_time.isoformat(),
+ "last_finished_status": None,
+ "last_finished_datetime": None,
"repeat": dummy_orm_job_data.repeat,
"repeat_interval": dummy_orm_job_data.interval,
"retry_interval": dummy_orm_job_data.retry_interval,
@@ -1259,6 +1279,8 @@ def subtract(x, y):
"kwargs": {},
"extra_metadata": {},
"scheduled_datetime": dummy_orm_job_data.scheduled_time.isoformat(),
+ "last_finished_status": None,
+ "last_finished_datetime": None,
"repeat": dummy_orm_job_data.repeat,
"repeat_interval": dummy_orm_job_data.interval,
"retry_interval": dummy_orm_job_data.retry_interval,
@@ -1488,3 +1510,52 @@ def test_csrf_protected_task(self):
reverse("kolibri:core:task-list"), {}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+
+class RepeatingTaskResponseAPITestCase(BaseAPITestCase):
+ TASK_ID = "kolibri.core.tasks.test.test_api.add"
+
+ def setUp(self):
+ @register_task(permission_classes=[IsSuperAdmin], queue="kolibri")
+ def add(x, y):
+ return x + y
+
+ TaskRegistry[self.TASK_ID] = add
+ self.client.login(username=self.superuser.username, password=DUMMY_PASSWORD)
+
+ def tearDown(self):
+ # Only drop what this case registered - clearing the whole registry
+ # leaves later test modules without the tasks they import.
+ del TaskRegistry[self.TASK_ID]
+ job_storage.clear(force=True)
+
+ def _run_and_reschedule_recurring_job(self):
+ job_id = job_storage.enqueue_at(
+ local_now(),
+ Job(self.TASK_ID, args=(1, 2)),
+ queue="kolibri",
+ interval=10,
+ repeat=None,
+ )
+ job_storage.complete_job(job_id)
+ job_storage.reschedule_finished_job_if_needed(job_id)
+
+ def _get_only_task(self):
+ response = self.client.get(reverse("kolibri:core:task-list"))
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(response.data), 1)
+ return response.data[0]
+
+ def test_rescheduled_recurring_job_reports_last_run(self):
+ self._run_and_reschedule_recurring_job()
+
+ task = self._get_only_task()
+
+ self.assertEqual(task["status"], State.QUEUED)
+ self.assertEqual(task["last_finished_status"], State.COMPLETED)
+ self.assertTrue(task["last_finished_datetime"])
+
+ def test_rescheduled_recurring_job_is_not_clearable(self):
+ self._run_and_reschedule_recurring_job()
+
+ self.assertFalse(self._get_only_task()["clearable"])
diff --git a/kolibri/core/tasks/viewsets/tasks.py b/kolibri/core/tasks/viewsets/tasks.py
index 77936d1eb55..b77064099f6 100644
--- a/kolibri/core/tasks/viewsets/tasks.py
+++ b/kolibri/core/tasks/viewsets/tasks.py
@@ -92,12 +92,16 @@ def _job_to_response(self, job):
"percentage": job.percentage_progress,
"id": job.job_id,
"cancellable": job.cancellable,
- "clearable": job.state in [State.FAILED, State.CANCELED, State.COMPLETED],
+ "clearable": job.state in State.FINISHED_STATES,
"facility_id": job.facility_id,
"args": job.args,
"kwargs": job.kwargs,
"extra_metadata": job.extra_metadata,
"scheduled_datetime": orm_job.scheduled_time.isoformat(),
+ "last_finished_status": orm_job.last_finished_state,
+ "last_finished_datetime": orm_job.last_finished_time.isoformat()
+ if orm_job.last_finished_time
+ else None,
"repeat": orm_job.repeat,
"repeat_interval": orm_job.interval,
"retry_interval": orm_job.retry_interval,
@@ -323,7 +327,7 @@ def clear(self, request, pk=None):
"""
job_to_clear = self._get_job_for_pk(request, pk)
- if job_to_clear.state not in (State.COMPLETED, State.FAILED, State.CANCELED):
+ if job_to_clear.state not in State.FINISHED_STATES:
raise serializers.ValidationError(
"Cannot clear job with state: {}".format(job_to_clear.state)
)
diff --git a/kolibri/plugins/device/frontend/views/FacilitiesPage/FacilitiesTasksPage.vue b/kolibri/plugins/device/frontend/views/FacilitiesPage/FacilitiesTasksPage.vue
index 8676412bde3..549e7a9acc2 100644
--- a/kolibri/plugins/device/frontend/views/FacilitiesPage/FacilitiesTasksPage.vue
+++ b/kolibri/plugins/device/frontend/views/FacilitiesPage/FacilitiesTasksPage.vue
@@ -16,7 +16,7 @@
-
+
{{ deviceString('emptyTasksMessage') }}
diff --git a/kolibri/plugins/device/frontend/views/FacilitiesPage/__tests__/FacilitiesPage.spec.js b/kolibri/plugins/device/frontend/views/FacilitiesPage/__tests__/FacilitiesPage.spec.js
new file mode 100644
index 00000000000..b01edb6b52b
--- /dev/null
+++ b/kolibri/plugins/device/frontend/views/FacilitiesPage/__tests__/FacilitiesPage.spec.js
@@ -0,0 +1,57 @@
+import { render, screen } from '@testing-library/vue';
+import TaskResource from 'kolibri/apiResources/TaskResource';
+import FacilityResource from 'kolibri-common/apiResources/FacilityResource';
+import { crossComponentTranslator } from 'kolibri/utils/i18n';
+import { TaskStatuses } from 'kolibri-common/utils/syncTaskUtils';
+import FacilityNameAndSyncStatus from 'kolibri-common/components/syncComponentSet/FacilityNameAndSyncStatus';
+import {
+ FACILITY_ID,
+ FACILITY_NAME,
+ syncSchedule,
+} from 'kolibri-common/utils/__tests__/syncSchedule';
+import FacilitiesPage from '../index.vue';
+
+jest.mock('kolibri/urls');
+jest.mock('kolibri-plugin-data', () => ({
+ __esModule: true,
+ default: { deprecationWarnings: {} },
+}));
+jest.mock('kolibri/apiResources/TaskResource', () => ({
+ list: jest.fn(),
+}));
+jest.mock('kolibri-common/apiResources/FacilityResource', () => ({
+ fetchCollection: jest.fn(),
+}));
+
+const { syncing$ } = crossComponentTranslator(FacilityNameAndSyncStatus);
+
+const FACILITY = {
+ id: FACILITY_ID,
+ name: FACILITY_NAME,
+ dataset: { registered: true },
+ last_successful_sync: null,
+};
+
+async function renderPage(tasks) {
+ TaskResource.list.mockResolvedValue(tasks);
+ FacilityResource.fetchCollection.mockResolvedValue([FACILITY]);
+ render(FacilitiesPage, {
+ routes: [{ path: '/facilities/tasks', name: 'FACILITIES_TASKS_PAGE' }],
+ });
+ await global.flushPromises();
+}
+
+describe('FacilitiesPage', () => {
+ it('shows a facility as syncing while its scheduled sync is running', async () => {
+ await renderPage([syncSchedule({ status: TaskStatuses.RUNNING })]);
+
+ expect(screen.getByText(syncing$())).toBeInTheDocument();
+ });
+
+ it('does not show a facility as syncing between runs of its sync schedule', async () => {
+ await renderPage([syncSchedule({ lastFinishedStatus: TaskStatuses.COMPLETED })]);
+
+ expect(screen.getByText(FACILITY_NAME)).toBeInTheDocument();
+ expect(screen.queryByText(syncing$())).not.toBeInTheDocument();
+ });
+});
diff --git a/kolibri/plugins/device/frontend/views/FacilitiesPage/__tests__/FacilitiesTasksPage.spec.js b/kolibri/plugins/device/frontend/views/FacilitiesPage/__tests__/FacilitiesTasksPage.spec.js
new file mode 100644
index 00000000000..390e63b9f38
--- /dev/null
+++ b/kolibri/plugins/device/frontend/views/FacilitiesPage/__tests__/FacilitiesTasksPage.spec.js
@@ -0,0 +1,47 @@
+import { render, screen } from '@testing-library/vue';
+import TaskResource from 'kolibri/apiResources/TaskResource';
+import { coreString } from 'kolibri/uiText/commonCoreStrings';
+import { syncFacilityTaskDisplayInfo, TaskStatuses } from 'kolibri-common/utils/syncTaskUtils';
+import { syncSchedule } from 'kolibri-common/utils/__tests__/syncSchedule';
+import { deviceStrings } from '../../commonDeviceStrings';
+import FacilitiesTasksPage from '../FacilitiesTasksPage';
+
+jest.mock('kolibri/apiResources/TaskResource', () => ({
+ list: jest.fn(),
+}));
+
+const { emptyTasksMessage$ } = deviceStrings;
+
+const SYNC_HEADING = syncFacilityTaskDisplayInfo(syncSchedule()).headingMsg;
+
+async function renderPage(tasks) {
+ TaskResource.list.mockResolvedValue(tasks);
+ render(FacilitiesTasksPage, {
+ routes: [{ path: '/facilities', name: 'FACILITIES_PAGE' }],
+ });
+ // Let the poll resolve, so an empty list and a filtered-out one differ.
+ await global.flushPromises();
+}
+
+describe('FacilitiesTasksPage', () => {
+ it('lists a repeating sync showing the run that just finished, with no clear or retry', async () => {
+ const task = syncSchedule({ lastFinishedStatus: TaskStatuses.COMPLETED });
+ // Wording is asserted in syncTaskUtils.spec.js; here only that it is rendered.
+ const { statusMsg, bytesTransferredMsg } = syncFacilityTaskDisplayInfo(task);
+ await renderPage([task]);
+
+ expect(screen.getByText(SYNC_HEADING)).toBeInTheDocument();
+ expect(screen.getByText(statusMsg)).toBeInTheDocument();
+ expect(screen.getByText(bytesTransferredMsg)).toBeInTheDocument();
+ for (const action of ['clearAction', 'retryAction', 'cancelAction']) {
+ expect(screen.queryByRole('button', { name: coreString(action) })).not.toBeInTheDocument();
+ }
+ });
+
+ it('shows the no-tasks message when a schedule that has never run is the only task', async () => {
+ await renderPage([syncSchedule()]);
+
+ expect(screen.getByText(emptyTasksMessage$())).toBeInTheDocument();
+ expect(screen.queryByText(SYNC_HEADING)).not.toBeInTheDocument();
+ });
+});
diff --git a/kolibri/plugins/device/frontend/views/FacilitiesPage/facilityTasksQueue.js b/kolibri/plugins/device/frontend/views/FacilitiesPage/facilityTasksQueue.js
index da469367090..0aa781cc3e7 100644
--- a/kolibri/plugins/device/frontend/views/FacilitiesPage/facilityTasksQueue.js
+++ b/kolibri/plugins/device/frontend/views/FacilitiesPage/facilityTasksQueue.js
@@ -10,10 +10,13 @@ function taskFacilityMatch(task, facility) {
}
function isActiveTask(task) {
- // Helper function filter tasks by whether they are 'active'
- // i.e. has a user just queued a non-repeating task, or is a repeating task
- // that is currently running.
- return task.repeat !== null || task.status === TaskStatuses.RUNNING;
+ // A schedule that has never run is not a task the user is watching — it
+ // belongs on Manage sync schedule.
+ return (
+ task.repeat !== null ||
+ task.status === TaskStatuses.RUNNING ||
+ Boolean(task.last_finished_datetime)
+ );
}
export default {
@@ -50,12 +53,13 @@ export default {
return this.facilityTasks.filter(isActiveTask);
},
facilityIsSyncing() {
- return function isSyncing(facility) {
- const inProcessSyncTasks = this.activeFacilityTasks.filter(
- t => isSyncTask(t) && !t.clearable,
+ return facility =>
+ this.facilityTasks.some(
+ task =>
+ isSyncTask(task) &&
+ task.status === TaskStatuses.RUNNING &&
+ taskFacilityMatch(task, facility),
);
- return Boolean(inProcessSyncTasks.find(task => taskFacilityMatch(task, facility)));
- };
},
facilityIsDeleting() {
return function isDeleting(facility) {
diff --git a/kolibri/plugins/device/frontend/views/FacilitiesPage/index.vue b/kolibri/plugins/device/frontend/views/FacilitiesPage/index.vue
index 2a9869c1dea..5299158c22c 100644
--- a/kolibri/plugins/device/frontend/views/FacilitiesPage/index.vue
+++ b/kolibri/plugins/device/frontend/views/FacilitiesPage/index.vue
@@ -204,7 +204,7 @@
import RegisterFacilityModal from 'kolibri-common/components/syncComponentSet/RegisterFacilityModal';
import ConfirmationRegisterModal from 'kolibri-common/components/syncComponentSet/ConfirmationRegisterModal';
import SyncFacilityModalGroup from 'kolibri-common/components/syncComponentSet/SyncFacilityModalGroup';
- import { TaskStatuses, TaskTypes } from 'kolibri-common/utils/syncTaskUtils';
+ import { runEndedSince, TaskStatuses, TaskTypes } from 'kolibri-common/utils/syncTaskUtils';
import some from 'lodash/some';
import useSnackbar from 'kolibri/composables/useSnackbar';
import { pageLoading } from 'kolibri-common/composables/usePageLoading';
@@ -331,10 +331,8 @@
return false;
}
const prevTask = prevTasks.find(({ id }) => id === task.id);
- if (!prevTask) {
- return false;
- }
- return prevTask.status === TaskStatuses.RUNNING && task.status === TaskStatuses.QUEUED;
+ // A RUNNING -> QUEUED transition is missed whenever polling straddles it.
+ return Boolean(prevTask) && runEndedSince(task, prevTask.last_finished_datetime);
},
facilityOptions() {
return [
diff --git a/kolibri/plugins/device/frontend/views/ManageContentPage/TasksBar.vue b/kolibri/plugins/device/frontend/views/ManageContentPage/TasksBar.vue
index 4ba9ed21b88..8e022cde964 100644
--- a/kolibri/plugins/device/frontend/views/ManageContentPage/TasksBar.vue
+++ b/kolibri/plugins/device/frontend/views/ManageContentPage/TasksBar.vue
@@ -39,6 +39,7 @@
import sumBy from 'lodash/sumBy';
import commonCoreStrings from 'kolibri/uiText/commonCoreStrings';
import commonTaskStrings from 'kolibri-common/uiText/tasks';
+ import { taskIsFinished } from 'kolibri-common/utils/syncTaskUtils';
export default {
name: 'TasksBar',
@@ -60,22 +61,24 @@
totalTasks() {
return this.tasks.length;
},
- clearableTasks() {
- return this.tasks.filter(t => t.clearable);
+ // Splitting on `clearable` would count a repeating task as in progress
+ // forever, since it is re-queued the instant a run ends.
+ finishedTasks() {
+ return this.tasks.filter(taskIsFinished);
},
inProgressTasks() {
- return this.tasks.filter(t => !t.clearable);
+ return this.tasks.filter(t => !taskIsFinished(t));
},
progress() {
return (
- ((this.clearableTasks.length + sumBy(this.inProgressTasks, 'percentage')) /
+ ((this.finishedTasks.length + sumBy(this.inProgressTasks, 'percentage')) /
this.totalTasks) *
100
);
},
tasksString() {
return this.$tr('someTasksComplete', {
- done: this.clearableTasks.length,
+ done: this.finishedTasks.length,
total: this.totalTasks,
});
},
diff --git a/kolibri/plugins/facility/frontend/views/DataPage/SyncInterface/index.vue b/kolibri/plugins/facility/frontend/views/DataPage/SyncInterface/index.vue
index 43156e1b7ae..2650778bcfa 100644
--- a/kolibri/plugins/facility/frontend/views/DataPage/SyncInterface/index.vue
+++ b/kolibri/plugins/facility/frontend/views/DataPage/SyncInterface/index.vue
@@ -120,7 +120,11 @@
import commonCoreStrings from 'kolibri/uiText/commonCoreStrings';
import CoreMenu from 'kolibri/components/CoreMenu';
import CoreMenuOption from 'kolibri/components/CoreMenu/CoreMenuOption';
- import { TaskStatuses } from 'kolibri-common/utils/syncTaskUtils';
+ import {
+ runEndedSince,
+ taskDisplayStatus,
+ TaskStatuses,
+ } from 'kolibri-common/utils/syncTaskUtils';
import { SyncPageNames } from 'kolibri-common/components/SyncSchedule/constants';
import useFacility from 'kolibri-common/composables/useFacility';
import PrivacyModal from './PrivacyModal';
@@ -163,6 +167,7 @@
kdpProject: null, // { name, token }
modalShown: null,
syncTaskId: '',
+ syncTaskLastFinished: null,
isSyncing: false,
syncHasFailed: false,
Modals,
@@ -219,13 +224,19 @@
pollSyncTask() {
// Like facilityTaskQueue, just keep polling until component is destroyed
TaskResource.get(this.syncTaskId).then(task => {
- if (task.clearable) {
+ if (runEndedSince(task, this.syncTaskLastFinished)) {
this.isSyncing = false;
- TaskResource.clear(this.syncTaskId);
+ // Clearing a repeating row would delete the facility's sync
+ // schedule, and it is briefly clearable between the run ending and
+ // the re-schedule that requeues it.
+ if (task.clearable && task.repeat === 0) {
+ TaskResource.clear(this.syncTaskId);
+ }
this.syncTaskId = '';
- if (task.status === TaskStatuses.FAILED) {
+ const status = taskDisplayStatus(task);
+ if (status === TaskStatuses.FAILED) {
this.syncHasFailed = true;
- } else if (task.status === TaskStatuses.COMPLETED) {
+ } else if (status === TaskStatuses.COMPLETED) {
this.fetchFacility();
}
} else if (this.syncTaskId) {
@@ -244,9 +255,12 @@
this.fetchFacilityConfig();
this.closeModal();
},
- handleSyncFacilitySuccess(taskId) {
+ handleSyncFacilitySuccess(task) {
this.isSyncing = true;
- this.syncTaskId = taskId;
+ this.syncTaskId = task.id;
+ // A manual sync may land on an existing schedule, which already carries
+ // the previous run's snapshot; the run we started is what changes it.
+ this.syncTaskLastFinished = task.last_finished_datetime;
this.pollSyncTask();
},
handleSyncFacilityFailure() {
@@ -260,7 +274,7 @@
this.closeModal();
this.startKdpSyncTask(this.facilityId)
.then(task => {
- this.handleSyncFacilitySuccess(task.id);
+ this.handleSyncFacilitySuccess(task);
})
.catch(() => {
this.handleSyncFacilityFailure();
@@ -273,7 +287,7 @@
device_id: peerData.id,
})
.then(task => {
- this.handleSyncFacilitySuccess(task.id);
+ this.handleSyncFacilitySuccess(task);
})
.catch(() => {
this.handleSyncFacilityFailure();
diff --git a/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue b/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue
index fa80f2cfeee..a2c05ee2cf9 100644
--- a/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue
+++ b/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue
@@ -37,10 +37,11 @@
-
+
| {{ coreString('deviceNameLabel') }} |
{{ $tr('Schedule') }} |
+ {{ $tr('lastSyncLabel') }} |
{{ coreString('statusLabel') }} |
|
@@ -58,6 +59,9 @@
{{ scheduleTime(task.repeat_interval, task.scheduled_datetime) }}
+
+
+ |
@@ -79,18 +83,9 @@
|
-
-
-
-
- | {{ coreString('deviceNameLabel') }} |
- {{ $tr('Schedule') }} |
- {{ coreString('statusLabel') }} |
- |
-
-
+
|
{{ $tr('NoSync') }}
@@ -125,7 +120,7 @@
useDeviceFacilityFilter,
useDevicesWithFilter,
} from 'kolibri-common/components/syncComponentSet/SelectDeviceModalGroup/useDevices';
- import { TaskTypes } from 'kolibri-common/utils/syncTaskUtils';
+ import { getLastRunStatusMsg, TaskTypes } from 'kolibri-common/utils/syncTaskUtils';
import useTaskPolling from '../../composables/useTaskPolling';
import { KDP_ID, oneHour, oneDay, oneWeek, twoWeeks, oneMonth } from './constants';
import { kdpNameTranslator } from './i18n';
@@ -212,6 +207,7 @@
deviceName,
deviceAvailable,
isKDP,
+ lastRunMsg: getLastRunStatusMsg(task),
};
});
},
@@ -277,6 +273,11 @@
},
$trs: {
+ lastSyncLabel: {
+ message: 'Last sync',
+ context:
+ 'Column heading for when a scheduled sync last ran, e.g. "Finished 2 minutes ago".',
+ },
syncSchedules: {
message: 'Sync schedules',
context: "Heading or title for 'manage sync schedule' page.",
diff --git a/packages/kolibri-common/components/syncComponentSet/FacilityNameAndSyncStatus/index.vue b/packages/kolibri-common/components/syncComponentSet/FacilityNameAndSyncStatus/index.vue
index 202c08e62f0..b9f5ca5c678 100644
--- a/packages/kolibri-common/components/syncComponentSet/FacilityNameAndSyncStatus/index.vue
+++ b/packages/kolibri-common/components/syncComponentSet/FacilityNameAndSyncStatus/index.vue
@@ -147,13 +147,6 @@
context:
'This is associated with the label "Last successful sync:", and the subject is the Facility.',
},
- /* eslint-disable kolibri/vue-no-unused-translations */
- nextSync: {
- message: 'Next sync: {relativeTime}',
- context:
- 'Used to indicate the next scheduled sync of facility data. For example, "in 5 days".\'\n',
- },
- /* eslint-enable kolibri/vue-no-unused-translations */
lastSync: {
message: 'Last synced: {relativeTime}',
context:
diff --git a/packages/kolibri-common/components/syncComponentSet/FacilityTaskPanelDetails.vue b/packages/kolibri-common/components/syncComponentSet/FacilityTaskPanelDetails.vue
index 3a4cf23f087..e0eb8ca93a5 100644
--- a/packages/kolibri-common/components/syncComponentSet/FacilityTaskPanelDetails.vue
+++ b/packages/kolibri-common/components/syncComponentSet/FacilityTaskPanelDetails.vue
@@ -134,7 +134,7 @@
import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow';
import commonCoreStrings from 'kolibri/uiText/commonCoreStrings';
import commonTaskStrings from 'kolibri-common/uiText/tasks';
- import { TaskStatuses, TaskTypes } from 'kolibri-common/utils/syncTaskUtils';
+ import { taskDisplayStatus, TaskStatuses, TaskTypes } from 'kolibri-common/utils/syncTaskUtils';
export default {
name: 'FacilityTaskPanelDetails',
@@ -186,17 +186,20 @@
},
},
computed: {
+ displayStatus() {
+ return taskDisplayStatus(this.task);
+ },
taskIsCompleted() {
- return this.task.status === TaskStatuses.COMPLETED;
+ return this.displayStatus === TaskStatuses.COMPLETED;
},
taskIsCanceling() {
- return this.task.status === TaskStatuses.CANCELING;
+ return this.displayStatus === TaskStatuses.CANCELING;
},
taskIsCanceled() {
- return this.task.status === TaskStatuses.CANCELED;
+ return this.displayStatus === TaskStatuses.CANCELED;
},
taskIsFailed() {
- return this.task.status === TaskStatuses.FAILED || this.taskIsCanceled;
+ return this.displayStatus === TaskStatuses.FAILED || this.taskIsCanceled;
},
taskPercentage() {
return this.task.percentage;
@@ -220,7 +223,7 @@
},
statusHidesLoader() {
return (
- this.task.status === TaskStatuses.PENDING ||
+ this.displayStatus === TaskStatuses.PENDING ||
this.taskIsCompleted ||
this.taskIsCanceling ||
this.taskIsFailed
diff --git a/packages/kolibri-common/uiText/tasks.js b/packages/kolibri-common/uiText/tasks.js
index fb3830d0b69..89a9b00871f 100644
--- a/packages/kolibri-common/uiText/tasks.js
+++ b/packages/kolibri-common/uiText/tasks.js
@@ -1,4 +1,5 @@
import { createTranslator } from 'kolibri/utils/i18n';
+import { now } from 'kolibri/utils/serverClock';
const taskStrings = createTranslator('TaskStrings', {
// Generic Task strings
@@ -22,6 +23,20 @@ const taskStrings = createTranslator('TaskStrings', {
message: 'Failed',
context: 'Generic task status',
},
+ taskFinishedRelativeStatus: {
+ message: 'Finished {relativeTime}',
+ context:
+ 'Status of a task run that has completed, with a relative time such as "2 minutes ago".',
+ },
+ taskFailedRelativeStatus: {
+ message: 'Failed {relativeTime}',
+ context: 'Status of a task run that has failed, with a relative time such as "5 minutes ago".',
+ },
+ taskCanceledRelativeStatus: {
+ message: 'Canceled {relativeTime}',
+ context:
+ 'Status of a task run that was canceled, with a relative time such as "5 minutes ago".',
+ },
taskUnknownStatus: {
message: 'Unknown',
context: 'A catch-all status for unknown task statuses',
@@ -84,6 +99,16 @@ const taskStrings = createTranslator('TaskStrings', {
message: '{bytesSent} sent • {bytesReceived} received',
context: 'Amounts of data transferred in sync task',
},
+ syncNextRunLabel: {
+ message: 'next sync {relativeTime}',
+ context:
+ 'When a repeating sync will next run. Shown after the status of its last run, e.g. "Finished 2 minutes ago, next sync in 1 day".',
+ },
+ syncNextRetryLabel: {
+ message: 'retrying {relativeTime}',
+ context:
+ 'When a failed sync will be retried. Shown after the status of its last run, e.g. "Failed 5 minutes ago, retrying in 1 hour".',
+ },
// Remove Facility Task strings
removingFacilityStatus: {
@@ -120,6 +145,12 @@ export function getTaskString(...args) {
return taskStrings.$tr(...args);
}
+// 'best fit' would render a day out as "tomorrow", which at 23:00 is an hour
+// off — ambiguous for a schedule.
+export function getRelativeTaskTime(datetime) {
+ return taskStrings.$formatRelative(datetime, { style: 'numeric', now: now() });
+}
+
export default {
methods: {
getTaskString,
diff --git a/packages/kolibri-common/utils/__tests__/syncSchedule.js b/packages/kolibri-common/utils/__tests__/syncSchedule.js
new file mode 100644
index 00000000000..919010a2c0e
--- /dev/null
+++ b/packages/kolibri-common/utils/__tests__/syncSchedule.js
@@ -0,0 +1,42 @@
+import { SyncTaskStatuses, TaskStatuses, TaskTypes } from '../syncTaskUtils';
+
+export const FACILITY_ID = 'fac1234567890';
+export const FACILITY_NAME = 'Test Facility';
+
+// Times are offset from the real clock rather than a frozen one, since
+// IntlRelativeFormat binds Date.now at module load and so cannot be reached by
+// fake timers.
+export function syncSchedule({
+ status = TaskStatuses.QUEUED,
+ lastFinishedStatus = null,
+ minutesAgo = 2,
+ hoursUntilNext = 24,
+ syncState = SyncTaskStatuses.COMPLETED,
+ retryInterval = null,
+} = {}) {
+ const now = Date.now();
+ return {
+ id: 'task1',
+ type: TaskTypes.SYNCDATAPORTAL,
+ status,
+ facility_id: FACILITY_ID,
+ repeat: null,
+ repeat_interval: 86400,
+ retry_interval: retryInterval,
+ percentage: 0,
+ clearable: false,
+ cancellable: true,
+ last_finished_status: lastFinishedStatus,
+ last_finished_datetime: lastFinishedStatus
+ ? new Date(now - minutesAgo * 60000).toISOString()
+ : null,
+ scheduled_datetime: new Date(now + hoursUntilNext * 3600000).toISOString(),
+ extra_metadata: {
+ facility_name: FACILITY_NAME,
+ started_by_username: 'devowner',
+ sync_state: syncState,
+ bytes_sent: 1000000,
+ bytes_received: 500000000,
+ },
+ };
+}
diff --git a/packages/kolibri-common/utils/__tests__/syncTaskUtils.spec.js b/packages/kolibri-common/utils/__tests__/syncTaskUtils.spec.js
index 88d70147805..e5ce10623cf 100644
--- a/packages/kolibri-common/utils/__tests__/syncTaskUtils.spec.js
+++ b/packages/kolibri-common/utils/__tests__/syncTaskUtils.spec.js
@@ -3,10 +3,14 @@ import {
syncStatusToDescriptionMap,
removeStatusToDescriptionMap,
removeFacilityTaskDisplayInfo,
+ getLastRunStatusMsg,
+ taskDisplayStatus,
+ taskIsFinished,
SyncTaskStatuses,
TaskStatuses,
TaskTypes,
} from '../syncTaskUtils';
+import { syncSchedule } from './syncSchedule';
const CLEARABLE_STATUSES = ['COMPLETED', 'CANCELED', 'FAILED'];
@@ -97,6 +101,16 @@ describe('syncTaskUtils.syncFacilityTaskDisplayInfo', () => {
});
});
+ it('statusMsg is correct when a canceled task still carries its terminal sync_state', () => {
+ // A cancelled sync writes sync_state=CANCELLED before the job is marked
+ // CANCELED, so both are present on the row the user sees.
+ const task = makeTask('CANCELED');
+ task.extra_metadata.sync_state = SyncTaskStatuses.CANCELLED;
+ expect(syncFacilityTaskDisplayInfo(task)).toMatchObject({
+ statusMsg: 'Canceled',
+ });
+ });
+
const orderedStatusesMsgTests = [
['SESSION_CREATION', '1 of 7: Establishing connection'],
['REMOTE_QUEUING', '2 of 7: Remotely preparing data'],
@@ -159,6 +173,140 @@ describe('syncTaskUtils.syncFacilityTaskDisplayInfo', () => {
);
});
+describe('syncTaskUtils re-scheduled recurring rows', () => {
+ const completedTask = () =>
+ syncSchedule({
+ lastFinishedStatus: TaskStatuses.COMPLETED,
+ minutesAgo: 2,
+ });
+
+ // A run that fails leaves sync_state on whichever step it got to, since
+ // nothing writes a terminal state on the failure path.
+ const failedTask = () =>
+ syncSchedule({
+ lastFinishedStatus: TaskStatuses.FAILED,
+ minutesAgo: 5,
+ hoursUntilNext: 1,
+ syncState: SyncTaskStatuses.PUSHING,
+ retryInterval: 3600,
+ });
+
+ const runningAgainTask = () =>
+ syncSchedule({
+ status: TaskStatuses.RUNNING,
+ lastFinishedStatus: TaskStatuses.COMPLETED,
+ syncState: SyncTaskStatuses.PUSHING,
+ });
+
+ const neverRunTask = () => syncSchedule({ syncState: SyncTaskStatuses.PENDING });
+
+ it('shows the finished run and when the next one is due', () => {
+ expect(syncFacilityTaskDisplayInfo(completedTask())).toMatchObject({
+ statusMsg: 'Finished 2 minutes ago, next sync in 1 day',
+ bytesTransferredMsg: '1 MB sent • 500 MB received',
+ deviceNameMsg: 'Kolibri Data Portal',
+ isRunning: false,
+ canClear: false,
+ canCancel: false,
+ });
+ });
+
+ it('shows a failed run and when it will be retried', () => {
+ expect(syncFacilityTaskDisplayInfo(failedTask())).toMatchObject({
+ statusMsg: 'Failed 5 minutes ago, retrying in 1 hour',
+ bytesTransferredMsg: '',
+ canClear: false,
+ canCancel: false,
+ canRetry: false,
+ });
+ });
+
+ it('calls the next run a sync, not a retry, when the schedule has no retry interval', () => {
+ // A schedule created with the retry checkbox off re-schedules a failed run
+ // onto its ordinary interval, so there is no retry to announce.
+ const task = failedTask();
+ task.retry_interval = null;
+ task.scheduled_datetime = new Date(Date.now() + 24 * 3600000).toISOString();
+ expect(syncFacilityTaskDisplayInfo(task)).toMatchObject({
+ statusMsg: 'Failed 5 minutes ago, next sync in 1 day',
+ });
+ });
+
+ it.each([
+ [TaskStatuses.RUNNING, true],
+ [TaskStatuses.CANCELING, false],
+ ])('shows the live run, not the snapshot, when %s', (status, isRunning) => {
+ const task = { ...runningAgainTask(), status };
+ expect(taskDisplayStatus(task)).toEqual(status);
+ expect(syncFacilityTaskDisplayInfo(task)).toMatchObject({
+ statusMsg: '6 of 7: Sending data',
+ bytesTransferredMsg: '',
+ isRunning,
+ });
+ });
+
+ it('treats a carried-over terminal sync_state as not-yet-started', () => {
+ const task = runningAgainTask();
+ task.extra_metadata.sync_state = SyncTaskStatuses.COMPLETED;
+ expect(syncFacilityTaskDisplayInfo(task)).toMatchObject({
+ statusMsg: 'Waiting',
+ bytesTransferredMsg: '',
+ });
+ });
+
+ it('shows the run that just ended, not the snapshot, before the re-schedule lands', () => {
+ // Between a run finishing and the re-schedule that requeues the row, the
+ // row's own state describes the newer run and the snapshot the one before.
+ const task = syncSchedule({
+ status: TaskStatuses.COMPLETED,
+ lastFinishedStatus: TaskStatuses.FAILED,
+ minutesAgo: 60,
+ });
+ expect(taskDisplayStatus(task)).toEqual(TaskStatuses.COMPLETED);
+ expect(syncFacilityTaskDisplayInfo(task)).toMatchObject({
+ statusMsg: 'Finished',
+ bytesTransferredMsg: '1 MB sent • 500 MB received',
+ });
+ });
+
+ it('announces the run a manual sync asked for, not the one before it', () => {
+ // Pressing Sync re-queues the schedule to run now, so its scheduled time is
+ // already past while it waits for the runner to pick it up.
+ const task = syncSchedule({
+ lastFinishedStatus: TaskStatuses.COMPLETED,
+ minutesAgo: 4,
+ hoursUntilNext: -1 / 60,
+ });
+ expect(taskDisplayStatus(task)).toEqual(TaskStatuses.QUEUED);
+ expect(syncFacilityTaskDisplayInfo(task)).toMatchObject({
+ statusMsg: 'Waiting',
+ bytesTransferredMsg: '',
+ isRunning: false,
+ canClear: false,
+ canCancel: false,
+ canRetry: false,
+ });
+ });
+
+ it('taskDisplayStatus is the last run only when the row is not running', () => {
+ expect(taskDisplayStatus(completedTask())).toEqual(TaskStatuses.COMPLETED);
+ expect(taskDisplayStatus(runningAgainTask())).toEqual(TaskStatuses.RUNNING);
+ expect(taskDisplayStatus(neverRunTask())).toEqual(TaskStatuses.QUEUED);
+ });
+
+ it('taskIsFinished follows the displayed status', () => {
+ expect(taskIsFinished(completedTask())).toBe(true);
+ expect(taskIsFinished(failedTask())).toBe(true);
+ expect(taskIsFinished(runningAgainTask())).toBe(false);
+ expect(taskIsFinished(neverRunTask())).toBe(false);
+ });
+
+ it('getLastRunStatusMsg describes the last run, and is null when there has been none', () => {
+ expect(getLastRunStatusMsg(completedTask())).toEqual('Finished 2 minutes ago');
+ expect(getLastRunStatusMsg(neverRunTask())).toBeNull();
+ });
+});
+
describe('syncTaskUtils.removeFacilityTaskDisplayInfo', () => {
function makeTask(status) {
return {
diff --git a/packages/kolibri-common/utils/syncTaskUtils.js b/packages/kolibri-common/utils/syncTaskUtils.js
index cf07b57327b..662e0aef539 100644
--- a/packages/kolibri-common/utils/syncTaskUtils.js
+++ b/packages/kolibri-common/utils/syncTaskUtils.js
@@ -1,6 +1,8 @@
import commonCoreStrings from 'kolibri/uiText/commonCoreStrings';
-import { getTaskString } from 'kolibri-common/uiText/tasks';
+import { getRelativeTaskTime, getTaskString } from 'kolibri-common/uiText/tasks';
import bytesForHumans from 'kolibri/uiText/bytesForHumans';
+import { formatList } from 'kolibri/utils/i18n';
+import { now } from 'kolibri/utils/serverClock';
export const TaskTypes = {
REMOTECHANNELIMPORT: 'kolibri.core.content.tasks.remotechannelimport',
@@ -103,6 +105,72 @@ function formatNameWithId(name, id) {
const PUSHPULLSTEPS = 7;
const PULLSTEPS = 4;
+const FINISHED_STATUSES = [TaskStatuses.COMPLETED, TaskStatuses.FAILED, TaskStatuses.CANCELED];
+
+// Note the double-L: SyncTaskStatuses spells this differently to TaskStatuses.
+const FINISHED_SYNC_STATES = [
+ SyncTaskStatuses.COMPLETED,
+ SyncTaskStatuses.FAILED,
+ SyncTaskStatuses.CANCELLED,
+];
+
+const LIVE_STATUSES = [TaskStatuses.RUNNING, TaskStatuses.CANCELING];
+
+function describesOwnRun(task) {
+ // Pressing Sync brings a schedule forward to run now rather than making a
+ // second row, so a queued row whose time has passed is waiting to run.
+ return (
+ LIVE_STATUSES.includes(task.status) ||
+ FINISHED_STATUSES.includes(task.status) ||
+ new Date(task.scheduled_datetime) <= now()
+ );
+}
+
+// A re-queued row's own status describes the next run, not the one the user
+// watched.
+function showsLastRun(task) {
+ return Boolean(task.last_finished_datetime) && !describesOwnRun(task);
+}
+
+export function taskDisplayStatus(task) {
+ return showsLastRun(task) ? task.last_finished_status : task.status;
+}
+
+export function taskIsFinished(task) {
+ return FINISHED_STATUSES.includes(taskDisplayStatus(task));
+}
+
+// A repeating row never settles in a finished state of its own, so a moved
+// snapshot is the only mark that the run being watched has ended.
+export function runEndedSince(task, lastFinishedDatetime) {
+ return task.last_finished_datetime !== lastFinishedDatetime;
+}
+
+const relativeStatusToStringMap = {
+ [TaskStatuses.COMPLETED]: 'taskFinishedRelativeStatus',
+ [TaskStatuses.FAILED]: 'taskFailedRelativeStatus',
+ [TaskStatuses.CANCELED]: 'taskCanceledRelativeStatus',
+};
+
+export function getLastRunStatusMsg(task) {
+ const stringId = relativeStatusToStringMap[task.last_finished_status];
+ if (!task.last_finished_datetime || !stringId) {
+ return null;
+ }
+ return getTaskString(stringId, {
+ relativeTime: getRelativeTaskTime(task.last_finished_datetime),
+ });
+}
+
+function nextRunMsg(task) {
+ const relativeTime = getRelativeTaskTime(task.scheduled_datetime);
+ // A failed run only re-schedules onto retry_interval when the schedule has
+ // one; without it the next run is the ordinary recurrence, not a retry.
+ return task.last_finished_status === TaskStatuses.FAILED && task.retry_interval
+ ? getTaskString('syncNextRetryLabel', { relativeTime })
+ : getTaskString('syncNextRunLabel', { relativeTime });
+}
+
// Consolidates logic on how Sync-Facility Tasks should be displayed
export function syncFacilityTaskDisplayInfo(task) {
let statusMsg;
@@ -110,6 +178,9 @@ export function syncFacilityTaskDisplayInfo(task) {
let deviceNameMsg = '';
let headingMsg = '';
+ const isLastRun = showsLastRun(task);
+ const status = taskDisplayStatus(task);
+
const facilityName = formatNameWithId(task.extra_metadata.facility_name, task.facility_id);
if (task.type === TaskTypes.SYNCPEERPULL) {
@@ -126,13 +197,22 @@ export function syncFacilityTaskDisplayInfo(task) {
task.extra_metadata.device_id,
);
}
- const syncStep = syncTaskStatusToStepMap[task.extra_metadata.sync_state];
+ // extra_metadata carries over between runs, so a run that has not reported a
+ // step yet still holds the previous run's sync_state.
+ const startingNewRun = describesOwnRun(task) && !FINISHED_STATUSES.includes(task.status);
+ const syncState =
+ startingNewRun && FINISHED_SYNC_STATES.includes(task.extra_metadata.sync_state)
+ ? SyncTaskStatuses.PENDING
+ : task.extra_metadata.sync_state;
+ const syncStep = syncTaskStatusToStepMap[syncState];
const statusDescription =
- syncStatusToDescriptionMap[task.extra_metadata.sync_state] ||
- syncStatusToDescriptionMap[task.status] ||
+ syncStatusToDescriptionMap[syncState] ||
+ syncStatusToDescriptionMap[status] ||
(() => getTaskString('taskUnknownStatus'));
- if (task.status === TaskStatuses.COMPLETED) {
+ if (isLastRun) {
+ statusMsg = formatList([getLastRunStatusMsg(task), nextRunMsg(task)]);
+ } else if (status === TaskStatuses.COMPLETED) {
statusMsg = getTaskString('taskFinishedStatus');
} else if (syncStep) {
statusMsg = getTaskString('syncStepAndDescription', {
@@ -141,20 +221,18 @@ export function syncFacilityTaskDisplayInfo(task) {
description: statusDescription(),
});
} else {
- if (task.type === TaskTypes.SYNCLOD && task.status === TaskStatuses.FAILED)
+ if (task.type === TaskTypes.SYNCLOD && status === TaskStatuses.FAILED)
statusMsg = `${statusDescription()}: ${task.exception}`;
else statusMsg = statusDescription();
}
- if (task.status === TaskStatuses.COMPLETED) {
+ if (status === TaskStatuses.COMPLETED) {
bytesTransferredMsg = getTaskString('syncBytesSentAndReceived', {
bytesReceived: bytesForHumans(task.extra_metadata.bytes_received),
bytesSent: bytesForHumans(task.extra_metadata.bytes_sent),
});
}
- const canClear = task.clearable;
-
return {
headingMsg,
statusMsg,
@@ -163,10 +241,13 @@ export function syncFacilityTaskDisplayInfo(task) {
}),
bytesTransferredMsg,
deviceNameMsg,
- isRunning: Boolean(syncStep) && !canClear,
- canClear,
- canCancel: !canClear && task.cancellable,
- canRetry: task.status === TaskStatuses.FAILED,
+ isRunning: task.status === TaskStatuses.RUNNING && Boolean(syncStep),
+ canClear: task.clearable,
+ // Only a live run can be cancelled: cancelling a queued job clears it,
+ // which for a repeating row would delete the schedule.
+ canCancel: LIVE_STATUSES.includes(task.status) && task.cancellable,
+ // A re-queued schedule retries itself, so it offers no retry either.
+ canRetry: !isLastRun && status === TaskStatuses.FAILED,
};
}
|