├── tests ├── __init__.py ├── test_tui.py ├── test_cli.py └── test_config.py ├── dokli ├── models │ ├── compose.py │ ├── __init__.py │ ├── deployment.py │ ├── redis.py │ └── project.py ├── tui │ ├── screens │ │ ├── __init__.py │ │ ├── docker.py │ │ ├── services │ │ │ ├── mysql.py │ │ │ ├── redis.py │ │ │ ├── __init__.py │ │ │ ├── compose.py │ │ │ ├── mongodb.py │ │ │ └── pgsql.py │ │ ├── settings │ │ │ ├── auth.py │ │ │ ├── user.py │ │ │ ├── users.py │ │ │ ├── cluster.py │ │ │ └── __init__.py │ │ ├── project.py │ │ ├── connection.py │ │ ├── projects.py │ │ └── connections.py │ ├── widgets │ │ ├── __init__.py │ │ ├── list_item.py │ │ └── main_menu.py │ ├── __init__.py │ ├── asciiart │ │ ├── dokploy-logo-textonly.txt │ │ ├── dokploy-logo-notext.txt │ │ └── dokploy-logo.txt │ ├── css │ │ └── tui.css │ ├── app.py │ └── forms.py ├── __init__.py ├── __main__.py ├── cli.py ├── commands.py ├── formatting.py ├── api_client.py ├── config.py └── openapi_cli.py ├── Makefile ├── .github ├── FUNDING.yml └── workflows │ └── python-package.yml ├── .gitignore ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_tui.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dokli/models/compose.py: -------------------------------------------------------------------------------- 1 | """Compose model.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/__init__.py: -------------------------------------------------------------------------------- 1 | """TUI screens.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/docker.py: -------------------------------------------------------------------------------- 1 | """Docker screen.""" 2 | -------------------------------------------------------------------------------- /dokli/__init__.py: -------------------------------------------------------------------------------- 1 | """Dokly a Dokploy CLI and TUI.""" 2 | -------------------------------------------------------------------------------- /dokli/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Dokli models package.""" 2 | -------------------------------------------------------------------------------- /dokli/models/deployment.py: -------------------------------------------------------------------------------- 1 | """Deployment model.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | """Widgets package.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/services/mysql.py: -------------------------------------------------------------------------------- 1 | """MySQL service screen.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/services/redis.py: -------------------------------------------------------------------------------- 1 | """Redis service screen.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/settings/auth.py: -------------------------------------------------------------------------------- 1 | """Auth settings screen.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/settings/user.py: -------------------------------------------------------------------------------- 1 | """User settings screen.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/settings/users.py: -------------------------------------------------------------------------------- 1 | """Users settings screen.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/services/__init__.py: -------------------------------------------------------------------------------- 1 | """Service screens package.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/services/compose.py: -------------------------------------------------------------------------------- 1 | """Compose service screen.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/services/mongodb.py: -------------------------------------------------------------------------------- 1 | """MongoDB service screen.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/services/pgsql.py: -------------------------------------------------------------------------------- 1 | """PostgreSQL service screen.""" 2 | -------------------------------------------------------------------------------- /dokli/tui/screens/settings/cluster.py: -------------------------------------------------------------------------------- 1 | """Cluster settings screen.""" 2 | -------------------------------------------------------------------------------- /dokli/__main__.py: -------------------------------------------------------------------------------- 1 | """Dokli main module.""" 2 | 3 | from dokli.cli import app 4 | 5 | app() 6 | -------------------------------------------------------------------------------- /dokli/tui/__init__.py: -------------------------------------------------------------------------------- 1 | """Dokli TUI.""" 2 | 3 | from .app import DokliApp, app 4 | 5 | __all__ = ( 6 | "DokliApp", 7 | "app", 8 | ) 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | pytest -vv --cov dokli --blockage 3 | 4 | format: 5 | ruff format dokli/ 6 | ruff check dokli/ --fix-only 7 | 8 | lint: 9 | ruff check dokli/ 10 | mypy dokli 11 | 12 | dev-tui: 13 | textual run --dev dokli.tui.app:DokliApp 14 | 15 | def-tui-console: 16 | textual console - SYSTEM -X EVENT 17 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Dokli CLI tests.""" 2 | 3 | from dokli.cli import app, main 4 | 5 | 6 | def test_loads_api_commands(mocker): 7 | """We expect the CLI to load API commands.""" 8 | assert app.registered_groups[0].typer_instance.info.name == "api" 9 | 10 | 11 | def test_loads_tui_command(): 12 | """We expect the CLI to load TUI command.""" 13 | assert "tui" in [cmd.name for cmd in app.registered_commands] 14 | 15 | 16 | def test_app_has_main_callback(): 17 | """We expect the CLI to have a main callback.""" 18 | assert app.registered_callback.callback is main 19 | assert app.registered_callback.no_args_is_help 20 | -------------------------------------------------------------------------------- /dokli/tui/widgets/list_item.py: -------------------------------------------------------------------------------- 1 | """List item widgets.""" 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from textual.widgets import Label, ListItem 6 | 7 | if TYPE_CHECKING: 8 | from textual.app import ComposeResult 9 | 10 | 11 | class AddItem(ListItem): 12 | """Add item.""" 13 | 14 | def __init__(self, item_name: str, *args, **kwargs) -> None: 15 | """Construct an add item.""" 16 | super().__init__(*args, **kwargs) 17 | self.item_name = item_name 18 | 19 | def compose(self) -> "ComposeResult": 20 | """Compose the widget.""" 21 | yield Label(f"✚ Add {self.item_name.title()}", id=f"add-{self.item_name.lower()}") 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: jonykalavera 4 | open_collective: # Replace with a single Open Collective username 5 | ko_fi: # Replace with a single Ko-fi username 6 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 7 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 8 | liberapay: # Replace with a single Liberapay username 9 | issuehunt: # Replace with a single IssueHunt username 10 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 11 | polar: # Replace with a single Polar username 12 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /dokli/cli.py: -------------------------------------------------------------------------------- 1 | """Dokli CLI.""" 2 | 3 | from typing import Any 4 | 5 | import typer 6 | 7 | from dokli.config import Config 8 | from dokli.openapi_cli import register_connections 9 | 10 | try: 11 | from dokli.tui import app as tui 12 | 13 | _tui_loaded = True 14 | except ImportError: 15 | _tui_loaded = False 16 | 17 | app = typer.Typer() 18 | state: dict[str, Any] = { 19 | "config": Config(), 20 | } 21 | register_connections(app, state["config"]) 22 | 23 | 24 | def tui_command() -> None: 25 | """Text User Interface.""" 26 | assert tui, "TUI not loaded" 27 | tui.config = state["config"] 28 | tui.run() 29 | 30 | 31 | if _tui_loaded: 32 | app.command(name="tui")(tui_command) 33 | 34 | 35 | @app.callback(no_args_is_help=True) 36 | def main(): 37 | """Magical Dokploy CLI/TUI.""" 38 | -------------------------------------------------------------------------------- /dokli/tui/asciiart/dokploy-logo-textonly.txt: -------------------------------------------------------------------------------- 1 | █████████ ███ ███ 2 | ███ ████ ███ ███ 3 | ███ ███ ██████ ███ ███ ███ █████ ███ ██████ ███ ███ 4 | ███ ██ ███ ███ ███ ███ ████ ███ ███ ███ ███ ███ ███ 5 | ███ ██ ███ ███ ██████ ███ ███ ███ ██ ██ ██ ██ 6 | ███ ███ ███ ███ ██████ ███ ███ ███ ██ ██ ███ ██ 7 | ███ ███ ███ ██ ███ ███ ████ ███ ███ ███ ███ ██ ██ 8 | ██████████ ███████ ███ ███ ███ █████ ███ ███████ ███ 9 | ███ ███ 10 | ███ █████ 11 | -------------------------------------------------------------------------------- /dokli/models/redis.py: -------------------------------------------------------------------------------- 1 | """Redis service model.""" 2 | 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | import humanize 7 | from pydantic import BaseModel, SecretStr 8 | 9 | 10 | class RedisService(BaseModel): 11 | """Redis service model.""" 12 | 13 | redisId: str 14 | name: str 15 | appName: str 16 | description: str 17 | databasePassword: SecretStr 18 | dockerImage: str 19 | command: dict[str, Any] | None 20 | env: dict[str, Any] | None 21 | memoryReservation: dict[str, Any] | None 22 | memoryLimit: dict[str, Any] | None 23 | cpuReservation: dict[str, Any] | None 24 | cpuLimit: dict[str, Any] | None 25 | externalPort: dict[str, Any] | None 26 | createdAt: datetime 27 | applicationStatus: str 28 | projectId: str 29 | 30 | @property 31 | def time_since_created(self) -> str: 32 | """Time since project created in natural language.""" 33 | return humanize.naturaltime(self.createdAt) 34 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.9", "3.10", "3.11"] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install poetry 31 | python -m poetry install 32 | - name: Run all tests 33 | run: python -m poetry run make lint test 34 | -------------------------------------------------------------------------------- /dokli/models/project.py: -------------------------------------------------------------------------------- 1 | """Project model.""" 2 | 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | import humanize 7 | from pydantic import BaseModel 8 | 9 | 10 | class Project(BaseModel): 11 | """Dockploy API project.""" 12 | 13 | projectId: str 14 | name: str 15 | description: str | None 16 | createdAt: datetime 17 | adminId: str 18 | applications: list[dict[str, Any]] 19 | mariadb: list[dict[str, Any]] 20 | mongo: list[dict[str, Any]] 21 | mysql: list[dict[str, Any]] 22 | postgres: list[dict[str, Any]] 23 | redis: list[dict[str, Any]] 24 | compose: list[dict[str, Any]] 25 | 26 | @property 27 | def time_since_created(self) -> str: 28 | """Time since project created in natural language.""" 29 | return humanize.naturaltime(self.createdAt) 30 | 31 | @property 32 | def services(self) -> list[dict[str, Any]]: 33 | """Get all services.""" 34 | return self.applications + self.mariadb + self.mongo + self.mysql + self.postgres + self.redis + self.compose 35 | -------------------------------------------------------------------------------- /dokli/tui/widgets/main_menu.py: -------------------------------------------------------------------------------- 1 | """Main menu widget.""" 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from textual import log 6 | from textual.widgets import Static, Tab, Tabs 7 | 8 | if TYPE_CHECKING: 9 | from textual.app import ComposeResult 10 | 11 | 12 | class MainMenu(Static): 13 | """Main menu widget.""" 14 | 15 | def __init__(self, active_screen: str | None = None, *args, **kwargs) -> None: 16 | """Construct a main menu.""" 17 | super().__init__(*args, **kwargs) 18 | self.active_screen = active_screen 19 | 20 | def compose(self) -> "ComposeResult": 21 | """Compose the widget.""" 22 | yield Tabs( 23 | Tab("Projects", id="Projects"), 24 | Tab("Settings", id="Settings"), 25 | active=self.active_screen, 26 | ) 27 | 28 | def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: 29 | """On tabs click.""" 30 | log("TAB ACTIVATED", event.tab, self.app.screen.name, self.app.screen.classes) 31 | if ( 32 | event.tab 33 | and event.tab.id 34 | and event.tab.id != self.active_screen 35 | and event.tab.id not in self.app.screen.classes 36 | ): 37 | self.app.push_screen(event.tab.id) 38 | -------------------------------------------------------------------------------- /dokli/tui/screens/settings/__init__.py: -------------------------------------------------------------------------------- 1 | """Settings screen.""" 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from textual import events 6 | from textual.reactive import reactive 7 | from textual.screen import Screen 8 | from textual.widgets import Footer, Header, Label 9 | 10 | from dokli.config import ConnectionConfig 11 | from dokli.tui.widgets.main_menu import MainMenu 12 | 13 | if TYPE_CHECKING: 14 | from textual.app import ComposeResult 15 | 16 | 17 | class SettingsScreen(Screen): 18 | """Settings screen.""" 19 | 20 | connection: reactive[ConnectionConfig | None] = reactive(None) 21 | 22 | def __init__(self, connection: ConnectionConfig | None = None, *args, **kwargs) -> None: 23 | """Construct a settings screen.""" 24 | super().__init__(*args, **kwargs) 25 | self.connection = connection 26 | 27 | def compose(self) -> "ComposeResult": 28 | """Compose the widget.""" 29 | yield Header() 30 | yield Footer() 31 | yield MainMenu(active_screen="Settings") 32 | yield Label("Settings Screen") 33 | yield Label(f"Connection: {self.connection}") 34 | 35 | def on_screen_resume(self, event: events.ScreenResume) -> None: 36 | """On screen resume.""" 37 | self.app.sub_title = "Settings" 38 | -------------------------------------------------------------------------------- /dokli/commands.py: -------------------------------------------------------------------------------- 1 | """Dokli commands.""" 2 | 3 | import json 4 | import re 5 | 6 | from httpx import HTTPError, Response 7 | from pydantic import ValidationError 8 | 9 | from dokli.api_client import APIClient 10 | from dokli.config import ConnectionConfig 11 | 12 | PARAM_REGEX = re.compile(r"^((?P[a-z][a-z0-9_]+)=)?(?P.+)$") 13 | 14 | 15 | MAGIC_JSON = "%json:" 16 | MAGIC_FILE = "%file:" 17 | 18 | 19 | def _parse_params(params: dict[str, str]) -> dict[str, str]: 20 | """Parse CLI params.""" 21 | kwargs = {} 22 | for key, value in params.items(): 23 | _value = value 24 | if value.startswith(MAGIC_JSON): 25 | _value = json.loads(value[len(MAGIC_JSON) :]) 26 | if value.startswith(MAGIC_FILE): 27 | with open(value[len(MAGIC_FILE) :]) as f: 28 | _value = json.load(f) 29 | kwargs[key] = _value 30 | return kwargs 31 | 32 | 33 | def run_command( 34 | connection: ConnectionConfig, 35 | method: str, 36 | route: str, 37 | params: dict[str, str] | None = None, 38 | client: APIClient | None = None, 39 | ) -> HTTPError | Response | ValidationError: 40 | """Run a dokploy API command using Dokli connection.""" 41 | api_client = client or APIClient(connection) 42 | # Enter a context with an instance of the API client 43 | # Create an instance of the API class 44 | kwargs = _parse_params(params or {}) 45 | 46 | try: 47 | return api_client.request(method, route, kwargs) 48 | except HTTPError as err: 49 | return err 50 | except ValidationError as err: 51 | return err 52 | -------------------------------------------------------------------------------- /dokli/formatting.py: -------------------------------------------------------------------------------- 1 | """Dokli formatting utility functions.""" 2 | 3 | import json 4 | from enum import Enum 5 | from typing import TypeVar 6 | 7 | import typer 8 | import yaml 9 | from httpx import Response 10 | from rich.table import Table 11 | 12 | app = typer.Typer() 13 | 14 | 15 | class Format(str, Enum): 16 | """API response format.""" 17 | 18 | python = "python" 19 | json = "json" 20 | yaml = "yaml" 21 | table = "table" 22 | 23 | 24 | def format_response(response: Response, format: Format) -> str | Table | dict | list: 25 | """Format the given Response in the given format.""" 26 | raw_data = response.text 27 | if not raw_data: 28 | return "" 29 | data = json.loads(raw_data) 30 | return format_data(data, format) 31 | 32 | 33 | D = TypeVar("D") 34 | 35 | 36 | def format_data(data: D, format: Format) -> str | D | Table: 37 | """Format the given data in the given format.""" 38 | match format: 39 | case Format.python: 40 | return data 41 | case Format.json: 42 | return json.dumps(data) 43 | case Format.yaml: 44 | return yaml.dump(data) 45 | case Format.table: 46 | table = _data_to_table(data) 47 | return table 48 | return data 49 | 50 | 51 | def _data_to_table(data: D) -> Table: 52 | table = Table(title="API Response") 53 | match data: 54 | case list(): 55 | if not data: 56 | return table 57 | for column in data[0]: 58 | table.add_column(column) 59 | for row in data: 60 | table.add_row(*(str(v) for v in row.values())) 61 | case dict(): 62 | table.add_column("Key") 63 | table.add_column("Value") 64 | for key, value in data.items(): 65 | table.add_row(key, str(value)) 66 | return table 67 | -------------------------------------------------------------------------------- /dokli/tui/css/tui.css: -------------------------------------------------------------------------------- 1 | $error-color: #b93c5b; 2 | $success-color: #61ff61; 3 | .hidden { 4 | display: none; 5 | } 6 | Label.title { 7 | text-style: bold; 8 | margin-top: 1; 9 | margin-left: 1; 10 | margin-right: 1; 11 | min-width: 50; 12 | width: 100%; 13 | } 14 | Label.subtitle { 15 | margin: 1; 16 | min-width: 50; 17 | width: 100%; 18 | } 19 | 20 | ListItem { 21 | margin: 1; 22 | height: 5; 23 | min-width: 50; 24 | } 25 | ListItem Label { 26 | margin-left: 1; 27 | } 28 | 29 | ListItem Label.title { 30 | text-style: bold; 31 | margin-top: 0; 32 | margin-left: 1; 33 | margin-right: 1; 34 | min-width: 50; 35 | width: 100%; 36 | } 37 | ListItem Label#icon { 38 | background: $boost; 39 | color: white; 40 | text-style: bold; 41 | dock: left; 42 | content-align: center top; 43 | height: 5; 44 | width: 3; 45 | margin-left: 0; 46 | } 47 | 48 | AddItem { 49 | layout: horizontal; 50 | margin: 1; 51 | min-width: 50; 52 | border: dashed $boost; 53 | } 54 | 55 | AddItem Label { 56 | width: 100%; 57 | height: 3; 58 | content-align: center middle; 59 | } 60 | 61 | FormControl { 62 | margin: 1; 63 | } 64 | FormControl Label { 65 | margin-left: 2; 66 | margin-right: 2; 67 | } 68 | FormControl Input { 69 | margin-left: 1; 70 | margin-right: 1; 71 | } 72 | FormControl Label.error-msg { 73 | text-style: bold; 74 | color: $error-color; 75 | display: none; 76 | } 77 | FormControl.error Label.error-msg { 78 | display: block; 79 | } 80 | FormControl.error Input { 81 | border: dashed $error-color; 82 | } 83 | FormControl.error Label.error-msg { 84 | text-style: bold; 85 | color: $error-color; 86 | } 87 | 88 | Static#logo { 89 | align: center middle; 90 | content-align: center middle; 91 | margin-top: 5; 92 | margin-bottom: 1; 93 | } 94 | 95 | ConnectionWidget Label#icon { 96 | background: $success; 97 | } 98 | 99 | ConnectionScreen Horizontal { 100 | margin-top: 1; 101 | min-width: 50; 102 | height: 5; 103 | } 104 | 105 | ConnectionScreen Button#cancel { 106 | dock: left; 107 | margin-top: 1; 108 | margin-left: 2; 109 | } 110 | 111 | ConnectionScreen Button#save { 112 | margin-top: 1; 113 | dock: right; 114 | margin-right: 2; 115 | } 116 | 117 | ConnectionScreen Button#delete { 118 | dock: right; 119 | margin-top: 1; 120 | margin-right: 20; 121 | } 122 | 123 | RedisServiceItem Label#icon { 124 | background: $error; 125 | } 126 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """Config tests.""" 2 | 3 | import pytest 4 | from polyfactory.factories.pydantic_factory import ModelFactory 5 | from pydantic import ValidationError 6 | 7 | from dokli.config import Config, ConnectionConfig 8 | 9 | 10 | class ConnectionConfigFactory(ModelFactory[ConnectionConfig]): 11 | """Connection factory.""" 12 | 13 | api_key = None 14 | api_key_cmd = "echo 'my api key from cmd'" 15 | 16 | 17 | class ConfigFactory(ModelFactory[Config]): 18 | """Config factory.""" 19 | 20 | connections = [ConnectionConfigFactory.build()] 21 | 22 | 23 | class TestConfig: 24 | """Config model tests.""" 25 | 26 | def test_config(self): 27 | """We expect to be able to declare an api with connections.""" 28 | config = ConfigFactory.build() 29 | assert config.connections 30 | 31 | def test_get_connection(self): 32 | """We expect to be able to get a connection by name.""" 33 | connection = ConnectionConfigFactory.build(name="dokploy") 34 | config = ConfigFactory.build(connections=[connection]) 35 | assert config.get_connection("dokploy") 36 | 37 | 38 | class TestConnectionConfig: 39 | """Connection config model tests.""" 40 | 41 | def test_must_provide_api_key_or_cmd(self): 42 | """We expect to raise an error if no api key or cmd is provided.""" 43 | with pytest.raises(ValidationError): 44 | ConnectionConfig(name="dokli", url="https://dokli.example.com") 45 | 46 | def test_connection_with_api_key(self): 47 | """We expect to be able to declare a connection with an API key.""" 48 | config = ConnectionConfigFactory.build(api_key="*" * 40) 49 | assert config.get_api_key() == config.api_key.get_secret_value() 50 | 51 | def test_connection_with_api_key_cmd(self, mocker): 52 | """We expect to be able to declare a connection with an API key command.""" 53 | config = ConnectionConfigFactory.build() 54 | assert config.api_key is None 55 | check_output = mocker.patch("dokli.config.subprocess.check_output", return_value=b"my api key from cmd") 56 | assert config.get_api_key() == "my api key from cmd" 57 | check_output.assert_called_once_with(config.api_key_cmd.split()) 58 | 59 | def test_model_dump_clear_prints_clear_secrets(self): 60 | """We expect to be able to dump the config with clear secrets.""" 61 | config = ConnectionConfigFactory.build(api_key="*" * 40) 62 | result = config.model_dump_clear() 63 | assert result["api_key"] == "*" * 40 64 | -------------------------------------------------------------------------------- /dokli/tui/asciiart/dokploy-logo-notext.txt: -------------------------------------------------------------------------------- 1 | █ 2 | ████ 3 | ███████ █ 4 | █████████████████████████ ████████ ████████ 5 | ███████████████████████████████ ██████████████████ 6 | ████ █████████ ██████████████ 7 | ████ ███ █████████ ████ 8 | ███ ███ ██████████ █████ 9 | ███ ████████████████████████ 10 | ████████████████ █████████████████ 11 | ██████████████████████ ███████ 12 | ████ ██████████ ██████████ ██████ 13 | ██████ ███████████████████████████████ ████████ 14 | ████████ ████████████████████ ████████ 15 | █ █████████ ████████ ███ 16 | █████ █████████ ████████ ██████ 17 | ████████ ███████████ ███████████ █████████ 18 | ████ ██████ ████████████████████████████████ ███████ ████ 19 | ████ ███████ ████████████████████ ███████ ████ 20 | █████ ████████ ████████ ██████ 21 | ███████ ████████ █████████ ███████ 22 | █████████ ████████████ ███████████ ██████████ 23 | ████ ██████ ████████████████████████████ ███████ ████ 24 | ████ ██████ ██████████████ ██████ ████ 25 | █████ ███████ ████████ █████ 26 | █████ █████████ █████████ █████ 27 | ██████ ████████████████████████████████ ██████ 28 | ██████ ██████████████████████ ██████ 29 | ███████ ██████ 30 | ████████ ████████ 31 | ███████████ ██████████ 32 | ██████████████████████████ 33 | ████████████ 34 | -------------------------------------------------------------------------------- /dokli/tui/app.py: -------------------------------------------------------------------------------- 1 | """Dokli TUI.""" 2 | 3 | from pathlib import Path 4 | 5 | from textual import events, log 6 | from textual.app import App, ComposeResult 7 | from textual.widgets import Footer, Header, Static 8 | 9 | from dokli.config import Config, ConnectionConfig 10 | from dokli.tui.screens.connections import ConnectionsScreen 11 | from dokli.tui.screens.projects import ProjectsScreen 12 | from dokli.tui.screens.settings import SettingsScreen 13 | 14 | TUI_PATH = Path(__file__).parent 15 | ASCII_ART_PATH = TUI_PATH / "asciiart" 16 | 17 | 18 | class DokliApp(App): 19 | """A Textual app to manage stopwatches.""" 20 | 21 | TITLE = "Dokli" 22 | CSS_PATH = "css/tui.css" 23 | BINDINGS = [ 24 | ("d", "toggle_dark", "Toggle dark mode"), 25 | ("C", "connections", "Connections"), 26 | ("escape", "cancel", "Cancel/Back"), 27 | ("q", "quit", "Quit"), 28 | ] 29 | 30 | config: Config 31 | connection: ConnectionConfig | None 32 | 33 | def __init__(self, config: Config | None = None, **kwargs) -> None: 34 | """Construct a new TUI app.""" 35 | super().__init__(**kwargs) 36 | self.config = config or Config() 37 | self.connection: ConnectionConfig | None = None 38 | 39 | def on_mount(self) -> None: 40 | """On mount.""" 41 | self.install_screen(ConnectionsScreen(self.config.connections), name="Connections") 42 | self.install_screen(SettingsScreen(name="Settings"), name="Settings") 43 | self.push_screen("Connections") 44 | 45 | def action_toggle_dark(self) -> None: 46 | """An action to toggle dark mode.""" 47 | self.dark = not self.dark 48 | 49 | def action_cancel(self) -> None: 50 | """Cancel action.""" 51 | if len(self.screen_stack) > 1: 52 | self.pop_screen() 53 | else: 54 | self.bell() 55 | 56 | def on_connections_screen_set_connection(self, event: ConnectionsScreen.SetConnection) -> None: 57 | """Set the active connection.""" 58 | self.connection = event.connection 59 | log.info(f"Setting connection: {event.connection}") 60 | self.install_screen(ProjectsScreen(name="Projects", connection=self.connection), name="Projects") 61 | self.push_screen("Projects") 62 | 63 | def action_connections(self) -> None: 64 | """Action connections.""" 65 | if self.connection: 66 | self.push_screen("Connections") 67 | 68 | def compose(self) -> "ComposeResult": 69 | """Compose the widget.""" 70 | yield Header() 71 | yield Footer() 72 | with open(ASCII_ART_PATH / "dokploy-logo-notext.txt") as logo: 73 | yield Static("".join(logo.readlines()), id="logo") 74 | 75 | def on_screen_resume(self, event: events.ScreenResume) -> None: 76 | """On screen resume.""" 77 | self.app.sub_title = "" 78 | 79 | 80 | app = DokliApp() 81 | -------------------------------------------------------------------------------- /dokli/api_client.py: -------------------------------------------------------------------------------- 1 | """API client for Dokli.""" 2 | 3 | import json 4 | from pathlib import Path 5 | from typing import Any 6 | 7 | import httpx 8 | 9 | from dokli.config import ConnectionConfig 10 | 11 | 12 | class APIClient: 13 | """Dokploy API client.""" 14 | 15 | def __init__(self, connection: ConnectionConfig, cache_path: str | None = None, force_refresh=False): 16 | """Initialize the API client.""" 17 | _base_url = str(connection.url).rstrip("/") 18 | self.connection = connection 19 | self.force_refresh = force_refresh 20 | self.cache_path = Path(cache_path) if cache_path else Path.home() / ".config/dokli/cache" 21 | self.base_url = f"{_base_url}/api/" 22 | self.headers = { 23 | "Authorization": f"Bearer {connection.get_api_key()}", 24 | "accept": "application/json", 25 | } 26 | self.session = httpx.Client(verify=False, follow_redirects=True) 27 | self.schema = self.get_open_api_document() 28 | 29 | def _get_cache_path(self): 30 | return self.cache_path / f"{self.connection.name}.openapi.json" 31 | 32 | def _get_open_api_document_cache(self): 33 | cache_path = self._get_cache_path() 34 | if not cache_path.exists(): 35 | return None 36 | return json.loads(cache_path.read_text()) 37 | 38 | def _set_open_api_document_cache(self, schema): 39 | cache_path = self._get_cache_path() 40 | cache_path.parent.mkdir(parents=True, exist_ok=True) 41 | cache_path.write_text(json.dumps(schema)) 42 | 43 | def _get_open_api_document(self): 44 | response = self.request(method="GET", path="settings.getOpenApiDocument", params={}) 45 | response.raise_for_status() 46 | return response.json() 47 | 48 | def get_open_api_document(self): 49 | """Retrieve OpenAPI document for the connection.""" 50 | schema = self._get_open_api_document_cache() 51 | if not schema or self.force_refresh: 52 | schema = self._get_open_api_document() 53 | self._set_open_api_document_cache(schema) 54 | return schema 55 | 56 | def request(self, method: str, path: str, params: dict[str, Any]) -> httpx.Response: 57 | """Send a request to the API.""" 58 | query_params = {} 59 | path_params = {} 60 | body = params.pop("body", None) 61 | 62 | for key, value in params.items(): 63 | if key in path: 64 | path_params[key] = value 65 | else: 66 | query_params[key] = value 67 | 68 | formatted_path = path.format(**path_params) 69 | 70 | response = getattr(self.session, method.lower())( 71 | url=f"{self.base_url}{formatted_path}", 72 | params=query_params, 73 | headers=self.headers, 74 | **({"data": body} if body else {}), 75 | ) 76 | response.raise_for_status() 77 | return response 78 | -------------------------------------------------------------------------------- /dokli/config.py: -------------------------------------------------------------------------------- 1 | """Configuration model.""" 2 | 3 | import json 4 | import subprocess 5 | from typing import Any 6 | 7 | from pydantic import BaseModel, Field, HttpUrl, SecretStr, field_serializer, model_validator 8 | from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, YamlConfigSettingsSource 9 | 10 | 11 | class ConnectionConfig(BaseModel): 12 | """Connection config.""" 13 | 14 | name: str = Field( 15 | ..., 16 | min_length=3, 17 | max_length=100, 18 | pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$", 19 | description="A name for the connection.", 20 | ) 21 | url: HttpUrl = Field(..., description="The URL of the dokploy instance.") 22 | api_key: SecretStr | None = Field( 23 | None, 24 | min_length=40, 25 | max_length=40, 26 | description="An API key for the dokploy instance.", 27 | ) 28 | api_key_cmd: str | None = Field(None, description="A command to get the API key.") 29 | notes: str = Field(default="", description="Notes about the connection.") 30 | 31 | @field_serializer("api_key", when_used="json") 32 | def dump_secret(self, v): 33 | """Allows dumping secret values.""" 34 | return v.get_secret_value() 35 | 36 | def model_dump_clear(self, **kwargs) -> dict[str, Any]: 37 | """Allows dumping the config with clear secrets.""" 38 | return json.loads(self.model_dump_json(**kwargs)) 39 | 40 | @model_validator(mode="after") 41 | def check_api_key_or_cmd(self) -> "ConnectionConfig": 42 | """Validate api_key or api_key_cmd is provided.""" 43 | if not self.api_key and not self.api_key_cmd: 44 | raise ValueError("Must provide api_key or api_key_cmd.") 45 | return self 46 | 47 | def get_api_key(self) -> str: 48 | """Returns the API key for the connection.""" 49 | if self.api_key is not None: 50 | return self.api_key.get_secret_value() 51 | assert self.api_key_cmd, "Must provide api_key or api_key_cmd." 52 | raw_output = subprocess.check_output(self.api_key_cmd.split()) 53 | output = raw_output.decode("utf-8").strip().strip("\n") 54 | return output 55 | 56 | 57 | class Config(BaseSettings): 58 | """Dokli config.""" 59 | 60 | connections: list[ConnectionConfig] = Field(default_factory=list) 61 | model_config = SettingsConfigDict( 62 | env_prefix="DOKLI_", 63 | yaml_file=[ 64 | "dokli.yaml", 65 | "~/.config/dokli/dokli.yaml", 66 | ], 67 | ) 68 | 69 | @classmethod 70 | def settings_customise_sources( 71 | cls, 72 | settings_cls: type[BaseSettings], 73 | init_settings: PydanticBaseSettingsSource, 74 | env_settings: PydanticBaseSettingsSource, 75 | dotenv_settings: PydanticBaseSettingsSource, 76 | file_secret_settings: PydanticBaseSettingsSource, 77 | ) -> tuple[PydanticBaseSettingsSource, ...]: 78 | """Override default settings sources.""" 79 | return ( 80 | env_settings, 81 | init_settings, 82 | dotenv_settings, 83 | file_secret_settings, 84 | YamlConfigSettingsSource(settings_cls), 85 | ) 86 | 87 | def get_connection(self, name: str) -> ConnectionConfig: 88 | """Get connection config.""" 89 | return next(filter(lambda x: x.name == name, self.connections)) 90 | -------------------------------------------------------------------------------- /dokli/tui/screens/project.py: -------------------------------------------------------------------------------- 1 | """Project detail screen.""" 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from textual import events 6 | from textual.binding import Binding 7 | from textual.css.query import NoMatches 8 | from textual.reactive import reactive 9 | from textual.screen import Screen 10 | from textual.widgets import Footer, Header, Label, ListItem, ListView 11 | 12 | from dokli.config import ConnectionConfig 13 | from dokli.models.project import Project 14 | from dokli.models.redis import RedisService 15 | from dokli.tui.widgets.list_item import AddItem 16 | from dokli.tui.widgets.main_menu import MainMenu 17 | 18 | if TYPE_CHECKING: 19 | from textual.app import ComposeResult 20 | 21 | 22 | class RedisServiceItem(ListItem): 23 | """Redis service item.""" 24 | 25 | ICON = "" 26 | 27 | service: RedisService 28 | 29 | def __init__(self, service: RedisService, *args, **kwargs) -> None: 30 | """Construct a redis service item.""" 31 | super().__init__(*args, **kwargs) 32 | self.service = service 33 | 34 | def compose(self) -> "ComposeResult": 35 | """Compose the widget.""" 36 | yield Label(self.ICON, id="icon") 37 | yield Label(self.service.name, id="name", classes="title") 38 | yield Label(self.service.appName, id="appName") 39 | yield Label(f"Created {self.service.time_since_created}", id="time_since_created") 40 | 41 | 42 | class ProjectDetailScreen(Screen): 43 | """Project detail screen.""" 44 | 45 | model = Project 46 | instance: reactive[Project | None] = reactive(None) 47 | 48 | BINDINGS = [ 49 | Binding("e", "edit", "Edit"), 50 | Binding("d", "delete", "Delete"), 51 | ] 52 | 53 | def __init__(self, instance: Project, connection: ConnectionConfig, *args, **kwargs) -> None: 54 | """Construct a project detail screen.""" 55 | super().__init__(*args, **kwargs) 56 | self.instance = instance 57 | self.connection = connection 58 | 59 | def on_screen_resume(self, event: events.ScreenResume) -> None: 60 | """On screen resume.""" 61 | self.app.sub_title = ( 62 | f"{self.connection.name}  Projects  {self.instance.name if self.instance else 'New Project'}" 63 | ) 64 | try: 65 | list_view = self.query_one(ListView) 66 | list_view.focus() 67 | except NoMatches: 68 | pass 69 | 70 | def compose(self) -> "ComposeResult": 71 | """Compose the widget.""" 72 | yield Header() 73 | yield Footer() 74 | yield MainMenu(active_screen="Projects") 75 | yield Label(self.instance.name if self.instance else "", id="name", classes="title") 76 | yield Label(self.instance.description or "" if self.instance else "", id="description", classes="subtitle") 77 | yield ListView( 78 | *( 79 | RedisServiceItem(RedisService.model_validate(service)) 80 | for service in (self.instance.services if self.instance else []) 81 | ), 82 | AddItem(id="__add__", item_name="service"), 83 | id="services", 84 | ) 85 | 86 | def watch_instance(self, old_value, new_value) -> None: 87 | """Watch instance changes.""" 88 | try: 89 | title = self.query_one("Label#title") 90 | description = self.query_one("Label#description") 91 | except NoMatches: 92 | return 93 | if new_value: 94 | assert isinstance(title, Label) 95 | title.renderable = new_value.name 96 | assert isinstance(description, Label) 97 | description.renderable = new_value.description 98 | -------------------------------------------------------------------------------- /dokli/tui/screens/connection.py: -------------------------------------------------------------------------------- 1 | """Connections screen.""" 2 | 3 | import contextlib 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING 6 | 7 | from textual import events, log 8 | from textual.binding import Binding 9 | from textual.containers import Horizontal 10 | from textual.css.query import NoMatches 11 | from textual.reactive import reactive 12 | from textual.screen import ModalScreen 13 | from textual.widgets import Button, Footer, Header, Input, Rule 14 | 15 | from dokli.config import ConnectionConfig 16 | from dokli.tui.forms import Form 17 | 18 | if TYPE_CHECKING: 19 | from textual.app import ComposeResult 20 | 21 | 22 | @dataclass 23 | class ConnectionResult: 24 | """Connection screen result.""" 25 | 26 | connection: ConnectionConfig | None = None 27 | delete: bool = False 28 | 29 | 30 | class ConnectionScreen(ModalScreen[ConnectionResult]): 31 | """Connection Screen.""" 32 | 33 | connection: reactive[ConnectionConfig | None] = reactive(None) 34 | 35 | def __init__(self, connection: ConnectionConfig | None = None, focus_on_resume=False, *args, **kwargs) -> None: 36 | """Construct a connection screen.""" 37 | super().__init__(*args, **kwargs) 38 | self.connection = connection 39 | self.focus_on_resume = focus_on_resume 40 | 41 | BINDINGS = [ 42 | Binding( 43 | "s", 44 | "save", 45 | "Save", 46 | ), 47 | Binding("d", "delete", "Delete"), 48 | Binding("c", "cancel", "Cancel"), 49 | ] 50 | 51 | def compose(self) -> "ComposeResult": 52 | """Create child widgets for the app.""" 53 | yield Header() 54 | yield Footer() 55 | yield Form.from_model(ConnectionConfig, self.connection) 56 | yield Rule() 57 | yield Horizontal( 58 | Button("Save", variant="success", id="save"), 59 | Button( 60 | "Delete", 61 | variant="error", 62 | id="delete", 63 | classes="hidden" if self.connection is None else "", 64 | ), 65 | Button( 66 | "Cancel", 67 | variant="default", 68 | id="cancel", 69 | ), 70 | ) 71 | 72 | def watch_connection(self, old_value: ConnectionConfig | None, new_value: ConnectionConfig) -> None: 73 | """Watch connection changes.""" 74 | self.app.sub_title = "New Connection" if not new_value else f"Edit Connection: {new_value.name}" 75 | log("ConnectionScreen", self.connection) 76 | 77 | def action_save(self) -> None: 78 | """Save connection.""" 79 | form = self.query_one(Form) 80 | if form.is_valid: 81 | self.dismiss(ConnectionResult(form.instance, False)) 82 | 83 | def action_delete(self) -> None: 84 | """Delete connection.""" 85 | self.dismiss(ConnectionResult(self.connection, True)) 86 | 87 | def action_cancel(self) -> None: 88 | """Cancel.""" 89 | self.dismiss(ConnectionResult(self.connection, False)) 90 | 91 | def on_button_pressed(self, event: Button.Pressed) -> None: 92 | """On button pressed.""" 93 | action = getattr(self, f"action_{event.button.id}", None) 94 | if not action: 95 | log("No action", event.button.id) 96 | return 97 | action() 98 | 99 | def on_screen_resume(self, event: events.ScreenResume) -> None: 100 | """On screen resume.""" 101 | log("on screen resume", self.connection) 102 | if self.focus_on_resume: 103 | with contextlib.suppress(NoMatches): 104 | self.query(Input).first().focus() 105 | 106 | def on_screen_suspend(self, event: events.ScreenSuspend) -> None: 107 | """On screen suspend.""" 108 | self.query_one(Form).reset() 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | poetry.lock 164 | .vscode/* 165 | .luarc* 166 | .nvimrc* 167 | .exrc* 168 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | 2 | ############################################################################## 3 | # MYPY SETTINGS # 4 | ############################################################################## 5 | [tool.mypy] 6 | python_version = "3.10" 7 | ignore_missing_imports = true 8 | strict_optional = true 9 | plugins = [ 10 | "pydantic.mypy" 11 | ] 12 | 13 | [tool.pydantic-mypy] 14 | init_forbid_extra = true 15 | init_typed = true 16 | warn_required_dynamic_aliases = true 17 | 18 | [[tool.mypy.overrides]] 19 | module = [ 20 | "tests.*" 21 | ] 22 | ignore_errors = true 23 | 24 | ############################################################################## 25 | # RUFF SETTINGS # 26 | ############################################################################## 27 | 28 | [tool.ruff] 29 | 30 | # Same as Black. 31 | line-length = 119 32 | 33 | # Assume Python 3.10 34 | target-version = "py310" 35 | 36 | [tool.ruff.format] 37 | exclude = ["__pycache__", ".pytest_cache", ".mypy_cache", ".venv",] 38 | 39 | [tool.ruff.lint] 40 | select = ["E", "F", "I", "PL", "B", "T20", "TCH", "ASYNC", "U", "UP", "LOG", "G", "ERA", "SIM", "D" ] 41 | 42 | # On top of the Google convention, disable `D417`, which requires 43 | # documentation for every function parameter. 44 | ignore = ["D417", "PLR0913", "PLR2004", ] 45 | 46 | [tool.ruff.lint.pydocstyle] 47 | convention = "google" 48 | 49 | [tool.ruff.lint.isort] 50 | section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] 51 | 52 | ############################################################################# 53 | # COVERAGE SETTINGS # 54 | ############################################################################## 55 | [tool.coverage.run] 56 | branch = true 57 | omit = [ 58 | "*tests.py", 59 | "*test_*.py", 60 | "*_tests.py", 61 | ] 62 | source = [ 63 | "dokli/" 64 | ] 65 | 66 | [tool.coverage.report] 67 | exclude_lines = [ 68 | "pragma: no cover", 69 | "raise NotImplementedError", 70 | "logger = logging.getLogger.__name__.", 71 | "[A-Z_][A-Z0-9_]* = .*", 72 | "from .* import .*", 73 | "import .*", 74 | "if __name__ == .__main__.:", 75 | "collections.namedtuple" 76 | ] 77 | skip_covered = true 78 | ignore_errors = true 79 | show_missing = true 80 | 81 | ############################################################################## 82 | # POETRY SETTINGS # 83 | ############################################################################## 84 | [tool.poetry] 85 | name = "dokli" 86 | version = "0.1.0" 87 | description = "Dokploy CLI and TUI" 88 | authors = ["Jony Kalavera "] 89 | license = "MIT" 90 | readme = "README.md" 91 | 92 | [tool.poetry.dependencies] 93 | python = "^3.10" 94 | typer = "^0.12.3" 95 | pydantic = "^2.8.2" 96 | textual = { optional = true, version = "^0.75.1"} 97 | pydantic-settings = {extras = ["yaml"], version = "^2.4.0"} 98 | humanize = "^4.10.0" 99 | httpx = "^0.28.1" 100 | 101 | [tool.poetry.group.dev.dependencies] 102 | ruff = "^0.5.6" 103 | mypy = "^1.11.1" 104 | pytest = "^8.3.2" 105 | pytest-cov = "^5.0.0" 106 | textual-dev = "^1.5.1" 107 | types-tree-sitter = "^0.20.1.20240311" 108 | types-setuptools = "^71.1.0.20240726" 109 | types-pyyaml = "^6.0.12.20240724" 110 | types-docutils = "^0.21.0.20240724" 111 | tree-sitter = "^0.22.3" 112 | types-tree-sitter-languages = "^1.10.0.20240612" 113 | types-pygments = "^2.18.0.20240506" 114 | datamodel-code-generator = "^0.25.8" 115 | pytest-mock = "^3.14.0" 116 | pytest-randomly = "^3.16.0" 117 | polyfactory = "^2.19.0" 118 | pytest-blockage = "^0.2.4" 119 | 120 | [tool.poetry.extras] 121 | tui = ["textual"] 122 | 123 | [tool.poetry.scripts] 124 | dokli = 'dokli.cli:app' 125 | 126 | [build-system] 127 | requires = ["poetry-core"] 128 | build-backend = "poetry.core.masonry.api" 129 | -------------------------------------------------------------------------------- /dokli/openapi_cli.py: -------------------------------------------------------------------------------- 1 | """OpenAPI CLI.""" 2 | 3 | import re 4 | from inspect import Parameter, Signature 5 | from typing import Annotated, Any 6 | 7 | import typer 8 | from httpx import HTTPError, Response 9 | from rich import print as rprint 10 | 11 | from dokli.api_client import APIClient 12 | from dokli.commands import run_command 13 | from dokli.formatting import Format, format_response 14 | 15 | OPENAPI_TO_PYTHON = { 16 | "string": str, 17 | "integer": int, 18 | "number": float, 19 | "boolean": bool, 20 | "array": list, 21 | "object": str, 22 | } 23 | 24 | 25 | def _infer_param_type(param: dict[str, Any]): 26 | """Infiera el tipo Python a partir de OpenAPI.""" 27 | schema = param.get("schema", {}) 28 | param_type = schema.get("type", "string") # Por defecto, string 29 | return OPENAPI_TO_PYTHON.get(param_type, str) 30 | 31 | 32 | def _camel_case_to_snake_case(camel_case_str): 33 | return re.sub(r"(? None: 45 | """Construct a project widget.""" 46 | super().__init__(*args, **kwargs) 47 | self.project = project 48 | 49 | def _get_markers(self) -> str: 50 | MARKER_FIELDS = {"applications", "mariadb", "mongo", "mysql", "postgres", "redis", "compose"} 51 | markers = [] 52 | num_services = 0 53 | for marker in MARKER_FIELDS: 54 | num = len(getattr(self.project, marker, [])) 55 | markers.append(f"{self.SERVICE_ICONS[marker]} {num}") 56 | num_services += num 57 | return f"{num_services} services" + ((": " + " ".join(markers)) if markers else "") 58 | 59 | def compose(self) -> "ComposeResult": 60 | """Compose the widget.""" 61 | yield Label(self.ICON, id="icon") 62 | yield Label(self.project.name, id="name", classes="title") 63 | yield Label(self.project.description or "", id="description") 64 | yield Label(self._get_markers(), id="markers") 65 | yield Label("Created " + self.project.time_since_created, id="time_since_created") 66 | 67 | 68 | class ProjectsScreen(Screen): 69 | """Projects screen.""" 70 | 71 | BINDINGS = [ 72 | Binding("r", "refresh_projects", "Refresh"), 73 | ] 74 | 75 | def __init__(self, connection: ConnectionConfig, *args, **kwargs) -> None: 76 | """Construct a project detail screen.""" 77 | super().__init__(*args, **kwargs) 78 | self.connection = connection 79 | 80 | def compose(self) -> "ComposeResult": 81 | """Create child widgets for the app.""" 82 | yield Header() 83 | yield Footer() 84 | yield MainMenu(active_screen="Projects") 85 | yield ListView() 86 | yield LoadingIndicator(id="loading") 87 | 88 | def on_screen_resume(self, event: events.ScreenResume) -> None: 89 | """On mount.""" 90 | assert hasattr(self.app, "connection") 91 | self.app.sub_title = f"{self.app.connection.name} - Projects" 92 | self.action_refresh_projects() 93 | 94 | def action_refresh_projects(self) -> None: 95 | """Refresh projects list.""" 96 | assert hasattr(self.app, "connection") 97 | self.run_worker(self._update_projects(self.app.connection), exclusive=True) 98 | 99 | async def _update_projects(self, connection: ConnectionConfig) -> None: 100 | """Update the weather for the given city.""" 101 | loading = self.query_one(LoadingIndicator) 102 | loading.classes = "" 103 | 104 | # Query the network API 105 | response = run_command(self.connection, method="GET", route="project.all") 106 | match response: 107 | case Response(): 108 | await self._load_response(response) 109 | case HTTPError(): 110 | self.notify( 111 | f"API error: {response!r}!", 112 | severity="error", 113 | timeout=10, 114 | ) 115 | log("api error", response, self.connection) 116 | loading.classes = "hidden" 117 | 118 | async def _load_response(self, response: Response) -> None: 119 | list_view = self.query_one(ListView) 120 | loading = self.query_one(LoadingIndicator) 121 | data = response.json() 122 | await list_view.clear() 123 | list_view.extend( 124 | [ 125 | *(ProjectListItem(Project.model_validate(item)) for item in data), 126 | AddItem(id="__add__", item_name="project"), 127 | ] 128 | ) 129 | loading.classes = "hidden" 130 | list_view.focus() 131 | 132 | def on_list_view_selected(self, event: ListView.Selected) -> None: 133 | """On list view selected.""" 134 | match type(event.item): 135 | case ProjectListItem(): 136 | self.app.push_screen(ProjectDetailScreen(event.item.project, classes="Projects")) 137 | case AddItem(): 138 | self.app.push_screen("AddProject") 139 | -------------------------------------------------------------------------------- /dokli/tui/screens/connections.py: -------------------------------------------------------------------------------- 1 | """Connections screen.""" 2 | 3 | import contextlib 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING 6 | 7 | from textual import events, log 8 | from textual.css.query import NoMatches 9 | from textual.message import Message 10 | from textual.reactive import reactive 11 | from textual.screen import Screen 12 | from textual.widgets import Button, Footer, Header, Label, ListItem, ListView 13 | 14 | from dokli.config import ConnectionConfig 15 | from dokli.tui.screens.connection import ConnectionResult, ConnectionScreen 16 | from dokli.tui.widgets.list_item import AddItem 17 | 18 | if TYPE_CHECKING: 19 | from textual.app import ComposeResult 20 | 21 | 22 | class ConnectionWidget(ListItem): 23 | """A Dokploy Connection.""" 24 | 25 | ICON = "" 26 | 27 | def __init__(self, connection: ConnectionConfig, *args, **kwargs) -> None: 28 | """Construct a connection widget.""" 29 | super().__init__(*args, **kwargs) 30 | self.connection = connection 31 | 32 | def compose(self) -> "ComposeResult": 33 | """Create child widgets.""" 34 | yield Label(self.ICON, id="icon") 35 | yield Label(self.connection.name, id="name", classes="title") 36 | yield Label(f" {self.connection.url}", id="url") 37 | yield Label(f" {self.connection.notes}" if self.connection.notes else "", id="notes") 38 | 39 | 40 | class ConnectionsScreen(Screen): 41 | """Connections Screen.""" 42 | 43 | connections: reactive[list[ConnectionConfig]] = reactive([]) 44 | 45 | @dataclass 46 | class AddConnection(Message): 47 | """New connection message.""" 48 | 49 | connection: ConnectionConfig 50 | 51 | @dataclass 52 | class UpdateConnection(Message): 53 | """Update connection message.""" 54 | 55 | connection: ConnectionConfig 56 | 57 | @dataclass 58 | class DeleteConnection(Message): 59 | """Delete connection message.""" 60 | 61 | connection: ConnectionConfig 62 | 63 | @dataclass 64 | class SetConnection(Message): 65 | """Set connection message.""" 66 | 67 | connection: ConnectionConfig 68 | 69 | BINDINGS = [ 70 | ("a", "add_connection", "Add Connection"), 71 | ("e", "edit_connection", "Edit Connection"), 72 | ("d", "delete_connection", "Delete Connection"), 73 | ] 74 | 75 | def __init__(self, connections: list[ConnectionConfig] | None = None) -> None: 76 | """Construct a connections screen.""" 77 | super().__init__() 78 | self.connections = connections or [] 79 | 80 | def compose(self) -> "ComposeResult": 81 | """Create child widgets for the app.""" 82 | yield Header() 83 | yield Footer() 84 | yield ListView( 85 | *self._get_connection_widgets(), 86 | id="connections-list", 87 | ) 88 | 89 | def on_screen_resume(self, event: events.ScreenResume) -> None: 90 | """On screen resume.""" 91 | self.app.sub_title = "Connections" 92 | 93 | def on_button_pressed(self, event: Button.Pressed) -> None: 94 | """On button click.""" 95 | if event.button.id == "edit": 96 | assert isinstance(event.button.parent, ConnectionWidget) 97 | self._show_connection_screen(event.button.parent.connection) 98 | 99 | def _show_connection_screen(self, connection: ConnectionConfig | None = None) -> None: 100 | detail = ConnectionScreen(connection) 101 | self.app.push_screen(detail, self._edit_connection_callback) 102 | 103 | def action_edit_connection(self) -> None: 104 | """Edit connection.""" 105 | list_view = self.query_one(ListView) 106 | if isinstance(list_view.highlighted_child, ConnectionWidget): 107 | self._show_connection_screen(list_view.highlighted_child.connection) 108 | 109 | def action_add_connection(self) -> None: 110 | """New connection.""" 111 | detail = ConnectionScreen() 112 | self.app.push_screen(detail, self._add_connection_callback) 113 | 114 | def _add_connection_callback(self, result: ConnectionResult | None) -> None: 115 | if result and result.connection: 116 | self.connections = [*self.connections, result.connection] 117 | self.post_message(self.AddConnection(result.connection)) 118 | 119 | def _edit_connection_callback(self, result: ConnectionResult | None) -> None: 120 | if not result or not result.connection: 121 | return 122 | message = ( 123 | self.UpdateConnection(result.connection) if not result.delete else self.DeleteConnection(result.connection) 124 | ) 125 | log("message", message) 126 | self.post_message(message) 127 | 128 | def on_list_view_selected(self, event: ListView.Selected) -> None: 129 | """Select cursor.""" 130 | if event.item.id == "__add__": 131 | self.action_add_connection() 132 | elif event.item.id: 133 | idx = int(event.item.id.split("__")[-1]) 134 | self.post_message(self.SetConnection(self.connections[idx])) 135 | 136 | def _get_connection_widgets(self) -> list[ConnectionWidget | AddItem]: 137 | return [ 138 | *(ConnectionWidget(c, id=f"conn__{n}") for n, c in enumerate(self.connections or [])), 139 | AddItem(id="__add__", item_name="connection"), 140 | ] 141 | 142 | async def watch_connections( 143 | self, 144 | old_connections: list[ConnectionConfig] | None, 145 | new_connections: list[ConnectionConfig] | None, 146 | ) -> None: 147 | """Watch connections.""" 148 | log("Connections changed", new_connections, self.connections) 149 | with contextlib.suppress(NoMatches): 150 | list = self.query_one(ListView) 151 | await list.clear() 152 | list.extend(self._get_connection_widgets()) 153 | -------------------------------------------------------------------------------- /dokli/tui/forms.py: -------------------------------------------------------------------------------- 1 | """Forms for TUI.""" 2 | 3 | import json 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any, Generic, TypeVar 6 | 7 | from pydantic import BaseModel, SecretBytes, SecretStr, ValidationError 8 | from pydantic_core import ErrorDetails 9 | from textual import log 10 | from textual.containers import Container 11 | from textual.css.query import NoMatches 12 | from textual.message import Message 13 | from textual.reactive import reactive 14 | from textual.widgets import Input, Label, Static 15 | 16 | if TYPE_CHECKING: 17 | from textual.app import ComposeResult 18 | 19 | 20 | M = TypeVar("M", bound=BaseModel) 21 | 22 | 23 | class FormControl(Static): 24 | """Form control widget.""" 25 | 26 | label = reactive("") 27 | value = reactive("") 28 | placeholder = reactive("") 29 | default = reactive("") 30 | error: reactive[ErrorDetails | None] = reactive(None) 31 | 32 | def __init__( 33 | self, 34 | id: str, 35 | label: str, 36 | value: str = "", 37 | placeholder: str = "", 38 | default: str = "", 39 | error: ErrorDetails | None = None, 40 | password: bool = False, 41 | **kwargs, 42 | ) -> None: 43 | """Construct a form control widget.""" 44 | super().__init__(id=id, **kwargs) 45 | self.label = label 46 | self.value = value 47 | self.placeholder = placeholder 48 | self.default = default 49 | self.password = password 50 | self.error = error 51 | 52 | def compose(self) -> "ComposeResult": 53 | """Yield children widgets.""" 54 | yield Label( 55 | self.label, 56 | id=f"{self.id}-label", 57 | classes="hidden form-label" if not self.label else "form-label", 58 | ) 59 | yield Input( 60 | self.value, 61 | id=f"{self.id}-input", 62 | placeholder=self.placeholder, 63 | password=self.password, 64 | ) 65 | yield Label( 66 | str(self.error) if self.error else "ERRORR", 67 | id=f"{self.id}-error", 68 | classes="error-msg", 69 | ) 70 | 71 | @staticmethod 72 | def from_field(name, field, **kwargs) -> "FormControl": 73 | """Construct a form control from a pydantic field.""" 74 | return FormControl( 75 | id=name, 76 | label=field.title if field.title else name.replace("_", " ").title(), 77 | placeholder=field.description or "", 78 | password=field.annotation in (SecretStr, SecretBytes), 79 | **kwargs, 80 | ) 81 | 82 | def watch_value(self, old_value: str, new_value: str) -> None: 83 | """Watch value changes.""" 84 | self.value = new_value 85 | try: 86 | input = self.query_one(f"#{self.id}-input") 87 | assert isinstance(input, Input) 88 | input.value = new_value 89 | except NoMatches: 90 | pass 91 | 92 | def watch_error(self, old_value: ErrorDetails | None, new_value: ErrorDetails | None) -> None: 93 | """Watch error changes.""" 94 | self.classes = (self.classes - {"error"}) if new_value is None else {"error", *self.classes} 95 | try: 96 | error = self.query_one(f"#{self.id}-error") 97 | assert isinstance(error, Label), "should be a label" 98 | error.renderable = new_value.get("msg") or "" if new_value else "" 99 | except NoMatches: 100 | pass 101 | 102 | def on_input_changed(self, event: Input.Changed) -> None: 103 | """On input changed.""" 104 | self.value = event.value 105 | 106 | def reset(self, reset_classes=True, reset_value=True, reset_error=True) -> None: 107 | """Reset.""" 108 | if reset_value: 109 | self.value = self.default 110 | if reset_classes: 111 | self.classes = [] 112 | if reset_error: 113 | self.error = None 114 | 115 | 116 | class Form(Generic[M], Container): 117 | """Form Widget.""" 118 | 119 | @dataclass 120 | class FormValid(Message): 121 | """Form is valid.""" 122 | 123 | data: dict[str, Any] 124 | instance: BaseModel | None 125 | 126 | class FormInvalid(Message): 127 | """Form is invalid.""" 128 | 129 | data: reactive[dict[str, Any]] = reactive(dict) 130 | model: reactive[type[M] | None] = reactive(None) 131 | cleaned_data: dict[str, Any] | None = None 132 | instance: M | None = None 133 | 134 | def __init__( 135 | self, 136 | *controls: FormControl, 137 | data: dict[str, Any] | None = None, 138 | instance: M | None = None, 139 | model: type[M] | None = None, 140 | validate_on_input: bool = True, 141 | **kwargs, 142 | ) -> None: 143 | """Construct a form widget.""" 144 | super().__init__(*controls, **kwargs) 145 | self.fields = {c.id: c for c in controls if isinstance(c, FormControl)} 146 | self.instance = instance 147 | self.data = data or (instance.model_dump() if instance else {}) 148 | self.model = model if not instance else type(instance) 149 | self.cleaned_data = None 150 | self.validate_on_input = validate_on_input 151 | 152 | @classmethod 153 | def from_model(cls, model: type[M], instance: M | None = None, **kwargs) -> "Form": 154 | """Construct a form form a model.""" 155 | data = cls._get_data_from_instance(instance) 156 | log("Form", data) 157 | controls = ( 158 | FormControl.from_field(name=name, field=field, value=data.get(name, "")) 159 | for n, (name, field) in enumerate(model.model_fields.items()) 160 | ) 161 | return cls(*controls, model=model, instance=instance, **kwargs) 162 | 163 | @classmethod 164 | def _get_data_from_instance(cls, instance: BaseModel | None) -> dict[str, Any]: 165 | return {} if instance is None else json.loads(instance.model_dump_json()) 166 | 167 | def on_input_changed(self, event: Input.Changed) -> None: 168 | """On input changed.""" 169 | if self.validate_on_input: 170 | self.validate() 171 | 172 | def validate(self) -> bool: 173 | """Validate form data against model, if any.""" 174 | data = self._get_form_data() 175 | self.reset(reset_value=False) 176 | if not self.model: 177 | self.cleaned_data = data 178 | return True 179 | try: 180 | self.instance = self.model.model_validate(data) 181 | self.cleaned_data = self.instance.model_dump() 182 | self.post_message(self.FormValid(data=self.cleaned_data or {}, instance=self.instance)) 183 | return True 184 | except ValidationError as err: 185 | self._set_errors(err.errors()) 186 | self.post_message(self.FormInvalid()) 187 | return False 188 | 189 | @property 190 | def is_valid(self): 191 | """Return whether the form is valid.""" 192 | return self.validate() 193 | 194 | def reset( 195 | self, 196 | reset_value=True, 197 | reset_classes=True, 198 | reset_instance=True, 199 | reset_cleaned_data=True, 200 | ) -> None: 201 | """Reset.""" 202 | if reset_cleaned_data: 203 | self.cleaned_data = None 204 | if reset_instance: 205 | self.instance = None 206 | for child in self.children: 207 | if not isinstance(child, FormControl): 208 | continue 209 | child.reset(reset_value=reset_value, reset_classes=reset_classes) 210 | 211 | def _set_errors(self, errors: list[ErrorDetails]) -> None: 212 | for error in errors: 213 | loc = str(error["loc"][-1]) 214 | field = self.fields.get(loc) 215 | assert field, f"Unknown field: {loc}" 216 | field.error = error 217 | 218 | def _get_form_data(self) -> dict[str, Any]: 219 | data = {child.id: child.value for child in self.children if child.id and isinstance(child, FormControl)} 220 | return data 221 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dokli 2 | 3 | [![Python package](https://github.com/jonykalavera/dokli/actions/workflows/python-package.yml/badge.svg)](https://github.com/jonykalavera/dokli/actions/workflows/python-package.yml) 4 | 5 | A magical CLI/TUI for interacting with [Dokploy](https://github.com/Dokploy/dokploy). 6 | 7 | ```txt 8 | █ 9 | ████ 10 | ███████ █ 11 | █████████████████████████ ████████ ████████ 12 | ███████████████████████████████ ██████████████████ 13 | ████ █████████ ██████████████ 14 | ████ ███ █████████ ████ 15 | ███ ███ ██████████ █████ 16 | ███ ████████████████████████ 17 | ████████████████ █████████████████ 18 | ██████████████████████ ███████ 19 | ████ ██████████ ██████████ ██████ 20 | ██████ ███████████████████████████████ ████████ 21 | ████████ ████████████████████ ████████ 22 | █ █████████ ████████ ███ 23 | █████ █████████ ████████ ██████ 24 | ████████ ███████████ ███████████ █████████ 25 | ████ ██████ ████████████████████████████████ ███████ ████ 26 | ████ ███████ ████████████████████ ███████ ████ 27 | █████ ████████ ████████ ██████ 28 | ███████ ████████ █████████ ███████ 29 | █████████ ████████████ ███████████ ██████████ 30 | ████ ██████ ████████████████████████████ ███████ ████ 31 | ████ ██████ ██████████████ ██████ ████ 32 | █████ ███████ ████████ █████ 33 | █████ █████████ █████████ █████ 34 | ██████ ████████████████████████████████ ██████ 35 | ██████ ██████████████████████ ██████ 36 | ███████ ██████ 37 | ████████ ████████ 38 | ███████████ ██████████ 39 | ██████████████████████████ 40 | ████████████ 41 | ``` 42 | 43 | ## Installation 44 | 45 | ```bash 46 | pip install git+https://github.com/jonykalavera/dokli.git 47 | # with TUI support 48 | pip install git+https://github.com/jonykalavera/dokli.git#egg=dokli[tui] 49 | ``` 50 | 51 | Tested with Dokploy versions: 52 | 53 | - 0.6.1 54 | - 0.18.1 55 | 56 | ## Configuration 57 | 58 | Create the configuration file at `~/.config/dokli/dokli.yaml`. Example: 59 | 60 | ```yaml 61 | connections: 62 | - name: test-env 63 | url: https://test.example.com 64 | api_key: **************************************** 65 | notes: "Our test environment. Handle with care!" 66 | - name: prod-env 67 | url: https://prod.example.com 68 | api_key_cmd: "secret-tool lookup dokli prodEnvApikey" 69 | notes: "Our prod environment. Handle with even more care!" 70 | ``` 71 | 72 | You can use `api_key_cmd` to load the API key from a command such as [secret-tool](https://manpages.org/secret-tool) instead of entering it in the config file. This is highly recommended for security reasons. 73 | 74 | Configuration uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) which means it can also be set via [environment variables](https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values) using the `DOKLI_` prefix. 75 | 76 | ## CLI 77 | 78 | ### Features 79 | 80 | - Commands are inferred from the OpenAPI spec, which allows: 81 | - support for multiple Dokploy API versions. 82 | - support for all API entities actions/verbs. 83 | - magical JSON parameters `%json:{"projectId": "daspdoada798sda"}` 84 | - magical file parameters `%file:/path/to/data/foo.redis.json` 85 | - output formats: 86 | - yaml 87 | - json 88 | - python 89 | - table (experimental) 90 | 91 | ## Usage 92 | 93 | ```bash 94 | $ dokly 95 | 96 | 97 | Usage: dokli [OPTIONS] COMMAND [ARGS]... 98 | 99 | Magical Dokploy CLI/TUI. 100 | 101 | ╭─ Options ────────────────────────────────────────────────────────────────────╮ 102 | │ --install-completion Install completion for the current shell. │ 103 | │ --show-completion Show completion for the current shell, to copy │ 104 | │ it or customize the installation. │ 105 | │ --help Show this message and exit. │ 106 | ╰──────────────────────────────────────────────────────────────────────────────╯ 107 | ╭─ Commands ───────────────────────────────────────────────────────────────────╮ 108 | │ api API commands. │ 109 | │ tui Text User Interface. │ 110 | ╰──────────────────────────────────────────────────────────────────────────────╯ 111 | 112 | 113 | $ dokly api test-env project all 114 | - adminId: ysHDHlhX4a3zOG2fLsske 115 | applications: [] 116 | compose: [] 117 | createdAt: '2024-08-05T02:45:38.168Z' 118 | description: null 119 | mariadb: [] 120 | mongo: [] 121 | mysql: [] 122 | name: Dokli 123 | postgres: [] 124 | projectId: zuanf1SWHMFO11y6xqpRR 125 | redis: [] 126 | 127 | $ dokli api test-env project create --body '%json:{"name": "Dokli"}' --format table 128 | API Response 129 | ┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓ 130 | ┃ Key ┃ Value ┃ 131 | ┡━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩ 132 | │ projectId │ zuanf1SWHMFO11y6xqpRR 133 | │ name │ Dokli │ 134 | │ description │ None │ 135 | │ createdAt │ 2024-08-05T02:45:38.168Z │ 136 | │ adminId │ ysHDHlhX4a3zOG2fLsske │ 137 | └─────────────┴──────────────────────────┘ 138 | 139 | $ dokli api test-env project one --format json zuanf1SWHMFO11y6xqpRR 140 | {"projectId": "zuanf1SWHMFO11y6xqpRR", "name": "Dokli", "description": null, 141 | "createdAt": "2024-08-05T02:45:38.168Z", "adminId": "ysHDHlhX4a3zOG2fLsske", 142 | "applications": [], "mariadb": [], "mongo": [], "mysql": [], "postgres": [], 143 | "redis": [], "compose": []} 144 | ``` 145 | 146 | ## TUI 147 | 148 | Still a WIP. Basic functionality will be implemented at 0.2.0 release. 149 | 150 | ![Screenshot from 2024-08-04 23-39-14](https://github.com/user-attachments/assets/9943d053-f3a6-40dd-90b7-07502fb81925) 151 | ![Screenshot from 2024-08-04 23-39-04](https://github.com/user-attachments/assets/acce2413-7b48-472d-899a-71d469b6113d) 152 | ![Screenshot from 2024-08-05 00-06-58](https://github.com/user-attachments/assets/17fefe01-e072-4c18-8cc1-159de9e94adc) 153 | 154 | [http://www.youtube.com/watch?v=IAnHfFV9_jU](http://www.youtube.com/watch?v=IAnHfFV9_jU) 155 | 156 | ## Motivation 157 | 158 | The CLI is designed to keep up with any changes in the API. Commands are dynamically inferred from the OpenAPI spec. 159 | I did this because I want to do some test automation and the official CLI seems incomplete at the moment. The TUI is because I am into tools like [yazi](https://yazi-rs.github.io/), [lazygit](https://github.com/jesseduffield/lazygit), [k9s](https://k9scli.io/), [dry](https://github.com/moncho/dry), etc. I like to keep my terminal open at all times `$`. 160 | Also, it seemed to me like something cool to do this weekend. I learned a bunch about [texual](https://textual.textualize.io/), [typer](https://github.com/tiangolo/typer) and [Dokploy](https://github.com/Dokploy/dokploy). 161 | 162 | ## Buy me a 🌮 163 | 164 | I'm Mexican, I prefer tacos. But ☕ is also nice. You can use the 🫶 sponsor button on the top. 165 | 166 | Also pretty please and thanks in advance 🥺. 167 | -------------------------------------------------------------------------------- /dokli/tui/asciiart/dokploy-logo.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | █ 17 | ████ 18 | ███████ █ 19 | █████████████████████████ ████████ ████████ 20 | ███████████████████████████████ ██████████████████ 21 | ████ █████████ ██████████████ 22 | ████ ███ █████████ ████ 23 | ███ ███ ██████████ █████ 24 | ███ ████████████████████████ 25 | ████████████████ █████████████████ 26 | ██████████████████████ ███████ 27 | ████ ██████████ ██████████ ██████ █████████ ███ ███ 28 | ██████ ███████████████████████████████ ████████ ███ ████ ███ ███ 29 | ████████ ████████████████████ ████████ ███ ███ ██████ ███ ███ ███ █████ ███ ██████ ███ ███ 30 | █ █████████ ████████ ███ ███ ██ ███ ███ ███ ███ ████ ███ ███ ███ ███ ███ ███ 31 | █████ █████████ ████████ ██████ ███ ██ ███ ███ ██████ ███ ███ ███ ██ ██ ██ ██ 32 | ████████ ███████████ ███████████ █████████ ███ ███ ███ ███ ██████ ███ ███ ███ ██ ██ ███ ██ 33 | ████ ██████ ████████████████████████████████ ███████ ████ ███ ███ ███ ██ ███ ███ ████ ███ ███ ███ ███ ██ ██ 34 | ████ ███████ ████████████████████ ███████ ████ ██████████ ███████ ███ ███ ███ █████ ███ ███████ ███ 35 | █████ ████████ ████████ ██████ ███ ███ 36 | ███████ ████████ █████████ ███████ ███ █████ 37 | █████████ ████████████ ███████████ ██████████ 38 | ████ ██████ ████████████████████████████ ███████ ████ 39 | ████ ██████ ██████████████ ██████ ████ 40 | █████ ███████ ████████ █████ 41 | █████ █████████ █████████ █████ 42 | ██████ ████████████████████████████████ ██████ 43 | ██████ ██████████████████████ ██████ 44 | ███████ ██████ 45 | ████████ ████████ 46 | ███████████ ██████████ 47 | ██████████████████████████ 48 | ████████████ 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | --------------------------------------------------------------------------------