Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -264,9 +264,9 @@ export default Vue.extend({
required: true,
},
container: {
type: Object as PropType<{status: string}>,
type: Object as PropType<RunningContainer>,
required: false,
default: undefined as {status: string} | undefined,
default: undefined as RunningContainer | undefined,
},
extensionData: {
type: Object as PropType<ExtensionData>,
Expand Down Expand Up @@ -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`
},
},
})
Expand Down
1 change: 1 addition & 0 deletions core/frontend/src/types/kraken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface RunningContainer {
image: string
imageId: string
status: string
uptime_seconds?: number | null
}

export interface ManifestSource {
Expand Down
52 changes: 33 additions & 19 deletions core/services/kraken/harbor/container.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
1 change: 1 addition & 0 deletions core/services/kraken/harbor/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class ContainerModel(BaseModel):
image: str
image_id: str
status: str
uptime_seconds: float | None = None


class ContainerUsageModel(BaseModel):
Expand Down
50 changes: 50 additions & 0 deletions core/services/kraken/harbor/test_container.py
Original file line number Diff line number Diff line change
@@ -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