From 14965c7e030dffc73cc7a143bb478664b45ed5a9 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Mon, 2 Mar 2026 23:24:03 +0800 Subject: [PATCH 1/4] 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} +
+
-- 2.52.0 From a91a1132c9fe6134e633e8c14bf1c8c03e4aa198 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Mon, 2 Mar 2026 23:35:10 +0800 Subject: [PATCH 2/4] feat: add info engine MVP pipeline and dashboard page Introduce a standalone scheduled info-engine worker with SQLite persistence and expose read-only dashboard APIs/UI so curated intelligence items can be collected and viewed with minimal architecture changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/backend/config.py | 3 + dashboard/backend/main.py | 4 +- dashboard/backend/routers/info_engine.py | 111 +++++++++++++ dashboard/docker-compose.yml | 1 + dashboard/frontend/src/App.svelte | 3 + .../frontend/src/components/Sidebar.svelte | 7 +- dashboard/frontend/src/lib/api.js | 15 ++ .../frontend/src/routes/InfoEngine.svelte | 139 ++++++++++++++++ info-engine/Dockerfile | 6 + info-engine/collector.py | 151 ++++++++++++++++++ info-engine/db.py | 64 ++++++++ info-engine/docker-compose.yml | 16 ++ info-engine/main.py | 37 +++++ info-engine/processor.py | 45 ++++++ info-engine/requirements.txt | 2 + 15 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 dashboard/backend/routers/info_engine.py create mode 100644 dashboard/frontend/src/routes/InfoEngine.svelte create mode 100644 info-engine/Dockerfile create mode 100644 info-engine/collector.py create mode 100644 info-engine/db.py create mode 100644 info-engine/docker-compose.yml create mode 100644 info-engine/main.py create mode 100644 info-engine/processor.py create mode 100644 info-engine/requirements.txt diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index 1d7475e..02b6f1c 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -44,6 +44,9 @@ CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https:// # Chat Summary CHAT_SUMMARY_DB = os.environ.get("CHAT_SUMMARY_DB", "/app/data/chat-summarizer/chat_summary.db") +# Info Engine +INFO_ENGINE_DB = os.environ.get("INFO_ENGINE_DB", "/app/data/info-engine/info_engine.db") + # LiteLLM LITELLM_URL = os.environ.get("LITELLM_URL", "http://127.0.0.1:4005") LITELLM_HEALTH_API_KEY = os.environ.get("LITELLM_HEALTH_API_KEY", "") diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index eb59587..7048a63 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, cc_connect +from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine import auth as auth_module from rbac import require_page, require_write @@ -185,6 +185,8 @@ 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(info_engine.router, prefix="/api/info-engine", + dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) app.include_router(security.router, prefix="/api/security", dependencies=[Depends(_inject_user), Depends(require_page("security"))]) diff --git a/dashboard/backend/routers/info_engine.py b/dashboard/backend/routers/info_engine.py new file mode 100644 index 0000000..b9e8d6a --- /dev/null +++ b/dashboard/backend/routers/info_engine.py @@ -0,0 +1,111 @@ +import json + +import aiosqlite +from fastapi import APIRouter, HTTPException, Query + +from config import INFO_ENGINE_DB + +router = APIRouter() + + +async def _db(): + return aiosqlite.connect(INFO_ENGINE_DB) + + +def _row_to_item(row): + tags = [] + if row[5]: + try: + tags = json.loads(row[5]) + except Exception: + tags = [] + + return { + "id": row[0], + "source": row[1], + "title": row[2], + "summary": row[3], + "url": row[4], + "tags": tags, + "published_at": row[6], + "collected_at": row[7], + "content_text": row[8], + "content_html": row[9], + } + + +@router.get("/items") +async def list_items( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + source: str = Query(""), + tag: str = Query(""), + since: str = Query(""), +): + where = [] + params = [] + + if source: + where.append("source = ?") + params.append(source) + if tag: + where.append("tags LIKE ?") + params.append(f'%"{tag}"%') + if since: + where.append("collected_at >= ?") + params.append(since) + + where_clause = f"WHERE {' AND '.join(where)}" if where else "" + + async with await _db() as db: + count_cursor = await db.execute( + f"SELECT COUNT(*) FROM info_items {where_clause}", + tuple(params), + ) + total = (await count_cursor.fetchone())[0] + + cursor = await db.execute( + f""" + SELECT id, source, title, summary, url, tags, published_at, collected_at, content_text, content_html + FROM info_items + {where_clause} + ORDER BY COALESCE(published_at, collected_at) DESC, id DESC + LIMIT ? OFFSET ? + """, + tuple(params + [limit, offset]), + ) + rows = await cursor.fetchall() + + return {"items": [_row_to_item(row) for row in rows], "total": total} + + +@router.get("/items/{item_id}") +async def get_item(item_id: int): + async with await _db() as db: + cursor = await db.execute( + """ + SELECT id, source, title, summary, url, tags, published_at, collected_at, content_text, content_html + FROM info_items + WHERE id = ? + """, + (item_id,), + ) + row = await cursor.fetchone() + + if not row: + raise HTTPException(404, "Item not found") + + return _row_to_item(row) + + +@router.get("/health") +async def health(): + async with await _db() as db: + cursor = await db.execute("SELECT COUNT(*), MAX(collected_at) FROM info_items") + row = await cursor.fetchone() + + return { + "status": "ok", + "item_count": row[0] if row else 0, + "last_collect_at": row[1] if row else None, + } diff --git a/dashboard/docker-compose.yml b/dashboard/docker-compose.yml index 3808068..fab1838 100644 --- a/dashboard/docker-compose.yml +++ b/dashboard/docker-compose.yml @@ -50,6 +50,7 @@ services: - /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro - /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro - /volume1/docker/chat-summarizer/data:/app/data/chat-summarizer:ro + - /volume1/docker/info-engine/data:/app/data/info-engine:ro - /volume1/docker/nas-dashboard/rbac.json:/volume1/docker/nas-dashboard/rbac.json healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"] diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index cdc57b6..3d3a533 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -11,6 +11,7 @@ import Security from "./routes/Security.svelte"; import LiteLLM from "./routes/LiteLLM.svelte"; import CcConnect from "./routes/CcConnect.svelte"; + import InfoEngine from "./routes/InfoEngine.svelte"; import Login from "./routes/Login.svelte"; import { onMount } from "svelte"; import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js"; @@ -159,6 +160,8 @@ {:else if page === "cc-connect" && hasPageAccess("dashboard")} + {:else if page === "info-engine" && hasPageAccess("dashboard")} + {:else} {/if} diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index 99d025b..e9fd1a5 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -4,7 +4,9 @@ function canAccess(id) { if (!id) return true; if (allowedPages === "*") return true; - return Array.isArray(allowedPages) && allowedPages.includes(id); + if (!Array.isArray(allowedPages)) return false; + if (id === "info-engine") return allowedPages.includes("dashboard"); + return allowedPages.includes(id); } function canSeeSidebarLink(item) { @@ -16,6 +18,7 @@ const defaultLinks = [ { id: "dashboard", label: "Overview", icon: "grid" }, + { id: "info-engine", label: "Info Engine", icon: "sparkles" }, { id: "litellm", label: "LiteLLM", icon: "bolt" }, { id: "docker", label: "Docker", icon: "box" }, { id: "files", label: "Files", icon: "folder" }, @@ -228,6 +231,8 @@ {#if icon === "grid"} + {:else if icon === "sparkles"} + {:else if icon === "box"} {:else if icon === "bolt"} diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index 8a169b7..2c72c26 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -147,6 +147,21 @@ export function getCcConnectHealth() { return get("/cc-connect/health"); } +export function getInfoEngineItems(params = {}) { + const query = new URLSearchParams(); + if (params.limit !== undefined) query.set("limit", String(params.limit)); + if (params.offset !== undefined) query.set("offset", String(params.offset)); + if (params.source) query.set("source", params.source); + if (params.tag) query.set("tag", params.tag); + if (params.since) query.set("since", params.since); + const suffix = query.toString() ? `?${query.toString()}` : ""; + return get(`/info-engine/items${suffix}`); +} + +export function getInfoEngineItem(id) { + return get(`/info-engine/items/${id}`); +} + export function put(path, data) { return request(path, { method: "PUT", json: data }); } diff --git a/dashboard/frontend/src/routes/InfoEngine.svelte b/dashboard/frontend/src/routes/InfoEngine.svelte new file mode 100644 index 0000000..25d7433 --- /dev/null +++ b/dashboard/frontend/src/routes/InfoEngine.svelte @@ -0,0 +1,139 @@ + + +
+
+
+

