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
760 lines
35 KiB
HTML
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>
|