xcad 5 miesięcy temu
rodzic
commit
81a3d86e26

+ 1 - 1
AGENTS.md

@@ -262,7 +262,7 @@ After creating the issue, update the TODO line in the `AGENTS.md` file with the
 * TODO Template Validation Command: A command to validate the structure and variable definitions of a template without generating it.
 * TODO Interactive Variable Prompt Improvements: The interactive prompt could be improved with better navigation, help text, and validation feedback.
 * TODO Better Error Recovery in Jinja2 Rendering
-* TODO Icon Management Class in DisplayManager: Create a centralized icon management system in `cli/core/display.py` to standardize icons used throughout the CLI for file types (.yaml, .j2, .json, etc.), status indicators (success, warning, error, info), and UI elements. This would improve consistency, make icons easier to maintain, and allow for theme customization.
+* FIXME Make sure all outputs in module.py and display.py use the IconManager for consistent icons
 
 ## Best Practices for Template Development
 

+ 136 - 25
cli/core/display.py

@@ -15,6 +15,130 @@ logger = logging.getLogger(__name__)
 console = Console()
 
 
+class IconManager:
+    """Centralized icon management system for consistent CLI display.
+    
+    This class provides standardized icons for file types, status indicators,
+    and UI elements. Icons use Nerd Font glyphs for consistent display.
+    
+    Categories:
+        - File types: .yaml, .j2, .json, .md, etc.
+        - Status: success, warning, error, info, skipped
+        - UI elements: folders, config, locks, etc.
+    """
+
+    # File Type Icons
+    FILE_FOLDER = "\uf07b"          # 
+    FILE_DEFAULT = "\uf15b"         # 
+    FILE_YAML = "\uf15c"            # 
+    FILE_JSON = "\ue60b"            # 
+    FILE_MARKDOWN = "\uf48a"        # 
+    FILE_JINJA2 = "\ue235"          # 
+    FILE_DOCKER = "\uf308"          # 
+    FILE_COMPOSE = "\uf308"         # 
+    FILE_SHELL = "\uf489"           # 
+    FILE_PYTHON = "\ue73c"          # 
+    FILE_TEXT = "\uf15c"            # 
+    
+    # Status Indicators
+    STATUS_SUCCESS = "\uf00c"       #  (check)
+    STATUS_ERROR = "\uf00d"         #  (times/x)
+    STATUS_WARNING = "\uf071"       #  (exclamation-triangle)
+    STATUS_INFO = "\uf05a"          #  (info-circle)
+    STATUS_SKIPPED = "\uf05e"       #  (ban/circle-slash)
+    
+    # UI Elements
+    UI_CONFIG = "\ue5fc"            # 
+    UI_LOCK = "\uf084"              # 
+    UI_SETTINGS = "\uf013"          # 
+    UI_ARROW_RIGHT = "\uf061"       #  (arrow-right)
+    UI_BULLET = "\uf111"            #  (circle)
+    
+    @classmethod
+    def get_file_icon(cls, file_path: str | Path) -> str:
+        """Get the appropriate icon for a file based on its extension or name.
+        
+        Args:
+            file_path: Path to the file (can be string or Path object)
+            
+        Returns:
+            Unicode icon character for the file type
+            
+        Examples:
+            >>> IconManager.get_file_icon("config.yaml")
+            '\uf15c'
+            >>> IconManager.get_file_icon("template.j2")
+            '\ue235'
+        """
+        if isinstance(file_path, str):
+            file_path = Path(file_path)
+        
+        file_name = file_path.name.lower()
+        suffix = file_path.suffix.lower()
+        
+        # Check for Docker Compose files
+        compose_names = {
+            "docker-compose.yml", "docker-compose.yaml",
+            "compose.yml", "compose.yaml"
+        }
+        if file_name in compose_names or file_name.startswith("docker-compose"):
+            return cls.FILE_DOCKER
+        
+        # Check by extension
+        extension_map = {
+            ".yaml": cls.FILE_YAML,
+            ".yml": cls.FILE_YAML,
+            ".json": cls.FILE_JSON,
+            ".md": cls.FILE_MARKDOWN,
+            ".j2": cls.FILE_JINJA2,
+            ".sh": cls.FILE_SHELL,
+            ".py": cls.FILE_PYTHON,
+            ".txt": cls.FILE_TEXT,
+        }
+        
+        return extension_map.get(suffix, cls.FILE_DEFAULT)
+    
+    @classmethod
+    def get_status_icon(cls, status: str) -> str:
+        """Get the appropriate icon for a status indicator.
+        
+        Args:
+            status: Status type (success, error, warning, info, skipped)
+            
+        Returns:
+            Unicode icon character for the status
+            
+        Examples:
+            >>> IconManager.get_status_icon("success")
+            '✓'
+            >>> IconManager.get_status_icon("warning")
+            '⚠'
+        """
+        status_map = {
+            "success": cls.STATUS_SUCCESS,
+            "error": cls.STATUS_ERROR,
+            "warning": cls.STATUS_WARNING,
+            "info": cls.STATUS_INFO,
+            "skipped": cls.STATUS_SKIPPED,
+        }
+        return status_map.get(status.lower(), cls.STATUS_INFO)
+    
+    @classmethod
+    def folder(cls) -> str:
+        """Get the folder icon."""
+        return cls.FILE_FOLDER
+    
+    @classmethod
+    def config(cls) -> str:
+        """Get the config icon."""
+        return cls.UI_CONFIG
+    
+    @classmethod
+    def lock(cls) -> str:
+        """Get the lock icon (for sensitive variables)."""
+        return cls.UI_LOCK
+
+
 class DisplayManager:
     """Handles all rich rendering for the CLI."""
 
