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:
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
|
||||
Reference in New Issue
Block a user