mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
Compare commits
13 Commits
v1.1.0
...
fix-toolti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43733143ba | ||
|
|
7ae8ea0229 | ||
|
|
612242eea9 | ||
|
|
ea650bff8a | ||
|
|
140bca4fd6 | ||
|
|
6dad6be8da | ||
|
|
d7f931e301 | ||
|
|
471936439e | ||
|
|
36e4bef46d | ||
|
|
2cac0bf263 | ||
|
|
3d07cbdff0 | ||
|
|
0f67c17281 | ||
|
|
bd22a1a55e |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
run: uv run playwright install chromium --with-deps
|
||||
|
||||
- name: Run browser tests
|
||||
run: uv run pytest -m browser -v --no-cov
|
||||
run: uv run pytest -m browser -n auto -v
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
4
.github/workflows/docs.yml
vendored
4
.github/workflows/docs.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: "./site"
|
||||
|
||||
|
||||
14
CLAUDE.md
14
CLAUDE.md
@@ -66,8 +66,8 @@ Use `just` for common tasks. Run `just` to list available commands:
|
||||
|---------|-------------|
|
||||
| `just install` | Install dev dependencies |
|
||||
| `just test` | Run all tests |
|
||||
| `just test-unit` | Run unit tests (parallel) |
|
||||
| `just test-browser` | Run browser tests |
|
||||
| `just test-cli` | Run CLI tests (parallel) |
|
||||
| `just test-web` | Run web UI tests (parallel) |
|
||||
| `just lint` | Lint, format, and type check |
|
||||
| `just web` | Start web UI (port 9001) |
|
||||
| `just doc` | Build and serve docs (port 9002) |
|
||||
@@ -78,17 +78,17 @@ Use `just` for common tasks. Run `just` to list available commands:
|
||||
Run tests with `just test` or `uv run pytest`. Browser tests require Chromium (system-installed or via `playwright install chromium`):
|
||||
|
||||
```bash
|
||||
# Unit tests only (skip browser tests, can parallelize)
|
||||
# Unit tests only (parallel)
|
||||
uv run pytest -m "not browser" -n auto
|
||||
|
||||
# Browser tests only (run sequentially, no coverage)
|
||||
uv run pytest -m browser --no-cov
|
||||
# Browser tests only (parallel)
|
||||
uv run pytest -m browser -n auto
|
||||
|
||||
# All tests
|
||||
uv run pytest --no-cov
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
Browser tests are marked with `@pytest.mark.browser`. They use Playwright to test HTMX behavior, JavaScript functionality (sidebar filter, command palette, terminals), and content stability during navigation. Run sequentially (no `-n`) to avoid resource contention.
|
||||
Browser tests are marked with `@pytest.mark.browser`. They use Playwright to test HTMX behavior, JavaScript functionality (sidebar filter, command palette, terminals), and content stability during navigation.
|
||||
|
||||
## Communication Notes
|
||||
|
||||
|
||||
14
justfile
14
justfile
@@ -9,17 +9,17 @@ default:
|
||||
install:
|
||||
uv sync --all-extras --dev
|
||||
|
||||
# Run all tests (no coverage for speed)
|
||||
# Run all tests (parallel)
|
||||
test:
|
||||
uv run pytest --no-cov
|
||||
uv run pytest -n auto
|
||||
|
||||
# Run unit tests only (parallel, with coverage)
|
||||
test-unit:
|
||||
# Run CLI tests only (parallel, with coverage)
|
||||
test-cli:
|
||||
uv run pytest -m "not browser" -n auto
|
||||
|
||||
# Run browser tests only (sequential, no coverage)
|
||||
test-browser:
|
||||
uv run pytest -m browser --no-cov
|
||||
# Run web UI tests only (parallel)
|
||||
test-web:
|
||||
uv run pytest -m browser -n auto
|
||||
|
||||
# Lint, format, and type check
|
||||
lint:
|
||||
|
||||
@@ -140,7 +140,7 @@ def pull(
|
||||
if service and len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
cmd = f"pull {service}" if service else "pull"
|
||||
cmd = f"pull --ignore-buildable {service}" if service else "pull --ignore-buildable"
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw))
|
||||
report_results(results)
|
||||
|
||||
@@ -336,3 +336,18 @@ def get_ports_for_service(
|
||||
if isinstance(ref_def, dict):
|
||||
return _parse_ports(ref_def.get("ports"), env)
|
||||
return _parse_ports(definition.get("ports"), env)
|
||||
|
||||
|
||||
def get_container_name(
|
||||
service_name: str,
|
||||
service_def: dict[str, Any] | None,
|
||||
project_name: str,
|
||||
) -> str:
|
||||
"""Get the container name for a service.
|
||||
|
||||
Uses container_name from compose if set, otherwise defaults to {project}-{service}-1.
|
||||
This matches Docker Compose's default naming convention.
|
||||
"""
|
||||
if isinstance(service_def, dict) and service_def.get("container_name"):
|
||||
return str(service_def["container_name"])
|
||||
return f"{project_name}-{service_name}-1"
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
@@ -14,7 +15,7 @@ from .paths import config_search_paths, find_config_path
|
||||
COMPOSE_FILENAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
|
||||
|
||||
|
||||
class Host(BaseModel):
|
||||
class Host(BaseModel, extra="forbid"):
|
||||
"""SSH host configuration."""
|
||||
|
||||
address: str
|
||||
@@ -22,7 +23,7 @@ class Host(BaseModel):
|
||||
port: int = 22
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
class Config(BaseModel, extra="forbid"):
|
||||
"""Main configuration."""
|
||||
|
||||
compose_dir: Path = Path("/opt/compose")
|
||||
@@ -113,7 +114,7 @@ class Config(BaseModel):
|
||||
return found
|
||||
|
||||
|
||||
def _parse_hosts(raw_hosts: dict[str, str | dict[str, str | int]]) -> dict[str, Host]:
|
||||
def _parse_hosts(raw_hosts: dict[str, Any]) -> dict[str, Host]:
|
||||
"""Parse hosts from config, handling both simple and full forms."""
|
||||
hosts = {}
|
||||
for name, value in raw_hosts.items():
|
||||
@@ -122,11 +123,7 @@ def _parse_hosts(raw_hosts: dict[str, str | dict[str, str | int]]) -> dict[str,
|
||||
hosts[name] = Host(address=value)
|
||||
else:
|
||||
# Full form: hostname: {address: ..., user: ..., port: ...}
|
||||
hosts[name] = Host(
|
||||
address=str(value.get("address", "")),
|
||||
user=str(value["user"]) if "user" in value else getpass.getuser(),
|
||||
port=int(value["port"]) if "port" in value else 22,
|
||||
)
|
||||
hosts[name] = Host(**value)
|
||||
return hosts
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use host-published ports for cross-host reachability.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -383,3 +384,53 @@ def render_traefik_config(dynamic: dict[str, Any]) -> str:
|
||||
"""Render Traefik dynamic config as YAML with a header comment."""
|
||||
body = yaml.safe_dump(dynamic, sort_keys=False)
|
||||
return _TRAEFIK_CONFIG_HEADER + body
|
||||
|
||||
|
||||
_HOST_RULE_PATTERN = re.compile(r"Host\(`([^`]+)`\)")
|
||||
|
||||
|
||||
def extract_website_urls(config: Config, stack: str) -> list[str]:
|
||||
"""Extract website URLs from Traefik labels in a stack's compose file.
|
||||
|
||||
Reuses generate_traefik_config to parse labels, then extracts Host() rules
|
||||
from router configurations.
|
||||
|
||||
Returns a list of unique URLs, preferring HTTPS over HTTP.
|
||||
"""
|
||||
try:
|
||||
dynamic, _ = generate_traefik_config(config, [stack], check_all=True)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
routers = dynamic.get("http", {}).get("routers", {})
|
||||
if not routers:
|
||||
return []
|
||||
|
||||
# Track URLs with their scheme preference (https > http)
|
||||
urls: dict[str, str] = {} # host -> scheme
|
||||
|
||||
for router_info in routers.values():
|
||||
if not isinstance(router_info, dict):
|
||||
continue
|
||||
|
||||
rule = router_info.get("rule", "")
|
||||
entrypoints = router_info.get("entrypoints", [])
|
||||
|
||||
# entrypoints can be a list or string
|
||||
if isinstance(entrypoints, list):
|
||||
entrypoints_str = ",".join(entrypoints)
|
||||
else:
|
||||
entrypoints_str = str(entrypoints)
|
||||
|
||||
# Determine scheme from entrypoint
|
||||
scheme = "https" if "websecure" in entrypoints_str else "http"
|
||||
|
||||
# Extract host(s) from rule
|
||||
for match in _HOST_RULE_PATTERN.finditer(str(rule)):
|
||||
host = match.group(1)
|
||||
# Prefer https over http
|
||||
if host not in urls or scheme == "https":
|
||||
urls[host] = scheme
|
||||
|
||||
# Build URL list, sorted for consistency
|
||||
return sorted(f"{scheme}://{host}" for host, scheme in urls.items())
|
||||
|
||||
@@ -19,6 +19,7 @@ import yaml
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from compose_farm.compose import get_container_name
|
||||
from compose_farm.executor import is_local, run_compose_on_host, ssh_connect_kwargs
|
||||
from compose_farm.paths import find_config_path
|
||||
from compose_farm.state import load_state
|
||||
@@ -116,14 +117,9 @@ def _get_compose_services(config: Any, stack: str, hosts: list[str]) -> list[dic
|
||||
containers = []
|
||||
for host in hosts:
|
||||
for svc_name, svc_def in raw_services.items():
|
||||
# Use container_name if set, otherwise default to {project}-{service}-1
|
||||
if isinstance(svc_def, dict) and svc_def.get("container_name"):
|
||||
container_name = svc_def["container_name"]
|
||||
else:
|
||||
container_name = f"{project_name}-{svc_name}-1"
|
||||
containers.append(
|
||||
{
|
||||
"Name": container_name,
|
||||
"Name": get_container_name(svc_name, svc_def, project_name),
|
||||
"Service": svc_name,
|
||||
"Host": host,
|
||||
"State": "unknown", # Status requires Docker query
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from compose_farm.compose import get_container_name
|
||||
from compose_farm.paths import find_config_path
|
||||
from compose_farm.state import (
|
||||
get_orphaned_stacks,
|
||||
@@ -16,6 +17,7 @@ from compose_farm.state import (
|
||||
group_running_stacks_by_host,
|
||||
load_state,
|
||||
)
|
||||
from compose_farm.traefik import extract_website_urls
|
||||
from compose_farm.web.deps import (
|
||||
extract_config_error,
|
||||
get_config,
|
||||
@@ -159,13 +161,28 @@ async def stack_detail(request: Request, name: str) -> HTMLResponse:
|
||||
# Get state
|
||||
current_host = get_stack_host(config, name)
|
||||
|
||||
# Get service names from compose file
|
||||
# Get service names and container info from compose file
|
||||
services: list[str] = []
|
||||
containers: dict[str, dict[str, str]] = {}
|
||||
shell_host = current_host[0] if isinstance(current_host, list) else current_host
|
||||
if compose_content:
|
||||
compose_data = yaml.safe_load(compose_content) or {}
|
||||
raw_services = compose_data.get("services", {})
|
||||
if isinstance(raw_services, dict):
|
||||
services = list(raw_services.keys())
|
||||
# Build container info for shell access (only if stack is running)
|
||||
if shell_host:
|
||||
project_name = compose_path.parent.name if compose_path else name
|
||||
containers = {
|
||||
svc: {
|
||||
"container": get_container_name(svc, svc_def, project_name),
|
||||
"host": shell_host,
|
||||
}
|
||||
for svc, svc_def in raw_services.items()
|
||||
}
|
||||
|
||||
# Extract website URLs from Traefik labels
|
||||
website_urls = extract_website_urls(config, name)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"stack.html",
|
||||
@@ -179,6 +196,8 @@ async def stack_detail(request: Request, name: str) -> HTMLResponse:
|
||||
"env_content": env_content,
|
||||
"env_path": str(env_path) if env_path else None,
|
||||
"services": services,
|
||||
"containers": containers,
|
||||
"website_urls": website_urls,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -523,9 +523,15 @@ function playFabIntro() {
|
||||
let originalTheme = null; // Store theme when palette opens for preview/restore
|
||||
|
||||
const post = (url) => () => htmx.ajax('POST', url, {swap: 'none'});
|
||||
const nav = (url) => () => {
|
||||
const nav = (url, afterNav) => () => {
|
||||
// Set hash before HTMX swap so inline scripts can read it
|
||||
const hashIndex = url.indexOf('#');
|
||||
if (hashIndex !== -1) {
|
||||
window.location.hash = url.substring(hashIndex);
|
||||
}
|
||||
htmx.ajax('GET', url, {target: '#main-content', select: '#main-content', swap: 'outerHTML'}).then(() => {
|
||||
history.pushState({}, '', url);
|
||||
afterNav?.();
|
||||
});
|
||||
};
|
||||
// Navigate to dashboard (if needed) and trigger action
|
||||
@@ -568,9 +574,12 @@ function playFabIntro() {
|
||||
const actions = [
|
||||
cmd('action', 'Apply', 'Make reality match config', dashboardAction('apply'), icons.check),
|
||||
cmd('action', 'Refresh', 'Update state from reality', dashboardAction('refresh'), icons.refresh_cw),
|
||||
cmd('action', 'Pull All', 'Pull latest images for all stacks', dashboardAction('pull-all'), icons.cloud_download),
|
||||
cmd('action', 'Update All', 'Update all stacks', dashboardAction('update-all'), icons.refresh_cw),
|
||||
cmd('app', 'Theme', 'Change color theme', openThemePicker, icons.palette),
|
||||
cmd('app', 'Dashboard', 'Go to dashboard', nav('/'), icons.home),
|
||||
cmd('app', 'Console', 'Go to console', nav('/console'), icons.terminal),
|
||||
cmd('app', 'Edit Config', 'Edit compose-farm.yaml', nav('/console#editor'), icons.file_code),
|
||||
cmd('app', 'Docs', 'Open documentation', openExternal('https://compose-farm.nijho.lt/'), icons.book_open),
|
||||
];
|
||||
|
||||
@@ -588,11 +597,26 @@ function playFabIntro() {
|
||||
stackCmd('Logs', 'View logs for', 'logs', icons.file_text),
|
||||
);
|
||||
|
||||
// Add service-specific commands from data-services attribute
|
||||
// Add Open Website commands if website URLs are available
|
||||
const websiteUrlsAttr = document.querySelector('[data-website-urls]')?.getAttribute('data-website-urls');
|
||||
if (websiteUrlsAttr) {
|
||||
const websiteUrls = JSON.parse(websiteUrlsAttr);
|
||||
for (const url of websiteUrls) {
|
||||
const displayUrl = url.replace(/^https?:\/\//, '');
|
||||
const label = websiteUrls.length > 1 ? `Open: ${displayUrl}` : 'Open Website';
|
||||
actions.unshift(cmd('stack', label, `Open ${displayUrl} in browser`, openExternal(url), icons.external_link));
|
||||
}
|
||||
}
|
||||
|
||||
// Add service-specific commands from data-services and data-containers attributes
|
||||
// Grouped by action (all Logs together, all Pull together, etc.) with services sorted alphabetically
|
||||
const servicesAttr = document.querySelector('[data-services]')?.getAttribute('data-services');
|
||||
const containersAttr = document.querySelector('[data-containers]')?.getAttribute('data-containers');
|
||||
if (servicesAttr) {
|
||||
const services = servicesAttr.split(',').filter(s => s).sort();
|
||||
// Parse container info for shell access: {service: {container, host}}
|
||||
const containers = containersAttr ? JSON.parse(containersAttr) : {};
|
||||
|
||||
const svcCmd = (action, service, desc, endpoint, icon) =>
|
||||
cmd('service', `${action}: ${service}`, desc, post(`/api/stack/${stack}/service/${service}/${endpoint}`), icon);
|
||||
const svcActions = [
|
||||
@@ -607,6 +631,14 @@ function playFabIntro() {
|
||||
actions.push(svcCmd(action, service, desc, endpoint, icon));
|
||||
}
|
||||
}
|
||||
// Add Shell commands if container info is available
|
||||
for (const service of services) {
|
||||
const info = containers[service];
|
||||
if (info?.container && info?.host) {
|
||||
actions.push(cmd('service', `Shell: ${service}`, 'Open interactive shell',
|
||||
() => initExecTerminal(stack, info.container, info.host), icons.terminal));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,18 +48,24 @@
|
||||
<div class="drawer-side">
|
||||
<label for="drawer-toggle" class="drawer-overlay" aria-label="close sidebar"></label>
|
||||
<aside id="sidebar" class="w-64 bg-base-100 border-r border-base-300 flex flex-col min-h-screen">
|
||||
<header class="p-4 border-b border-base-300">
|
||||
<header class="p-4 border-b border-base-300 relative z-10">
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
<span class="rainbow-hover">Compose Farm</span>
|
||||
<a href="https://compose-farm.nijho.lt/" target="_blank" title="Docs" class="opacity-50 hover:opacity-100 transition-opacity">
|
||||
{{ book_open() }}
|
||||
</a>
|
||||
<a href="https://github.com/basnijholt/compose-farm" target="_blank" title="GitHub" class="opacity-50 hover:opacity-100 transition-opacity">
|
||||
{{ github() }}
|
||||
</a>
|
||||
<button type="button" id="theme-btn" class="opacity-50 hover:opacity-100 transition-opacity cursor-pointer" title="Change theme (opens command palette)">
|
||||
{{ palette() }}
|
||||
</button>
|
||||
<div class="tooltip tooltip-bottom" data-tip="Docs">
|
||||
<a href="https://compose-farm.nijho.lt/" target="_blank" class="opacity-50 hover:opacity-100 transition-opacity">
|
||||
{{ book_open() }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="tooltip tooltip-bottom" data-tip="GitHub">
|
||||
<a href="https://github.com/basnijholt/compose-farm" target="_blank" class="opacity-50 hover:opacity-100 transition-opacity">
|
||||
{{ github() }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="tooltip tooltip-bottom" data-tip="Change theme">
|
||||
<button type="button" id="theme-btn" class="opacity-50 hover:opacity-100 transition-opacity cursor-pointer">
|
||||
{{ palette() }}
|
||||
</button>
|
||||
</div>
|
||||
</h2>
|
||||
</header>
|
||||
<nav class="flex-1 overflow-y-auto p-2" hx-get="/partials/sidebar" hx-trigger="load, cf:refresh from:body" hx-swap="innerHTML">
|
||||
|
||||
@@ -97,7 +97,10 @@ function connectConsole() {
|
||||
consoleWs.onopen = () => {
|
||||
statusEl.textContent = `Connected to ${host}`;
|
||||
sendSize(term.cols, term.rows);
|
||||
term.focus();
|
||||
// Focus terminal unless #editor hash is present (command palette Edit Config)
|
||||
if (window.location.hash !== '#editor') {
|
||||
term.focus();
|
||||
}
|
||||
// Auto-load the default file once editor is ready
|
||||
const pathInput = document.getElementById('console-file-path');
|
||||
if (pathInput && pathInput.value) {
|
||||
@@ -133,6 +136,14 @@ function initConsoleEditor() {
|
||||
|
||||
loadMonaco(() => {
|
||||
consoleEditor = createEditor(editorEl, '', 'plaintext', { onSave: saveFile });
|
||||
// Focus editor if #editor hash is present (command palette Edit Config)
|
||||
if (window.location.hash === '#editor') {
|
||||
// Small delay for Monaco to fully initialize before focusing
|
||||
setTimeout(() => {
|
||||
consoleEditor.focus();
|
||||
editorEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% from "partials/icons.html" import search, play, square, rotate_cw, cloud_download, refresh_cw, file_text, check, home, terminal, box, palette, book_open %}
|
||||
{% from "partials/icons.html" import search, play, square, rotate_cw, cloud_download, refresh_cw, file_text, file_code, check, home, terminal, box, palette, book_open, external_link %}
|
||||
|
||||
<!-- Icons for command palette (referenced by JS) -->
|
||||
<template id="cmd-icons">
|
||||
@@ -14,6 +14,8 @@
|
||||
<span data-icon="box">{{ box() }}</span>
|
||||
<span data-icon="palette">{{ palette() }}</span>
|
||||
<span data-icon="book_open">{{ book_open() }}</span>
|
||||
<span data-icon="file_code">{{ file_code() }}</span>
|
||||
<span data-icon="external_link">{{ external_link() }}</span>
|
||||
</template>
|
||||
<dialog id="cmd-palette" class="modal">
|
||||
<div class="modal-box max-w-lg p-0">
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
{# Container list for a stack on a single host #}
|
||||
{% from "partials/icons.html" import terminal, rotate_ccw, scroll_text, square, play, cloud_download %}
|
||||
{% macro container_row(stack, container, host) %}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if container.State == "running" %}
|
||||
<span class="badge badge-success">running</span>
|
||||
{% elif container.State == "unknown" %}
|
||||
<span class="badge badge-ghost"><span class="loading loading-spinner loading-xs"></span></span>
|
||||
{% elif container.State == "exited" %}
|
||||
{% if container.ExitCode == 0 %}
|
||||
<span class="badge badge-neutral">exited (0)</span>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-3 p-2 bg-base-200 rounded-lg">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
{% if container.State == "running" %}
|
||||
<span class="badge badge-success">running</span>
|
||||
{% elif container.State == "unknown" %}
|
||||
<span class="badge badge-ghost"><span class="loading loading-spinner loading-xs"></span></span>
|
||||
{% elif container.State == "exited" %}
|
||||
{% if container.ExitCode == 0 %}
|
||||
<span class="badge badge-neutral">exited (0)</span>
|
||||
{% else %}
|
||||
<span class="badge badge-error">exited ({{ container.ExitCode }})</span>
|
||||
{% endif %}
|
||||
{% elif container.State == "created" %}
|
||||
<span class="badge badge-neutral">created</span>
|
||||
{% else %}
|
||||
<span class="badge badge-error">exited ({{ container.ExitCode }})</span>
|
||||
<span class="badge badge-warning">{{ container.State }}</span>
|
||||
{% endif %}
|
||||
{% elif container.State == "created" %}
|
||||
<span class="badge badge-neutral">created</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">{{ container.State }}</span>
|
||||
{% endif %}
|
||||
<code class="text-sm flex-1">{{ container.Name }}</code>
|
||||
<div class="join">
|
||||
<code class="text-sm truncate">{{ container.Name }}</code>
|
||||
</div>
|
||||
<div class="join sm:ml-auto shrink-0">
|
||||
<div class="tooltip tooltip-top" data-tip="View logs">
|
||||
<button class="btn btn-sm btn-outline join-item"
|
||||
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/logs"
|
||||
|
||||
@@ -170,3 +170,9 @@
|
||||
<circle cx="13.5" cy="6.5" r="0.5" fill="currentColor"/><circle cx="17.5" cy="10.5" r="0.5" fill="currentColor"/><circle cx="8.5" cy="7.5" r="0.5" fill="currentColor"/><circle cx="6.5" cy="12.5" r="0.5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.555C21.965 6.012 17.461 2 12 2z"/>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro external_link(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 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% 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 %}
|
||||
{% from "partials/icons.html" import play, square, rotate_cw, download, cloud_download, file_text, save, file_code, terminal, settings, external_link %}
|
||||
{% block title %}{{ name }} - Compose Farm{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-5xl" data-services="{{ services | join(',') }}">
|
||||
<div class="max-w-5xl" data-services="{{ services | join(',') }}" data-containers='{{ containers | tojson }}' data-website-urls='{{ website_urls | tojson }}'>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold rainbow-hover">{{ name }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||
@@ -31,6 +31,19 @@
|
||||
{{ action_btn("Pull", "/api/stack/" ~ name ~ "/pull", "outline", "Pull latest images (no restart)", cloud_download()) }}
|
||||
{{ action_btn("Logs", "/api/stack/" ~ name ~ "/logs", "outline", "Show recent logs", file_text()) }}
|
||||
<div class="tooltip" data-tip="Save compose and .env files"><button id="save-btn" class="btn btn-outline">{{ save() }} Save All</button></div>
|
||||
|
||||
{% if website_urls %}
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
|
||||
<!-- Open Website -->
|
||||
{% for url in website_urls %}
|
||||
<div class="tooltip" data-tip="Open {{ url }}">
|
||||
<a href="{{ url }}" target="_blank" rel="noopener noreferrer" class="btn btn-outline">
|
||||
{{ external_link() }} {% if website_urls | length > 1 %}{{ url | replace('https://', '') | replace('http://', '') }}{% else %}Open Website{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% call collapse("Compose File", badge=compose_path, icon=file_code()) %}
|
||||
|
||||
@@ -236,8 +236,8 @@ async def _run_shell_session(
|
||||
await websocket.send_text(f"{RED}Host '{host_name}' not found{RESET}{CRLF}")
|
||||
return
|
||||
|
||||
# Start interactive shell in home directory (avoid login shell to prevent job control warnings)
|
||||
shell_cmd = "cd ~ && exec bash -i 2>/dev/null || exec sh -i"
|
||||
# Start interactive shell in home directory
|
||||
shell_cmd = "cd ~ && exec bash -i || exec sh -i"
|
||||
|
||||
if is_local(host):
|
||||
# Local: use argv list with shell -c to interpret the command
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
# Thresholds in seconds, per OS
|
||||
if sys.platform == "win32":
|
||||
CLI_STARTUP_THRESHOLD = 2.0
|
||||
@@ -16,6 +19,10 @@ else: # Linux
|
||||
CLI_STARTUP_THRESHOLD = 0.25
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"PYTEST_XDIST_WORKER" in os.environ,
|
||||
reason="Skip in parallel mode due to resource contention",
|
||||
)
|
||||
def test_cli_startup_time() -> None:
|
||||
"""Verify CLI startup time stays within acceptable bounds.
|
||||
|
||||
|
||||
60
tests/test_compose.py
Normal file
60
tests/test_compose.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Tests for compose file parsing utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from compose_farm.compose import get_container_name
|
||||
|
||||
|
||||
class TestGetContainerName:
|
||||
"""Test get_container_name helper function."""
|
||||
|
||||
def test_explicit_container_name(self) -> None:
|
||||
"""Uses container_name from service definition when set."""
|
||||
service_def = {"image": "nginx", "container_name": "my-custom-name"}
|
||||
result = get_container_name("web", service_def, "myproject")
|
||||
assert result == "my-custom-name"
|
||||
|
||||
def test_default_naming_pattern(self) -> None:
|
||||
"""Falls back to {project}-{service}-1 pattern."""
|
||||
service_def = {"image": "nginx"}
|
||||
result = get_container_name("web", service_def, "myproject")
|
||||
assert result == "myproject-web-1"
|
||||
|
||||
def test_none_service_def(self) -> None:
|
||||
"""Handles None service definition gracefully."""
|
||||
result = get_container_name("web", None, "myproject")
|
||||
assert result == "myproject-web-1"
|
||||
|
||||
def test_empty_service_def(self) -> None:
|
||||
"""Handles empty service definition."""
|
||||
result = get_container_name("web", {}, "myproject")
|
||||
assert result == "myproject-web-1"
|
||||
|
||||
def test_container_name_none_value(self) -> None:
|
||||
"""Handles container_name set to None."""
|
||||
service_def = {"image": "nginx", "container_name": None}
|
||||
result = get_container_name("web", service_def, "myproject")
|
||||
assert result == "myproject-web-1"
|
||||
|
||||
def test_container_name_empty_string(self) -> None:
|
||||
"""Handles container_name set to empty string."""
|
||||
service_def = {"image": "nginx", "container_name": ""}
|
||||
result = get_container_name("web", service_def, "myproject")
|
||||
assert result == "myproject-web-1"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service_name", "project_name", "expected"),
|
||||
[
|
||||
("redis", "plex", "plex-redis-1"),
|
||||
("plex-server", "media", "media-plex-server-1"),
|
||||
("db", "my-app", "my-app-db-1"),
|
||||
],
|
||||
)
|
||||
def test_various_naming_combinations(
|
||||
self, service_name: str, project_name: str, expected: str
|
||||
) -> None:
|
||||
"""Test various service/project name combinations."""
|
||||
result = get_container_name(service_name, {"image": "test"}, project_name)
|
||||
assert result == expected
|
||||
@@ -6,7 +6,7 @@ import yaml
|
||||
|
||||
from compose_farm.compose import parse_external_networks
|
||||
from compose_farm.config import Config, Host
|
||||
from compose_farm.traefik import generate_traefik_config
|
||||
from compose_farm.traefik import extract_website_urls, generate_traefik_config
|
||||
|
||||
|
||||
def _write_compose(path: Path, data: dict[str, object]) -> None:
|
||||
@@ -336,3 +336,330 @@ def test_parse_external_networks_missing_compose(tmp_path: Path) -> None:
|
||||
|
||||
networks = parse_external_networks(cfg, "app")
|
||||
assert networks == []
|
||||
|
||||
|
||||
class TestExtractWebsiteUrls:
|
||||
"""Test extract_website_urls function."""
|
||||
|
||||
def _create_config(self, tmp_path: Path) -> Config:
|
||||
"""Create a test config."""
|
||||
return Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"nas": Host(address="192.168.1.10")},
|
||||
stacks={"mystack": "nas"},
|
||||
)
|
||||
|
||||
def test_extract_https_url(self, tmp_path: Path) -> None:
|
||||
"""Extracts HTTPS URL from websecure entrypoint."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
def test_extract_http_url(self, tmp_path: Path) -> None:
|
||||
"""Extracts HTTP URL from web entrypoint."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.local`)",
|
||||
"traefik.http.routers.web.entrypoints": "web",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["http://app.local"]
|
||||
|
||||
def test_extract_multiple_urls(self, tmp_path: Path) -> None:
|
||||
"""Extracts multiple URLs from different routers."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
"traefik.http.routers.web-local.rule": "Host(`app.local`)",
|
||||
"traefik.http.routers.web-local.entrypoints": "web",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["http://app.local", "https://app.example.com"]
|
||||
|
||||
def test_https_preferred_over_http(self, tmp_path: Path) -> None:
|
||||
"""HTTPS is preferred when same host has both."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
# Same host with different entrypoints
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web-http.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web-http.entrypoints": "web",
|
||||
"traefik.http.routers.web-https.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web-https.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
def test_traefik_disabled(self, tmp_path: Path) -> None:
|
||||
"""Returns empty list when traefik.enable is false."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "false",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == []
|
||||
|
||||
def test_no_traefik_labels(self, tmp_path: Path) -> None:
|
||||
"""Returns empty list when no traefik labels."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == []
|
||||
|
||||
def test_compose_file_not_exists(self, tmp_path: Path) -> None:
|
||||
"""Returns empty list when compose file doesn't exist."""
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == []
|
||||
|
||||
def test_env_variable_interpolation(self, tmp_path: Path) -> None:
|
||||
"""Interpolates environment variables in host rule."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
env_file = stack_dir / ".env"
|
||||
|
||||
env_file.write_text("DOMAIN=example.com\n")
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.${DOMAIN}`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
def test_multiple_hosts_in_one_rule_with_or(self, tmp_path: Path) -> None:
|
||||
"""Extracts multiple hosts from a single rule with || operator."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`) || Host(`app.backup.com`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.backup.com", "https://app.example.com"]
|
||||
|
||||
def test_host_with_path_prefix(self, tmp_path: Path) -> None:
|
||||
"""Extracts host from rule that includes PathPrefix."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`) && PathPrefix(`/api`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
def test_multiple_services_in_stack(self, tmp_path: Path) -> None:
|
||||
"""Extracts URLs from multiple services in one stack (like arr stack)."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"radarr": {
|
||||
"image": "radarr",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.radarr.rule": "Host(`radarr.example.com`)",
|
||||
"traefik.http.routers.radarr.entrypoints": "websecure",
|
||||
},
|
||||
},
|
||||
"sonarr": {
|
||||
"image": "sonarr",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.sonarr.rule": "Host(`sonarr.example.com`)",
|
||||
"traefik.http.routers.sonarr.entrypoints": "websecure",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://radarr.example.com", "https://sonarr.example.com"]
|
||||
|
||||
def test_labels_in_list_format(self, tmp_path: Path) -> None:
|
||||
"""Handles labels in list format (- key=value)."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": [
|
||||
"traefik.enable=true",
|
||||
"traefik.http.routers.web.rule=Host(`app.example.com`)",
|
||||
"traefik.http.routers.web.entrypoints=websecure",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
def test_no_entrypoints_defaults_to_http(self, tmp_path: Path) -> None:
|
||||
"""When no entrypoints specified, defaults to http."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`)",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["http://app.example.com"]
|
||||
|
||||
def test_multiple_entrypoints_with_websecure(self, tmp_path: Path) -> None:
|
||||
"""When entrypoints includes websecure, use https."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web.entrypoints": "web,websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
@@ -39,6 +39,15 @@ services:
|
||||
image: grafana/grafana
|
||||
""")
|
||||
|
||||
# Create a single-service stack for testing service commands
|
||||
redis_dir = compose_path / "redis"
|
||||
redis_dir.mkdir()
|
||||
(redis_dir / "compose.yaml").write_text("""
|
||||
services:
|
||||
redis:
|
||||
image: redis:alpine
|
||||
""")
|
||||
|
||||
return compose_path
|
||||
|
||||
|
||||
@@ -59,6 +68,7 @@ hosts:
|
||||
stacks:
|
||||
plex: server-1
|
||||
grafana: server-2
|
||||
redis: server-1
|
||||
""")
|
||||
|
||||
# State file must be alongside config file
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,6 +84,15 @@ class TestPageTemplatesRender:
|
||||
assert response.status_code == 200
|
||||
assert "test-service" in response.text
|
||||
|
||||
def test_stack_detail_has_containers_data(self, client: TestClient) -> None:
|
||||
"""Test stack detail page includes data-containers for command palette shell."""
|
||||
response = client.get("/stack/test-service")
|
||||
assert response.status_code == 200
|
||||
# Should have data-containers attribute with JSON
|
||||
assert "data-containers=" in response.text
|
||||
# Container name should follow {project}-{service}-1 pattern
|
||||
assert "test-service-app-1" in response.text
|
||||
|
||||
|
||||
class TestPartialTemplatesRender:
|
||||
"""Test that partial templates render without missing variables."""
|
||||
|
||||
Reference in New Issue
Block a user