feat(web): Add Lucide icons to web UI (#14)

This commit is contained in:
Bas Nijholt
2025-12-17 23:04:53 -08:00
committed by GitHub
parent 5afda8cbb2
commit 957e828a5b
12 changed files with 168 additions and 36 deletions

View File

@@ -28,6 +28,10 @@ compose_farm/
└── traefik.py # Traefik file-provider config generation from labels
```
## Web UI Icons
Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templates/partials/icons.html` by copying SVG paths from their site. The `action_btn`, `stat_card`, and `collapse` macros in `components.html` accept an optional `icon` parameter.
## Key Design Decisions
1. **Hybrid SSH approach**: asyncssh for parallel streaming with prefixes; native `ssh -t` for raw mode (progress bars)

View File

@@ -6,7 +6,7 @@ RUN apk add --no-cache openssh-client
# Install compose-farm from PyPI
ARG VERSION
RUN uv tool install compose-farm${VERSION:+==$VERSION}
RUN uv tool install "compose-farm[web]${VERSION:+==$VERSION}"
# Add uv tool bin to PATH
ENV PATH="/root/.local/bin:$PATH"

View File

@@ -12,7 +12,7 @@ services:
web:
image: ghcr.io/basnijholt/compose-farm:latest
command: cf web --host 0.0.0.0 --port 9000
command: web --host 0.0.0.0 --port 9000
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
- ${CF_COMPOSE_DIR:-/opt/stacks}:${CF_COMPOSE_DIR:-/opt/stacks}

View File

@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% from "partials/components.html" import page_header, collapse, stat_card, table, action_btn %}
{% from "partials/icons.html" import check, refresh_cw, save, settings, server, database %}
{% block title %}Dashboard - Compose Farm{% endblock %}
{% block content %}
@@ -11,15 +12,15 @@
<!-- Global Actions -->
<div class="flex flex-wrap gap-2 mb-6">
{{ action_btn("Apply", "/api/apply", "primary", "Make reality match config") }}
{{ action_btn("Refresh", "/api/refresh", "outline", "Update state from reality") }}
<button id="save-config-btn" class="btn btn-outline">Save Config</button>
{{ action_btn("Apply", "/api/apply", "primary", "Make reality match config", check()) }}
{{ action_btn("Refresh", "/api/refresh", "outline", "Update state from reality", refresh_cw()) }}
<button id="save-config-btn" class="btn btn-outline">{{ save() }} Save Config</button>
</div>
{% include "partials/terminal.html" %}
<!-- Config Editor -->
{% call collapse("Edit Config", badge="compose-farm.yaml") %}
{% call collapse("Edit Config", badge="compose-farm.yaml", icon=settings()) %}
<div class="editor-wrapper yaml-wrapper">
<div id="config-editor" class="yaml-editor" data-content="{{ config_content | e }}" data-save-url="/api/config"></div>
</div>
@@ -32,7 +33,7 @@
{% include "partials/services_by_host.html" %}
<!-- Hosts Configuration -->
{% call collapse("Hosts (" ~ (hosts | length) ~ ")") %}
{% call collapse("Hosts (" ~ (hosts | length) ~ ")", icon=server()) %}
{% call table() %}
<thead>
<tr>
@@ -56,7 +57,7 @@
{% endcall %}
<!-- State Viewer -->
{% call collapse("Raw State", badge="compose-farm-state.yaml") %}
{% call collapse("Raw State", badge="compose-farm-state.yaml", icon=database()) %}
<div class="editor-wrapper viewer-wrapper">
<div id="state-viewer" class="yaml-viewer" data-content="{{ state_content | e }}"></div>
</div>

View File

