From ac85040e4dab7c8ba5e5dc99e6c1c40683bdbf86 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Sat, 9 May 2026 16:36:18 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20DevOpsDash=20=E2=80=94=20FastAPI=20+?= =?UTF-8?q?=20Alpine.js=20dashboard=20for=20DevOpsMCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 15 + .gitea/workflows/ci.yml | 26 ++ .gitignore | 9 + Dockerfile | 23 ++ README.md | 71 ++++ app/__init__.py | 1 + app/main.py | 55 +++ app/mcp_client.py | 56 +++ app/redis_client.py | 132 ++++++ app/routers/knowledge.py | 134 +++++++ app/routers/tasks.py | 228 +++++++++++ app/routers/worklog.py | 39 ++ app/templates/dashboard.html | 759 +++++++++++++++++++++++++++++++++++ devops-dash.nomad | 84 ++++ requirements.txt | 6 + 15 files changed, 1638 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/main.py create mode 100644 app/mcp_client.py create mode 100644 app/redis_client.py create mode 100644 app/routers/knowledge.py create mode 100644 app/routers/tasks.py create mode 100644 app/routers/worklog.py create mode 100644 app/templates/dashboard.html create mode 100644 devops-dash.nomad create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..125d649 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Environment variables for DevOpsDash + +# Redis (same instance as DevOpsMCP) +REDIS_URL=redis://192.168.15.124:6379 + +# DevOpsMCP service URL (for worklog proxy calls) +DEVOPS_MCP_URL=http://devops-mcp.i80.dk + +# Data directory (bind-mounted from DevOpsMCP host volume, read-only) +# In production: /data (Nomad bind-mount from /opt/devops-mcp/data) +# In development: set to the local DevOpsMCP data directory +DATA_DIR=/Users/lrihni/Projects/DevOpsMCP/devops_mcp/data + +# Port +PORT=8001 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8164c0b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,26 @@ +name: Build and push DevOpsDash + +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to registry + uses: docker/login-action@v3 + with: + registry: registry.i80.dk + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: registry.i80.dk/gitea/devops-dash:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf0c0eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +__pycache__/ +*.pyc +*.pyo +.DS_Store +.venv/ +*.egg-info/ +dist/ +build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..365b035 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim AS base + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy app +COPY app/ app/ + +# Non-root user +RUN useradd -r -u 1001 appuser && chown -R appuser /app +USER appuser + +# Data dir (will be bind-mounted read-only in production) +RUN mkdir -p /data + +EXPOSE 8001 + +ENV PORT=8001 + +CMD ["sh", "-c", "python -m uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fc818e --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# DevOpsDash + +Visual dashboard for [DevOpsMCP](https://devops-mcp.i80.dk) — built as a **separate service** so updates to DevOpsMCP never break the dashboard. + +Live at: **https://dash.i80.dk** + +## Features + +- **Taskz** — Kanban board for all task boards (create/update tasks, add findings, change status/priority) +- **Worklog** — Git commit timeline proxied from DevOpsMCP (egmont/personal/all, configurable date range) +- **Standup** — AI-generated standup summary via DevOpsMCP +- **Knowledge** — Browse ADRs, memories, and knowledge catalog files + +## Stack + +- **Backend**: FastAPI (Python 3.11) +- **Frontend**: Alpine.js + Tailwind CSS (CDN, no build step) +- **Data**: Read-only access to DevOpsMCP's Redis + data directory (bind-mount) + +## Architecture + +``` +DevOpsMCP (devops-mcp.i80.dk) DevOpsDash (dash.i80.dk) + Redis ◄────────────────────────────── read same Redis + /opt/devops-mcp/data (rw) ─────────► /data (read-only bind-mount) + MCP HTTP API ──────────────────────── worklog proxy calls +``` + +## Development + +```bash +# Copy .env.example +cp .env.example .env +# Edit DATA_DIR to point at local DevOpsMCP data directory + +# Install dependencies +pip install -r requirements.txt + +# Run +python -m uvicorn app.main:app --reload --port 8001 +``` + +Open http://localhost:8001 + +## Deployment + +```bash +# Build + push image +docker build -t registry.i80.dk/gitea/devops-dash:latest . +docker push registry.i80.dk/gitea/devops-dash:latest + +# Deploy to Nomad +# First: ensure the host volume exists on autobox.i80.dk +# See devops-mcp.nomad for the DevOpsMCP side bind-mount setup + +scp devops-dash.nomad autobox.i80.dk:/tmp/ +ssh autobox.i80.dk 'NOMAD_ADDR=https://nomad.i80.dk:4646 nomad job run /tmp/devops-dash.nomad' +``` + +## Nomad Host Volume (one-time setup on autobox.i80.dk) + +Add to `/etc/nomad.d/client.hcl`: + +```hcl +host_volume "devops-mcp-data" { + path = "/opt/devops-mcp/data" + read_only = false +} +``` + +Then restart Nomad. The DevOpsDash job mounts the same volume as read-only. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..69244c7 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""App package init.""" diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2506da1 --- /dev/null +++ b/app/main.py @@ -0,0 +1,55 @@ +"""DevOpsDash — FastAPI entry point.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from app.routers import tasks, worklog, knowledge + +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) + +TEMPLATES_DIR = Path(__file__).parent / "templates" +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + + +@app.get("/health") +def health(): + from app.redis_client import get_redis + try: + r = get_redis() + r.ping() + redis_ok = True + except Exception: + redis_ok = False + data_dir = os.environ.get("DATA_DIR", "/data") + return { + "status": "ok", + "redis": "ok" if redis_ok else "unavailable", + "data_dir": data_dir, + "data_dir_exists": Path(data_dir).exists(), + } + + +@app.get("/", response_class=HTMLResponse) +async def dashboard(request: Request): + mcp_url = os.environ.get("DEVOPS_MCP_URL", "http://devops-mcp:8000") + return templates.TemplateResponse("dashboard.html", { + "request": request, + "mcp_url": mcp_url, + }) + + +if __name__ == "__main__": + import uvicorn + port = int(os.environ.get("PORT", 8001)) + uvicorn.run("app.main:app", host="0.0.0.0", port=port, reload=True) diff --git a/app/mcp_client.py b/app/mcp_client.py new file mode 100644 index 0000000..70a7b25 --- /dev/null +++ b/app/mcp_client.py @@ -0,0 +1,56 @@ +"""MCP proxy client — calls DevOpsMCP's worklog/standup tools over HTTP MCP protocol.""" + +from __future__ import annotations + +import os +from typing import Any, Dict, Optional + +import httpx + +MCP_URL = os.environ.get("DEVOPS_MCP_URL", "http://localhost:8000") +_MCP_ENDPOINT = f"{MCP_URL}/mcp" + + +async def _call_tool(tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + """Call a DevOpsMCP tool via MCP JSON-RPC 2.0 over HTTP.""" + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + "id": 1, + } + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(_MCP_ENDPOINT, json=payload) + resp.raise_for_status() + data = resp.json() + if "error" in data: + raise RuntimeError(f"MCP error: {data['error']}") + result = data.get("result", {}) + content = result.get("content", []) + if content and isinstance(content[0], dict): + import json + text = content[0].get("text", "{}") + try: + return json.loads(text) + except json.JSONDecodeError: + return {"raw": text} + return result + + +async def get_worklog( + context: str = "egmont", + days: int = 7, + group_by: str = "repo", + since_date: Optional[str] = None, + until_date: Optional[str] = None, +) -> Dict[str, Any]: + args: Dict[str, Any] = {"context": context, "days": days, "group_by": group_by} + if since_date: + args["since_date"] = since_date + if until_date: + args["until_date"] = until_date + return await _call_tool("worklog", args) + + +async def get_standup(days: int = 2, context: str = "egmont") -> Dict[str, Any]: + return await _call_tool("generate_standup", {"days": days, "context": context}) diff --git a/app/redis_client.py b/app/redis_client.py new file mode 100644 index 0000000..3e12068 --- /dev/null +++ b/app/redis_client.py @@ -0,0 +1,132 @@ +"""Redis client for DevOpsDash — reads the same keys as DevOpsMCP.""" + +from __future__ import annotations + +import json +import os +from typing import Any, Dict, List, Optional + +import redis + +# ── Key prefixes (must match DevOpsMCP exactly) ─────────────────────────────── +BOARD_KEY_PREFIX = "devops-mcp:taskboard:" +TASK_KEY_PREFIX = "devops-mcp:task:" +BOARD_INDEX_KEY = "devops-mcp:taskboard:_index" + +ADR_KEY_PREFIX = "devops-mcp:adr:" +ADR_INDEX_KEY = "devops-mcp:adr:_index" + +MEMORY_KEY_PREFIX = "devops-mcp:memory:" + +PROJECT_KEY_PREFIX = "devops-mcp:projects:" +PROJECT_INDEX_KEY = "devops-mcp:projects:_index" + + +def get_redis() -> redis.Redis: + url = os.environ.get("REDIS_URL", "redis://localhost:6379") + return redis.from_url(url, decode_responses=True, socket_connect_timeout=3) + + +# ── Task boards ─────────────────────────────────────────────────────────────── + +def list_boards(r: redis.Redis, project: Optional[str] = None, status: Optional[str] = None) -> List[Dict[str, Any]]: + index: List[str] = json.loads(r.get(BOARD_INDEX_KEY) or "[]") + boards = [] + for bid in index: + raw = r.get(f"{BOARD_KEY_PREFIX}{bid}") + if raw: + board = json.loads(raw) + if project and project.lower() not in board.get("project", "").lower(): + continue + if status and board.get("status") != status: + continue + boards.append(board) + boards.sort(key=lambda b: b.get("updated_at", ""), reverse=True) + return boards + + +def get_board(r: redis.Redis, board_id: str) -> Optional[Dict[str, Any]]: + raw = r.get(f"{BOARD_KEY_PREFIX}{board_id}") + return json.loads(raw) if raw else None + + +def save_board(r: redis.Redis, board: Dict[str, Any]) -> None: + bid = board["board_id"] + r.set(f"{BOARD_KEY_PREFIX}{bid}", json.dumps(board), ex=86400 * 365 * 3) + index: List[str] = json.loads(r.get(BOARD_INDEX_KEY) or "[]") + if bid not in index: + index.append(bid) + r.set(BOARD_INDEX_KEY, json.dumps(index), ex=86400 * 365 * 3) + + +def save_task(r: redis.Redis, task: Dict[str, Any]) -> None: + tid = task["task_id"] + r.set(f"{TASK_KEY_PREFIX}{tid}", json.dumps(task), ex=86400 * 365 * 3) + board = get_board(r, task["board_id"]) + if board: + tasks = board.setdefault("tasks", []) + existing = next((i for i, t in enumerate(tasks) if t["task_id"] == tid), None) + if existing is not None: + tasks[existing] = task + else: + tasks.append(task) + from datetime import datetime, timezone + board["updated_at"] = datetime.now(timezone.utc).isoformat() + save_board(r, board) + + +# ── ADRs ────────────────────────────────────────────────────────────────────── + +def list_adrs(r: redis.Redis, status_filter: Optional[str] = None) -> List[Dict[str, Any]]: + index: List[str] = json.loads(r.get(ADR_INDEX_KEY) or "[]") + adrs = [] + for adr_id in index: + raw = r.get(f"{ADR_KEY_PREFIX}{adr_id}") + if raw: + adr = json.loads(raw) + if status_filter and adr.get("status", "").lower() != status_filter.lower(): + continue + adrs.append(adr) + adrs.sort(key=lambda a: a.get("number", 0)) + return adrs + + +def get_adr(r: redis.Redis, adr_id: str) -> Optional[Dict[str, Any]]: + raw = r.get(f"{ADR_KEY_PREFIX}{adr_id}") + return json.loads(raw) if raw else None + + +# ── Memories ────────────────────────────────────────────────────────────────── + +def list_memories(r: redis.Redis, entity_type: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]: + keys = r.keys(f"{MEMORY_KEY_PREFIX}*") + memories = [] + for key in keys: + if key.endswith("_index"): + continue + raw = r.get(key) + if raw: + try: + mem = json.loads(raw) + if entity_type and mem.get("entity_type", "").lower() != entity_type.lower(): + continue + memories.append(mem) + except json.JSONDecodeError: + pass + memories.sort(key=lambda m: m.get("created_at", ""), reverse=True) + return memories[:limit] + + +# ── Projects ────────────────────────────────────────────────────────────────── + +def list_projects(r: redis.Redis, context: Optional[str] = None) -> List[Dict[str, Any]]: + index: List[str] = json.loads(r.get(PROJECT_INDEX_KEY) or "[]") + projects = [] + for path in index: + raw = r.get(f"{PROJECT_KEY_PREFIX}{path}") + if raw: + proj = json.loads(raw) + if context and proj.get("context", "").lower() != context.lower(): + continue + projects.append(proj) + return projects diff --git a/app/routers/knowledge.py b/app/routers/knowledge.py new file mode 100644 index 0000000..0e13dee --- /dev/null +++ b/app/routers/knowledge.py @@ -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) diff --git a/app/routers/tasks.py b/app/routers/tasks.py new file mode 100644 index 0000000..e920bbd --- /dev/null +++ b/app/routers/tasks.py @@ -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} diff --git a/app/routers/worklog.py b/app/routers/worklog.py new file mode 100644 index 0000000..ef1372b --- /dev/null +++ b/app/routers/worklog.py @@ -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 diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..0a4f349 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,759 @@ + + + + + + DevOpsDash + + + + + + + + + + + + + +
+
+ DevOpsDash + i80.dk +
+ +
+ +
+
+
+ + +
+ + +
+ + + + + +
+ +
+
+ +

