Initial DevOpsDash — FastAPI + Alpine.js dashboard for DevOpsMCP
Some checks failed
Build and push DevOpsDash / build (push) Has been cancelled

- Taskz kanban board (create/edit tasks, findings, status/priority)
- Worklog timeline + standup summary (proxied from DevOpsMCP MCP API)
- Knowledge browser (ADRs, memories, knowledge catalog files)
- FastAPI backend reading same Redis as DevOpsMCP
- Read-only bind-mount for DevOpsMCP data directory (/data)
- Nomad job spec (dash.i80.dk, Traefik TLS, host volume read-only)
- Gitea Actions CI → registry.i80.dk/gitea/devops-dash:latest
This commit is contained in:
Henrik Jess Nielsen
2026-05-09 16:36:18 +02:00
commit ac85040e4d
15 changed files with 1638 additions and 0 deletions

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""App package init."""

55
app/main.py Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,134 @@
"""Knowledge router — ADRs, memories, and file browser for mounted data directory."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import PlainTextResponse
from app.redis_client import get_redis, list_adrs, get_adr, list_memories
router = APIRouter(prefix="/api/v1", tags=["knowledge"])
# Data dir is mounted from DevOpsMCP at /data (read-only bind mount in production)
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
KNOWLEDGE_CATALOG = DATA_DIR / "knowledge" / "catalog.json"
# ── ADRs ──────────────────────────────────────────────────────────────────────
@router.get("/adrs")
def api_list_adrs(status: Optional[str] = None):
r = get_redis()
adrs = list_adrs(r, status_filter=status)
return {"adrs": adrs, "total": len(adrs)}
@router.get("/adrs/{adr_id}")
def api_get_adr(adr_id: str):
r = get_redis()
adr = get_adr(r, adr_id)
if not adr:
raise HTTPException(status_code=404, detail="ADR not found")
return adr
# ── Memories ──────────────────────────────────────────────────────────────────
@router.get("/memories")
def api_list_memories(entity_type: Optional[str] = None, limit: int = 50):
r = get_redis()
memories = list_memories(r, entity_type=entity_type, limit=limit)
return {"memories": memories, "total": len(memories)}
# ── Knowledge catalog (file-based, from bind-mounted data dir) ────────────────
@router.get("/knowledge")
def api_list_knowledge(category: Optional[str] = None, tag: Optional[str] = None):
if not KNOWLEDGE_CATALOG.exists():
return {"entries": [], "total": 0, "data_dir_available": False}
try:
catalog: dict = json.loads(KNOWLEDGE_CATALOG.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Catalog read error: {exc}") from exc
entries = list(catalog.values())
if category:
entries = [e for e in entries if e.get("category", "").lower() == category.lower()]
if tag:
entries = [e for e in entries if tag.lower() in [t.lower() for t in e.get("tags", [])]]
summaries = [
{
"storage_filename": e["storage_filename"],
"original_filename": e["original_filename"],
"title": e.get("title", e["original_filename"]),
"file_type": e.get("file_type", "markdown"),
"category": e.get("category", "general"),
"tags": e.get("tags", []),
"summary": e.get("summary", "")[:200],
"char_count": e.get("char_count", 0),
"learned_at": e.get("learned_at", ""),
}
for e in entries
]
summaries.sort(key=lambda e: e.get("learned_at", ""), reverse=True)
return {"entries": summaries, "total": len(summaries), "data_dir_available": True}
@router.get("/knowledge/{filename}")
def api_get_knowledge_file(filename: str):
"""Return raw markdown content of a knowledge file."""
safe_name = Path(filename).name # prevent path traversal
file_path = DATA_DIR / "knowledge" / safe_name
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
content = file_path.read_text(encoding="utf-8")
return PlainTextResponse(content, media_type="text/markdown")
# ── Generic file browser for data directory ───────────────────────────────────
@router.get("/files")
def api_list_files(subdir: str = ""):
"""List files in the bound data directory."""
target = DATA_DIR / subdir if subdir else DATA_DIR
target = target.resolve()
# Safety: must stay within DATA_DIR
if not str(target).startswith(str(DATA_DIR.resolve())):
raise HTTPException(status_code=400, detail="Path traversal not allowed")
if not target.exists():
return {"files": [], "dirs": [], "path": subdir}
files = []
dirs = []
for item in sorted(target.iterdir()):
if item.is_file() and item.suffix in {".md", ".yml", ".yaml", ".txt", ".rst", ".json"}:
files.append({
"name": item.name,
"size": item.stat().st_size,
"path": str(item.relative_to(DATA_DIR)),
})
elif item.is_dir():
dirs.append(item.name)
return {"files": files, "dirs": dirs, "path": subdir}
@router.get("/files/{file_path:path}")
def api_get_file(file_path: str):
"""Return raw content of a file from the data directory."""
target = (DATA_DIR / file_path).resolve()
if not str(target).startswith(str(DATA_DIR.resolve())):
raise HTTPException(status_code=400, detail="Path traversal not allowed")
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="File not found")
allowed = {".md", ".yml", ".yaml", ".txt", ".rst"}
if target.suffix not in allowed:
raise HTTPException(status_code=400, detail="File type not allowed")
content = target.read_text(encoding="utf-8")
media_type = "text/markdown" if target.suffix == ".md" else "text/plain"
return PlainTextResponse(content, media_type=media_type)

