diff --git a/supervisor/api/const.py b/supervisor/api/const.py index 8f3f342e9..7fdf4eb1e 100644 --- a/supervisor/api/const.py +++ b/supervisor/api/const.py @@ -33,6 +33,7 @@ ATTR_FALLBACK = "fallback" ATTR_FILESYSTEMS = "filesystems" ATTR_HEARTBEAT_LED = "heartbeat_led" ATTR_IDENTIFIERS = "identifiers" +ATTR_JOBS = "jobs" ATTR_LLMNR = "llmnr" ATTR_LLMNR_HOSTNAME = "llmnr_hostname" ATTR_MDNS = "mdns" diff --git a/supervisor/api/jobs.py b/supervisor/api/jobs.py index 10ed860a8..9aec7166f 100644 --- a/supervisor/api/jobs.py +++ b/supervisor/api/jobs.py @@ -6,7 +6,9 @@ from aiohttp import web import voluptuous as vol from ..coresys import CoreSysAttributes +from ..jobs import SupervisorJob from ..jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition +from .const import ATTR_JOBS from .utils import api_process, api_validate _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -19,11 +21,45 @@ SCHEMA_OPTIONS = vol.Schema( class APIJobs(CoreSysAttributes): """Handle RESTful API for OS functions.""" + def _list_jobs(self) -> list[dict[str, Any]]: + """Return current job tree.""" + jobs_by_parent: dict[str | None, list[SupervisorJob]] = {} + for job in self.sys_jobs.jobs: + if job.internal: + continue + + if job.parent_id not in jobs_by_parent: + jobs_by_parent[job.parent_id] = [job] + else: + jobs_by_parent[job.parent_id].append(job) + + job_list: list[dict[str, Any]] = [] + queue: list[tuple[list[dict[str, Any]], SupervisorJob]] = [ + (job_list, job) for job in jobs_by_parent.get(None, []) + ] + + while queue: + (current_list, current_job) = queue.pop(0) + child_jobs: list[dict[str, Any]] = [] + + # We remove parent_id and instead use that info to represent jobs as a tree + job_dict = current_job.as_dict() | {"child_jobs": child_jobs} + job_dict.pop("parent_id") + current_list.append(job_dict) + + if current_job.uuid in jobs_by_parent: + queue.extend( + [(child_jobs, job) for job in jobs_by_parent.get(current_job.uuid)] + ) + + return job_list + @api_process async def info(self, request: web.Request) -> dict[str, Any]: """Return JobManager information.""" return { ATTR_IGNORE_CONDITIONS: self.sys_jobs.ignore_conditions, + ATTR_JOBS: self._list_jobs(), } @api_process diff --git a/tests/api/test_jobs.py b/tests/api/test_jobs.py index 9c79cf33f..69e79fd23 100644 --- a/tests/api/test_jobs.py +++ b/tests/api/test_jobs.py @@ -1,20 +1,25 @@ """Test Docker API.""" -import pytest +import asyncio +from unittest.mock import ANY + +from aiohttp.test_utils import TestClient + +from supervisor.coresys import CoreSys from supervisor.jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition +from supervisor.jobs.decorator import Job -@pytest.mark.asyncio -async def test_api_jobs_info(api_client): +async def test_api_jobs_info(api_client: TestClient): """Test jobs info api.""" resp = await api_client.get("/jobs/info") result = await resp.json() assert result["data"][ATTR_IGNORE_CONDITIONS] == [] + assert result["data"]["jobs"] == [] -@pytest.mark.asyncio -async def test_api_jobs_options(api_client, coresys): +async def test_api_jobs_options(api_client: TestClient, coresys: CoreSys): """Test jobs options api.""" resp = await api_client.post( "/jobs/options", json={ATTR_IGNORE_CONDITIONS: [JobCondition.HEALTHY]} @@ -29,8 +34,7 @@ async def test_api_jobs_options(api_client, coresys): assert coresys.jobs.save_data.called -@pytest.mark.asyncio -async def test_api_jobs_reset(api_client, coresys): +async def test_api_jobs_reset(api_client: TestClient, coresys: CoreSys): """Test jobs reset api.""" resp = await api_client.post( "/jobs/options", json={ATTR_IGNORE_CONDITIONS: [JobCondition.HEALTHY]} @@ -52,3 +56,93 @@ async def test_api_jobs_reset(api_client, coresys): assert coresys.jobs.ignore_conditions == [] coresys.jobs.save_data.assert_called_once() + + +async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys): + """Test jobs are correctly represented in a tree.""" + + class TestClass: + """Test class.""" + + def __init__(self, coresys: CoreSys): + """Initialize the test class.""" + self.coresys = coresys + self.event = asyncio.Event() + + @Job(name="test_jobs_tree_outer") + async def test_jobs_tree_outer(self): + """Outer test method.""" + coresys.jobs.current.progress = 50 + await self.test_jobs_tree_inner() + + @Job(name="test_jobs_tree_inner") + async def test_jobs_tree_inner(self): + """Inner test method.""" + await self.event.wait() + + @Job(name="test_jobs_tree_alt", cleanup=False) + async def test_jobs_tree_alt(self): + """Alternate test method.""" + coresys.jobs.current.stage = "init" + await self.test_jobs_tree_internal() + coresys.jobs.current.stage = "end" + + @Job(name="test_jobs_tree_internal", internal=True) + async def test_jobs_tree_internal(self): + """Internal test method.""" + await self.event.wait() + + test = TestClass(coresys) + asyncio.create_task(test.test_jobs_tree_outer()) + asyncio.create_task(test.test_jobs_tree_alt()) + await asyncio.sleep(0) + + resp = await api_client.get("/jobs/info") + result = await resp.json() + assert result["data"]["jobs"] == [ + { + "name": "test_jobs_tree_outer", + "reference": None, + "uuid": ANY, + "progress": 50, + "stage": None, + "done": False, + "child_jobs": [ + { + "name": "test_jobs_tree_inner", + "reference": None, + "uuid": ANY, + "progress": 0, + "stage": None, + "done": False, + "child_jobs": [], + }, + ], + }, + { + "name": "test_jobs_tree_alt", + "reference": None, + "uuid": ANY, + "progress": 0, + "stage": "init", + "done": False, + "child_jobs": [], + }, + ] + + test.event.set() + await asyncio.sleep(0) + + resp = await api_client.get("/jobs/info") + result = await resp.json() + assert result["data"]["jobs"] == [ + { + "name": "test_jobs_tree_alt", + "reference": None, + "uuid": ANY, + "progress": 0, + "stage": "end", + "done": True, + "child_jobs": [], + }, + ]