@@ -9,11 +9,11 @@
{% endmacro %}
{# Collapsible section #}
{% macro collapse(title, id=None, checked=False, badge=None) %}
{% macro collapse(title, id=None, checked=False, badge=None, icon=None) %}
<div class="collapse collapse-arrow bg-base-100 shadow mb-4">
<input type="checkbox" {% if id %}id="{{ id }}"{% endif %} {% if checked %}checked{% endif %} />
<div class="collapse-title font-medium">
{{ title }}
<div class="collapse-title font-medium flex items-center gap-2">
{% if icon %}{{ icon }}{% endif %}{{ title }}
{% if badge %}<code class="text-xs ml-2 opacity-60">{{ badge }}</code>{% endif %}
</div>
<div class="collapse-content">
@@ -23,20 +23,20 @@
{% endmacro %}
{# Action button with htmx #}
{% macro action_btn(label, url, style="outline", title=None) %}
{% macro action_btn(label, url, style="outline", title=None, icon=None) %}
<button hx-post="{{ url }}"
hx-swap="none"
class="btn btn-{{ style }}"
{% if title %}title="{{ title }}"{% endif %}>
{{ label }}
{% if icon %}{{ icon }}{% endif %}{{ label }}
</button>
{% endmacro %}
{# Stat card for dashboard #}
{% macro stat_card(label, value, color=None) %}
{% macro stat_card(label, value, color=None, icon=None) %}
<div class="card bg-base-100 shadow">
<div class="card-body items-center text-center">
<h2 class="card-title text-base-content/60 text-sm">{{ label }}</h2>
<h2 class="card-title text-base-content/60 text-sm gap-1">{% if icon %}{{ icon }}{% endif %}{{ label }}</h2>
<p class="text-4xl font-bold {% if color %}text-{{ color }}{% endif %}">{{ value }}</p>
</div>
</div>

View File

@@ -1,4 +1,5 @@
{# Container list for a service on a single host #}
{% from "partials/icons.html" import terminal %}
{% macro container_row(service, container, host) %}
<div class="flex items-center gap-2 mb-2">
{% if container.State == "running" %}
@@ -11,7 +12,7 @@
<code class="text-sm flex-1">{{ container.Name }}</code>
<button class="btn btn-sm btn-outline"
onclick="initExecTerminal('{{ service }}', '{{ container.Name }}', '{{ host }}')">
Shell
{{ terminal() }} Shell
</button>
</div>
{% endmacro %}

View File

@@ -1,9 +1,130 @@
{# Lucide-style icons (https://lucide.dev) - 24x24 viewBox, 2px stroke, round caps #}
{# Brand icons #}
{% macro github(size=16) %}
<svg height="{{ size }}" width="{{ size }}" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
{% endmacro %}
{% macro hamburger() %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
{# UI icons #}
{% macro hamburger(size=20) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/>
</svg>
{% endmacro %}
{# Action icons #}
{% macro play(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="6 3 20 12 6 21 6 3"/>
</svg>
{% endmacro %}
{% macro square(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="5" y="5" rx="2"/>
</svg>
{% endmacro %}
{% macro rotate_cw(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>
</svg>
{% endmacro %}
{% macro download(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>
</svg>
{% endmacro %}
{% macro cloud_download(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 13v8l-4-4"/><path d="m12 21 4-4"/><path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284"/>
</svg>
{% endmacro %}
{% macro file_text(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>
</svg>
{% endmacro %}
{% macro save(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>
</svg>
{% endmacro %}
{% macro check(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
{% endmacro %}
{% macro refresh_cw(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>
</svg>
{% endmacro %}
{% macro terminal(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>
</svg>
{% endmacro %}
{# Stats/navigation icons #}
{% macro server(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>
</svg>
{% endmacro %}
{% macro layers(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>
</svg>
{% endmacro %}
{% macro circle_check(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>
</svg>
{% endmacro %}
{% macro circle_x(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>
</svg>
{% endmacro %}
{% macro home(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>
{% endmacro %}
{% macro box(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>
</svg>
{% endmacro %}
{# Section icons #}
{% macro settings(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>
</svg>
{% endmacro %}
{% macro file_code(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 12.5 8 15l2 2.5"/><path d="m14 12.5 2 2.5-2 2.5"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/>
</svg>
{% endmacro %}
{% macro database(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>
</svg>
{% endmacro %}

View File

@@ -1,6 +1,7 @@
{% from "partials/components.html" import collapse %}
{% from "partials/icons.html" import layers %}
<div id="services-by-host">
{% call collapse("Services by Host", id="services-by-host-collapse", checked=expanded|default(true)) %}
{% call collapse("Services by Host", id="services-by-host-collapse", checked=expanded|default(true), icon=layers()) %}
{% for host_name, host_services in services_by_host.items() | sort %}
<h4 class="font-semibold mt-3 mb-1">
{{ host_name }}

View File

@@ -1,7 +1,8 @@
{% from "partials/icons.html" import home %}
<!-- Dashboard Link -->
<div class="mb-4">
<ul class="menu" hx-boost="true" hx-target="#main-content" hx-select="#main-content" hx-swap="outerHTML">
<li><a href="/" class="font-semibold"><span class="font-mono opacity-60">~</span> Dashboard</a></li>
<li><a href="/" class="font-semibold">{{ home() }} Dashboard</a></li>
</ul>
</div>

View File

@@ -1,7 +1,8 @@
{% from "partials/components.html" import stat_card %}
{% from "partials/icons.html" import server, layers, circle_check, circle_x %}
<div id="stats-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{{ stat_card("Hosts", hosts | length) }}
{{ stat_card("Services", services | length) }}
{{ stat_card("Running", running_count, "success") }}
{{ stat_card("Stopped", stopped_count) }}
{{ stat_card("Hosts", hosts | length, icon=server()) }}
{{ stat_card("Services", services | length, icon=layers()) }}
{{ stat_card("Running", running_count, "success", circle_check()) }}
{{ stat_card("Stopped", stopped_count, icon=circle_x()) }}
</div>

View File

@@ -1,8 +1,9 @@
{% from "partials/icons.html" import terminal %}
<!-- Shared Terminal Component -->
<div class="collapse collapse-arrow bg-base-100 shadow mb-4" id="terminal-collapse">
<input type="checkbox" id="terminal-toggle" />
<div class="collapse-title font-medium flex items-center gap-2">
Terminal Output
{{ terminal() }} Terminal Output
<span id="terminal-spinner" class="loading loading-spinner loading-sm hidden"></span>
</div>
<div class="collapse-content">

View File

@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% from "partials/components.html" import collapse, action_btn %}
{% from "partials/icons.html" import play, square, rotate_cw, download, cloud_download, file_text, save, file_code, terminal, settings %}
{% block title %}{{ name }} - Compose Farm{% endblock %}
{% block content %}
@@ -19,26 +20,26 @@
<!-- Action Buttons -->
<div class="flex flex-wrap gap-2 mb-6">
<!-- Lifecycle -->
{{ action_btn("Up", "/api/service/" ~ name ~ "/up", "primary", "Start service (docker compose up -d)") }}
{{ action_btn("Down", "/api/service/" ~ name ~ "/down", "outline", "Stop service (docker compose down)") }}
{{ action_btn("Restart", "/api/service/" ~ name ~ "/restart", "secondary", "Restart service (down + up)") }}
{{ action_btn("Update", "/api/service/" ~ name ~ "/update", "accent", "Update to latest (pull + build + down + up)") }}
{{ action_btn("Up", "/api/service/" ~ name ~ "/up", "primary", "Start service (docker compose up -d)", play()) }}
{{ action_btn("Down", "/api/service/" ~ name ~ "/down", "outline", "Stop service (docker compose down)", square()) }}
{{ action_btn("Restart", "/api/service/" ~ name ~ "/restart", "secondary", "Restart service (down + up)", rotate_cw()) }}
{{ action_btn("Update", "/api/service/" ~ name ~ "/update", "accent", "Update to latest (pull + build + down + up)", download()) }}
<div class="divider divider-horizontal mx-0"></div>
<!-- Other -->
{{ action_btn("Pull", "/api/service/" ~ name ~ "/pull", "outline", "Pull latest images (no restart)") }}
{{ action_btn("Logs", "/api/service/" ~ name ~ "/logs", "outline", "Show recent logs") }}
<button id="save-btn" class="btn btn-outline">Save All</button>
{{ action_btn("Pull", "/api/service/" ~ name ~ "/pull", "outline", "Pull latest images (no restart)", cloud_download()) }}
{{ action_btn("Logs", "/api/service/" ~ name ~ "/logs", "outline", "Show recent logs", file_text()) }}
<button id="save-btn" class="btn btn-outline">{{ save() }} Save All</button>
</div>
{% call collapse("Compose File", badge=compose_path) %}
{% call collapse("Compose File", badge=compose_path, icon=file_code()) %}
<div class="editor-wrapper yaml-wrapper">
<div id="compose-editor" class="yaml-editor" data-content="{{ compose_content | e }}" data-save-url="/api/service/{{ name }}/compose"></div>
</div>
{% endcall %}
{% call collapse(".env File", badge=env_path) %}
{% call collapse(".env File", badge=env_path, icon=settings()) %}
<div class="editor-wrapper env-wrapper">
<div id="env-editor" class="env-editor" data-content="{{ env_content | e }}" data-save-url="/api/service/{{ name }}/env"></div>
</div>
@@ -48,7 +49,7 @@
<!-- Exec Terminal -->
{% if current_host %}
{% call collapse("Container Shell", id="exec-collapse", checked=True) %}
{% call collapse("Container Shell", id="exec-collapse", checked=True, icon=terminal()) %}
<div id="containers-list" class="mb-4"
hx-get="/api/service/{{ name }}/containers"
hx-trigger="load"