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