228
app/routers/tasks.py Normal file
View File

@@ -0,0 +1,228 @@
"""Task board router — Taskz CRUD over Redis."""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.redis_client import (
get_redis,
get_board,
list_boards,
save_board,
save_task,
BOARD_KEY_PREFIX,
BOARD_INDEX_KEY,
)
router = APIRouter(prefix="/api/v1", tags=["tasks"])
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _slugify(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:20]
def _make_board_code(project: str, title: str) -> str:
proj = re.sub(r"[^a-z0-9]+", "", project.lower())[:12].upper()
title_word = re.sub(r"[^a-z0-9]+", "", title.lower().split()[0])[:6].upper()
return f"{proj}-{title_word}"
def _next_task_number(board: Dict[str, Any]) -> int:
tasks = board.get("tasks", [])
return max((t.get("number", 0) for t in tasks), default=0) + 1
# ── Schemas ───────────────────────────────────────────────────────────────────
class BoardCreate(BaseModel):
project: str
title: str
description: str = ""
tags: List[str] = []
class TaskCreate(BaseModel):
title: str
description: str = ""
priority: str = "medium"
tags: List[str] = []
scope: Optional[str] = None
class TaskUpdate(BaseModel):
status: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
findings: Optional[str] = None
priority: Optional[str] = None
tags: Optional[List[str]] = None
class BoardStatusUpdate(BaseModel):
new_status: str = "completed"
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/boards")
def api_list_boards(project: Optional[str] = None, status: Optional[str] = None):
r = get_redis()
boards = list_boards(r, project=project, status=status)
summaries = []
for b in boards:
tasks = b.get("tasks", [])
done = sum(1 for t in tasks if t["status"] == "done")
summaries.append({
"board_id": b["board_id"],
"board_code": b.get("board_code", ""),
"project": b["project"],
"title": b["title"],
"description": b.get("description", ""),
"status": b.get("status", "active"),
"tags": b.get("tags", []),
"task_count": len(tasks),
"done_count": done,
"created_at": b.get("created_at", ""),
"updated_at": b.get("updated_at", ""),
})
return {"boards": summaries, "total": len(summaries)}
@router.post("/boards", status_code=201)
def api_create_board(body: BoardCreate):
r = get_redis()
code = _make_board_code(body.project, body.title)
board_id = f"{_slugify(body.project)}-{_slugify(body.title)}-{_now()[:10].replace('-', '')}"
board = {
"board_id": board_id,
"board_code": code,
"project": body.project,
"title": body.title,
"description": body.description,
"status": "active",
"tags": body.tags,
"tasks": [],
"created_at": _now(),
"updated_at": _now(),
}
save_board(r, board)
return {"board_id": board_id, "board_code": code}
@router.get("/boards/{board_id}")
def api_get_board(board_id: str):
r = get_redis()
board = get_board(r, board_id)
if not board:
raise HTTPException(status_code=404, detail="Board not found")
return board
@router.patch("/boards/{board_id}/status")
def api_close_board(board_id: str, body: BoardStatusUpdate):
r = get_redis()
board = get_board(r, board_id)
if not board:
raise HTTPException(status_code=404, detail="Board not found")
board["status"] = body.new_status
board["updated_at"] = _now()
save_board(r, board)
return {"board_id": board_id, "status": body.new_status}
@router.post("/boards/{board_id}/tasks", status_code=201)
def api_add_task(board_id: str, body: TaskCreate):
r = get_redis()
board = get_board(r, board_id)
if not board:
raise HTTPException(status_code=404, detail="Board not found")
code = board.get("board_code", board_id.upper()[:10])
num = _next_task_number(board)
task_id = f"{code}-{num:03d}"
task: Dict[str, Any] = {
"task_id": task_id,
"board_id": board_id,
"number": num,
"title": body.title,
"description": body.description,
"priority": body.priority,
"status": "pending",
"tags": body.tags,
"scope": body.scope,
"findings": [],
"created_at": _now(),
"updated_at": _now(),
}
save_task(r, task)
return {"task_id": task_id}
@router.patch("/tasks/{task_id}")
def api_update_task(task_id: str, body: TaskUpdate):
r = get_redis()
# find the task inside any board
from app.redis_client import list_boards as _lb, TASK_KEY_PREFIX
raw = r.get(f"{TASK_KEY_PREFIX}{task_id}")
if not raw:
# fallback: scan boards
for board in _lb(r):
task = next((t for t in board.get("tasks", []) if t["task_id"] == task_id), None)
if task:
raw = json.dumps(task)
break
if not raw:
raise HTTPException(status_code=404, detail="Task not found")
task = json.loads(raw)
valid_statuses = {"pending", "in_progress", "done", "blocked", "wont_do"}
valid_priorities = {"low", "medium", "high", "critical"}
if body.status:
if body.status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status: {body.status}")
task["status"] = body.status
if body.status == "done":
task["completed_at"] = _now()
if body.title:
task["title"] = body.title
if body.description is not None:
task["description"] = body.description
if body.findings:
findings = task.setdefault("findings", [])
findings.append({"text": body.findings, "at": _now()})
if body.priority:
if body.priority not in valid_priorities:
raise HTTPException(status_code=400, detail=f"Invalid priority: {body.priority}")
task["priority"] = body.priority
if body.tags is not None:
task["tags"] = body.tags
task["updated_at"] = _now()
save_task(r, task)
return {"task_id": task_id, "status": task["status"]}
@router.delete("/tasks/{task_id}")
def api_delete_task(task_id: str):
r = get_redis()
from app.redis_client import TASK_KEY_PREFIX
raw = r.get(f"{TASK_KEY_PREFIX}{task_id}")
if not raw:
raise HTTPException(status_code=404, detail="Task not found")
task = json.loads(raw)
board = get_board(r, task["board_id"])
if board:
board["tasks"] = [t for t in board.get("tasks", []) if t["task_id"] != task_id]
board["updated_at"] = _now()
save_board(r, board)
r.delete(f"{TASK_KEY_PREFIX}{task_id}")
return {"deleted": task_id}

39
app/routers/worklog.py Normal file
View File

@@ -0,0 +1,39 @@
"""Worklog router — proxies worklog/standup calls to DevOpsMCP."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, HTTPException
from app import mcp_client
router = APIRouter(prefix="/api/v1", tags=["worklog"])
@router.get("/worklog")
async def api_worklog(
context: str = "egmont",
days: int = 7,
group_by: str = "repo",
since_date: Optional[str] = None,
until_date: Optional[str] = None,
):
try:
return await mcp_client.get_worklog(
context=context,
days=days,
group_by=group_by,
since_date=since_date,
until_date=until_date,
)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"DevOpsMCP error: {exc}") from exc
@router.get("/standup")
async def api_standup(days: int = 2, context: str = "egmont"):
try:
return await mcp_client.get_standup(days=days, context=context)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"DevOpsMCP error: {exc}") from exc

View 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>