From 13e63957eb9e9142c80cca668cb33e92ffb15acf Mon Sep 17 00:00:00 2001 From: Julien Mercier-Rojas <2981531+jeckel@users.noreply.github.com> Date: Sat, 9 Nov 2024 10:32:58 +0100 Subject: [PATCH] Add docker panel on project summary (#9) * Add docker container status card on summary page * Patch composer command * Add CircleCI version checks --- pyproject.toml | 9 ++ src/models/project.py | 7 +- .../composer/composer_container.py | 1 + src/presentation/summary/composer_card.py | 48 +++++----- src/presentation/summary/docker_card.py | 54 +++++++++++ src/presentation/summary/summary_container.py | 2 + src/presentation/summary/summary_screen.py | 4 +- src/presentation/summary/system_card.py | 13 ++- src/service_locator.py | 2 +- src/services/__init__.py | 2 +- src/services/composer_client.py | 2 +- src/services/docker_client.py | 61 ++++++++++-- src/services/system_status.py | 96 +++++++++---------- src/tcss/layout.tcss | 13 +++ uv.lock | 11 +++ 15 files changed, 223 insertions(+), 102 deletions(-) create mode 100644 src/presentation/summary/docker_card.py diff --git a/pyproject.toml b/pyproject.toml index ab39695..2b1481b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,4 +16,13 @@ dependencies = [ dev-dependencies = [ "pre-commit>=4.0.1", "textual-dev>=1.6.1", + "types-pyyaml>=6.0.12.20240917", ] +[tool.mypy] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = [ + "yaml" +] +ignore_missing_imports = true 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..8b2ac1c 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,9 +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._project - ) + self._composer = ServiceLocator.composer_client().composer_json(self._project) self._composer_panel.update(self.get_composer_panel()) self.query_one(Button).loading = True self._load_composer() @@ -53,8 +43,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,38 +53,44 @@ 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: - updatable_packages_keys = self._packages_updatable.keys() - updatable_packages = len( - set(updatable_packages_keys) & set(self._packages_updatable.keys()) + if self._project.composer and self._composer is not None: + updatable_packages = self._count_updatable_packages( + self._composer.required_packages, self._packages_updatable ) 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] " + f"([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()) + updatable_packages_dev = self._count_updatable_packages( + self._composer.required_packages_dev, self._packages_updatable ) 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 + @staticmethod + def _count_updatable_packages( + packages: dict[str, str], updatable_packages: dict[str, str] + ) -> int: + return len(set(packages.keys()) & set(updatable_packages.keys())) + @work(exclusive=True, thread=True) async def _load_composer(self, no_cache: bool = False) -> dict[str, str]: return ServiceLocator.composer_client().updatable_packages() diff --git a/src/presentation/summary/docker_card.py b/src/presentation/summary/docker_card.py new file mode 100644 index 0000000..470dc9c --- /dev/null +++ b/src/presentation/summary/docker_card.py @@ -0,0 +1,54 @@ +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() 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/summary_screen.py b/src/presentation/summary/summary_screen.py index 4b5dc95..af64f11 100644 --- a/src/presentation/summary/summary_screen.py +++ b/src/presentation/summary/summary_screen.py @@ -25,9 +25,7 @@ def compose(self) -> ComposeResult: with TabPane(title="Summary", id="summary-pan"): yield ProjectSummaryContainer() with TabPane(title="Docker", id="docker-pan"): - yield DockerContainer( - project=ServiceLocator.context().current_project - ) + yield DockerContainer(project=ServiceLocator.context().current_project) yield Footer() @on(ScreenResume) diff --git a/src/presentation/summary/system_card.py b/src/presentation/summary/system_card.py index 41a7a0b..41f9f3e 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,23 +26,23 @@ 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) system_status = ServiceLocator.system_status() self._add_system_row(table, "Php", system_status.php_version()) self._add_system_row(table, "Composer", system_status.composer_version()) - self._add_system_row(table, "Symfony-Cli", system_status.symfony_version()) + self._add_system_row(table, "Symfony cli", system_status.symfony_version()) 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._add_system_row(table, "CircleCI cli", system_status.circleci_version()) self._system_panel.update(table) @staticmethod - def _add_system_row(table: Table, label: str, version: str|None) -> None: + def _add_system_row(table: Table, label: str, version: str | None) -> None: table.add_row( f"[label]{label}:", f"[blue]{version}" if version is not None else "[orange1]N/A", 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/__init__.py b/src/services/__init__.py index 134c976..151cb4e 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -1,3 +1,3 @@ from .docker_client import DockerClient from .composer_client import ComposerClient -from .system_status import SystemStatus \ No newline at end of file +from .system_status import SystemStatus diff --git a/src/services/composer_client.py b/src/services/composer_client.py index 107dad8..88758c9 100644 --- a/src/services/composer_client.py +++ b/src/services/composer_client.py @@ -18,7 +18,7 @@ def updatable_packages(self) -> dict[str, str]: return self._context.composer_updatable_packages with subprocess.Popen( - ["composer", "update", "--dry-run", "--no-ansi"], + ["composer", "update", "--dry-run", "--no-ansi", "-n"], cwd=project.path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/src/services/docker_client.py b/src/services/docker_client.py index 64d753e..38445dd 100644 --- a/src/services/docker_client.py +++ b/src/services/docker_client.py @@ -1,18 +1,65 @@ +import os +from enum import Enum + 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): + status = running_container.status + if "Health" in running_container.attrs["State"]: + health = running_container.attrs["State"]["Health"][ + "Status" + ] + status += f" ({health})" + container_names[container_name] = status + break + + return container_names diff --git a/src/services/system_status.py b/src/services/system_status.py index 3c9de23..0e5fe5c 100644 --- a/src/services/system_status.py +++ b/src/services/system_status.py @@ -2,62 +2,58 @@ 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 - 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 - 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] + @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 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:] - - 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 - - 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] - - def ansible_version(self) -> str|None: - output = self._capture_output(['ansible', '--version']) - if output is None: - return None - - version_line = output.splitlines()[0] - version = version_line.split()[2] - return version[:-1] + def php_version(self) -> str | None: + output = self._capture_output(["php", "-v"]) + return None if output is None else self._capture_version(output) + + def composer_version(self) -> str | None: + output = self._capture_output(["composer", "--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"]) + 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"]) + 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"]) + 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"]) + return ( + None if output is None else self._capture_version(output, position=2)[:-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) + + def circleci_version(self) -> str | None: + output = self._capture_output(["circleci", "version"]) + return ( + None + if output is None + else self._capture_version(output, position=0).split("+")[0] + ) diff --git a/src/tcss/layout.tcss b/src/tcss/layout.tcss index ce27a40..ba1ff91 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; + } +} diff --git a/uv.lock b/uv.lock index 090a005..41fbe72 100644 --- a/uv.lock +++ b/uv.lock @@ -512,6 +512,7 @@ dependencies = [ dev = [ { name = "pre-commit" }, { name = "textual-dev" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -527,6 +528,7 @@ requires-dist = [ dev = [ { name = "pre-commit", specifier = ">=4.0.1" }, { name = "textual-dev", specifier = ">=1.6.1" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20240917" }, ] [[package]] @@ -776,6 +778,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/2b/886d13e742e514f704c33c4caa7df0f3b89e5a25ef8db02aa9ca3d9535d5/typer-0.12.5-py3-none-any.whl", hash = "sha256:62fe4e471711b147e3365034133904df3e235698399bc4de2b36c8579298d52b", size = 47288 }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20240917" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/7d/a95df0a11f95c8f48d7683f03e4aed1a2c0fc73e9de15cca4d38034bea1a/types-PyYAML-6.0.12.20240917.tar.gz", hash = "sha256:d1405a86f9576682234ef83bcb4e6fff7c9305c8b1fbad5e0bcd4f7dbdc9c587", size = 12381 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2c/c1d81d680997d24b0542aa336f0a65bd7835e5224b7670f33a7d617da379/types_PyYAML-6.0.12.20240917-py3-none-any.whl", hash = "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570", size = 15264 }, +] + [[package]] name = "typing-extensions" version = "4.12.2"