Initial DevOpsDash — FastAPI + Alpine.js dashboard for DevOpsMCP
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:
Henrik Jess Nielsen
2026-05-09 16:36:18 +02:00
commit ac85040e4d
15 changed files with 1638 additions and 0 deletions

134
app/routers/knowledge.py Normal file
View 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
View 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
View 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