Info Engine

+

Context-aware stream of curated signals

+
+ +
+ + {#if requestError} +
+

{requestError}

+
+ {/if} + +
+
+
+ Latest items ({total}) +
+ {#if loading} +
+ {#each Array(6) as _} +
+ {/each} +
+ {:else if items.length === 0} +
No items yet.
+ {:else} +
+ {#each items as item} + + {/each} +
+ {/if} +
+ +
+ {#if loadingDetail} +
+
+
+
+
+
+ {:else if selectedItem} +

{selectedItem.title}

+
+

Source: {selectedItem.source}

+

Published: {formatDate(selectedItem.published_at)}

+

Collected: {formatDate(selectedItem.collected_at)}

+
+ + {#if selectedItem.tags?.length} +
+ {#each selectedItem.tags as tag} + {tag} + {/each} +
+ {/if} + +

{selectedItem.summary || selectedItem.content_text || "No summary available."}

+ + + Open source + + {:else} +

Select an item to view details.

+ {/if} +
+
+
diff --git a/info-engine/Dockerfile b/info-engine/Dockerfile new file mode 100644 index 0000000..3613a77 --- /dev/null +++ b/info-engine/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["python", "-u", "main.py"] diff --git a/info-engine/collector.py b/info-engine/collector.py new file mode 100644 index 0000000..373c0ff --- /dev/null +++ b/info-engine/collector.py @@ -0,0 +1,151 @@ +import logging +import os +from datetime import datetime +from email.utils import parsedate_to_datetime +from urllib.parse import urlparse +import xml.etree.ElementTree as ET + +import httpx + +log = logging.getLogger(__name__) + + +def _source_name(url: str) -> str: + host = urlparse(url).netloc + return host or url + + +def _first_text(parent, paths: list[str]) -> str: + for path in paths: + node = parent.find(path) + if node is not None and node.text: + value = node.text.strip() + if value: + return value + return "" + + +def _atom_link(entry) -> str: + for link in entry.findall("{http://www.w3.org/2005/Atom}link"): + href = (link.attrib.get("href") or "").strip() + rel = (link.attrib.get("rel") or "alternate").strip() + if href and rel == "alternate": + return href + links = entry.findall("{http://www.w3.org/2005/Atom}link") + if links: + return (links[0].attrib.get("href") or "").strip() + return "" + + +def _parse_datetime(value: str | None) -> str | None: + if not value: + return None + value = value.strip() + if not value: + return None + + try: + return parsedate_to_datetime(value).isoformat() + except Exception: + pass + + try: + iso = value.replace("Z", "+00:00") + return datetime.fromisoformat(iso).isoformat() + except Exception: + return None + + +def _parse_feed(feed_url: str, xml_text: str) -> list[dict]: + root = ET.fromstring(xml_text) + source = _source_name(feed_url) + + items = [] + + channel = root.find("channel") + if channel is not None: + for item in channel.findall("item"): + title = _first_text(item, ["title"]) + link = _first_text(item, ["link"]) + summary = _first_text(item, ["description"]) + pub = _first_text(item, ["pubDate", "date"]) + tags = [n.text.strip() for n in item.findall("category") if n.text and n.text.strip()] + if title and link: + items.append( + { + "source": source, + "title": title, + "summary": summary, + "url": link, + "tags": tags, + "published_at": _parse_datetime(pub), + "content_text": summary, + "content_html": summary, + } + ) + return items + + for entry in root.findall("{http://www.w3.org/2005/Atom}entry"): + title = _first_text(entry, ["{http://www.w3.org/2005/Atom}title"]) + link = _atom_link(entry) + summary = _first_text( + entry, + [ + "{http://www.w3.org/2005/Atom}summary", + "{http://www.w3.org/2005/Atom}content", + ], + ) + pub = _first_text( + entry, + [ + "{http://www.w3.org/2005/Atom}published", + "{http://www.w3.org/2005/Atom}updated", + ], + ) + tags = [ + (cat.attrib.get("term") or "").strip() + for cat in entry.findall("{http://www.w3.org/2005/Atom}category") + if (cat.attrib.get("term") or "").strip() + ] + if title and link: + items.append( + { + "source": source, + "title": title, + "summary": summary, + "url": link, + "tags": tags, + "published_at": _parse_datetime(pub), + "content_text": summary, + "content_html": summary, + } + ) + + return items + + +async def collect_sources() -> list[dict]: + sources_env = os.environ.get( + "INFO_ENGINE_SOURCES", + "https://hnrss.org/frontpage,https://lobste.rs/rss", + ) + sources = [s.strip() for s in sources_env.split(",") if s.strip()] + if not sources: + log.warning("No INFO_ENGINE_SOURCES configured") + return [] + + timeout = httpx.Timeout(15.0) + all_items = [] + + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + for source_url in sources: + try: + response = await client.get(source_url) + response.raise_for_status() + parsed = _parse_feed(source_url, response.text) + all_items.extend(parsed) + log.info("Collected %d items from %s", len(parsed), source_url) + except Exception as exc: + log.error("Failed to collect from %s: %s", source_url, exc) + + return all_items diff --git a/info-engine/db.py b/info-engine/db.py new file mode 100644 index 0000000..72f2b94 --- /dev/null +++ b/info-engine/db.py @@ -0,0 +1,64 @@ +import json +import os +from datetime import datetime, timezone + +import aiosqlite + +DB_PATH = os.environ.get("DB_PATH", "/app/data/info_engine.db") + + +async def init_db(): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + """ + CREATE TABLE IF NOT EXISTS info_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + title TEXT NOT NULL, + summary TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + tags TEXT NOT NULL, + published_at DATETIME, + collected_at DATETIME NOT NULL, + content_text TEXT, + content_html TEXT + ) + """ + ) + await db.execute("CREATE INDEX IF NOT EXISTS idx_info_items_collected_at ON info_items(collected_at DESC)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_info_items_published_at ON info_items(published_at DESC)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_info_items_source ON info_items(source)") + await db.commit() + + +async def upsert_items(items: list[dict]) -> int: + if not items: + return 0 + + inserted = 0 + now = datetime.now(timezone.utc).isoformat() + + async with aiosqlite.connect(DB_PATH) as db: + for item in items: + cursor = await db.execute( + """ + INSERT OR IGNORE INTO info_items + (source, title, summary, url, tags, published_at, collected_at, content_text, content_html) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + item["source"], + item["title"], + item.get("summary", ""), + item["url"], + json.dumps(item.get("tags", []), ensure_ascii=False), + item.get("published_at"), + now, + item.get("content_text"), + item.get("content_html"), + ), + ) + inserted += cursor.rowcount + await db.commit() + + return inserted diff --git a/info-engine/docker-compose.yml b/info-engine/docker-compose.yml new file mode 100644 index 0000000..488fead --- /dev/null +++ b/info-engine/docker-compose.yml @@ -0,0 +1,16 @@ +services: + info-engine: + build: . + container_name: info-engine + restart: unless-stopped + volumes: + - /volume1/docker/info-engine/data:/app/data + environment: + - DB_PATH=/app/data/info_engine.db + - INFO_ENGINE_INTERVAL_SECONDS=${INFO_ENGINE_INTERVAL_SECONDS:-900} + - INFO_ENGINE_SOURCES=${INFO_ENGINE_SOURCES:-https://hnrss.org/frontpage,https://lobste.rs/rss} + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/info-engine/main.py b/info-engine/main.py new file mode 100644 index 0000000..9728d3a --- /dev/null +++ b/info-engine/main.py @@ -0,0 +1,37 @@ +import asyncio +import logging +import os + +from collector import collect_sources +from db import init_db, upsert_items +from processor import process_items + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") +log = logging.getLogger(__name__) + +COLLECT_INTERVAL_SECONDS = int(os.environ.get("INFO_ENGINE_INTERVAL_SECONDS", "900")) + + +async def run_once() -> int: + raw_items = await collect_sources() + processed = process_items(raw_items) + inserted = await upsert_items(processed) + return inserted + + +async def main(): + await init_db() + log.info("Info engine started (interval=%ss)", COLLECT_INTERVAL_SECONDS) + + while True: + try: + inserted = await run_once() + log.info("Info engine cycle complete (inserted=%d)", inserted) + except Exception as exc: + log.error("Info engine cycle failed: %s", exc) + + await asyncio.sleep(COLLECT_INTERVAL_SECONDS) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/info-engine/processor.py b/info-engine/processor.py new file mode 100644 index 0000000..181e1b0 --- /dev/null +++ b/info-engine/processor.py @@ -0,0 +1,45 @@ +from urllib.parse import urldefrag, urlparse + + +def _normalize_url(url: str) -> str: + clean, _ = urldefrag(url.strip()) + parsed = urlparse(clean) + if parsed.scheme and parsed.netloc: + return clean + return "" + + +def process_items(items: list[dict]) -> list[dict]: + seen = set() + processed = [] + + for item in items: + title = (item.get("title") or "").strip() + url = _normalize_url(item.get("url") or "") + if not title or not url: + continue + + dedupe_key = url.lower() + if dedupe_key in seen: + continue + seen.add(dedupe_key) + + summary = (item.get("summary") or "").strip() + tags = item.get("tags") or [] + if not isinstance(tags, list): + tags = [] + + processed.append( + { + "source": (item.get("source") or "unknown").strip() or "unknown", + "title": title, + "summary": summary, + "url": url, + "tags": [str(t).strip() for t in tags if str(t).strip()], + "published_at": item.get("published_at"), + "content_text": item.get("content_text") or summary, + "content_html": item.get("content_html") or summary, + } + ) + + return processed diff --git a/info-engine/requirements.txt b/info-engine/requirements.txt new file mode 100644 index 0000000..42d058a --- /dev/null +++ b/info-engine/requirements.txt @@ -0,0 +1,2 @@ +aiosqlite +httpx -- 2.52.0 From 755a0b6ae9dceb9e4519fb698e4ccada9bb95e00 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Mon, 2 Mar 2026 23:40:44 +0800 Subject: [PATCH 3/4] feat: add CI-gated claude-dev image package Define claude-dev capabilities as a tested contract and deploy immutable tags only after smoke checks so daily tooling upgrades remain CI-first and rollback-safe. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/deploy-claude-dev-dev.yml | 107 +++++++++++++++++++++ CLAUDE.md | 15 ++- claude-dev/Dockerfile | 60 ++++++++++++ claude-dev/docker-compose.yml | 15 +++ claude-dev/required-tools.txt | 16 +++ claude-dev/scripts/smoke-auth.sh | 60 ++++++++++++ claude-dev/scripts/smoke-network.sh | 27 ++++++ claude-dev/scripts/smoke-tools.sh | 26 +++++ claude-dev/target-tag.txt | 1 + 9 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/deploy-claude-dev-dev.yml create mode 100644 claude-dev/Dockerfile create mode 100644 claude-dev/docker-compose.yml create mode 100644 claude-dev/required-tools.txt create mode 100644 claude-dev/scripts/smoke-auth.sh create mode 100644 claude-dev/scripts/smoke-network.sh create mode 100644 claude-dev/scripts/smoke-tools.sh create mode 100644 claude-dev/target-tag.txt diff --git a/.gitea/workflows/deploy-claude-dev-dev.yml b/.gitea/workflows/deploy-claude-dev-dev.yml new file mode 100644 index 0000000..6756c50 --- /dev/null +++ b/.gitea/workflows/deploy-claude-dev-dev.yml @@ -0,0 +1,107 @@ +name: Deploy Claude Dev (Dev) +on: + push: + branches: [dev] + paths: + - 'claude-dev/**' + - '.gitea/workflows/deploy-claude-dev-dev.yml' + workflow_dispatch: + inputs: + rollback_image: + description: 'Optional full image ref (for rollback), e.g. claude-dev:dev-abc123' + required: false + +jobs: + deploy-claude-dev-dev: + runs-on: ubuntu-latest + container: + volumes: + - /var/packages/ContainerManager/target/usr/bin/docker:/usr/bin/docker + - /volume1/docker/claude-dev:/claude-dev-runtime + steps: + - name: Checkout dev branch + run: git clone --depth 1 --branch dev http://gitea:3000/jimmy/nas-tools.git . + + - name: Resolve image tags + run: | + set -euo pipefail + if [ -n "${{ inputs.rollback_image }}" ]; then + image_ref="${{ inputs.rollback_image }}" + image_tag="${image_ref#claude-dev:}" + if [ "$image_ref" = "$image_tag" ]; then + echo "rollback_image must be in claude-dev: format" >&2 + exit 1 + fi + echo "ROLLBACK_ONLY=1" >> "$GITHUB_ENV" + else + short_sha="$(git rev-parse --short=12 HEAD)" + date_tag="$(date +%Y%m%d%H%M%S)" + image_tag="dev-${date_tag}-${short_sha}" + image_ref="claude-dev:${image_tag}" + echo "ROLLBACK_ONLY=0" >> "$GITHUB_ENV" + fi + echo "CLAUDE_DEV_IMAGE_TAG=${image_tag}" >> "$GITHUB_ENV" + echo "CLAUDE_DEV_IMAGE=${image_ref}" >> "$GITHUB_ENV" + + - name: Build claude-dev image + if: env.ROLLBACK_ONLY == '0' + run: | + set -euo pipefail + DOCKER_BUILDKIT=0 docker build \ + --cache-from claude-dev:latest \ + -t "$CLAUDE_DEV_IMAGE" \ + -t claude-dev:latest \ + claude-dev/ + + - name: Smoke test required tools + if: env.ROLLBACK_ONLY == '0' + run: | + set -euo pipefail + docker run --rm "$CLAUDE_DEV_IMAGE" bash /opt/claude-dev/scripts/smoke-tools.sh + + - name: Smoke test network assumptions + if: env.ROLLBACK_ONLY == '0' + run: | + set -euo pipefail + docker run --rm --network host "$CLAUDE_DEV_IMAGE" bash /opt/claude-dev/scripts/smoke-network.sh + + - name: Smoke test auth and mount expectations + if: env.ROLLBACK_ONLY == '0' + run: | + set -euo pipefail + docker run --rm --network host \ + -e REQUIRE_MOUNTS=1 \ + -v /volume1/docker/claude-dev/claude-config:/root/.claude \ + -v /volume1/docker/claude-dev/claude-config/.claude.json:/root/.claude.json \ + -v /volume1/docker/claude-dev/gitea_token:/root/.gitea_token:ro \ + "$CLAUDE_DEV_IMAGE" \ + bash /opt/claude-dev/scripts/smoke-auth.sh + + - name: Record previous image + run: | + set -euo pipefail + previous_image="$(docker inspect -f '{{.Config.Image}}' claude-dev 2>/dev/null || true)" + if [ -n "$previous_image" ]; then + printf '%s\n' "$previous_image" > /claude-dev-runtime/previous-image.txt + echo "Saved previous image: $previous_image" + else + echo "No existing claude-dev container found" + fi + + - name: Sync compose and target tag + run: | + set -euo pipefail + cp claude-dev/docker-compose.yml /claude-dev-runtime/docker-compose.yml + printf '%s\n' "$CLAUDE_DEV_IMAGE_TAG" > /claude-dev-runtime/target-tag.txt + + - name: Deploy claude-dev + run: | + set -euo pipefail + CLAUDE_DEV_IMAGE_TAG="$CLAUDE_DEV_IMAGE_TAG" docker compose -f /claude-dev-runtime/docker-compose.yml up -d --pull never + + - name: Post-deploy runtime checks + run: | + set -euo pipefail + docker exec claude-dev bash -lc 'claude --version' + docker exec claude-dev bash -lc 'gh --version' + docker exec claude-dev bash -lc 'gh auth status || true' diff --git a/CLAUDE.md b/CLAUDE.md index 558166a..9287eec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,20 @@ - Build images on Mac: `docker build --platform linux/amd64` - Transfer: `docker save | ssh zjgump@100.78.131.124 "/volume1/@appstore/ContainerManager/usr/bin/docker load"` - Deploy: `git push` triggers Gitea Actions CI (`docker compose up -d --pull never`) — do NOT manually restart -- CI workflow `deploy.yml` is stored in Gitea, not in this repo +- CI workflows are in `.gitea/workflows/` in this repo + +## claude-dev CI Contract +- Source package lives in `claude-dev/` (`Dockerfile`, `required-tools.txt`, and smoke scripts under `claude-dev/scripts/`) +- CI-first only: update `claude-dev/*` in git, then push; do not do ad-hoc local image rebuild/redeploy for normal changes +- Required capabilities are declared in `claude-dev/required-tools.txt`; adding tools must update this file and related smoke checks +- Build/deploy gate (`.gitea/workflows/deploy-claude-dev-dev.yml`) uses NAS runner with `DOCKER_BUILDKIT=0`, then runs: + - tools smoke (`smoke-tools.sh`) + - network smoke (`smoke-network.sh`) + - auth/mount smoke (`smoke-auth.sh` with required runtime mounts) +- Deployment only happens after smoke pass, using immutable tag format `claude-dev:dev--` +- Runtime compose definition is `claude-dev/docker-compose.yml`; CI syncs it to `/volume1/docker/claude-dev/docker-compose.yml` +- Runtime requirements preserved: `network_mode: host`, `/volume1/repos` mount, `/root/.claude` mount, `/root/.claude.json` mount, `/root/.gitea_token` mount +- Rollback: run workflow manually with `rollback_image` (for example the value in `/volume1/docker/claude-dev/previous-image.txt`) to redeploy a known-good immutable tag via CI ## Synology Quirks - No `crontab` — edit `/etc/crontab` with `sudo` + `printf`, restart crond diff --git a/claude-dev/Dockerfile b/claude-dev/Dockerfile new file mode 100644 index 0000000..d281067 --- /dev/null +++ b/claude-dev/Dockerfile @@ -0,0 +1,60 @@ +FROM node:20-bookworm + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + fd-find \ + gh \ + git \ + jq \ + make \ + openssh-client \ + python3 \ + ripgrep \ + rsync \ + unzip \ + wget \ + zip \ + && rm -rf /var/lib/apt/lists/* + +RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd + +ARG YQ_VERSION=v4.44.6 +RUN wget -q -O /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \ + && chmod +x /usr/local/bin/yq + +RUN npm install -g @anthropic-ai/claude-code + +RUN python3 - <<'PY' +from pathlib import Path + +path = Path("/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js") +if not path.exists(): + raise SystemExit(f"claude cli not found: {path}") + +content = path.read_text() +old = 'F8.get("https://api.anthropic.com/api/hello",{headers:K8($)}).catch((()=>!1))' +if old not in content: + raise SystemExit("preflight patch target not found in claude cli; refusing to build") + +patched = content.replace(old, "!0") +path.write_text(patched) + +if old in path.read_text(): + raise SystemExit("preflight patch did not apply cleanly") + +print("Applied Claude preflight bypass patch") +PY + +WORKDIR /repos/nas-tools + +COPY required-tools.txt /opt/claude-dev/required-tools.txt +COPY scripts /opt/claude-dev/scripts + +RUN chmod +x /opt/claude-dev/scripts/*.sh + +CMD ["bash"] diff --git a/claude-dev/docker-compose.yml b/claude-dev/docker-compose.yml new file mode 100644 index 0000000..5f7f765 --- /dev/null +++ b/claude-dev/docker-compose.yml @@ -0,0 +1,15 @@ +services: + claude-dev: + image: claude-dev:${CLAUDE_DEV_IMAGE_TAG:?CLAUDE_DEV_IMAGE_TAG is required} + container_name: claude-dev + restart: unless-stopped + network_mode: host + stdin_open: true + tty: true + working_dir: /repos/nas-tools + volumes: + - /volume1/repos:/repos + - /volume1/docker/claude-dev/claude-config:/root/.claude + - /volume1/docker/claude-dev/claude-config/.claude.json:/root/.claude.json + - /volume1/docker/claude-dev/gitea_token:/root/.gitea_token:ro + - /volume1/docker/claude-dev/bashrc:/root/.bashrc diff --git a/claude-dev/required-tools.txt b/claude-dev/required-tools.txt new file mode 100644 index 0000000..c1641eb --- /dev/null +++ b/claude-dev/required-tools.txt @@ -0,0 +1,16 @@ +gh +git +jq +yq +curl +wget +python3 +node +npm +rg +fd +rsync +zip +unzip +make +claude diff --git a/claude-dev/scripts/smoke-auth.sh b/claude-dev/scripts/smoke-auth.sh new file mode 100644 index 0000000..a653e8f --- /dev/null +++ b/claude-dev/scripts/smoke-auth.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +require_mounts="${REQUIRE_MOUNTS:-0}" + +auth_ok=true + +check_required_file() { + local path="$1" + if [[ ! -e "$path" ]]; then + echo "Missing required mounted path: $path" >&2 + auth_ok=false + return + fi + if [[ ! -r "$path" ]]; then + echo "Path is not readable: $path" >&2 + auth_ok=false + return + fi + if [[ -f "$path" ]] && [[ ! -s "$path" ]]; then + echo "Path is empty: $path" >&2 + auth_ok=false + return + fi + echo "Verified mounted path: $path" +} + +if [[ "$require_mounts" == "1" ]]; then + check_required_file "/root/.claude" + check_required_file "/root/.claude.json" + check_required_file "/root/.gitea_token" + + if [[ -f "/root/.claude/.git-credentials" ]]; then + echo "Verified mounted path: /root/.claude/.git-credentials" + else + echo "Missing expected git credentials file: /root/.claude/.git-credentials" >&2 + auth_ok=false + fi +fi + +if [[ -n "${GH_TOKEN:-}" ]]; then + if GH_TOKEN="$GH_TOKEN" gh auth status >/dev/null 2>&1; then + echo "gh auth status succeeded with GH_TOKEN" + else + echo "gh auth status failed with GH_TOKEN provided" >&2 + auth_ok=false + fi +else + if gh auth status >/dev/null 2>&1; then + echo "gh auth status succeeded using persisted auth" + else + echo "gh auth status not configured (allowed without GH_TOKEN)" + fi +fi + +if [[ "$auth_ok" != "true" ]]; then + exit 1 +fi + +echo "Auth/config smoke checks passed" diff --git a/claude-dev/scripts/smoke-network.sh b/claude-dev/scripts/smoke-network.sh new file mode 100644 index 0000000..fbe2196 --- /dev/null +++ b/claude-dev/scripts/smoke-network.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +check_http_status() { + local url="$1" + local allowed_regex="$2" + + local code + code=$(curl -sS -o /dev/null -m 10 -w "%{http_code}" "$url") + if [[ ! "$code" =~ $allowed_regex ]]; then + echo "Unexpected HTTP status from $url: $code" >&2 + return 1 + fi + echo "$url => HTTP $code" +} + +if ! getent hosts api.bytecatcode.org >/dev/null 2>&1; then + echo "DNS lookup failed for api.bytecatcode.org" >&2 + exit 1 +fi + +echo "DNS lookup passed for api.bytecatcode.org" + +check_http_status "https://api.bytecatcode.org" '^(200|301|302|400|401|403|404)$' +check_http_status "http://127.0.0.1:3300" '^(200|301|302|401|403|404)$' + +echo "Network smoke checks passed" diff --git a/claude-dev/scripts/smoke-tools.sh b/claude-dev/scripts/smoke-tools.sh new file mode 100644 index 0000000..919360b --- /dev/null +++ b/claude-dev/scripts/smoke-tools.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +TOOLS_FILE="${1:-/opt/claude-dev/required-tools.txt}" + +if [[ ! -f "$TOOLS_FILE" ]]; then + echo "required tools file not found: $TOOLS_FILE" >&2 + exit 1 +fi + +missing=() +while IFS= read -r tool; do + [[ -z "$tool" ]] && continue + [[ "$tool" =~ ^# ]] && continue + if ! command -v "$tool" >/dev/null 2>&1; then + missing+=("$tool") + fi +done < "$TOOLS_FILE" + +if (( ${#missing[@]} > 0 )); then + echo "Missing required tools:" >&2 + printf ' - %s\n' "${missing[@]}" >&2 + exit 1 +fi + +echo "All required tools are present" diff --git a/claude-dev/target-tag.txt b/claude-dev/target-tag.txt new file mode 100644 index 0000000..865faf1 --- /dev/null +++ b/claude-dev/target-tag.txt @@ -0,0 +1 @@ +auto -- 2.52.0 From d8e6f5716a01fae73e6ca4056a8d42219b3bfe3f Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Tue, 3 Mar 2026 00:11:53 +0800 Subject: [PATCH 4/4] feat: add cc-connect start/stop controls Add backend start/stop endpoints and wire cc-connect UI controls to toggle container state with post-action health refresh and clear error handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/backend/routers/cc_connect.py | 87 ++++++++++++++++++- dashboard/frontend/src/lib/api.js | 8 ++ .../frontend/src/routes/CcConnect.svelte | 46 ++++++++-- 3 files changed, 133 insertions(+), 8 deletions(-) diff --git a/dashboard/backend/routers/cc_connect.py b/dashboard/backend/routers/cc_connect.py index 8d59573..bc8361d 100644 --- a/dashboard/backend/routers/cc_connect.py +++ b/dashboard/backend/routers/cc_connect.py @@ -10,15 +10,96 @@ router = APIRouter() client = docker.DockerClient(base_url=DOCKER_HOST, timeout=5) +def _get_cc_connect_container(): + matches = client.containers.list(all=True, filters={"name": "cc-connect"}) + return matches[0] if matches else None + + +@router.post("/start") +def start_cc_connect(): + try: + container = _get_cc_connect_container() + except Exception as exc: + return { + "ok": False, + "status": "down", + "container_status": None, + "error": f"docker check failed: {exc}", + } + + if not container: + return { + "ok": False, + "status": "down", + "container_status": "not_found", + "error": "cc-connect container not found", + } + + try: + container.start() + container.reload() + container_status = container.status + return { + "ok": container_status == "running", + "status": "up" if container_status == "running" else "down", + "container_status": container_status, + "error": None if container_status == "running" else "cc-connect failed to start", + } + except Exception as exc: + return { + "ok": False, + "status": "down", + "container_status": container.status, + "error": f"docker start failed: {exc}", + } + + +@router.post("/stop") +def stop_cc_connect(): + try: + container = _get_cc_connect_container() + except Exception as exc: + return { + "ok": False, + "status": "down", + "container_status": None, + "error": f"docker check failed: {exc}", + } + + if not container: + return { + "ok": False, + "status": "down", + "container_status": "not_found", + "error": "cc-connect container not found", + } + + try: + container.stop() + container.reload() + container_status = container.status + return { + "ok": container_status != "running", + "status": "down", + "container_status": container_status, + "error": None if container_status != "running" else "cc-connect failed to stop", + } + except Exception as exc: + return { + "ok": False, + "status": "down", + "container_status": container.status, + "error": f"docker stop failed: {exc}", + } + + @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] + container = _get_cc_connect_container() except Exception as exc: return { "ok": False, diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index 2c72c26..30dcb90 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -147,6 +147,14 @@ export function getCcConnectHealth() { return get("/cc-connect/health"); } +export function startCcConnect() { + return post("/cc-connect/start"); +} + +export function stopCcConnect() { + return post("/cc-connect/stop"); +} + export function getInfoEngineItems(params = {}) { const query = new URLSearchParams(); if (params.limit !== undefined) query.set("limit", String(params.limit)); diff --git a/dashboard/frontend/src/routes/CcConnect.svelte b/dashboard/frontend/src/routes/CcConnect.svelte index 237864f..7d72a9e 100644 --- a/dashboard/frontend/src/routes/CcConnect.svelte +++ b/dashboard/frontend/src/routes/CcConnect.svelte @@ -1,10 +1,11 @@