浏览代码

icon manager

xcad 6 月之前
父节点
当前提交
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 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 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 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
 ## Best Practices for Template Development
 
 

+ 136 - 25
cli/core/display.py

@@ -15,6 +15,130 @@ logger = logging.getLogger(__name__)
 console = Console()
 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:
 class DisplayManager:
     """Handles all rich rendering for the CLI."""
     """Handles all rich rendering for the CLI."""
 
 
@@ -86,7 +210,7 @@ class DisplayManager:
         console.print()
         console.print()
         console.print("[bold blue]Template File Structure:[/bold blue]")
         console.print("[bold blue]Template File Structure:[/bold blue]")
         # Use the template id as the root directory label (folder glyph + white name)
         # 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}
         tree_nodes = {Path("."): file_tree}
 
 
         for template_file in sorted(
         for template_file in sorted(
@@ -99,7 +223,7 @@ class DisplayManager:
             for part in parts[:-1]:
             for part in parts[:-1]:
                 current_path = current_path / part
                 current_path = current_path / part
                 if current_path not in tree_nodes:
                 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
                     tree_nodes[current_path] = new_node
                     current_node = new_node
                     current_node = new_node
                 else:
                 else:
@@ -108,23 +232,9 @@ class DisplayManager:
             # Determine display name (use output_path to detect final filename)
             # 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
             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:
         if file_tree.children:
             console.print(file_tree)
             console.print(file_tree)
@@ -175,7 +285,7 @@ class DisplayManager:
                 default_val = variable.get_display_value(mask_sensitive=True, max_length=30)
                 default_val = variable.get_display_value(mask_sensitive=True, max_length=30)
                 
                 
                 # Add lock icon for sensitive variables
                 # 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}"
                 var_display = f"  {var_name}{sensitive_icon}"
 
 
                 variables_table.add_row(
                 variables_table.add_row(
@@ -206,7 +316,7 @@ class DisplayManager:
         console.print("[bold]Files to be generated:[/bold]")
         console.print("[bold]Files to be generated:[/bold]")
         
         
         # Create a tree view of files
         # 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}
         tree_nodes = {Path("."): file_tree}
         
         
         # Sort files for better display
         # Sort files for better display
@@ -222,18 +332,19 @@ class DisplayManager:
             for part in parts[:-1]:
             for part in parts[:-1]:
                 current_path = current_path / part
                 current_path = current_path / part
                 if current_path not in tree_nodes:
                 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
                     tree_nodes[current_path] = new_node
                 current_node = tree_nodes[current_path]
                 current_node = tree_nodes[current_path]
             
             
             # Add file with indicator if it will be overwritten
             # Add file with indicator if it will be overwritten
             file_name = parts[-1]
             file_name = parts[-1]
             full_path = output_dir / file_path
             full_path = output_dir / file_path
+            icon = IconManager.get_file_icon(file_name)
             
             
             if existing_files and full_path in existing_files:
             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:
             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(file_tree)
         console.print()
         console.print()
@@ -251,7 +362,7 @@ class DisplayManager:
             return
             return
 
 
         # Create root tree node
         # 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():
         for section_name, section_data in spec.items():
             if not isinstance(section_data, dict):
             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.prompt import Prompt, Confirm, IntPrompt
 from rich.table import Table
 from rich.table import Table
 
 
-from .display import DisplayManager
+from .display import DisplayManager, IconManager
 from .variables import Variable, VariableCollection
 from .variables import Variable, VariableCollection
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -45,7 +45,7 @@ class PromptHandler:
         unsatisfied = [dep for dep in section.needs if not variables.is_section_satisfied(dep)]
         unsatisfied = [dep for dep in section.needs if not variables.is_section_satisfied(dep)]
         dep_names = ", ".join(unsatisfied) if unsatisfied else "unknown"
         dep_names = ", ".join(unsatisfied) if unsatisfied else "unknown"
         self.console.print(
         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}")
         logger.debug(f"Skipping section '{section_key}' - dependencies not satisfied: {dep_names}")
         continue
         continue

+ 10 - 0
cli/modules/compose.py

@@ -144,6 +144,16 @@ spec = OrderedDict(
             "type": "int",
             "type": "int",
             "default": 1,
             "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": {
       "database": {

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

@@ -7,8 +7,14 @@
 # Cloudflare API Token
 # Cloudflare API Token
 # Required permissions: Zone:DNS:Edit
 # Required permissions: Zone:DNS:Edit
 # Create token at: https://dash.cloudflare.com/profile/api-tokens
 # 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 }}
 CF_API_TOKEN={{ traefik_tls_acme_token }}
 {% endif %}
 {% endif %}
+{% endif %}
 
 
 {% else %}
 {% else %}
 # ACME/TLS is disabled - no DNS provider credentials needed
 # ACME/TLS is disabled - no DNS provider credentials needed

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

@@ -1,7 +1,9 @@
 services:
 services:
-  {{ service_name | default("traefik") }}:
+  {{ service_name }}:
     image: docker.io/library/traefik:v3.2
     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 %}
     {% if ports_enabled %}
     ports:
     ports:
       - "80:80"
       - "80:80"
@@ -24,7 +26,30 @@ services:
     networks:
     networks:
       - {{ network_name }}
       - {{ network_name }}
     {% endif %}
     {% 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 }}
     restart: {{ restart_policy }}
+    {% endif %}
+
+{% if swarm_enabled and traefik_tls_enabled %}
+secrets:
+  {{ traefik_tls_acme_secret_name }}:
+    external: true
+{% endif %}
 
 
 {% if network_enabled %}
 {% if network_enabled %}
 networks:
 networks:

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

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