+ +
+

+ +
+ +
+
+ +
+ Select a board to view tasks +
+ + +
+ +
+
+
+ + +
+ +
+
+

Worklog

+ +
+ + +
+ +
+ + +
Loading worklog…
+ + +
+ + +
+ + +
+
+

Standup summary

+
+ + +
+ +
+

+      
+
+
+ + +
+ +
+ +
+ + +
+ +
+
+ + +
+
+ +
+
Loading ADRs…
+
+ +
No ADRs found
+
+
+ + +
+
Loading memories…
+
+ +
No memories found
+
+
+ + +
+ +
+
Loading…
+ +
+ No knowledge docs found.
(data dir may not be mounted) +
+
+ +
+
+ Select a document to read it +
+

+        
+
+
+
+
+ + + + +
+
+

New Board

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+

Add Task

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ + +
+

+

+ + +
+
Status
+
+ +
+
+ + +
+
Priority
+
+ +
+
+ + +
+
Findings
+
+ +
+
+ + +
+
+ +
+
Created:
+
Updated:
+
+
+ + + + + + diff --git a/devops-dash.nomad b/devops-dash.nomad new file mode 100644 index 0000000..e290a84 --- /dev/null +++ b/devops-dash.nomad @@ -0,0 +1,84 @@ +job "devops-dash" { + datacenters = ["dc1"] + type = "service" + + constraint { + attribute = "${node.unique.name}" + value = "autobox.i80.dk" + } + + group "devops-dash" { + count = 1 + + network { + port "http" {} + } + + volume "devops-mcp-data" { + type = "host" + read_only = true + source = "devops-mcp-data" + } + + task "devops-dash" { + driver = "docker" + + volume_mount { + volume = "devops-mcp-data" + destination = "/data" + read_only = true + } + + config { + image = "registry.i80.dk/gitea/devops-dash:latest" + ports = ["http"] + + auth { + username = "robot$gitserver" + password_env = "REGISTRY_TOKEN" + server_address = "registry.i80.dk" + } + } + + template { + data = <