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:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""App package init."""
|
||||
55
app/main.py
Normal file
55
app/main.py
Normal file
@@ -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)
|
||||
56
app/mcp_client.py
Normal file
56
app/mcp_client.py
Normal file
@@ -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})
|
||||
132
app/redis_client.py
Normal file
132
app/redis_client.py
Normal file
@@ -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
|
||||
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
|
||||
759
app/templates/dashboard.html
Normal file
759
app/templates/dashboard.html
Normal file
@@ -0,0 +1,759 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DevOpsDash</title>
|
||||
|
||||
<!-- Tailwind v3 CDN with dark-mode support -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: { DEFAULT: '#6366f1', hover: '#4f46e5' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
.kanban-col { min-height: 200px; }
|
||||
.task-card { transition: box-shadow .15s ease; }
|
||||
.task-card:hover { box-shadow: 0 0 0 2px #6366f1; }
|
||||
/* scrollbars */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-100 min-h-screen flex flex-col" x-cloak>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════ TOPBAR ══ -->
|
||||
<header class="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-gray-900 sticky top-0 z-50">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-indigo-400 text-xl font-bold tracking-tight">DevOpsDash</span>
|
||||
<span class="text-gray-500 text-xs">i80.dk</span>
|
||||
</div>
|
||||
<nav class="flex gap-1" x-data>
|
||||
<template x-for="tab in ['taskz','worklog','knowledge']" :key="tab">
|
||||
<button
|
||||
@click="$dispatch('switch-tab', tab)"
|
||||
class="px-4 py-1.5 rounded text-sm font-medium transition-colors"
|
||||
:class="$store.ui.tab === tab
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800'"
|
||||
x-text="tab.charAt(0).toUpperCase() + tab.slice(1)"
|
||||
></button>
|
||||
</template>
|
||||
</nav>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-gray-500" x-data x-text="$store.ui.tab === 'taskz' ? $store.boards.activeProject || 'All projects' : ''"></span>
|
||||
<div class="w-2 h-2 rounded-full" :class="$store.health.redis ? 'bg-green-400' : 'bg-red-400'" title="Redis status" x-data></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════ MAIN ══ -->
|
||||
<div class="flex flex-1 overflow-hidden" x-data x-on:switch-tab.window="$store.ui.tab = $event.detail">
|
||||
|
||||
<!-- ══════════════════════════════════════ TASKZ TAB ══ -->
|
||||
<div class="flex flex-1 overflow-hidden" x-show="$store.ui.tab === 'taskz'">
|
||||
|
||||
<!-- Sidebar: board list -->
|
||||
<aside class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0 overflow-y-auto">
|
||||
<div class="p-4 border-b border-gray-800">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-semibold text-gray-200">Boards</span>
|
||||
<button @click="$store.boards.showNewBoard = true" class="text-indigo-400 hover:text-indigo-300 text-xs">+ New</button>
|
||||
</div>
|
||||
<select x-model="$store.boards.activeProject" @change="$store.boards.filterByProject()" class="w-full bg-gray-800 border border-gray-700 text-sm rounded px-2 py-1 text-gray-300">
|
||||
<option value="">All projects</option>
|
||||
<template x-for="proj in $store.boards.projects" :key="proj">
|
||||
<option :value="proj" x-text="proj"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<template x-for="board in $store.boards.filtered" :key="board.board_id">
|
||||
<button
|
||||
@click="$store.boards.select(board.board_id)"
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-gray-800 transition-colors border-l-2"
|
||||
:class="$store.boards.selectedId === board.board_id ? 'border-indigo-500 bg-gray-800' : 'border-transparent'"
|
||||
>
|
||||
<div class="text-xs font-mono text-indigo-400 mb-0.5" x-text="board.board_code"></div>
|
||||
<div class="text-sm text-gray-200 leading-tight" x-text="board.title"></div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs text-gray-500" x-text="board.project"></span>
|
||||
<span class="ml-auto text-xs text-gray-500" x-text="`${board.done_count}/${board.task_count}`"></span>
|
||||
</div>
|
||||
<!-- progress bar -->
|
||||
<div class="mt-1.5 h-1 bg-gray-700 rounded overflow-hidden">
|
||||
<div class="h-full bg-indigo-500 rounded"
|
||||
:style="board.task_count ? `width:${Math.round(board.done_count/board.task_count*100)}%` : 'width:0%'"></div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div x-show="$store.boards.filtered.length === 0" class="px-4 py-6 text-xs text-gray-500 text-center">No boards found</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Kanban area -->
|
||||
<main class="flex-1 overflow-auto p-6" x-data>
|
||||
<!-- Board header -->
|
||||
<div class="mb-6" x-show="$store.boards.current">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="font-mono text-indigo-400 text-sm" x-text="$store.boards.current?.board_code"></span>
|
||||
<h1 class="text-xl font-bold text-white" x-text="$store.boards.current?.title"></h1>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-800 text-gray-400" x-text="$store.boards.current?.status"></span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400" x-text="$store.boards.current?.description"></p>
|
||||
<!-- Add task button -->
|
||||
<div class="mt-3">
|
||||
<button @click="$store.tasks.showNew = true" class="text-xs px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded font-medium transition-colors">
|
||||
+ Add task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="!$store.boards.current" class="flex items-center justify-center h-64 text-gray-500 text-sm">
|
||||
Select a board to view tasks
|
||||
</div>
|
||||
|
||||
<!-- Kanban columns -->
|
||||
<div class="grid grid-cols-4 gap-4" x-show="$store.boards.current">
|
||||
<template x-for="col in ['pending','in_progress','blocked','done']" :key="col">
|
||||
<div class="kanban-col flex flex-col gap-3">
|
||||
<!-- Column header -->
|
||||
<div class="flex items-center gap-2 pb-2 border-b border-gray-800">
|
||||
<span class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'pending':'bg-gray-500',
|
||||
'in_progress':'bg-blue-400',
|
||||
'blocked':'bg-red-400',
|
||||
'done':'bg-green-400'
|
||||
}[col]"></span>
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-400"
|
||||
x-text="col.replace('_',' ')"></span>
|
||||
<span class="ml-auto text-xs text-gray-600"
|
||||
x-text="($store.tasks.byStatus[col] || []).length"></span>
|
||||
</div>
|
||||
|
||||
<!-- Task cards -->
|
||||
<template x-for="task in ($store.tasks.byStatus[col] || [])" :key="task.task_id">
|
||||
<div class="task-card bg-gray-900 border border-gray-800 rounded-lg p-3 cursor-pointer"
|
||||
@click="$store.tasks.select(task)">
|
||||
<div class="flex items-start justify-between gap-2 mb-1.5">
|
||||
<span class="text-xs font-mono text-indigo-400" x-text="task.task_id"></span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-medium"
|
||||
:class="{
|
||||
'critical':'bg-red-900 text-red-300',
|
||||
'high':'bg-orange-900 text-orange-300',
|
||||
'medium':'bg-yellow-900 text-yellow-300',
|
||||
'low':'bg-gray-800 text-gray-400'
|
||||
}[task.priority]"
|
||||
x-text="task.priority"></span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-200 leading-snug" x-text="task.title"></p>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
<template x-for="tag in task.tags" :key="tag">
|
||||
<span class="text-xs px-1.5 py-0.5 bg-gray-800 text-gray-400 rounded" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="task.scope" class="mt-1 text-xs text-gray-500 italic" x-text="task.scope"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════ WORKLOG TAB ══ -->
|
||||
<div class="flex-1 overflow-auto p-6" x-show="$store.ui.tab === 'worklog'" x-data="{
|
||||
context: 'egmont',
|
||||
days: 7,
|
||||
loading: false,
|
||||
data: null,
|
||||
standup: null,
|
||||
standupDays: 2,
|
||||
standupLoading: false,
|
||||
standupText: '',
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/worklog?context=${this.context}&days=${this.days}`);
|
||||
this.data = await r.json();
|
||||
} catch(e) { console.error(e); }
|
||||
this.loading = false;
|
||||
},
|
||||
async loadStandup() {
|
||||
this.standupLoading = true;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/standup?days=${this.standupDays}&context=${this.context}`);
|
||||
const d = await r.json();
|
||||
this.standupText = d.standup_text || d.raw || JSON.stringify(d, null, 2);
|
||||
} catch(e) { this.standupText = 'Error loading standup'; }
|
||||
this.standupLoading = false;
|
||||
}
|
||||
}" x-init="load()">
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<h2 class="text-lg font-bold text-white">Worklog</h2>
|
||||
<select x-model="context" @change="load()" class="bg-gray-800 border border-gray-700 text-sm rounded px-2 py-1 text-gray-300">
|
||||
<option value="egmont">Egmont</option>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="range" x-model.number="days" min="1" max="30" @change="load()" class="w-28 accent-indigo-500">
|
||||
<span class="text-sm text-gray-400" x-text="`${days}d`"></span>
|
||||
</div>
|
||||
<button @click="load()" class="text-xs px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded border border-gray-700 transition-colors">↺ Refresh</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-16 text-gray-500">Loading worklog…</div>
|
||||
|
||||
<!-- Commit timeline -->
|
||||
<div x-show="!loading && data">
|
||||
<template x-if="data && data.repos">
|
||||
<div class="space-y-6">
|
||||
<template x-for="(commits, repo) in data.repos" :key="repo">
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="font-mono text-indigo-400 text-sm" x-text="repo"></span>
|
||||
<span class="text-xs text-gray-500" x-text="`${commits.length} commit${commits.length !== 1 ? 's' : ''}`"></span>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<template x-for="commit in commits" :key="commit.sha">
|
||||
<div class="flex items-start gap-3 text-sm">
|
||||
<span class="font-mono text-xs text-gray-600 mt-0.5 shrink-0" x-text="commit.sha?.slice(0,7)"></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-gray-200" x-text="commit.message?.split('\n')[0]"></span>
|
||||
<template x-if="commit.message?.includes('AZ-') || commit.message?.includes('az-')">
|
||||
<span class="ml-2 text-xs text-yellow-400 font-mono" x-text="commit.message?.match(/AZ-\d+/i)?.[0]"></span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600 shrink-0" x-text="commit.datetime?.slice(0,10)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="data && !data.repos">
|
||||
<pre class="text-xs text-gray-400 bg-gray-900 p-4 rounded-xl overflow-auto" x-text="JSON.stringify(data, null, 2)"></pre>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Standup section -->
|
||||
<div class="mt-8 border-t border-gray-800 pt-6">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-200">Standup summary</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="range" x-model.number="standupDays" min="1" max="7" class="w-20 accent-indigo-500">
|
||||
<span class="text-xs text-gray-400" x-text="`${standupDays}d`"></span>
|
||||
</div>
|
||||
<button @click="loadStandup()" class="text-xs px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded transition-colors" :disabled="standupLoading">
|
||||
<span x-show="!standupLoading">Generate</span>
|
||||
<span x-show="standupLoading">…</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre x-show="standupText" class="text-xs text-gray-300 bg-gray-900 border border-gray-800 p-4 rounded-xl overflow-auto whitespace-pre-wrap" x-text="standupText"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════ KNOWLEDGE TAB ══ -->
|
||||
<div class="flex-1 overflow-auto p-6" x-show="$store.ui.tab === 'knowledge'" x-data="{
|
||||
activeKnowledgeTab: 'adrs',
|
||||
adrs: [],
|
||||
memories: [],
|
||||
knowledge: [],
|
||||
query: '',
|
||||
adrStatusFilter: '',
|
||||
adrLoading: false,
|
||||
memLoading: false,
|
||||
knLoading: false,
|
||||
selectedAdr: null,
|
||||
selectedKn: null,
|
||||
selectedKnContent: '',
|
||||
async loadAdrs() {
|
||||
this.adrLoading = true;
|
||||
const url = this.adrStatusFilter ? `/api/v1/adrs?status=${this.adrStatusFilter}` : '/api/v1/adrs';
|
||||
const r = await fetch(url);
|
||||
const d = await r.json();
|
||||
this.adrs = d.adrs || [];
|
||||
this.adrLoading = false;
|
||||
},
|
||||
async loadMemories() {
|
||||
this.memLoading = true;
|
||||
const r = await fetch('/api/v1/memories?limit=100');
|
||||
const d = await r.json();
|
||||
this.memories = d.memories || [];
|
||||
this.memLoading = false;
|
||||
},
|
||||
async loadKnowledge() {
|
||||
this.knLoading = true;
|
||||
const r = await fetch('/api/v1/knowledge');
|
||||
const d = await r.json();
|
||||
this.knowledge = d.entries || [];
|
||||
this.knLoading = false;
|
||||
},
|
||||
async openKnFile(filename) {
|
||||
this.selectedKnContent = 'Loading…';
|
||||
const r = await fetch(`/api/v1/knowledge/${filename}`);
|
||||
this.selectedKnContent = await r.text();
|
||||
this.selectedKn = filename;
|
||||
},
|
||||
filteredAdrs() {
|
||||
if (!this.query) return this.adrs;
|
||||
const q = this.query.toLowerCase();
|
||||
return this.adrs.filter(a => a.title?.toLowerCase().includes(q) || a.decision?.toLowerCase().includes(q));
|
||||
},
|
||||
filteredMemories() {
|
||||
if (!this.query) return this.memories;
|
||||
const q = this.query.toLowerCase();
|
||||
return this.memories.filter(m => m.content?.toLowerCase().includes(q) || m.name?.toLowerCase().includes(q));
|
||||
},
|
||||
filteredKnowledge() {
|
||||
if (!this.query) return this.knowledge;
|
||||
const q = this.query.toLowerCase();
|
||||
return this.knowledge.filter(k => k.title?.toLowerCase().includes(q) || k.tags?.some(t => t.toLowerCase().includes(q)));
|
||||
},
|
||||
init() { this.loadAdrs(); this.loadMemories(); this.loadKnowledge(); }
|
||||
}" x-init="init()">
|
||||
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<!-- Sub-tabs -->
|
||||
<div class="flex items-center gap-1 mb-6">
|
||||
<template x-for="kt in ['adrs','memories','knowledge']" :key="kt">
|
||||
<button @click="activeKnowledgeTab = kt"
|
||||
class="px-4 py-1.5 rounded text-sm font-medium transition-colors"
|
||||
:class="activeKnowledgeTab === kt ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:bg-gray-800'">
|
||||
<span x-text="kt.charAt(0).toUpperCase() + kt.slice(1)"></span>
|
||||
</button>
|
||||
</template>
|
||||
<!-- search -->
|
||||
<div class="ml-auto relative">
|
||||
<input type="text" x-model="query" placeholder="Search…"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 w-56 focus:outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ──── ADRs ──── -->
|
||||
<div x-show="activeKnowledgeTab === 'adrs'">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<select x-model="adrStatusFilter" @change="loadAdrs()" class="bg-gray-800 border border-gray-700 text-xs rounded px-2 py-1 text-gray-300">
|
||||
<option value="">All statuses</option>
|
||||
<option>Accepted</option><option>Proposed</option><option>Deprecated</option><option>Superseded</option>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="adrLoading" class="text-center py-12 text-gray-500">Loading ADRs…</div>
|
||||
<div class="grid gap-3" x-show="!adrLoading">
|
||||
<template x-for="adr in filteredAdrs()" :key="adr.adr_id">
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 cursor-pointer hover:border-indigo-700 transition-colors"
|
||||
@click="selectedAdr = (selectedAdr?.adr_id === adr.adr_id ? null : adr)">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium mt-0.5"
|
||||
:class="{
|
||||
'Accepted':'bg-green-900 text-green-300',
|
||||
'Proposed':'bg-yellow-900 text-yellow-300',
|
||||
'Deprecated':'bg-gray-800 text-gray-500',
|
||||
'Superseded':'bg-red-900 text-red-400'
|
||||
}[adr.status] || 'bg-gray-800 text-gray-400'"
|
||||
x-text="adr.status"></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-gray-100" x-text="adr.title"></div>
|
||||
<div class="text-xs text-gray-500 mt-0.5" x-text="adr.created_at?.slice(0,10)"></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 justify-end">
|
||||
<template x-for="tag in (adr.tags || [])" :key="tag">
|
||||
<span class="text-xs px-1.5 py-0.5 bg-gray-800 text-gray-400 rounded" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Expanded detail -->
|
||||
<div x-show="selectedAdr?.adr_id === adr.adr_id" class="mt-4 space-y-3 border-t border-gray-800 pt-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-400 mb-1 uppercase tracking-wide">Context</div>
|
||||
<p class="text-sm text-gray-300 leading-relaxed" x-text="adr.context"></p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-400 mb-1 uppercase tracking-wide">Decision</div>
|
||||
<p class="text-sm text-gray-300 leading-relaxed" x-text="adr.decision"></p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-400 mb-1 uppercase tracking-wide">Consequences</div>
|
||||
<p class="text-sm text-gray-300 leading-relaxed" x-text="adr.consequences"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="filteredAdrs().length === 0" class="text-center py-12 text-gray-500 text-sm">No ADRs found</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ──── Memories ──── -->
|
||||
<div x-show="activeKnowledgeTab === 'memories'">
|
||||
<div x-show="memLoading" class="text-center py-12 text-gray-500">Loading memories…</div>
|
||||
<div class="grid gap-3" x-show="!memLoading">
|
||||
<template x-for="mem in filteredMemories()" :key="mem.memory_id">
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs px-2 py-0.5 bg-gray-800 text-gray-400 rounded font-mono" x-text="mem.entity_type || 'fact'"></span>
|
||||
<span class="text-sm font-medium text-gray-200" x-text="mem.name || '—'"></span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 leading-snug" x-text="mem.content"></p>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
<template x-for="tag in (mem.tags || [])" :key="tag">
|
||||
<span class="text-xs px-1.5 py-0.5 bg-gray-800 text-gray-400 rounded" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600 shrink-0" x-text="mem.created_at?.slice(0,10)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="filteredMemories().length === 0" class="text-center py-12 text-gray-500 text-sm">No memories found</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ──── Knowledge docs ──── -->
|
||||
<div x-show="activeKnowledgeTab === 'knowledge'" class="flex gap-4 h-[70vh]">
|
||||
<!-- File list -->
|
||||
<div class="w-80 shrink-0 overflow-y-auto space-y-2">
|
||||
<div x-show="knLoading" class="text-center py-12 text-gray-500">Loading…</div>
|
||||
<template x-for="kn in filteredKnowledge()" :key="kn.storage_filename">
|
||||
<button @click="openKnFile(kn.storage_filename)"
|
||||
class="w-full text-left bg-gray-900 border border-gray-800 hover:border-indigo-700 rounded-lg p-3 transition-colors"
|
||||
:class="selectedKn === kn.storage_filename ? 'border-indigo-500' : ''">
|
||||
<div class="text-xs font-semibold text-gray-200 leading-tight" x-text="kn.title || kn.original_filename"></div>
|
||||
<div class="text-xs text-gray-500 mt-0.5" x-text="kn.category"></div>
|
||||
<div class="flex flex-wrap gap-1 mt-1.5">
|
||||
<template x-for="tag in (kn.tags || []).slice(0,4)" :key="tag">
|
||||
<span class="text-xs px-1.5 py-0.5 bg-gray-800 text-gray-500 rounded" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div x-show="!knLoading && filteredKnowledge().length === 0" class="text-center py-12 text-gray-500 text-sm">
|
||||
No knowledge docs found.<br/><span class="text-xs">(data dir may not be mounted)</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content viewer -->
|
||||
<div class="flex-1 overflow-y-auto bg-gray-900 border border-gray-800 rounded-xl p-5">
|
||||
<div x-show="!selectedKn" class="flex items-center justify-center h-full text-gray-600 text-sm">
|
||||
Select a document to read it
|
||||
</div>
|
||||
<pre x-show="selectedKn" class="text-xs text-gray-300 whitespace-pre-wrap leading-relaxed" x-text="selectedKnContent"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════ MODALS ══ -->
|
||||
|
||||
<!-- New Board Modal -->
|
||||
<div x-data x-show="$store.boards.showNewBoard" x-cloak
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-96 shadow-2xl"
|
||||
@click.outside="$store.boards.showNewBoard = false">
|
||||
<h2 class="text-base font-bold text-white mb-4">New Board</h2>
|
||||
<form @submit.prevent="$store.boards.createBoard()">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Project</label>
|
||||
<input type="text" x-model="$store.boards.newBoard.project" required
|
||||
class="w-full mt-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Title</label>
|
||||
<input type="text" x-model="$store.boards.newBoard.title" required
|
||||
class="w-full mt-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Description</label>
|
||||
<textarea x-model="$store.boards.newBoard.description" rows="2"
|
||||
class="w-full mt-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-indigo-500"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button type="button" @click="$store.boards.showNewBoard = false"
|
||||
class="flex-1 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 text-sm rounded-lg transition-colors">Cancel</button>
|
||||
<button type="submit"
|
||||
class="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg transition-colors">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Task Modal -->
|
||||
<div x-data x-show="$store.tasks.showNew" x-cloak
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-96 shadow-2xl"
|
||||
@click.outside="$store.tasks.showNew = false">
|
||||
<h2 class="text-base font-bold text-white mb-4">Add Task</h2>
|
||||
<form @submit.prevent="$store.tasks.createTask()">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Title</label>
|
||||
<input type="text" x-model="$store.tasks.newTask.title" required
|
||||
class="w-full mt-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Description</label>
|
||||
<textarea x-model="$store.tasks.newTask.description" rows="2"
|
||||
class="w-full mt-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-indigo-500"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Priority</label>
|
||||
<select x-model="$store.tasks.newTask.priority"
|
||||
class="w-full mt-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-indigo-500">
|
||||
<option>low</option><option selected>medium</option><option>high</option><option>critical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Scope (optional)</label>
|
||||
<input type="text" x-model="$store.tasks.newTask.scope"
|
||||
class="w-full mt-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button type="button" @click="$store.tasks.showNew = false"
|
||||
class="flex-1 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 text-sm rounded-lg transition-colors">Cancel</button>
|
||||
<button type="submit"
|
||||
class="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg transition-colors">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Detail Drawer -->
|
||||
<div x-data x-show="$store.tasks.selected" x-cloak
|
||||
class="fixed right-0 top-0 bottom-0 w-96 bg-gray-900 border-l border-gray-800 shadow-2xl z-40 overflow-y-auto p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="font-mono text-indigo-400 text-sm" x-text="$store.tasks.selected?.task_id"></span>
|
||||
<button @click="$store.tasks.selected = null" class="text-gray-500 hover:text-gray-300 text-lg">✕</button>
|
||||
</div>
|
||||
<h2 class="text-base font-bold text-white mb-2" x-text="$store.tasks.selected?.title"></h2>
|
||||
<p class="text-sm text-gray-400 mb-4" x-text="$store.tasks.selected?.description"></p>
|
||||
|
||||
<!-- Status changer -->
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Status</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<template x-for="s in ['pending','in_progress','blocked','done','wont_do']" :key="s">
|
||||
<button @click="$store.tasks.updateStatus(s)"
|
||||
class="text-xs px-2.5 py-1 rounded font-medium transition-colors"
|
||||
:class="$store.tasks.selected?.status === s
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'"
|
||||
x-text="s.replace('_',' ')"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Priority</div>
|
||||
<div class="flex gap-1.5">
|
||||
<template x-for="p in ['low','medium','high','critical']" :key="p">
|
||||
<button @click="$store.tasks.updatePriority(p)"
|
||||
class="text-xs px-2.5 py-1 rounded font-medium transition-colors"
|
||||
:class="$store.tasks.selected?.priority === p
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'"
|
||||
x-text="p"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Findings -->
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-gray-500 mb-2">Findings</div>
|
||||
<div class="space-y-1.5 mb-2">
|
||||
<template x-for="f in ($store.tasks.selected?.findings || [])" :key="f.at">
|
||||
<div class="text-xs text-gray-300 bg-gray-800 rounded p-2">
|
||||
<span class="text-gray-500 mr-1" x-text="f.at?.slice(0,10)"></span>
|
||||
<span x-text="f.text"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<form @submit.prevent="$store.tasks.addFinding()" class="flex gap-2">
|
||||
<input type="text" x-model="$store.tasks.findingInput" placeholder="Add finding…"
|
||||
class="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-indigo-500" />
|
||||
<button type="submit" class="text-xs px-2.5 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded transition-colors">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-600 space-y-0.5">
|
||||
<div>Created: <span x-text="$store.tasks.selected?.created_at?.slice(0,10)"></span></div>
|
||||
<div>Updated: <span x-text="$store.tasks.selected?.updated_at?.slice(0,10)"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════ ALPINE STORES ══ -->
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
|
||||
Alpine.store('health', {
|
||||
redis: false,
|
||||
async check() {
|
||||
try {
|
||||
const r = await fetch('/health');
|
||||
const d = await r.json();
|
||||
this.redis = d.redis === 'ok';
|
||||
} catch(e) {}
|
||||
}
|
||||
});
|
||||
|
||||
Alpine.store('ui', {
|
||||
tab: 'taskz',
|
||||
});
|
||||
|
||||
Alpine.store('boards', {
|
||||
all: [],
|
||||
filtered: [],
|
||||
activeProject: '',
|
||||
projects: [],
|
||||
selectedId: null,
|
||||
current: null,
|
||||
showNewBoard: false,
|
||||
newBoard: { project: '', title: '', description: '' },
|
||||
|
||||
async load() {
|
||||
const r = await fetch('/api/v1/boards');
|
||||
const d = await r.json();
|
||||
this.all = d.boards || [];
|
||||
this.filtered = this.all;
|
||||
const projs = [...new Set(this.all.map(b => b.project))].sort();
|
||||
this.projects = projs;
|
||||
if (this.all.length > 0 && !this.selectedId) {
|
||||
// auto-select first active board
|
||||
const first = this.all.find(b => b.status === 'active') || this.all[0];
|
||||
this.select(first.board_id);
|
||||
}
|
||||
},
|
||||
|
||||
filterByProject() {
|
||||
this.filtered = this.activeProject
|
||||
? this.all.filter(b => b.project === this.activeProject)
|
||||
: this.all;
|
||||
},
|
||||
|
||||
async select(boardId) {
|
||||
this.selectedId = boardId;
|
||||
const r = await fetch(`/api/v1/boards/${boardId}`);
|
||||
this.current = await r.json();
|
||||
Alpine.store('tasks').loadFromBoard(this.current);
|
||||
},
|
||||
|
||||
async createBoard() {
|
||||
const r = await fetch('/api/v1/boards', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(this.newBoard)
|
||||
});
|
||||
const d = await r.json();
|
||||
this.showNewBoard = false;
|
||||
this.newBoard = { project: '', title: '', description: '' };
|
||||
await this.load();
|
||||
this.select(d.board_id);
|
||||
}
|
||||
});
|
||||
|
||||
Alpine.store('tasks', {
|
||||
byStatus: { pending: [], in_progress: [], blocked: [], done: [] },
|
||||
selected: null,
|
||||
showNew: false,
|
||||
newTask: { title: '', description: '', priority: 'medium', scope: '' },
|
||||
findingInput: '',
|
||||
|
||||
loadFromBoard(board) {
|
||||
const tasks = board.tasks || [];
|
||||
this.byStatus = {
|
||||
pending: tasks.filter(t => t.status === 'pending'),
|
||||
in_progress: tasks.filter(t => t.status === 'in_progress'),
|
||||
blocked: tasks.filter(t => t.status === 'blocked'),
|
||||
done: tasks.filter(t => t.status === 'done'),
|
||||
};
|
||||
},
|
||||
|
||||
select(task) {
|
||||
this.selected = task;
|
||||
},
|
||||
|
||||
async createTask() {
|
||||
const boardId = Alpine.store('boards').selectedId;
|
||||
if (!boardId) return;
|
||||
await fetch(`/api/v1/boards/${boardId}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(this.newTask)
|
||||
});
|
||||
this.showNew = false;
|
||||
this.newTask = { title: '', description: '', priority: 'medium', scope: '' };
|
||||
await Alpine.store('boards').select(boardId);
|
||||
},
|
||||
|
||||
async updateStatus(status) {
|
||||
if (!this.selected) return;
|
||||
await fetch(`/api/v1/tasks/${this.selected.task_id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
this.selected.status = status;
|
||||
const boardId = Alpine.store('boards').selectedId;
|
||||
await Alpine.store('boards').select(boardId);
|
||||
// re-select the updated task from refreshed board
|
||||
const board = Alpine.store('boards').current;
|
||||
const updatedTask = (board?.tasks || []).find(t => t.task_id === this.selected?.task_id);
|
||||
if (updatedTask) this.selected = updatedTask;
|
||||
},
|
||||
|
||||
async updatePriority(priority) {
|
||||
if (!this.selected) return;
|
||||
await fetch(`/api/v1/tasks/${this.selected.task_id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ priority })
|
||||
});
|
||||
this.selected.priority = priority;
|
||||
},
|
||||
|
||||
async addFinding() {
|
||||
if (!this.selected || !this.findingInput.trim()) return;
|
||||
await fetch(`/api/v1/tasks/${this.selected.task_id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ findings: this.findingInput.trim() })
|
||||
});
|
||||
const findings = this.selected.findings || [];
|
||||
findings.push({ text: this.findingInput.trim(), at: new Date().toISOString() });
|
||||
this.selected.findings = findings;
|
||||
this.findingInput = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Bootstrap
|
||||
Alpine.store('health').check();
|
||||
Alpine.store('boards').load();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user