Initial DevOpsDash — FastAPI + Alpine.js dashboard for DevOpsMCP
Some checks failed
Build and push DevOpsDash / build (push) Has been cancelled
Some checks failed
Build and push DevOpsDash / build (push) Has been cancelled
- Taskz kanban board (create/edit tasks, findings, status/priority) - Worklog timeline + standup summary (proxied from DevOpsMCP MCP API) - Knowledge browser (ADRs, memories, knowledge catalog files) - FastAPI backend reading same Redis as DevOpsMCP - Read-only bind-mount for DevOpsMCP data directory (/data) - Nomad job spec (dash.i80.dk, Traefik TLS, host volume read-only) - Gitea Actions CI → registry.i80.dk/gitea/devops-dash:latest
This commit is contained in:
134
app/routers/knowledge.py
Normal file
134
app/routers/knowledge.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Knowledge router — ADRs, memories, and file browser for mounted data directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.redis_client import get_redis, list_adrs, get_adr, list_memories
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ── ADRs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/adrs")
|
||||
def api_list_adrs(status: Optional[str] = None):
|
||||
r = get_redis()
|
||||
adrs = list_adrs(r, status_filter=status)
|
||||
return {"adrs": adrs, "total": len(adrs)}
|
||||
|
||||
|
||||
@router.get("/adrs/{adr_id}")
|
||||
def api_get_adr(adr_id: str):
|
||||
r = get_redis()
|
||||
adr = get_adr(r, adr_id)
|
||||
if not adr:
|
||||
raise HTTPException(status_code=404, detail="ADR not found")
|
||||
return adr
|
||||
|
||||
|
||||
# ── Memories ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/memories")
|
||||
def api_list_memories(entity_type: Optional[str] = None, limit: int = 50):
|
||||
r = get_redis()
|
||||
memories = list_memories(r, entity_type=entity_type, limit=limit)
|
||||
return {"memories": memories, "total": len(memories)}
|
||||
|
||||
|
||||
# ── Knowledge catalog (file-based, from bind-mounted data dir) ────────────────
|
||||
|
||||
@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}
|
||||
try:
|
||||
catalog: dict = json.loads(KNOWLEDGE_CATALOG.read_text(encoding="utf-8"))
|
||||
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}
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
# ── Generic file browser for data directory ───────────────────────────────────
|
||||
|
||||
@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
|
||||
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 = []
|
||||
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)),
|
||||
})
|
||||
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)
|
||||
228
app/routers/tasks.py
Normal file
228
app/routers/tasks.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Task board router — Taskz CRUD over Redis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.redis_client import (
|
||||
get_redis,
|
||||
get_board,
|
||||
list_boards,
|
||||
save_board,
|
||||
save_task,
|
||||
BOARD_KEY_PREFIX,
|
||||
BOARD_INDEX_KEY,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["tasks"])
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:20]
|
||||
|
||||
|
||||
def _make_board_code(project: str, title: str) -> str:
|
||||
proj = re.sub(r"[^a-z0-9]+", "", project.lower())[:12].upper()
|
||||
title_word = re.sub(r"[^a-z0-9]+", "", title.lower().split()[0])[:6].upper()
|
||||
return f"{proj}-{title_word}"
|
||||
|
||||
|
||||
def _next_task_number(board: Dict[str, Any]) -> int:
|
||||
tasks = board.get("tasks", [])
|
||||
return max((t.get("number", 0) for t in tasks), default=0) + 1
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class BoardCreate(BaseModel):
|
||||
project: str
|
||||
title: str
|
||||
description: str = ""
|
||||
tags: List[str] = []
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str
|
||||
description: str = ""
|
||||
priority: str = "medium"
|
||||
tags: List[str] = []
|
||||
scope: Optional[str] = None
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
findings: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class BoardStatusUpdate(BaseModel):
|
||||
new_status: str = "completed"
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/boards")
|
||||
def api_list_boards(project: Optional[str] = None, status: Optional[str] = None):
|
||||
r = get_redis()
|
||||
boards = list_boards(r, project=project, status=status)
|
||||
summaries = []
|
||||
for b in boards:
|
||||
tasks = b.get("tasks", [])
|
||||
done = sum(1 for t in tasks if t["status"] == "done")
|
||||
summaries.append({
|
||||
"board_id": b["board_id"],
|
||||
"board_code": b.get("board_code", ""),
|
||||
"project": b["project"],
|
||||
"title": b["title"],
|
||||
"description": b.get("description", ""),
|
||||
"status": b.get("status", "active"),
|
||||
"tags": b.get("tags", []),
|
||||
"task_count": len(tasks),
|
||||
"done_count": done,
|
||||
"created_at": b.get("created_at", ""),
|
||||
"updated_at": b.get("updated_at", ""),
|
||||
})
|
||||
return {"boards": summaries, "total": len(summaries)}
|
||||
|
||||
|
||||
@router.post("/boards", status_code=201)
|
||||
def api_create_board(body: BoardCreate):
|
||||
r = get_redis()
|
||||
code = _make_board_code(body.project, body.title)
|
||||
board_id = f"{_slugify(body.project)}-{_slugify(body.title)}-{_now()[:10].replace('-', '')}"
|
||||
board = {
|
||||
"board_id": board_id,
|
||||
"board_code": code,
|
||||
"project": body.project,
|
||||
"title": body.title,
|
||||
"description": body.description,
|
||||
"status": "active",
|
||||
"tags": body.tags,
|
||||
"tasks": [],
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
save_board(r, board)
|
||||
return {"board_id": board_id, "board_code": code}
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}")
|
||||
def api_get_board(board_id: str):
|
||||
r = get_redis()
|
||||
board = get_board(r, board_id)
|
||||
if not board:
|
||||
raise HTTPException(status_code=404, detail="Board not found")
|
||||
return board
|
||||
|
||||
|
||||
@router.patch("/boards/{board_id}/status")
|
||||
def api_close_board(board_id: str, body: BoardStatusUpdate):
|
||||
r = get_redis()
|
||||
board = get_board(r, board_id)
|
||||
if not board:
|
||||
raise HTTPException(status_code=404, detail="Board not found")
|
||||
board["status"] = body.new_status
|
||||
board["updated_at"] = _now()
|
||||
save_board(r, board)
|
||||
return {"board_id": board_id, "status": body.new_status}
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/tasks", status_code=201)
|
||||
def api_add_task(board_id: str, body: TaskCreate):
|
||||
r = get_redis()
|
||||
board = get_board(r, board_id)
|
||||
if not board:
|
||||
raise HTTPException(status_code=404, detail="Board not found")
|
||||
code = board.get("board_code", board_id.upper()[:10])
|
||||
num = _next_task_number(board)
|
||||
task_id = f"{code}-{num:03d}"
|
||||
task: Dict[str, Any] = {
|
||||
"task_id": task_id,
|
||||
"board_id": board_id,
|
||||
"number": num,
|
||||
"title": body.title,
|
||||
"description": body.description,
|
||||
"priority": body.priority,
|
||||
"status": "pending",
|
||||
"tags": body.tags,
|
||||
"scope": body.scope,
|
||||
"findings": [],
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
save_task(r, task)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}")
|
||||
def api_update_task(task_id: str, body: TaskUpdate):
|
||||
r = get_redis()
|
||||
# find the task inside any board
|
||||
from app.redis_client import list_boards as _lb, TASK_KEY_PREFIX
|
||||
raw = r.get(f"{TASK_KEY_PREFIX}{task_id}")
|
||||
if not raw:
|
||||
# fallback: scan boards
|
||||
for board in _lb(r):
|
||||
task = next((t for t in board.get("tasks", []) if t["task_id"] == task_id), None)
|
||||
if task:
|
||||
raw = json.dumps(task)
|
||||
break
|
||||
if not raw:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task = json.loads(raw)
|
||||
|
||||
valid_statuses = {"pending", "in_progress", "done", "blocked", "wont_do"}
|
||||
valid_priorities = {"low", "medium", "high", "critical"}
|
||||
|
||||
if body.status:
|
||||
if body.status not in valid_statuses:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {body.status}")
|
||||
task["status"] = body.status
|
||||
if body.status == "done":
|
||||
task["completed_at"] = _now()
|
||||
if body.title:
|
||||
task["title"] = body.title
|
||||
if body.description is not None:
|
||||
task["description"] = body.description
|
||||
if body.findings:
|
||||
findings = task.setdefault("findings", [])
|
||||
findings.append({"text": body.findings, "at": _now()})
|
||||
if body.priority:
|
||||
if body.priority not in valid_priorities:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid priority: {body.priority}")
|
||||
task["priority"] = body.priority
|
||||
if body.tags is not None:
|
||||
task["tags"] = body.tags
|
||||
task["updated_at"] = _now()
|
||||
save_task(r, task)
|
||||
return {"task_id": task_id, "status": task["status"]}
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}")
|
||||
def api_delete_task(task_id: str):
|
||||
r = get_redis()
|
||||
from app.redis_client import TASK_KEY_PREFIX
|
||||
raw = r.get(f"{TASK_KEY_PREFIX}{task_id}")
|
||||
if not raw:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task = json.loads(raw)
|
||||
board = get_board(r, task["board_id"])
|
||||
if board:
|
||||
board["tasks"] = [t for t in board.get("tasks", []) if t["task_id"] != task_id]
|
||||
board["updated_at"] = _now()
|
||||
save_board(r, board)
|
||||
r.delete(f"{TASK_KEY_PREFIX}{task_id}")
|
||||
return {"deleted": task_id}
|
||||
39
app/routers/worklog.py
Normal file
39
app/routers/worklog.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Worklog router — proxies worklog/standup calls to DevOpsMCP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app import mcp_client
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["worklog"])
|
||||
|
||||
|
||||
@router.get("/worklog")
|
||||
async def api_worklog(
|
||||
context: str = "egmont",
|
||||
days: int = 7,
|
||||
group_by: str = "repo",
|
||||
since_date: Optional[str] = None,
|
||||
until_date: Optional[str] = None,
|
||||
):
|
||||
try:
|
||||
return await mcp_client.get_worklog(
|
||||
context=context,
|
||||
days=days,
|
||||
group_by=group_by,
|
||||
since_date=since_date,
|
||||
until_date=until_date,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"DevOpsMCP error: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/standup")
|
||||
async def api_standup(days: int = 2, context: str = "egmont"):
|
||||
try:
|
||||
return await mcp_client.get_standup(days=days, context=context)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"DevOpsMCP error: {exc}") from exc
|
||||
Reference in New Issue
Block a user