Skip to content

Commit

Permalink
Add docker panel on project summary (#9)
Browse files Browse the repository at this point in the history
* Add docker container status card on summary page

* Patch composer command

* Add CircleCI version checks
  • Loading branch information
jeckel authored Nov 9, 2024
1 parent 3dab87b commit 13e6395
Show file tree
Hide file tree
Showing 15 changed files with 223 additions and 102 deletions.
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 1 addition & 6 deletions src/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
1 change: 1 addition & 0 deletions src/presentation/composer/composer_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
48 changes: 22 additions & 26 deletions src/presentation/summary/composer_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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()
Expand Down
54 changes: 54 additions & 0 deletions src/presentation/summary/docker_card.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions src/presentation/summary/summary_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from .composer_card import ComposerCard
from service_locator import ServiceLocator
from .docker_card import DockerCard
from .system_card import SystemCard


Expand Down Expand Up @@ -31,6 +32,7 @@ def compose(self):
)
yield ComposerCard()
yield SystemCard()
yield DockerCard()

def refresh_composer(self):
self.query_one(ComposerCard).on_mount()
4 changes: 1 addition & 3 deletions src/presentation/summary/summary_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 6 additions & 7 deletions src/presentation/summary/system_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/service_locator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion src/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .docker_client import DockerClient
from .composer_client import ComposerClient
from .system_status import SystemStatus
from .system_status import SystemStatus
2 changes: 1 addition & 1 deletion src/services/composer_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 54 additions & 7 deletions src/services/docker_client.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 13e6395

Please sign in to comment.