From 14965c7e030dffc73cc7a143bb478664b45ed5a9 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Mon, 2 Mar 2026 23:24:03 +0800 Subject: [PATCH] feat: add cc-connect dashboard health integration Add a protected backend health endpoint and a new dashboard page/sidebar entry so cc-connect operational status is visible in the UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- PLAN.md | 3 +- dashboard/backend/config.py | 3 + dashboard/backend/main.py | 4 +- dashboard/backend/routers/cc_connect.py | 107 ++++++++++++++++ dashboard/frontend/src/App.svelte | 3 + .../frontend/src/components/Sidebar.svelte | 1 + dashboard/frontend/src/lib/api.js | 4 + .../frontend/src/routes/CcConnect.svelte | 119 ++++++++++++++++++ 8 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 dashboard/backend/routers/cc_connect.py create mode 100644 dashboard/frontend/src/routes/CcConnect.svelte diff --git a/PLAN.md b/PLAN.md index 1a8089a..25bc4d1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -176,11 +176,12 @@ nas-tools/ ### Phase 14 — Hybrid AI Gateway (LiteLLM + cc-connect) 🚧 49. ~~Add `litellm/` service scaffold (`docker-compose.yml`, `config/litellm.yaml`, `.env.example`) with localhost-only host binding and env-driven provider keys~~ — Done 50. ~~Add dashboard backend LiteLLM operational probe (`/api/litellm/health`) and wire into protected routes~~ — Done -51. Add `cc-connect/` templates (`README.md`, `config.example.toml`, optional compose) for mobile bridge rollout +51. ~~Add `cc-connect/` templates (`README.md`, `config.example.toml`, optional compose) for mobile bridge rollout~~ — Done 52. ~~Deploy LiteLLM image to NAS using server2 pull + stream load workflow to avoid slow GHCR pulls on NAS~~ — Done 53. ~~Network fix: attach LiteLLM to `nas-dashboard_internal` and set dashboard-dev `LITELLM_URL=http://litellm:4005`~~ — Done 54. ~~Update backend health probe to support auth-required mode and configured health auth key~~ — Done 55. ~~Add LiteLLM dashboard UI page with sidebar link and health status card~~ — Done +56. ~~Add cc-connect dashboard UI page, sidebar link, and backend health/status endpoint~~ — Done ### Phase 13 — Off-site Backup 46. Add cloud backup (OneDrive or Google Drive) for critical data diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index 46814f5..1d7475e 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -48,6 +48,9 @@ CHAT_SUMMARY_DB = os.environ.get("CHAT_SUMMARY_DB", "/app/data/chat-summarizer/c LITELLM_URL = os.environ.get("LITELLM_URL", "http://127.0.0.1:4005") LITELLM_HEALTH_API_KEY = os.environ.get("LITELLM_HEALTH_API_KEY", "") +# CC Connect +CC_CONNECT_URL = os.environ.get("CC_CONNECT_URL", "") + # OpenClaw OPENCLAW_GATEWAY_TOKEN = os.environ.get("OPENCLAW_GATEWAY_TOKEN", "") diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index 924a929..eb59587 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse from slowapi import Limiter from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded -from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm +from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect import auth as auth_module from rbac import require_page, require_write @@ -181,6 +181,8 @@ app.include_router(system.router, prefix="/api/system", dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) app.include_router(litellm.router, prefix="/api/litellm", dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) +app.include_router(cc_connect.router, prefix="/api/cc-connect", + dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) app.include_router(chat_summary.router, prefix="/api/chat-summary", dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))]) app.include_router(security.router, prefix="/api/security", diff --git a/dashboard/backend/routers/cc_connect.py b/dashboard/backend/routers/cc_connect.py new file mode 100644 index 0000000..8d59573 --- /dev/null +++ b/dashboard/backend/routers/cc_connect.py @@ -0,0 +1,107 @@ +import time + +import docker +import httpx +from fastapi import APIRouter + +from config import CC_CONNECT_URL, DOCKER_HOST + +router = APIRouter() +client = docker.DockerClient(base_url=DOCKER_HOST, timeout=5) + + +@router.get("/health") +async def cc_connect_health(): + url = (CC_CONNECT_URL or "").strip() + + container = None + try: + matches = client.containers.list(all=True, filters={"name": "cc-connect"}) + if matches: + container = matches[0] + except Exception as exc: + return { + "ok": False, + "status": "down", + "source": "docker", + "container_name": None, + "container_status": None, + "endpoint": None, + "http_status": None, + "latency_ms": None, + "error": f"docker check failed: {exc}", + } + + container_name = container.name if container else None + container_status = container.status if container else "not_found" + container_up = container_status == "running" + + if not url: + return { + "ok": container_up, + "status": "up" if container_up else "down", + "source": "docker", + "container_name": container_name, + "container_status": container_status, + "endpoint": None, + "http_status": None, + "latency_ms": None, + "error": None if container_up else "cc-connect container is not running", + } + + timeout = httpx.Timeout(2.0) + started = time.perf_counter() + try: + async with httpx.AsyncClient(timeout=timeout) as http_client: + response = await http_client.get(url) + latency_ms = round((time.perf_counter() - started) * 1000, 1) + + if response.is_success and container_up: + return { + "ok": True, + "status": "up", + "source": "docker+http", + "container_name": container_name, + "container_status": container_status, + "endpoint": url, + "http_status": response.status_code, + "latency_ms": latency_ms, + "error": None, + } + + if response.is_success and not container_up: + return { + "ok": False, + "status": "down", + "source": "docker+http", + "container_name": container_name, + "container_status": container_status, + "endpoint": url, + "http_status": response.status_code, + "latency_ms": latency_ms, + "error": "HTTP probe succeeded but cc-connect container is not running", + } + + return { + "ok": False, + "status": "down", + "source": "docker+http", + "container_name": container_name, + "container_status": container_status, + "endpoint": url, + "http_status": response.status_code, + "latency_ms": latency_ms, + "error": f"HTTP probe returned {response.status_code}", + } + except Exception as exc: + return { + "ok": False, + "status": "down", + "source": "docker+http", + "container_name": container_name, + "container_status": container_status, + "endpoint": url, + "http_status": None, + "latency_ms": None, + "error": str(exc), + } diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index 16a0870..cdc57b6 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -10,6 +10,7 @@ import ChatSummary from "./routes/ChatSummary.svelte"; import Security from "./routes/Security.svelte"; import LiteLLM from "./routes/LiteLLM.svelte"; + import CcConnect from "./routes/CcConnect.svelte"; import Login from "./routes/Login.svelte"; import { onMount } from "svelte"; import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js"; @@ -156,6 +157,8 @@ {:else if page === "litellm" && hasPageAccess("dashboard")} + {:else if page === "cc-connect" && hasPageAccess("dashboard")} + {:else} {/if} diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index bb69cf4..99d025b 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -42,6 +42,7 @@ const defaultTools = [ { id: "openclaw", label: "OpenClaw", icon: "openclaw" }, + { id: "cc-connect", label: "cc-connect", icon: "users" }, { id: "chat-digest", label: "Chat Digest", icon: "chat" }, { id: "gitea", label: "Repos", icon: "git" }, { label: "Gitea Web", remoteHref: "https://git.jimmygan.com:8443", icon: "git", external: true }, diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index 39242aa..8a169b7 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -143,6 +143,10 @@ export function getLiteLLMHealth() { return get("/litellm/health"); } +export function getCcConnectHealth() { + return get("/cc-connect/health"); +} + export function put(path, data) { return request(path, { method: "PUT", json: data }); } diff --git a/dashboard/frontend/src/routes/CcConnect.svelte b/dashboard/frontend/src/routes/CcConnect.svelte new file mode 100644 index 0000000..237864f --- /dev/null +++ b/dashboard/frontend/src/routes/CcConnect.svelte @@ -0,0 +1,119 @@ + + +
+
+
+

cc-connect

+

Mobile bridge operational status

+
+ up + down +
+
+ +
+ + {#if requestError} +
+

Failed to fetch cc-connect health

+

{requestError}

+
+ {/if} + +
+ {#if loading && !health} +
+
+
+ {#each Array(8) as _} +
+ {/each} +
+
+ {:else if health} +
+

Health Status

+ {valueOrDash(health.status)} +
+ +
+
+

OK

+

{String(Boolean(health.ok))}

+
+ +
+

Source

+

{valueOrDash(health.source)}

+
+ +
+

Container

+

{valueOrDash(health.container_name)}

+
+ +
+

Container Status

+

{valueOrDash(health.container_status)}

+
+ +
+

Endpoint

+

{valueOrDash(health.endpoint)}

+
+ +
+

HTTP Status

+

{valueOrDash(health.http_status)}

+
+ +
+

Latency

+

{health.latency_ms === null || health.latency_ms === undefined ? "—" : `${health.latency_ms} ms`}

+
+ +
+

Error

+

{valueOrDash(health.error)}

+
+
+ {:else} +

No health data available.

+ {/if} +
+