From fa4534bfb2c006684e272577ed7279c0302e5da5 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Sat, 9 May 2026 16:54:30 +0200 Subject: [PATCH] feat: Projects tab, 4-tab dashboard, full knowledge browser - Add projects router (Redis projects + workcontexts merged) - Register projects router in main.py - Extend knowledge router: howtos, agents, skills via MCP proxy - Extend mcp_client: list/get howtos, agents, skills - Rewrite dashboard.html: 4 tabs (Taskz/Worklog/Projects/Knowledge) - Taskz: sidebar board list + Kanban columns with task modal - Worklog: context/days picker + standup button - Projects: context filter sidebar + work context display - Knowledge: 6 sub-tabs (docs/howtos/agents/skills/adrs/memories) with markdown rendering via marked.js --- app/main.py | 3 +- app/mcp_client.py | 43 ++ app/redis_client.py | 18 + app/routers/knowledge.py | 162 +++-- app/routers/projects.py | 61 ++ app/templates/dashboard.html | 1311 ++++++++++++++++------------------ 6 files changed, 829 insertions(+), 769 deletions(-) create mode 100644 app/routers/projects.py diff --git a/app/main.py b/app/main.py index 2506da1..5b9d1e4 100644 --- a/app/main.py +++ b/app/main.py @@ -10,13 +10,14 @@ from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from app.routers import tasks, worklog, knowledge +from app.routers import tasks, worklog, knowledge, projects app = FastAPI(title="DevOpsDash", version="1.0.0", docs_url="/api/docs") app.include_router(tasks.router) app.include_router(worklog.router) app.include_router(knowledge.router) +app.include_router(projects.router) TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) diff --git a/app/mcp_client.py b/app/mcp_client.py index 70a7b25..67fd54a 100644 --- a/app/mcp_client.py +++ b/app/mcp_client.py @@ -54,3 +54,46 @@ async def get_worklog( async def get_standup(days: int = 2, context: str = "egmont") -> Dict[str, Any]: return await _call_tool("generate_standup", {"days": days, "context": context}) + + +async def list_knowledge(category: Optional[str] = None, tag: Optional[str] = None) -> Dict[str, Any]: + args: Dict[str, Any] = {} + if category: + args["category"] = category + if tag: + args["tag"] = tag + return await _call_tool("list_knowledge", args) + + +async def get_knowledge(storage_filename: str) -> Dict[str, Any]: + return await _call_tool("get_knowledge", {"storage_filename": storage_filename, "include_metadata": False}) + + +async def list_howtos() -> Dict[str, Any]: + return await _call_tool("list_howtos", {}) + + +async def get_howto(filename: str) -> Dict[str, Any]: + return await _call_tool("get_howto_content", {"filename": filename}) + + +async def list_agents(domain: Optional[str] = None) -> Dict[str, Any]: + args: Dict[str, Any] = {} + if domain: + args["domain"] = domain + return await _call_tool("get_agent", args) + + +async def get_agent(name: str) -> Dict[str, Any]: + return await _call_tool("get_agent", {"name": name}) + + +async def list_skills(domain: Optional[str] = None) -> Dict[str, Any]: + args: Dict[str, Any] = {} + if domain: + args["domain"] = domain + return await _call_tool("get_skill", args) + + +async def get_skill(name: str) -> Dict[str, Any]: + return await _call_tool("get_skill", {"name": name}) diff --git a/app/redis_client.py b/app/redis_client.py index 3e12068..7ebbaa3 100644 --- a/app/redis_client.py +++ b/app/redis_client.py @@ -21,6 +21,8 @@ MEMORY_KEY_PREFIX = "devops-mcp:memory:" PROJECT_KEY_PREFIX = "devops-mcp:projects:" PROJECT_INDEX_KEY = "devops-mcp:projects:_index" +WORKCONTEXT_KEY_PREFIX = "devops-mcp:workcontext:" + def get_redis() -> redis.Redis: url = os.environ.get("REDIS_URL", "redis://localhost:6379") @@ -130,3 +132,19 @@ def list_projects(r: redis.Redis, context: Optional[str] = None) -> List[Dict[st continue projects.append(proj) return projects + + +def list_workcontexts(r: redis.Redis) -> List[Dict[str, Any]]: + keys = r.keys(f"{WORKCONTEXT_KEY_PREFIX}*") + contexts = [] + for key in keys: + raw = r.get(key) + if raw: + try: + ctx = json.loads(raw) + ctx["path"] = key[len(WORKCONTEXT_KEY_PREFIX):] + contexts.append(ctx) + except json.JSONDecodeError: + pass + contexts.sort(key=lambda c: c.get("saved_at", ""), reverse=True) + return contexts diff --git a/app/routers/knowledge.py b/app/routers/knowledge.py index 0e13dee..90a6ad9 100644 --- a/app/routers/knowledge.py +++ b/app/routers/knowledge.py @@ -1,4 +1,4 @@ -"""Knowledge router — ADRs, memories, and file browser for mounted data directory.""" +"""Knowledge router — ADRs, memories, knowledge catalog, HOWTOs, agents, skills via MCP proxy.""" from __future__ import annotations @@ -11,10 +11,10 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import PlainTextResponse from app.redis_client import get_redis, list_adrs, get_adr, list_memories +import app.mcp_client as mcp router = APIRouter(prefix="/api/v1", tags=["knowledge"]) -# Data dir is mounted from DevOpsMCP at /data (read-only bind mount in production) DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")) KNOWLEDGE_CATALOG = DATA_DIR / "knowledge" / "catalog.json" @@ -46,89 +46,113 @@ def api_list_memories(entity_type: Optional[str] = None, limit: int = 50): return {"memories": memories, "total": len(memories)} -# ── Knowledge catalog (file-based, from bind-mounted data dir) ──────────────── +# ── Knowledge catalog — MCP proxy (file-based in DevOpsMCP) ────────────────── @router.get("/knowledge") -def api_list_knowledge(category: Optional[str] = None, tag: Optional[str] = None): - if not KNOWLEDGE_CATALOG.exists(): - return {"entries": [], "total": 0, "data_dir_available": False} +async def api_list_knowledge(category: Optional[str] = None, tag: Optional[str] = None): try: - catalog: dict = json.loads(KNOWLEDGE_CATALOG.read_text(encoding="utf-8")) + return await mcp.list_knowledge(category=category, tag=tag) except Exception as exc: - raise HTTPException(status_code=500, detail=f"Catalog read error: {exc}") from exc - - entries = list(catalog.values()) - if category: - entries = [e for e in entries if e.get("category", "").lower() == category.lower()] - if tag: - entries = [e for e in entries if tag.lower() in [t.lower() for t in e.get("tags", [])]] - - summaries = [ - { - "storage_filename": e["storage_filename"], - "original_filename": e["original_filename"], - "title": e.get("title", e["original_filename"]), - "file_type": e.get("file_type", "markdown"), - "category": e.get("category", "general"), - "tags": e.get("tags", []), - "summary": e.get("summary", "")[:200], - "char_count": e.get("char_count", 0), - "learned_at": e.get("learned_at", ""), - } - for e in entries - ] - summaries.sort(key=lambda e: e.get("learned_at", ""), reverse=True) - return {"entries": summaries, "total": len(summaries), "data_dir_available": True} + # Fallback: try local bind-mount + if not KNOWLEDGE_CATALOG.exists(): + return {"entries": [], "total": 0, "error": str(exc)} + try: + catalog = json.loads(KNOWLEDGE_CATALOG.read_text(encoding="utf-8")) + entries = list(catalog.values()) + if category: + entries = [e for e in entries if e.get("category", "").lower() == category.lower()] + if tag: + entries = [e for e in entries if tag.lower() in [t.lower() for t in e.get("tags", [])]] + entries.sort(key=lambda e: e.get("learned_at", ""), reverse=True) + return {"entries": entries, "total": len(entries)} + except Exception as e2: + raise HTTPException(status_code=500, detail=str(e2)) from e2 @router.get("/knowledge/{filename}") -def api_get_knowledge_file(filename: str): - """Return raw markdown content of a knowledge file.""" - safe_name = Path(filename).name # prevent path traversal - file_path = DATA_DIR / "knowledge" / safe_name - if not file_path.exists() or not file_path.is_file(): - raise HTTPException(status_code=404, detail="File not found") - content = file_path.read_text(encoding="utf-8") - return PlainTextResponse(content, media_type="text/markdown") +async def api_get_knowledge_file(filename: str): + try: + result = await mcp.get_knowledge(filename) + content = result.get("content", result.get("raw", "")) + return PlainTextResponse(str(content), media_type="text/markdown") + except Exception as exc: + # Fallback: local bind-mount + safe_name = Path(filename).name + file_path = DATA_DIR / "knowledge" / safe_name + if not file_path.exists(): + raise HTTPException(status_code=404, detail="File not found") from exc + return PlainTextResponse(file_path.read_text(encoding="utf-8"), media_type="text/markdown") -# ── Generic file browser for data directory ─────────────────────────────────── +# ── HOWTOs — MCP proxy ──────────────────────────────────────────────────────── + +@router.get("/howtos") +async def api_list_howtos(): + try: + return await mcp.list_howtos() + except Exception as exc: + raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc + + +@router.get("/howtos/{filename}") +async def api_get_howto(filename: str): + try: + result = await mcp.get_howto(filename) + content = result.get("content", result.get("raw", "")) + return PlainTextResponse(str(content), media_type="text/markdown") + except Exception as exc: + raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc + + +# ── Agents — MCP proxy ──────────────────────────────────────────────────────── + +@router.get("/agents") +async def api_list_agents(domain: Optional[str] = None): + try: + return await mcp.list_agents(domain=domain) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc + + +@router.get("/agents/{name}") +async def api_get_agent(name: str): + try: + return await mcp.get_agent(name) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc + + +# ── Skills — MCP proxy ──────────────────────────────────────────────────────── + +@router.get("/skills") +async def api_list_skills(domain: Optional[str] = None): + try: + return await mcp.list_skills(domain=domain) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc + + +@router.get("/skills/{name}") +async def api_get_skill(name: str): + try: + return await mcp.get_skill(name) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc + + +# ── Generic file browser (bind-mount fallback) ──────────────────────────────── @router.get("/files") def api_list_files(subdir: str = ""): - """List files in the bound data directory.""" - target = DATA_DIR / subdir if subdir else DATA_DIR - target = target.resolve() - # Safety: must stay within DATA_DIR + target = (DATA_DIR / subdir if subdir else DATA_DIR).resolve() if not str(target).startswith(str(DATA_DIR.resolve())): raise HTTPException(status_code=400, detail="Path traversal not allowed") if not target.exists(): - return {"files": [], "dirs": [], "path": subdir} - files = [] - dirs = [] + return {"files": [], "dirs": [], "path": subdir, "available": False} + files, dirs = [], [] for item in sorted(target.iterdir()): if item.is_file() and item.suffix in {".md", ".yml", ".yaml", ".txt", ".rst", ".json"}: - files.append({ - "name": item.name, - "size": item.stat().st_size, - "path": str(item.relative_to(DATA_DIR)), - }) + files.append({"name": item.name, "size": item.stat().st_size, "path": str(item.relative_to(DATA_DIR))}) elif item.is_dir(): dirs.append(item.name) - return {"files": files, "dirs": dirs, "path": subdir} - - -@router.get("/files/{file_path:path}") -def api_get_file(file_path: str): - """Return raw content of a file from the data directory.""" - target = (DATA_DIR / file_path).resolve() - if not str(target).startswith(str(DATA_DIR.resolve())): - raise HTTPException(status_code=400, detail="Path traversal not allowed") - if not target.exists() or not target.is_file(): - raise HTTPException(status_code=404, detail="File not found") - allowed = {".md", ".yml", ".yaml", ".txt", ".rst"} - if target.suffix not in allowed: - raise HTTPException(status_code=400, detail="File type not allowed") - content = target.read_text(encoding="utf-8") - media_type = "text/markdown" if target.suffix == ".md" else "text/plain" - return PlainTextResponse(content, media_type=media_type) + return {"files": files, "dirs": dirs, "path": subdir, "available": True} diff --git a/app/routers/projects.py b/app/routers/projects.py new file mode 100644 index 0000000..9d6a311 --- /dev/null +++ b/app/routers/projects.py @@ -0,0 +1,61 @@ +"""Projects router — registered git repos + work contexts from Redis.""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter + +from app.redis_client import get_redis, list_projects, list_workcontexts, PROJECT_KEY_PREFIX, WORKCONTEXT_KEY_PREFIX + +router = APIRouter(prefix="/api/v1", tags=["projects"]) + + +@router.get("/projects") +def api_list_projects(context: Optional[str] = None, q: Optional[str] = None): + r = get_redis() + projects = list_projects(r, context=context) + + # Merge in work contexts (keyed by path) + wc_keys = r.keys(f"{WORKCONTEXT_KEY_PREFIX}*") + import json + work_contexts: dict = {} + for key in wc_keys: + raw = r.get(key) + if raw: + try: + path = key[len(WORKCONTEXT_KEY_PREFIX):] + work_contexts[path] = json.loads(raw) + except Exception: + pass + + # Attach work context and apply search filter + result = [] + for p in projects: + path = p.get("path", "") + p["work_context"] = work_contexts.get(path) + if q and q.lower() not in (p.get("name", "") + p.get("path", "") + " ".join(p.get("tags", []))).lower(): + continue + result.append(p) + + # Sort: projects with recent work context first + result.sort( + key=lambda p: ( + p["work_context"]["saved_at"] if p.get("work_context") else "", + ), + reverse=True, + ) + + # Context breakdown + contexts = {} + for p in result: + c = p.get("context", "unknown") + contexts[c] = contexts.get(c, 0) + 1 + + return {"projects": result, "total": len(result), "contexts": contexts} + + +@router.get("/workcontexts") +def api_list_workcontexts(): + r = get_redis() + return {"contexts": list_workcontexts(r)} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 0a4f349..098e159 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -4,756 +4,669 @@ DevOpsDash - - - - - + - -
+ +
- DevOpsDash - i80.dk + ⚡ DevOpsDash + i80.dk