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.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))

View File

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

View File

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

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

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