feat: Projects tab, 4-tab dashboard, full knowledge browser
All checks were successful
Build and Deploy DevOpsDash / build-image (push) Successful in 8s

- 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
This commit is contained in:
Henrik Jess Nielsen
2026-05-09 16:54:30 +02:00
parent 70418bc45b
commit fa4534bfb2
6 changed files with 829 additions and 769 deletions

View File

@@ -10,13 +10,14 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates 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 = FastAPI(title="DevOpsDash", version="1.0.0", docs_url="/api/docs")
app.include_router(tasks.router) app.include_router(tasks.router)
app.include_router(worklog.router) app.include_router(worklog.router)
app.include_router(knowledge.router) app.include_router(knowledge.router)
app.include_router(projects.router)
TEMPLATES_DIR = Path(__file__).parent / "templates" TEMPLATES_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))

View File

@@ -54,3 +54,46 @@ async def get_worklog(
async def get_standup(days: int = 2, context: str = "egmont") -> Dict[str, Any]: async def get_standup(days: int = 2, context: str = "egmont") -> Dict[str, Any]:
return await _call_tool("generate_standup", {"days": days, "context": context}) 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})

View File

@@ -21,6 +21,8 @@ MEMORY_KEY_PREFIX = "devops-mcp:memory:"
PROJECT_KEY_PREFIX = "devops-mcp:projects:" PROJECT_KEY_PREFIX = "devops-mcp:projects:"
PROJECT_INDEX_KEY = "devops-mcp:projects:_index" PROJECT_INDEX_KEY = "devops-mcp:projects:_index"
WORKCONTEXT_KEY_PREFIX = "devops-mcp:workcontext:"
def get_redis() -> redis.Redis: def get_redis() -> redis.Redis:
url = os.environ.get("REDIS_URL", "redis://localhost:6379") 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 continue
projects.append(proj) projects.append(proj)
return projects 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

View File

@@ -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 from __future__ import annotations
@@ -11,10 +11,10 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from app.redis_client import get_redis, list_adrs, get_adr, list_memories 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"]) 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")) DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
KNOWLEDGE_CATALOG = DATA_DIR / "knowledge" / "catalog.json" 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)} 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") @router.get("/knowledge")
def api_list_knowledge(category: Optional[str] = None, tag: Optional[str] = None): async 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}
try: 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: except Exception as exc:
raise HTTPException(status_code=500, detail=f"Catalog read error: {exc}") from exc # 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()) entries = list(catalog.values())
if category: if category:
entries = [e for e in entries if e.get("category", "").lower() == category.lower()] entries = [e for e in entries if e.get("category", "").lower() == category.lower()]
if tag: if tag:
entries = [e for e in entries if tag.lower() in [t.lower() for t in e.get("tags", [])]] 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)
summaries = [ return {"entries": entries, "total": len(entries)}
{ except Exception as e2:
"storage_filename": e["storage_filename"], raise HTTPException(status_code=500, detail=str(e2)) from e2
"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}
@router.get("/knowledge/{filename}") @router.get("/knowledge/{filename}")
def api_get_knowledge_file(filename: str): async def api_get_knowledge_file(filename: str):
"""Return raw markdown content of a knowledge file.""" try:
safe_name = Path(filename).name # prevent path traversal 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 file_path = DATA_DIR / "knowledge" / safe_name
if not file_path.exists() or not file_path.is_file(): if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found") from exc
content = file_path.read_text(encoding="utf-8") return PlainTextResponse(file_path.read_text(encoding="utf-8"), media_type="text/markdown")
return PlainTextResponse(content, 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") @router.get("/files")
def api_list_files(subdir: str = ""): def api_list_files(subdir: str = ""):
"""List files in the bound data directory.""" target = (DATA_DIR / subdir if subdir else DATA_DIR).resolve()
target = DATA_DIR / subdir if subdir else DATA_DIR
target = target.resolve()
# Safety: must stay within DATA_DIR
if not str(target).startswith(str(DATA_DIR.resolve())): if not str(target).startswith(str(DATA_DIR.resolve())):
raise HTTPException(status_code=400, detail="Path traversal not allowed") raise HTTPException(status_code=400, detail="Path traversal not allowed")
if not target.exists(): if not target.exists():
return {"files": [], "dirs": [], "path": subdir} return {"files": [], "dirs": [], "path": subdir, "available": False}
files = [] files, dirs = [], []
dirs = []
for item in sorted(target.iterdir()): for item in sorted(target.iterdir()):
if item.is_file() and item.suffix in {".md", ".yml", ".yaml", ".txt", ".rst", ".json"}: if item.is_file() and item.suffix in {".md", ".yml", ".yaml", ".txt", ".rst", ".json"}:
files.append({ files.append({"name": item.name, "size": item.stat().st_size, "path": str(item.relative_to(DATA_DIR))})
"name": item.name,
"size": item.stat().st_size,
"path": str(item.relative_to(DATA_DIR)),
})
elif item.is_dir(): elif item.is_dir():
dirs.append(item.name) dirs.append(item.name)
return {"files": files, "dirs": dirs, "path": subdir} return {"files": files, "dirs": dirs, "path": subdir, "available": True}
@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)

61
app/routers/projects.py Normal file
View File

@@ -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)}

File diff suppressed because it is too large Load Diff