feat: Projects tab, 4-tab dashboard, full knowledge browser
All checks were successful
Build and Deploy DevOpsDash / build-image (push) Successful in 8s
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:
@@ -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))
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
61
app/routers/projects.py
Normal 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
Reference in New Issue
Block a user