@@ -86,7 +210,7 @@ class DisplayManager:
         console.print()
         console.print("[bold blue]Template File Structure:[/bold blue]")
         # Use the template id as the root directory label (folder glyph + white name)
-        file_tree = Tree(f"\uf07b [white]{template.id}[/white]")
+        file_tree = Tree(f"{IconManager.folder()} [white]{template.id}[/white]")
         tree_nodes = {Path("."): file_tree}
 
         for template_file in sorted(
@@ -99,7 +223,7 @@ class DisplayManager:
             for part in parts[:-1]:
                 current_path = current_path / part
                 if current_path not in tree_nodes:
-                    new_node = current_node.add(f"\uf07b [white]{part}[/white]")
+                    new_node = current_node.add(f"{IconManager.folder()} [white]{part}[/white]")
                     tree_nodes[current_path] = new_node
                     current_node = new_node
                 else:
@@ -108,23 +232,9 @@ class DisplayManager:
             # Determine display name (use output_path to detect final filename)
             display_name = template_file.output_path.name if hasattr(template_file, 'output_path') else template_file.relative_path.name
 
-            # Default icons: use Nerd Font private-use-area codepoints (PUA).
-            # Docker (Font Awesome) is typically U+F308. Default file U+F15B.
-            docker_icon = "\uf308"
-            default_file_icon = "\uf15b"
-            j2_icon = "\ue235"
-
-            if template_file.file_type == "j2":
-                # Detect common docker compose filenames from the resulting output path
-                lower_name = display_name.lower()
-                compose_names = {"docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"}
-                if lower_name in compose_names or lower_name.startswith("docker-compose") or "compose" in lower_name:
-                    icon = docker_icon
-                else:
-                    icon = j2_icon
-                current_node.add(f"[white]{icon} {display_name}[/white]")
-            elif template_file.file_type == "static":
-                current_node.add(f"[white]{default_file_icon} {display_name}[/white]")
+            # Get appropriate icon based on file type/name
+            icon = IconManager.get_file_icon(display_name)
+            current_node.add(f"[white]{icon} {display_name}[/white]")
 
         if file_tree.children:
             console.print(file_tree)
@@ -175,7 +285,7 @@ class DisplayManager:
                 default_val = variable.get_display_value(mask_sensitive=True, max_length=30)
                 
                 # Add lock icon for sensitive variables
-                sensitive_icon = " \uf084" if variable.sensitive else ""
+                sensitive_icon = f" {IconManager.lock()}" if variable.sensitive else ""
                 var_display = f"  {var_name}{sensitive_icon}"
 
                 variables_table.add_row(
@@ -206,7 +316,7 @@ class DisplayManager:
         console.print("[bold]Files to be generated:[/bold]")
         
         # Create a tree view of files
-        file_tree = Tree(f"\uf07b [cyan]{output_dir.resolve()}[/cyan]")
+        file_tree = Tree(f"{IconManager.folder()} [cyan]{output_dir.resolve()}[/cyan]")
         tree_nodes = {Path("."): file_tree}
         
         # Sort files for better display
@@ -222,18 +332,19 @@ class DisplayManager:
             for part in parts[:-1]:
                 current_path = current_path / part
                 if current_path not in tree_nodes:
-                    new_node = current_node.add(f"\uf07b [white]{part}[/white]")
+                    new_node = current_node.add(f"{IconManager.folder()} [white]{part}[/white]")
                     tree_nodes[current_path] = new_node
                 current_node = tree_nodes[current_path]
             
             # Add file with indicator if it will be overwritten
             file_name = parts[-1]
             full_path = output_dir / file_path
+            icon = IconManager.get_file_icon(file_name)
             
             if existing_files and full_path in existing_files:
-                current_node.add(f"\uf15c [yellow]{file_name}[/yellow] [red](will overwrite)[/red]")
+                current_node.add(f"{icon} [yellow]{file_name}[/yellow] [red](will overwrite)[/red]")
             else:
-                current_node.add(f"\uf15c [green]{file_name}[/green]")
+                current_node.add(f"{icon} [green]{file_name}[/green]")
         
         console.print(file_tree)
         console.print()
@@ -251,7 +362,7 @@ class DisplayManager:
             return
 
         # Create root tree node
-        tree = Tree(f"[bold blue]\ue5fc {str.capitalize(module_name)} Configuration[/bold blue]")
+        tree = Tree(f"[bold blue]{IconManager.config()} {str.capitalize(module_name)} Configuration[/bold blue]")
 
         for section_name, section_data in spec.items():
             if not isinstance(section_data, dict):

+ 2 - 2
cli/core/prompt.py

@@ -6,7 +6,7 @@ from rich.console import Console
 from rich.prompt import Prompt, Confirm, IntPrompt
 from rich.table import Table
 
-from .display import DisplayManager
+from .display import DisplayManager, IconManager
 from .variables import Variable, VariableCollection
 
 logger = logging.getLogger(__name__)
@@ -45,7 +45,7 @@ class PromptHandler:
         unsatisfied = [dep for dep in section.needs if not variables.is_section_satisfied(dep)]
         dep_names = ", ".join(unsatisfied) if unsatisfied else "unknown"
         self.console.print(
-          f"\n[dim] {section.title} (skipped - requires {dep_names} to be enabled)[/dim]"
+          f"\n[dim]{IconManager.get_status_icon('skipped')} {section.title} (skipped - requires {dep_names} to be enabled)[/dim]"
         )
         logger.debug(f"Skipping section '{section_key}' - dependencies not satisfied: {dep_names}")
         continue

+ 10 - 0
cli/modules/compose.py

@@ -144,6 +144,16 @@ spec = OrderedDict(
             "type": "int",
             "default": 1,
           },
+          "swarm_placement_mode": {
+            "description": "Swarm placement mode",
+            "type": "enum",
+            "options": ["global", "replicated"],
+            "default": "replicated"
+          },
+          "swarm_placement_host": {
+            "description": "Limit placement to specific node",
+            "type": "str",
+          }
         },
       },
       "database": {

+ 6 - 0
library/compose/traefik/.env.j2

@@ -7,8 +7,14 @@
 # Cloudflare API Token
 # Required permissions: Zone:DNS:Edit
 # Create token at: https://dash.cloudflare.com/profile/api-tokens
+{% if swarm_enabled %}
+# Swarm mode: API token read from Docker secret
+CF_DNS_API_TOKEN_FILE=/run/secrets/{{ traefik_tls_acme_secret_name }}
+{% else %}
+# Standard mode: API token from environment variable
 CF_API_TOKEN={{ traefik_tls_acme_token }}
 {% endif %}
+{% endif %}
 
 {% else %}
 # ACME/TLS is disabled - no DNS provider credentials needed

+ 27 - 2
library/compose/traefik/compose.yaml.j2

@@ -1,7 +1,9 @@
 services:
-  {{ service_name | default("traefik") }}:
+  {{ service_name }}:
     image: docker.io/library/traefik:v3.2
-    container_name: {{ container_name | default("traefik") }}
+    {% if not swarm_enabled %}
+    container_name: {{ container_name }}
+    {% endif %}
     {% if ports_enabled %}
     ports:
       - "80:80"
@@ -24,7 +26,30 @@ services:
     networks:
       - {{ network_name }}
     {% endif %}
+    {% if swarm_enabled %}
+    {% if traefik_tls_enabled %}
+    secrets:
+      - {{ traefik_tls_acme_secret_name }}
+    {% endif %}
+    deploy:
+      mode: {{ swarm_placement_mode }}
+      {% if swarm_placement_mode == 'replicated' %}
+      replicas: {{ swarm_replicas }}
+      {% endif %}
+      {% if swarm_placement_host %}
+      placement:
+        constraints:
+          - {{ swarm_placement_host }}
+      {% endif %}
+    {% else %}
     restart: {{ restart_policy }}
+    {% endif %}
+
+{% if swarm_enabled and traefik_tls_enabled %}
+secrets:
+  {{ traefik_tls_acme_secret_name }}:
+    external: true
+{% endif %}
 
 {% if network_enabled %}
 networks:

+ 15 - 8
library/compose/traefik/template.yaml

@@ -14,15 +14,17 @@ metadata:
   author: "Christian Lempa"
   date: "2025-10-02"
   tags:
-    - traefik
     - reverse-proxy
     - load-balancer
-    - edge-router
 spec:
   general:
     title: "General"
     required: true
     vars:
+      service_name:
+        default: "traefik"
+      container_name:
+        default: "traefik"
       accesslog_enabled:
         type: "bool"
         description: "Enable Traefik access log"
@@ -53,6 +55,11 @@ spec:
         default: "your-api-token-here"
         sensitive: true
         extra: "For Cloudflare, create an API token with Zone:DNS:Edit permissions"
+      traefik_tls_acme_secret_name:
+        type: "str"
+        description: "Docker Swarm secret name for API token (swarm mode only)"
+        default: "cloudflare_api_token"
+        extra: "The secret name to use in Docker Swarm for storing the API token"
       traefik_tls_acme_email:
         type: "str"
         description: "Email address for ACME (Let's Encrypt) registration"
@@ -63,14 +70,8 @@ spec:
         description: "Redirect all HTTP traffic to HTTPS"
         default: true
   ports:
-    name: "Ports"
-    prompt: "Expose ports via 'ports' mapping?"
     toggle: "ports_enabled"
     vars:
-      ports_enabled:
-        type: "bool"
-        description: "Expose ports via 'ports' mapping"
-        default: true
       traefik_dashboard_enabled:
         type: "bool"
         description: "Enable Traefik dashboard (don't use in production)"
@@ -82,6 +83,12 @@ spec:
         default: true
       network_name:
         default: "proxy"
+  swarm:
+    vars:
+      swarm_placement_mode:
+        default: "global"
+      swarm_placement_host:
+        description: "Placement constraint for node selection (optional)"
   authentik:
     title: Authentik Middleware
     description: Enable Authentik SSO integration for Traefik