diff --git a/supervisor/api/jobs.py b/supervisor/api/jobs.py index dc9365541..0280250e4 100644 --- a/supervisor/api/jobs.py +++ b/supervisor/api/jobs.py @@ -31,9 +31,16 @@ class APIJobs(CoreSysAttributes): raise APINotFound("Job does not exist") from None def _list_jobs(self, start: SupervisorJob | None = None) -> list[dict[str, Any]]: - """Return current job tree.""" + """Return current job tree. + + Jobs are added to cache as they are created so by default they are in oldest to newest. + This is correct ordering for child jobs as it makes logical sense to present those in + the order they occurred within the parent. For the list as a whole, sort from newest + to oldest as its likely any client is most interested in the newer ones. + """ + # Initially sort oldest to newest so all child lists end up in correct order jobs_by_parent: dict[str | None, list[SupervisorJob]] = {} - for job in self.sys_jobs.jobs: + for job in sorted(self.sys_jobs.jobs): if job.internal: continue @@ -42,11 +49,15 @@ class APIJobs(CoreSysAttributes): else: jobs_by_parent[job.parent_id].append(job) + # After parent-child organization, sort the root jobs only from newest to oldest job_list: list[dict[str, Any]] = [] queue: list[tuple[list[dict[str, Any]], SupervisorJob]] = ( [(job_list, start)] if start - else [(job_list, job) for job in jobs_by_parent.get(None, [])] + else [ + (job_list, job) + for job in sorted(jobs_by_parent.get(None, []), reverse=True) + ] ) while queue: diff --git a/supervisor/jobs/__init__.py b/supervisor/jobs/__init__.py index 432a4bd6f..01bad3615 100644 --- a/supervisor/jobs/__init__.py +++ b/supervisor/jobs/__init__.py @@ -19,6 +19,7 @@ from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import HassioError, JobNotFound, JobStartException from ..homeassistant.const import WSEvent from ..utils.common import FileConfiguration +from ..utils.dt import utcnow from ..utils.sentry import capture_exception from .const import ATTR_IGNORE_CONDITIONS, FILE_CONFIG_JOBS, JobCondition from .validate import SCHEMA_JOBS_CONFIG @@ -79,10 +80,12 @@ class SupervisorJobError: return {"type": self.type_.__name__, "message": self.message} -@define +@define(order=True) class SupervisorJob: """Representation of a job running in supervisor.""" + created: datetime = field(init=False, factory=utcnow, on_setattr=frozen) + uuid: UUID = field(init=False, factory=lambda: uuid4().hex, on_setattr=frozen) name: str | None = field(default=None, validator=[_invalid_if_started]) reference: str | None = field(default=None, on_setattr=_on_change) progress: float = field( @@ -94,7 +97,6 @@ class SupervisorJob: stage: str | None = field( default=None, validator=[_invalid_if_done], on_setattr=_on_change ) - uuid: UUID = field(init=False, factory=lambda: uuid4().hex, on_setattr=frozen) parent_id: UUID | None = field( factory=lambda: _CURRENT_JOB.get(None), on_setattr=frozen ) @@ -119,6 +121,7 @@ class SupervisorJob: "done": self.done, "parent_id": self.parent_id, "errors": [err.as_dict() for err in self.errors], + "created": self.created.isoformat(), } def capture_error(self, err: HassioError | None = None) -> None: diff --git a/tests/api/test_jobs.py b/tests/api/test_jobs.py index 00e798e22..55b5565e9 100644 --- a/tests/api/test_jobs.py +++ b/tests/api/test_jobs.py @@ -102,6 +102,18 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys result = await resp.json() assert result["data"]["jobs"] == [ { + "created": ANY, + "name": "test_jobs_tree_alt", + "reference": None, + "uuid": ANY, + "progress": 0, + "stage": "init", + "done": False, + "child_jobs": [], + "errors": [], + }, + { + "created": ANY, "name": "test_jobs_tree_outer", "reference": None, "uuid": ANY, @@ -111,6 +123,7 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys "errors": [], "child_jobs": [ { + "created": ANY, "name": "test_jobs_tree_inner", "reference": None, "uuid": ANY, @@ -122,16 +135,6 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys }, ], }, - { - "name": "test_jobs_tree_alt", - "reference": None, - "uuid": ANY, - "progress": 0, - "stage": "init", - "done": False, - "child_jobs": [], - "errors": [], - }, ] test.event.set() @@ -141,6 +144,7 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys result = await resp.json() assert result["data"]["jobs"] == [ { + "created": ANY, "name": "test_jobs_tree_alt", "reference": None, "uuid": ANY, @@ -182,6 +186,7 @@ async def test_job_manual_cleanup(api_client: TestClient, coresys: CoreSys): assert resp.status == 200 result = await resp.json() assert result["data"] == { + "created": ANY, "name": "test_job_manual_cleanup", "reference": None, "uuid": test.job_id, @@ -229,3 +234,86 @@ async def test_job_not_found(api_client: TestClient, method: str, url: str): assert resp.status == 404 body = await resp.json() assert body["message"] == "Job does not exist" + + +async def test_jobs_sorted(api_client: TestClient, coresys: CoreSys): + """Test jobs are sorted by datetime in results.""" + + class TestClass: + """Test class.""" + + def __init__(self, coresys: CoreSys): + """Initialize the test class.""" + self.coresys = coresys + + @Job(name="test_jobs_sorted_1", cleanup=False) + async def test_jobs_sorted_1(self): + """Sorted test method 1.""" + await self.test_jobs_sorted_inner_1() + await self.test_jobs_sorted_inner_2() + + @Job(name="test_jobs_sorted_inner_1", cleanup=False) + async def test_jobs_sorted_inner_1(self): + """Sorted test inner method 1.""" + + @Job(name="test_jobs_sorted_inner_2", cleanup=False) + async def test_jobs_sorted_inner_2(self): + """Sorted test inner method 2.""" + + @Job(name="test_jobs_sorted_2", cleanup=False) + async def test_jobs_sorted_2(self): + """Sorted test method 2.""" + + test = TestClass(coresys) + await test.test_jobs_sorted_1() + await test.test_jobs_sorted_2() + + resp = await api_client.get("/jobs/info") + result = await resp.json() + assert result["data"]["jobs"] == [ + { + "created": ANY, + "name": "test_jobs_sorted_2", + "reference": None, + "uuid": ANY, + "progress": 0, + "stage": None, + "done": True, + "errors": [], + "child_jobs": [], + }, + { + "created": ANY, + "name": "test_jobs_sorted_1", + "reference": None, + "uuid": ANY, + "progress": 0, + "stage": None, + "done": True, + "errors": [], + "child_jobs": [ + { + "created": ANY, + "name": "test_jobs_sorted_inner_1", + "reference": None, + "uuid": ANY, + "progress": 0, + "stage": None, + "done": True, + "errors": [], + "child_jobs": [], + }, + { + "created": ANY, + "name": "test_jobs_sorted_inner_2", + "reference": None, + "uuid": ANY, + "progress": 0, + "stage": None, + "done": True, + "errors": [], + "child_jobs": [], + }, + ], + }, + ] diff --git a/tests/backups/test_manager.py b/tests/backups/test_manager.py index bbf4f4720..dacdb96db 100644 --- a/tests/backups/test_manager.py +++ b/tests/backups/test_manager.py @@ -1066,6 +1066,7 @@ def _make_backup_message_for_assert( "done": done, "parent_id": None, "errors": [], + "created": ANY, }, }, } diff --git a/tests/jobs/test_job_decorator.py b/tests/jobs/test_job_decorator.py index 8d618d948..cb8035dba 100644 --- a/tests/jobs/test_job_decorator.py +++ b/tests/jobs/test_job_decorator.py @@ -997,6 +997,7 @@ async def test_internal_jobs_no_notify(coresys: CoreSys): "done": True, "parent_id": None, "errors": [], + "created": ANY, }, }, } diff --git a/tests/jobs/test_job_manager.py b/tests/jobs/test_job_manager.py index 77c8d0354..831e18443 100644 --- a/tests/jobs/test_job_manager.py +++ b/tests/jobs/test_job_manager.py @@ -104,6 +104,7 @@ async def test_notify_on_change(coresys: CoreSys): "done": None, "parent_id": None, "errors": [], + "created": ANY, }, }, } @@ -125,6 +126,7 @@ async def test_notify_on_change(coresys: CoreSys): "done": None, "parent_id": None, "errors": [], + "created": ANY, }, }, } @@ -146,6 +148,7 @@ async def test_notify_on_change(coresys: CoreSys): "done": None, "parent_id": None, "errors": [], + "created": ANY, }, }, } @@ -167,6 +170,7 @@ async def test_notify_on_change(coresys: CoreSys): "done": False, "parent_id": None, "errors": [], + "created": ANY, }, }, } @@ -193,6 +197,7 @@ async def test_notify_on_change(coresys: CoreSys): "message": "Unknown error, see supervisor logs", } ], + "created": ANY, }, }, } @@ -218,6 +223,7 @@ async def test_notify_on_change(coresys: CoreSys): "message": "Unknown error, see supervisor logs", } ], + "created": ANY, }, }, }