diff --git a/core/frontend/src/components/kraken/cards/InstalledExtensionCard.vue b/core/frontend/src/components/kraken/cards/InstalledExtensionCard.vue index b45290b775..55d42dd4ce 100644 --- a/core/frontend/src/components/kraken/cards/InstalledExtensionCard.vue +++ b/core/frontend/src/components/kraken/cards/InstalledExtensionCard.vue @@ -242,7 +242,7 @@ import SpinningLogo from '@/components/common/SpinningLogo.vue' import settings from '@/libs/settings' import system_information from '@/store/system-information' import { JSONValue } from '@/types/common' -import { ExtensionData, InstalledExtensionData } from '@/types/kraken' +import { ExtensionData, InstalledExtensionData, RunningContainer } from '@/types/kraken' import { Disk } from '@/types/system-information/system' import { prettifySize } from '@/utils/helper_functions' @@ -264,9 +264,9 @@ export default Vue.extend({ required: true, }, container: { - type: Object as PropType<{status: string}>, + type: Object as PropType, required: false, - default: undefined as {status: string} | undefined, + default: undefined as RunningContainer | undefined, }, extensionData: { type: Object as PropType, @@ -381,7 +381,29 @@ export default Vue.extend({ return 100 }, getStatus(): string { - return this.container?.status ?? 'N/A' + if (!this.container) return 'N/A' + if (this.container.uptime_seconds == null) return this.container.status + + const suffix_start = this.container.status.indexOf(' (') + const suffix = suffix_start >= 0 ? this.container.status.slice(suffix_start) : '' + return `Up ${this.humanDuration(this.container.uptime_seconds)}${suffix}` + }, + humanDuration(duration_seconds: number): string { + const seconds = Math.floor(duration_seconds) + if (seconds < 1) return 'Less than a second' + if (seconds === 1) return '1 second' + if (seconds < 60) return `${seconds} seconds` + + const minutes = Math.floor(duration_seconds / 60) + const hours = Math.floor(duration_seconds / 60 / 60 + 0.5) + if (minutes === 1) return 'About a minute' + if (minutes < 60) return `${minutes} minutes` + if (hours === 1) return 'About an hour' + if (hours < 48) return `${hours} hours` + if (hours < 24 * 7 * 2) return `${Math.floor(hours / 24)} days` + if (hours < 24 * 30 * 2) return `${Math.floor(hours / 24 / 7)} weeks` + if (hours < 24 * 365 * 2) return `${Math.floor(hours / 24 / 30)} months` + return `${Math.floor(hours / 24 / 365)} years` }, }, }) diff --git a/core/frontend/src/types/kraken.ts b/core/frontend/src/types/kraken.ts index 239da35691..5be5c62a65 100644 --- a/core/frontend/src/types/kraken.ts +++ b/core/frontend/src/types/kraken.ts @@ -80,6 +80,7 @@ export interface RunningContainer { image: string imageId: string status: string + uptime_seconds?: number | null } export interface ManifestSource { diff --git a/core/services/kraken/harbor/container.py b/core/services/kraken/harbor/container.py index 4dab1d8253..af9ca28c7c 100644 --- a/core/services/kraken/harbor/container.py +++ b/core/services/kraken/harbor/container.py @@ -1,5 +1,6 @@ import asyncio -from typing import AsyncGenerator, Dict, List +import time +from typing import Any, AsyncGenerator, Dict, List import psutil from aiodocker import Docker @@ -13,6 +14,28 @@ class ContainerManager: + @staticmethod + def _monotonic_uptime(pid: int) -> float | None: + if pid <= 0: + return None + + try: + process_start_since_boot = psutil.Process(pid).create_time() - psutil.boot_time() + return float(max(0.0, time.monotonic() - process_start_since_boot)) + except psutil.Error: + return None + + @classmethod + def _container_model(cls, container: DockerContainer, details: Dict[str, Any]) -> ContainerModel: + pid = details.get("State", {}).get("Pid", 0) + return ContainerModel( + name=container["Names"][0], + image=container["Image"], + image_id=container["ImageID"], + status=container["Status"], + uptime_seconds=cls._monotonic_uptime(pid), + ) + @staticmethod async def get_raw_container_by_name(client: Docker, container_name: str) -> DockerContainer: containers = await client.containers.list(filters={"name": {container_name: True}}) # type: ignore @@ -31,7 +54,9 @@ async def kill_all_by_name(client: Docker, container_name: str) -> None: @staticmethod # pylint: disable=too-many-locals - async def _get_stats_from_containers(containers: List[DockerContainer]) -> Dict[str, ContainerUsageModel]: + async def _get_stats_from_containers( + containers: List[DockerContainer], + ) -> Dict[str, ContainerUsageModel]: result: Dict[str, ContainerUsageModel] = {} # Create separate lists of coroutine objects for stats and show @@ -84,32 +109,21 @@ async def _get_stats_from_containers(containers: List[DockerContainer]) -> Dict[ return result - @staticmethod - async def get_running_containers() -> List[ContainerModel]: + @classmethod + async def get_running_containers(cls) -> List[ContainerModel]: async with DockerCtx() as client: containers = await client.containers.list(filters={"status": ["running"]}) # type: ignore + details = await asyncio.gather(*(container.show() for container in containers)) - return [ - ContainerModel( - name=container["Names"][0], - image=container["Image"], - image_id=container["ImageID"], - status=container["Status"], - ) - for container in containers - ] + return [cls._container_model(container, detail) for container, detail in zip(containers, details)] @classmethod async def get_running_container_by_name(cls, container_name: str) -> ContainerModel: async with DockerCtx() as client: container = await cls.get_raw_container_by_name(client, container_name) + details = await container.show() - return ContainerModel( - name=container["Names"][0], - image=container["Image"], - image_id=container["ImageID"], - status=container["Status"], - ) + return cls._container_model(container, details) @classmethod async def get_container_log_by_name(cls, container_name: str) -> AsyncGenerator[str, None]: diff --git a/core/services/kraken/harbor/models.py b/core/services/kraken/harbor/models.py index f9520e6278..1d3cc2c94f 100644 --- a/core/services/kraken/harbor/models.py +++ b/core/services/kraken/harbor/models.py @@ -6,6 +6,7 @@ class ContainerModel(BaseModel): image: str image_id: str status: str + uptime_seconds: float | None = None class ContainerUsageModel(BaseModel): diff --git a/core/services/kraken/harbor/test_container.py b/core/services/kraken/harbor/test_container.py new file mode 100644 index 0000000000..bcc46ba392 --- /dev/null +++ b/core/services/kraken/harbor/test_container.py @@ -0,0 +1,50 @@ +import time + +import psutil +import pytest +from harbor.container import ContainerManager + + +def test_uptime_uses_process_start_relative_to_boot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeProcess: + @staticmethod + def create_time() -> float: + return 4_600.0 + + monkeypatch.setattr(psutil, "Process", lambda _pid: FakeProcess()) + monkeypatch.setattr(psutil, "boot_time", lambda: 1_000.0) + monkeypatch.setattr(time, "monotonic", lambda: 10_800.0) + + uptime = ContainerManager._monotonic_uptime(42) + + assert uptime == 7_200.0 + + +def test_uptime_is_unavailable_when_process_is_gone( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def missing_process(pid: int) -> None: + raise psutil.NoSuchProcess(pid) + + monkeypatch.setattr(psutil, "Process", missing_process) + + assert ContainerManager._monotonic_uptime(42) is None + + +def test_container_model_keeps_status_and_exposes_uptime( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(ContainerManager, "_monotonic_uptime", lambda _pid: 7_200.0) + container = { + "Names": ["/example"], + "Image": "example/image:latest", + "ImageID": "sha256:123", + "Status": "Up 17 hours (healthy)", + } + + model = ContainerManager._container_model(container, {"State": {"Pid": 42}}) # type: ignore[arg-type] + + assert model.status == "Up 17 hours (healthy)" + assert model.uptime_seconds == 7_200.0