From 485476a297c111e37fec9913535a63a2383ca06e Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 21 Feb 2026 16:32:53 +0100
Subject: [PATCH] feat: Rewrite dashboard with sidebar nav, drill-down, and registry-based container resolution
---
app/app/routers/services.py | 126 +++++++++++++++++++++++++++++++++++++-----
1 files changed, 111 insertions(+), 15 deletions(-)
diff --git a/app/app/routers/services.py b/app/app/routers/services.py
index a23bade..7cdad19 100644
--- a/app/app/routers/services.py
+++ b/app/app/routers/services.py
@@ -1,5 +1,7 @@
+import os
from typing import Any
+import yaml
from fastapi import APIRouter, Depends, HTTPException, Query
from app.auth import verify_token
@@ -8,14 +10,111 @@
router = APIRouter()
_DOCKER = "docker"
+_REGISTRY_PATH = os.environ.get(
+ "REGISTRY_PATH",
+ "/opt/infrastructure/servers/hetzner-vps/registry.yaml",
+)
+
+# ---------------------------------------------------------------------------
+# Registry-based name prefix lookup (cached)
+# ---------------------------------------------------------------------------
+_prefix_cache: dict[str, str] | None = None
-def _container_name(project: str, env: str, service: str) -> str:
+def _load_prefixes() -> dict[str, str]:
+ """Load project -> name_prefix mapping from the ops registry."""
+ global _prefix_cache
+ if _prefix_cache is not None:
+ return _prefix_cache
+
+ try:
+ with open(_REGISTRY_PATH) as f:
+ data = yaml.safe_load(f)
+ _prefix_cache = {}
+ for proj_name, cfg in data.get("projects", {}).items():
+ _prefix_cache[proj_name] = cfg.get("name_prefix", proj_name)
+ return _prefix_cache
+ except Exception:
+ return {}
+
+
+# ---------------------------------------------------------------------------
+# Container name resolution
+# ---------------------------------------------------------------------------
+
+
+async def _find_by_prefix(pattern: str) -> str | None:
+ """Find first running container whose name starts with `pattern`."""
+ result = await run_command(
+ [_DOCKER, "ps", "--filter", f"name={pattern}", "--format", "{{.Names}}"],
+ timeout=10,
+ )
+ if not result["success"]:
+ return None
+ for name in result["output"].strip().splitlines():
+ name = name.strip()
+ if name and name.startswith(pattern):
+ return name
+ return None
+
+
+async def _find_exact(name: str) -> str | None:
+ """Find a running container with exactly this name."""
+ result = await run_command(
+ [_DOCKER, "ps", "--filter", f"name={name}", "--format", "{{.Names}}"],
+ timeout=10,
+ )
+ if not result["success"]:
+ return None
+ for n in result["output"].strip().splitlines():
+ if n.strip() == name:
+ return name
+ return None
+
+
+async def _resolve_container(project: str, env: str, service: str) -> str:
"""
- Derive the Docker container name from project, env, and service.
- Docker Compose v2 default: {project}-{env}-{service}-1
+ Resolve the actual Docker container name from project/env/service.
+
+ Uses the ops registry name_prefix mapping and tries patterns in order:
+ 1. {env}-{prefix}-{service} (mdf, seriousletter: dev-mdf-mysql-UUID)
+ 2. {prefix}-{service} (ringsaday: ringsaday-website-UUID, coolify: coolify-db)
+ 3. {prefix}-{env} (ringsaday: ringsaday-dev-UUID)
+ 4. exact {prefix} (coolify infra: coolify)
"""
- return f"{project}-{env}-{service}-1"
+ prefixes = _load_prefixes()
+ prefix = prefixes.get(project, project)
+
+ # Pattern 1: {env}-{prefix}-{service}
+ hit = await _find_by_prefix(f"{env}-{prefix}-{service}")
+ if hit:
+ return hit
+
+ # Pattern 2: {prefix}-{service}
+ hit = await _find_by_prefix(f"{prefix}-{service}")
+ if hit:
+ return hit
+
+ # Pattern 3: {prefix}-{env}
+ hit = await _find_by_prefix(f"{prefix}-{env}")
+ if hit:
+ return hit
+
+ # Pattern 4: exact match when service == prefix (e.g., coolify)
+ if service == prefix:
+ hit = await _find_exact(prefix)
+ if hit:
+ return hit
+
+ raise HTTPException(
+ status_code=404,
+ detail=f"Container not found for {project}/{env}/{service}",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
@router.get("/logs/{project}/{env}/{service}", summary="Get container logs")
@@ -23,20 +122,19 @@
project: str,
env: str,
service: str,
- lines: int = Query(default=100, ge=1, le=10000, description="Number of log lines to return"),
+ lines: int = Query(
+ default=100, ge=1, le=10000, description="Number of log lines to return"
+ ),
_: str = Depends(verify_token),
) -> dict[str, Any]:
- """
- Fetch the last N lines of logs from a container.
- Uses `docker logs --tail {lines} {container}`.
- """
- container = _container_name(project, env, service)
+ """Fetch the last N lines of logs from a container."""
+ container = await _resolve_container(project, env, service)
result = await run_command(
[_DOCKER, "logs", "--tail", str(lines), container],
timeout=30,
)
- # docker logs writes to stderr by default; treat combined output as logs
+ # docker logs writes to stderr by default; combine both streams
combined = result["output"] + result["error"]
if not result["success"] and not combined.strip():
@@ -59,10 +157,8 @@
service: str,
_: str = Depends(verify_token),
) -> dict[str, Any]:
- """
- Restart a Docker container via `docker restart {container}`.
- """
- container = _container_name(project, env, service)
+ """Restart a Docker container."""
+ container = await _resolve_container(project, env, service)
result = await run_command(
[_DOCKER, "restart", container],
timeout=60,
--
Gitblit v1.3.1