Skip to content

Commit

Permalink
Add docker container status card on summary page
Browse files Browse the repository at this point in the history
  • Loading branch information
jeckel committed Nov 6, 2024
1 parent 3dab87b commit 33e01ec
Show file tree
Hide file tree
Showing 10 changed files with 151 additions and 75 deletions.
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()
34 changes: 13 additions & 21 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,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())
Expand All @@ -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)
Expand All @@ -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

Expand Down
51 changes: 51 additions & 0 deletions src/presentation/summary/docker_card.py
Original file line number Diff line number Diff line change
@@ -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()
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()
8 changes: 3 additions & 5 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,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)
Expand All @@ -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)

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)
55 changes: 48 additions & 7 deletions src/services/docker_client.py
Original file line number Diff line number Diff line change
@@ -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
53 changes: 18 additions & 35 deletions src/services/system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
13 changes: 13 additions & 0 deletions src/tcss/layout.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

0 comments on commit 33e01ec

Please sign in to comment.