Files
DevOpsDash/app/templates/dashboard.html
Henrik Jess Nielsen ac85040e4d
Some checks failed
Build and push DevOpsDash / build (push) Has been cancelled
Initial DevOpsDash — FastAPI + Alpine.js dashboard for DevOpsMCP
- 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
2026-05-09 16:36:18 +02:00

760 lines
35 KiB
HTML

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