From 33e01ecdae0488f63fd72283d733f787e41a35ee Mon Sep 17 00:00:00 2001 From: Jeckel Date: Wed, 6 Nov 2024 08:28:58 +0100 Subject: [PATCH] Add docker container status card on summary page --- src/models/project.py | 7 +-- .../composer/composer_container.py | 1 + src/presentation/summary/composer_card.py | 34 +++++------- src/presentation/summary/docker_card.py | 51 +++++++++++++++++ src/presentation/summary/summary_container.py | 2 + src/presentation/summary/system_card.py | 8 +-- src/service_locator.py | 2 +- src/services/docker_client.py | 55 ++++++++++++++++--- src/services/system_status.py | 53 ++++++------------ src/tcss/layout.tcss | 13 +++++ 10 files changed, 151 insertions(+), 75 deletions(-) create mode 100644 src/presentation/summary/docker_card.py diff --git a/src/models/project.py b/src/models/project.py index ddbb673..e61282d 100644 --- a/src/models/project.py +++ b/src/models/project.py @@ -22,6 +22,7 @@ class Project(BaseModel): composer_cmd: list[str] = ["composer"] docker_composer_cmd: list[str] = ["docker", "compose"] actions: Optional[dict[str, list[ProjectAction]]] = None + docker_compose_files: list[str] = ["docker-compose.yml"] @classmethod def from_json(cls, json_path: str): @@ -46,9 +47,3 @@ def check_composer_file(self): if os.path.exists(composer_file): self.composer = True return self - - # @cached_property - # def composer_json(self) -> Optional[Composer]: - # if not self.composer: - # return None - # return Composer.from_json(self.path) diff --git a/src/presentation/composer/composer_container.py b/src/presentation/composer/composer_container.py index 4731560..b3032ad 100644 --- a/src/presentation/composer/composer_container.py +++ b/src/presentation/composer/composer_container.py @@ -91,4 +91,5 @@ async def refresh_packages(self, event: Worker.StateChanged) -> None: @on(Button.Pressed, "#composer-refresh-button") def on_refresh_pressed(self): + ServiceLocator.composer_client().reset_updatable_packages() self.action_refresh() diff --git a/src/presentation/summary/composer_card.py b/src/presentation/summary/composer_card.py index edfbdc1..83a0d55 100644 --- a/src/presentation/summary/composer_card.py +++ b/src/presentation/summary/composer_card.py @@ -16,24 +16,16 @@ class ComposerCard(Container): DEFAULT_CSS = """ ComposerCard { - height: auto; width: 45; - border: $primary-background round; - - Button, Button:focus, Button:hover { - height: 1; - border: none; - width: 100%; - margin-top: 1; - } } """ + BORDER_TITLE = "Composer status" - _composer_config: Optional[Composer] = None + _composer: Optional[Composer] = None _packages_updatable: dict[str, str] = {} def __init__(self, **kwargs): - super().__init__(**kwargs) + super().__init__(**kwargs, classes="card") self._project = ServiceLocator.context().current_project self._composer_panel = Static(id="composer_panel") @@ -42,7 +34,7 @@ def compose(self) -> ComposeResult: yield Button("[underline]Manage packages", id="toggle_composer_tab") def on_mount(self) -> None: - self._composer_config = ServiceLocator.composer_client().composer_json( + self._composer = ServiceLocator.composer_client().composer_json( self._project ) self._composer_panel.update(self.get_composer_panel()) @@ -53,8 +45,8 @@ def get_composer_panel(self) -> Table: table = Table( show_header=False, box=None, - title="Composer status", - title_style=Style(color="#bbc8e8", bold=True), + # title="Composer status", + # title_style=Style(color="#bbc8e8", bold=True), ) table.add_column() table.add_column(min_width=25, max_width=27) @@ -63,35 +55,35 @@ def get_composer_panel(self) -> Table: "[green]Enabled" if self._project.composer else "[red]Disabled", ) - if self._project.composer and self._composer_config is not None: + if self._project.composer and self._composer is not None: updatable_packages_keys = self._packages_updatable.keys() updatable_packages = len( - set(updatable_packages_keys) & set(self._packages_updatable.keys()) + set(self._composer.required_packages.keys()) & set(updatable_packages_keys) ) if updatable_packages > 0: table.add_row( "[label]Packages:", - f"{len(self._composer_config.required_packages)} ([orange1]{updatable_packages} updates available[/orange1])", + f"[blue]{len(self._composer.required_packages)}[/blue] ([orange1]{updatable_packages} updates available[/orange1])", ) else: table.add_row( "[label]Packages:", - f"{len(self._composer_config.required_packages)}", + f"[blue]{len(self._composer.required_packages)}", ) updatable_packages_dev = len( - set(updatable_packages_keys) & set(self._packages_updatable.keys()) + set(self._composer.required_packages_dev.keys()) & set(updatable_packages_keys) ) if updatable_packages_dev > 0: table.add_row( "[label]Packages-dev:", - f"{len(self._composer_config.required_packages_dev)} " + f"[blue]{len(self._composer.required_packages_dev)}[/blue] " f"([orange1]{updatable_packages_dev} updates available[/orange1])", ) else: table.add_row( "[label]Packages-dev:", - f"{len(self._composer_config.required_packages_dev)}", + f"[blue]{len(self._composer.required_packages_dev)}", ) return table diff --git a/src/presentation/summary/docker_card.py b/src/presentation/summary/docker_card.py new file mode 100644 index 0000000..e00bc3c --- /dev/null +++ b/src/presentation/summary/docker_card.py @@ -0,0 +1,51 @@ +from rich.table import Table +from textual import on +from textual.app import ComposeResult +from textual.containers import Container +from textual.widgets import Static, Button + +from service_locator import ServiceLocator + + +class DockerCard(Container): + DEFAULT_CSS = """ + DockerCard { + width: 60; + } + """ + BORDER_TITLE = "Docker containers" + + def __init__(self, **kwargs): + super().__init__(**kwargs, classes="card") + self._docker_panel = Static(id="system_panel") + + def compose(self) -> ComposeResult: + yield self._docker_panel + yield Button("[underline]Refresh", id="refresh-docker_panel") + + def on_mount(self) -> None: + table = Table( + show_header=False, + box=None, + ) + table.add_column() + table.add_column() + docker_client = ServiceLocator.docker_client() + for container, status in docker_client.list_container_names().items(): + table.add_row(f"[label]{container}", f"[{self._color_by_status(status)}]{status.capitalize()}") + self._docker_panel.update(table) + + @staticmethod + def _color_by_status(status: str) -> str: + if status == "running" or status == "running (healthy)": + return "green" + elif status == "paused" or status.startswith("running ("): + return "yellow" + elif status.startswith("exited"): + return "red" + else: + return "grey39" + + @on(Button.Pressed, "#refresh-docker_panel") + def refresh_docker_panel(self) -> None: + self.on_mount() \ No newline at end of file diff --git a/src/presentation/summary/summary_container.py b/src/presentation/summary/summary_container.py index 485b754..28c914d 100644 --- a/src/presentation/summary/summary_container.py +++ b/src/presentation/summary/summary_container.py @@ -3,6 +3,7 @@ from .composer_card import ComposerCard from service_locator import ServiceLocator +from .docker_card import DockerCard from .system_card import SystemCard @@ -31,6 +32,7 @@ def compose(self): ) yield ComposerCard() yield SystemCard() + yield DockerCard() def refresh_composer(self): self.query_one(ComposerCard).on_mount() diff --git a/src/presentation/summary/system_card.py b/src/presentation/summary/system_card.py index 41a7a0b..9a6a2ad 100644 --- a/src/presentation/summary/system_card.py +++ b/src/presentation/summary/system_card.py @@ -10,14 +10,13 @@ class SystemCard(Container): DEFAULT_CSS = """ SystemCard { - height: auto; width: 45; - border: $primary-background round; } """ + BORDER_TITLE = "System versions" def __init__(self, **kwargs): - super().__init__(**kwargs) + super().__init__(**kwargs, classes="card") self._system_panel = Static(id="system_panel") def compose(self) -> ComposeResult: @@ -27,8 +26,6 @@ def on_mount(self) -> None: table = Table( show_header=False, box=None, - title="System status", - title_style=Style(color="#bbc8e8", bold=True), ) table.add_column() table.add_column(min_width=25, max_width=27) @@ -39,6 +36,7 @@ def on_mount(self) -> None: self._add_system_row(table, "Castor", system_status.castor_version()) self._add_system_row(table, "Docker", system_status.docker_version()) self._add_system_row(table, "Ansible", system_status.ansible_version()) + self._add_system_row(table, "Git", system_status.git_version()) self._system_panel.update(table) diff --git a/src/service_locator.py b/src/service_locator.py index 441e398..2eba6a4 100644 --- a/src/service_locator.py +++ b/src/service_locator.py @@ -6,7 +6,7 @@ class ServiceLocator(containers.DeclarativeContainer): config = providers.Configuration() - docker_client = providers.Singleton(DockerClient) context = providers.Singleton(AppContext) + docker_client = providers.Singleton(DockerClient, context=context) composer_client = providers.Singleton(ComposerClient, context=context) system_status = providers.Singleton(SystemStatus) diff --git a/src/services/docker_client.py b/src/services/docker_client.py index 64d753e..16d8dd3 100644 --- a/src/services/docker_client.py +++ b/src/services/docker_client.py @@ -1,18 +1,59 @@ +import os +from enum import Enum +from typing import Literal + import docker +import yaml +from models.app_context import AppContext from .base_service import BaseService +class ContainerStatus(Enum): + NA = "n/a" + RUNNING = "Running" + class DockerClient(BaseService): - def __init__(self): - self.client = docker.from_env() + def __init__(self, context: AppContext): + self._client = docker.from_env() + self._context = context def get_running_containers(self) -> list: - """Fetches a list of running containers.""" - return self.client.containers.list() + """ + Fetches a list of running containers. + """ + return self._client.containers.list(all=True) def get_container_logs(self, container_id): - """Fetches logs from a specific container.""" - container = self.client.containers.get(container_id) - # return container.logs(follow=True, tail=1000) + """ + Fetches logs from a specific container. + """ + container = self._client.containers.get(container_id) return container.logs(stream=True, follow=True) + + def list_container_names(self) -> dict[str, str]: + """ + List docker containers names and their statuses. + """ + project = self._context.current_project + basename = os.path.basename(project.path) + container_names = {} + running_containers = self.get_running_containers() + for compose_file in project.docker_compose_files: + file_path = os.path.join(project.path, compose_file) + with open(file_path, 'r') as file: + docker_compose = yaml.safe_load(file) + + services = docker_compose.get("services", {}) + for service_name, service_config in services.items(): + container_name = service_config.get("container_name", f"{basename}-{service_name}") + container_names[container_name] = ContainerStatus.NA.value + + for running_container in running_containers: + if running_container.name.startswith(container_name): + container_names[container_name] = running_container.status + if "Health" in running_container.attrs["State"]: + container_names[container_name] += f" ({running_container.attrs["State"]["Health"]["Status"]})" + break + + return container_names diff --git a/src/services/system_status.py b/src/services/system_status.py index 3c9de23..3e71b07 100644 --- a/src/services/system_status.py +++ b/src/services/system_status.py @@ -3,61 +3,44 @@ from .base_service import BaseService class SystemStatus(BaseService): - def _capture_output(self, command: list[str]) -> str|None: + @staticmethod + def _capture_output(command: list[str]) -> str | None: try: result = subprocess.run(command, capture_output=True, text=True, check=True) return result.stdout except (subprocess.CalledProcessError, FileNotFoundError): return None + @staticmethod + def _capture_version(output: str, line_number: int = 0, position: int = 1) -> str: + version_line = output.splitlines()[line_number] + version = version_line.split()[position] + return version + def php_version(self) -> str|None: output = self._capture_output(['php', '-v']) - if output is None: - return None - version_line = output.splitlines()[0] - version = version_line.split()[1] - return version + return None if output is None else self._capture_version(output) + def composer_version(self) -> str|None: output = self._capture_output(['composer', '--version']) - if output is None: - return None - - version_line = output.splitlines()[0] - version = version_line.split()[2] - return version + return None if output is None else self._capture_version(output, position=2) def castor_version(self) -> str|None: output = self._capture_output(['castor', '--version']) - if output is None: - return None - - version_line = output.splitlines()[0] - version = version_line.split()[1] - return version[1:] + return None if output is None else self._capture_version(output)[1:] def symfony_version(self) -> str|None: output = self._capture_output(['symfony', 'version', '--no-ansi']) - if output is None: - return None - - version_line = output.splitlines()[0] - version = version_line.split()[3] - return version + return None if output is None else self._capture_version(output, position=3) def docker_version(self) -> str|None: output = self._capture_output(['docker', '-v']) - if output is None: - return None - - version_line = output.splitlines()[0] - version = version_line.split()[2] - return version[:-1] + return None if output is None else self._capture_version(output, position=2)[:-1] def ansible_version(self) -> str|None: output = self._capture_output(['ansible', '--version']) - if output is None: - return None + return None if output is None else self._capture_version(output, position=2)[:-1] - version_line = output.splitlines()[0] - version = version_line.split()[2] - return version[:-1] + def git_version(self) -> str|None: + output = self._capture_output(['git', '--version']) + return None if output is None else self._capture_version(output, position=2) diff --git a/src/tcss/layout.tcss b/src/tcss/layout.tcss index ce27a40..70df8e1 100644 --- a/src/tcss/layout.tcss +++ b/src/tcss/layout.tcss @@ -31,3 +31,16 @@ OptionList { .ml-1 { margin-left: 1; } + +.card { + height: auto; + border: $primary-background round; + border-title-color: $accent; + + Button, Button:focus, Button:hover { + height: 1; + border: none; + width: 100%; + margin-top: 1; + } +} \ No newline at end of file