Files
DevOpsDash/app/templates/dashboard.html

673 lines
33 KiB
HTML
Raw Normal View History

<!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>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { brand: { DEFAULT: '#6366f1', hover: '#4f46e5' } } } }
}
</script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
[x-cloak] { display: none !important; }
.task-card:hover { box-shadow: 0 0 0 2px #6366f1; }
.kanban-col { min-height: 180px; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
.prose-dark { color: #d1d5db; }
.prose-dark h1,.prose-dark h2,.prose-dark h3 { color: #f9fafb; margin-top:.75rem; margin-bottom:.25rem; font-weight:600; }
.prose-dark h1 { font-size:1.25rem; }
.prose-dark h2 { font-size:1.1rem; }
.prose-dark code { background:#1f2937; border-radius:3px; padding:1px 4px; font-size:.85em; color:#a5b4fc; }
.prose-dark pre { background:#111827; border-radius:6px; padding:.75rem; overflow-x:auto; margin:.5rem 0; }
.prose-dark pre code { background:none; padding:0; color:#d1fae5; }
.prose-dark a { color:#818cf8; }
.prose-dark ul { list-style:disc; padding-left:1.25rem; }
.prose-dark ol { list-style:decimal; padding-left:1.25rem; }
.prose-dark li { margin:.15rem 0; }
.prose-dark blockquote { border-left:3px solid #4b5563; padding-left:.75rem; color:#9ca3af; }
.prose-dark table { width:100%; border-collapse:collapse; font-size:.85em; }
.prose-dark th { background:#1f2937; padding:.4rem .6rem; text-align:left; }
.prose-dark td { padding:.35rem .6rem; border-top:1px solid #374151; }
.prose-dark hr { border-color:#374151; margin:.75rem 0; }
</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-5 py-2.5 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-lg font-bold tracking-tight">⚡ DevOpsDash</span>
<span class="text-gray-600 text-xs">i80.dk</span>
</div>
<nav class="flex gap-1" x-data>
<template x-for="t in [{id:'taskz',icon:'🗂️'},{id:'worklog',icon:'📜'},{id:'projects',icon:'📁'},{id:'knowledge',icon:'📚'}]" :key="t.id">
<button @click="$dispatch('switch-tab', t.id)"
class="px-3 py-1.5 rounded text-sm font-medium transition-colors flex items-center gap-1.5"
:class="$store.ui.tab===t.id?'bg-indigo-600 text-white':'text-gray-400 hover:text-white hover:bg-gray-800'">
<span x-text="t.icon"></span>
<span x-text="t.id.charAt(0).toUpperCase()+t.id.slice(1)"></span>
</button>
</template>
</nav>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500" x-data x-text="$store.status.msg"></span>
<div class="w-2 h-2 rounded-full" :class="$store.health.ok?'bg-green-400 animate-pulse':'bg-red-500'" title="Redis" x-data></div>
</div>
</header>
<div class="flex flex-1 overflow-hidden" x-data x-on:switch-tab.window="$store.ui.tab=$event.detail">
<!-- ═══════════════════════════════════════════════ TASKZ ═══ -->
<div class="flex flex-1 overflow-hidden" x-show="$store.ui.tab==='taskz'">
<!-- Sidebar -->
<aside class="w-60 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0 overflow-y-auto" x-data="taskzSidebar()">
<div class="p-3 border-b border-gray-800">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wide">Boards</span>
<button @click="$store.boards.openNewBoard()" class="text-indigo-400 hover:text-indigo-300 text-xs">+ New</button>
</div>
<input x-model="boardSearch" @input="filter()" placeholder="Search boards…"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 outline-none focus:border-indigo-500"/>
<div class="mt-2 flex gap-1 flex-wrap">
<template x-for="s in ['active','completed','archived']" :key="s">
<button @click="statusFilter=s===statusFilter?'':s; filter()"
class="px-2 py-0.5 rounded text-xs transition-colors"
:class="statusFilter===s?'bg-indigo-600 text-white':'bg-gray-800 text-gray-400 hover:text-white'"
x-text="s"></button>
</template>
</div>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-1">
<template x-for="b in filteredBoards" :key="b.board_id">
<button @click="$store.boards.select(b.board_id)"
class="w-full text-left px-3 py-2 rounded-lg transition-colors text-sm"
:class="$store.boards.activeId===b.board_id?'bg-indigo-600/20 border border-indigo-500/40 text-white':'hover:bg-gray-800 text-gray-300'">
<div class="font-medium truncate" x-text="b.title"></div>
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5">
<span x-text="b.project||'—'"></span>
<span class="rounded-full px-1.5 text-xs"
:class="{
'bg-green-900/50 text-green-400': b.status==='active',
'bg-gray-800 text-gray-500': b.status==='completed'||b.status==='archived'
}"
x-text="Math.round(((b.tasks||[]).filter(t=>t.status==='done').length/Math.max(1,(b.tasks||[]).length))*100)+'%'"></span>
</div>
</button>
</template>
</div>
</aside>
<!-- Kanban -->
<main class="flex-1 overflow-y-auto p-4" x-data="kanban()">
<template x-if="!$store.boards.activeId">
<div class="flex items-center justify-center h-full text-gray-600 text-sm">Select a board →</div>
</template>
<template x-if="$store.boards.activeId && board">
<div>
<div class="flex items-center gap-3 mb-4">
<h2 class="text-lg font-bold" x-text="board.title"></h2>
<span class="text-xs text-gray-500" x-text="board.project"></span>
<span class="ml-auto text-xs text-gray-500"
x-text="(board.tasks||[]).filter(t=>t.status==='done').length+' / '+(board.tasks||[]).length+' done'"></span>
</div>
<div class="grid grid-cols-4 gap-3">
<template x-for="col in columns" :key="col.id">
<div class="bg-gray-900 rounded-xl p-3 kanban-col">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold uppercase tracking-wide" :class="col.color" x-text="col.label"></span>
<span class="text-xs text-gray-600" x-text="tasksFor(col.id).length"></span>
</div>
<div class="space-y-2">
<template x-for="task in tasksFor(col.id)" :key="task.task_id">
<div class="task-card bg-gray-800 rounded-lg p-2.5 text-xs cursor-pointer transition-shadow"
@click="openTask(task)">
<div class="font-medium text-gray-100 mb-1 leading-snug" x-text="task.title"></div>
<div class="text-gray-500 truncate text-xs" x-text="task.task_id"></div>
<template x-if="(task.tags||[]).length">
<div class="flex flex-wrap gap-1 mt-1.5">
<template x-for="tag in (task.tags||[]).slice(0,3)" :key="tag">
<span class="bg-indigo-900/40 text-indigo-300 rounded px-1.5 py-0.5 text-xs" x-text="tag"></span>
</template>
</div>
</template>
</div>
</template>
<button @click="$store.boards.openNewTask(col.id)"
class="w-full text-gray-700 hover:text-gray-400 text-xs py-1 rounded hover:bg-gray-800 transition-colors">
+ Add task
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Task modal -->
<template x-if="$store.boards.taskModal">
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" @click.self="$store.boards.taskModal=null">
<div class="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg p-5 space-y-3">
<div class="flex items-start justify-between">
<h3 class="font-semibold text-sm" x-text="$store.boards.taskModal.title"></h3>
<button @click="$store.boards.taskModal=null" class="text-gray-500 hover:text-white text-lg leading-none">×</button>
</div>
<div class="text-xs text-gray-500" x-text="$store.boards.taskModal.task_id"></div>
<div class="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap"
x-text="$store.boards.taskModal.description||'—'"></div>
<template x-if="$store.boards.taskModal.findings?.length">
<div class="bg-gray-800 rounded p-2 text-xs text-gray-400">
<div class="text-gray-500 mb-1 font-semibold">Findings</div>
<template x-for="f in $store.boards.taskModal.findings" :key="f">
<div class="border-l-2 border-indigo-500 pl-2 mb-1" x-text="f"></div>
</template>
</div>
</template>
<div class="flex gap-2 pt-1">
<template x-for="s in ['pending','in_progress','done','blocked']" :key="s">
<button @click="$store.boards.updateTaskStatus($store.boards.taskModal, s)"
class="px-2.5 py-1 rounded text-xs transition-colors"
:class="$store.boards.taskModal.status===s?'bg-indigo-600 text-white':'bg-gray-800 text-gray-400 hover:text-white'"
x-text="s.replace('_',' ')"></button>
</template>
</div>
</div>
</div>
</template>
</main>
</div>
<!-- ═══════════════════════════════════════════════ WORKLOG ═══ -->
<div class="flex-1 overflow-y-auto p-5" x-show="$store.ui.tab==='worklog'" x-data="worklogTab()">
<div class="max-w-3xl mx-auto space-y-4">
<div class="flex items-center gap-3 flex-wrap">
<div class="flex rounded-lg overflow-hidden border border-gray-700">
<template x-for="ctx in ['egmont','personal','all']" :key="ctx">
<button @click="context=ctx; load()"
class="px-3 py-1.5 text-sm transition-colors"
:class="context===ctx?'bg-indigo-600 text-white':'bg-gray-900 text-gray-400 hover:text-white'"
x-text="ctx"></button>
</template>
</div>
<div class="flex items-center gap-2 text-sm text-gray-400">
<span>Days:</span>
<input type="range" x-model.number="days" min="1" max="30" @change="load()" class="w-24 accent-indigo-500"/>
<span x-text="days" class="w-6 text-center text-gray-300"></span>
</div>
<button @click="loadStandup()" class="ml-auto px-3 py-1.5 text-sm bg-indigo-600/20 border border-indigo-500/40 text-indigo-300 rounded-lg hover:bg-indigo-600/30 transition-colors">
📋 Standup
</button>
</div>
<template x-if="loading"><div class="text-gray-500 text-sm">Loading…</div></template>
<template x-if="standup">
<div class="bg-gray-900 border border-indigo-500/30 rounded-xl p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-indigo-400">Standup Summary</span>
<button @click="standup=null" class="text-gray-600 hover:text-white text-xs"></button>
</div>
<div class="prose-dark text-xs" x-html="renderMd(standup.summary||standup.text||JSON.stringify(standup,null,2))"></div>
</div>
</template>
<template x-if="!loading && data">
<div x-html="renderWorklog(data)" class="prose-dark text-sm"></div>
</template>
<template x-if="!loading && !data && !standup">
<div class="text-gray-600 text-sm">No worklog data — check DevOpsMCP connectivity.</div>
</template>
</div>
</div>
<!-- ═══════════════════════════════════════════════ PROJECTS ═══ -->
<div class="flex-1 overflow-hidden flex" x-show="$store.ui.tab==='projects'" x-data="projectsTab()">
<!-- Sidebar filters -->
<aside class="w-52 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0 p-3 gap-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Context</span>
<template x-for="c in ['all','egmont','personal','private']" :key="c">
<button @click="context=c; filter()"
class="text-left px-3 py-1.5 rounded text-sm transition-colors"
:class="context===c?'bg-indigo-600 text-white':'text-gray-400 hover:text-white hover:bg-gray-800'">
<span x-text="{all:'🌐 All',egmont:'🏢 Egmont',personal:'🏠 Personal',private:'🔒 Private'}[c]"></span>
<span class="ml-1 text-xs text-gray-500"
x-text="c==='all'?projects.length:(projects.filter(p=>p.context===c).length||'')"></span>
</button>
</template>
<hr class="border-gray-800 my-1"/>
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Filter</span>
<label class="flex items-center gap-2 text-xs text-gray-400 cursor-pointer">
<input type="checkbox" x-model="withContextOnly" @change="filter()" class="accent-indigo-500"/>
With work context
</label>
</aside>
<!-- Project list -->
<div class="flex flex-1 overflow-hidden">
<div class="w-80 overflow-y-auto border-r border-gray-800 p-3 space-y-2 shrink-0">
<input x-model="q" @input="filter()" placeholder="Search projects, tags…"
class="w-full bg-gray-800 border border-gray-700 rounded px-2.5 py-1.5 text-sm text-gray-300 placeholder-gray-600 outline-none focus:border-indigo-500 mb-1"/>
<div class="text-xs text-gray-600 mb-1" x-text="filtered.length+' projects'"></div>
<template x-for="p in filtered" :key="p.path">
<button @click="selected=p"
class="w-full text-left p-2.5 rounded-lg transition-colors border"
:class="selected?.path===p.path?'bg-indigo-600/15 border-indigo-500/40':'hover:bg-gray-900 border-transparent'">
<div class="flex items-center gap-2 mb-0.5">
<span class="font-medium text-sm text-gray-100 truncate" x-text="p.name"></span>
<span class="text-xs rounded-full px-1.5 shrink-0"
:class="{
'bg-blue-900/50 text-blue-400':p.context==='egmont',
'bg-green-900/50 text-green-400':p.context==='personal',
'bg-gray-800 text-gray-500':p.context==='private'
}"
x-text="p.context"></span>
</div>
<template x-if="p.work_context">
<div class="text-xs text-gray-500 truncate" x-text="'↳ '+p.work_context.what"></div>
</template>
<template x-if="(p.tags||[]).length">
<div class="flex flex-wrap gap-1 mt-1">
<template x-for="tag in (p.tags||[]).slice(0,4)" :key="tag">
<span class="bg-gray-800 text-gray-500 rounded px-1.5 text-xs" x-text="tag"></span>
</template>
</div>
</template>
</button>
</template>
</div>
<!-- Project detail -->
<div class="flex-1 overflow-y-auto p-5">
<template x-if="!selected">
<div class="text-gray-600 text-sm flex items-center justify-center h-full">Select a project</div>
</template>
<template x-if="selected">
<div class="max-w-xl space-y-4">
<div class="flex items-start justify-between">
<div>
<h2 class="text-xl font-bold" x-text="selected.name"></h2>
<div class="text-xs text-gray-500 mt-0.5" x-text="selected.path"></div>
</div>
<span class="text-sm rounded-full px-2.5 py-1"
:class="{
'bg-blue-900/50 text-blue-400':selected.context==='egmont',
'bg-green-900/50 text-green-400':selected.context==='personal',
'bg-gray-800 text-gray-400':selected.context==='private'
}"
x-text="selected.context"></span>
</div>
<template x-if="selected.remote">
<a :href="selected.remote.replace('git@','https://').replace('.com:','.com/').replace('.dk:','.dk/').replace('.git','')"
target="_blank" class="text-indigo-400 text-xs hover:underline truncate block"
x-text="selected.remote"></a>
</template>
<template x-if="selected.work_context">
<div class="bg-gray-900 border border-indigo-500/20 rounded-xl p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-indigo-400 text-sm font-semibold">🧠 Last worked on</span>
<span class="text-xs text-gray-600"
x-text="new Date(selected.work_context.saved_at).toLocaleDateString('da-DK',{day:'numeric',month:'short'})"></span>
</div>
<div class="text-sm text-gray-300 leading-relaxed" x-text="selected.work_context.what"></div>
<template x-if="selected.work_context.next_steps">
<div class="mt-3 pt-3 border-t border-gray-800">
<div class="text-xs text-gray-500 mb-1">Next steps</div>
<div class="text-sm text-gray-400" x-text="selected.work_context.next_steps"></div>
</div>
</template>
</div>
</template>
<template x-if="(selected.tags||[]).length">
<div>
<div class="text-xs text-gray-500 mb-1">Tags</div>
<div class="flex flex-wrap gap-1.5">
<template x-for="tag in selected.tags" :key="tag">
<span class="bg-indigo-900/30 text-indigo-400 rounded-full px-2.5 py-0.5 text-xs" x-text="tag"></span>
</template>
</div>
</div>
</template>
<div class="text-xs text-gray-700"
x-text="'Registered '+new Date(selected.registered_at||'').toLocaleDateString('da-DK')"></div>
</div>
</template>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════ KNOWLEDGE ═══ -->
<div class="flex-1 overflow-hidden flex flex-col" x-show="$store.ui.tab==='knowledge'" x-data="knowledgeTab()">
<!-- Sub-tabs -->
<div class="bg-gray-900 border-b border-gray-800 px-5 flex items-center gap-1 pt-2">
<template x-for="t in ['docs','howtos','agents','skills','adrs','memories']" :key="t">
<button @click="subTab=t; loadList()"
class="px-3 py-1.5 text-sm rounded-t font-medium transition-colors border-b-2"
:class="subTab===t?'border-indigo-500 text-white bg-gray-800':'border-transparent text-gray-500 hover:text-gray-300'">
<span x-text="{docs:'📄 Docs',howtos:'📖 HOWTOs',agents:'🤖 Agents',skills:'⚡ Skills',adrs:'📋 ADRs',memories:'💾 Memories'}[t]"></span>
</button>
</template>
<input x-model="q" @input="filterList()" placeholder="Search…"
class="ml-auto bg-gray-800 border border-gray-700 rounded px-2.5 py-1 text-xs text-gray-300 placeholder-gray-600 outline-none focus:border-indigo-500 w-48 mb-2"/>
</div>
<div class="flex flex-1 overflow-hidden">
<!-- List -->
<div class="w-72 border-r border-gray-800 overflow-y-auto p-2 space-y-1 shrink-0">
<div class="text-xs text-gray-600 px-2 py-1" x-text="filteredItems.length+' items'"></div>
<template x-for="item in filteredItems" :key="item.id||item.storage_filename||item.title">
<button @click="select(item)"
class="w-full text-left px-3 py-2 rounded-lg transition-colors text-sm"
:class="selectedItem?.id===item.id||selectedItem?.storage_filename===item.storage_filename?'bg-indigo-600/20 border border-indigo-500/40 text-white':'hover:bg-gray-900 text-gray-300 border border-transparent'">
<div class="font-medium truncate text-sm" x-text="item.title||item.name||item.storage_filename"></div>
<template x-if="item.category||item.entity_type">
<div class="text-xs text-gray-600 mt-0.5" x-text="item.category||item.entity_type"></div>
</template>
<template x-if="(item.tags||[]).length">
<div class="flex flex-wrap gap-1 mt-1">
<template x-for="tag in (item.tags||[]).slice(0,3)" :key="tag">
<span class="bg-gray-800 text-gray-500 rounded px-1.5 text-xs" x-text="tag"></span>
</template>
</div>
</template>
</button>
</template>
<template x-if="loading">
<div class="text-gray-600 text-xs text-center py-4">Loading…</div>
</template>
<template x-if="!loading && filteredItems.length===0">
<div class="text-gray-700 text-xs text-center py-4">No items</div>
</template>
</div>
<!-- Content viewer -->
<div class="flex-1 overflow-y-auto p-5">
<template x-if="!selectedItem && !detailLoading">
<div class="flex items-center justify-center h-full text-gray-700 text-sm">Select an item to view</div>
</template>
<template x-if="detailLoading">
<div class="text-gray-600 text-sm">Loading content…</div>
</template>
<template x-if="selectedItem && !detailLoading">
<div>
<!-- ADR detail -->
<template x-if="subTab==='adrs'">
<div class="max-w-xl space-y-4">
<div class="flex items-start justify-between">
<h2 class="text-lg font-bold" x-text="'ADR-'+selectedItem.number+': '+selectedItem.title"></h2>
<span class="text-xs px-2 py-0.5 rounded-full"
:class="{'bg-green-900/50 text-green-400':selectedItem.status==='Accepted','bg-yellow-900/50 text-yellow-400':selectedItem.status==='Proposed','bg-gray-800 text-gray-500':true}"
x-text="selectedItem.status"></span>
</div>
<div class="bg-gray-900 rounded-xl p-4 space-y-3 text-sm">
<div><div class="text-xs text-gray-500 mb-1 uppercase tracking-wide">Context</div><div class="text-gray-300" x-text="selectedItem.context"></div></div>
<div><div class="text-xs text-gray-500 mb-1 uppercase tracking-wide">Decision</div><div class="text-gray-300" x-text="selectedItem.decision"></div></div>
<div><div class="text-xs text-gray-500 mb-1 uppercase tracking-wide">Consequences</div><div class="text-gray-300" x-text="selectedItem.consequences"></div></div>
</div>
<template x-if="(selectedItem.tags||[]).length">
<div class="flex flex-wrap gap-1.5">
<template x-for="tag in selectedItem.tags" :key="tag">
<span class="bg-indigo-900/30 text-indigo-400 rounded-full px-2.5 py-0.5 text-xs" x-text="tag"></span>
</template>
</div>
</template>
</div>
</template>
<!-- Memory detail -->
<template x-if="subTab==='memories'">
<div class="max-w-xl space-y-3">
<h2 class="text-lg font-bold" x-text="selectedItem.name||'Memory'"></h2>
<div class="bg-gray-900 rounded-xl p-4 text-sm text-gray-300 leading-relaxed" x-text="selectedItem.content"></div>
<div class="text-xs text-gray-600">
<span x-text="selectedItem.entity_type"></span>
<span class="mx-1">·</span>
<span x-text="new Date(selectedItem.created_at||'').toLocaleDateString('da-DK')"></span>
</div>
</div>
</template>
<!-- Markdown content (docs, howtos, agents, skills) -->
<template x-if="['docs','howtos','agents','skills'].includes(subTab) && detail">
<div>
<h2 class="text-base font-bold mb-3 text-gray-100"
x-text="selectedItem.title||selectedItem.name||selectedItem.storage_filename"></h2>
<div class="prose-dark text-sm leading-relaxed"
x-html="renderMd(extractContent(detail))"></div>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
</div><!-- /main flex -->
<script>
// ── Alpine stores ──────────────────────────────────────────────────────────────
document.addEventListener('alpine:init', () => {
Alpine.store('ui', { tab: 'taskz' });
Alpine.store('health', { ok: false });
Alpine.store('status', { msg: '' });
Alpine.store('boards', {
list: [], activeId: null, taskModal: null, showNewBoard: false,
projects: [],
activeProject: '',
async init() {
const r = await fetch('/api/v1/boards').then(r=>r.json());
this.list = r.boards||[];
this.projects = [...new Set(this.list.map(b=>b.project).filter(Boolean))].sort();
},
async select(id) {
this.activeId = id;
},
openNewBoard() { /* TODO */ },
openNewTask(status) { /* TODO */ },
async updateTaskStatus(task, status) {
await fetch(`/api/v1/tasks/${task.task_id}`,{
method:'PATCH',headers:{'Content-Type':'application/json'},
body:JSON.stringify({status})
});
task.status = status;
// refresh board
const idx = this.list.findIndex(b=>b.board_id===task.board_id);
if(idx>=0){
const r=await fetch(`/api/v1/boards/${task.board_id}`).then(r=>r.json());
this.list[idx]=r.board;
}
}
});
// Health check
fetch('/health').then(r=>r.json()).then(d=>{
Alpine.store('health').ok = d.redis==='ok';
}).catch(()=>{});
// Init boards
Alpine.store('boards').init();
});
// ── Taskz sidebar ──────────────────────────────────────────────────────────────
function taskzSidebar() {
return {
boardSearch: '',
statusFilter: '',
filteredBoards: [],
init() {
this.$watch('$store.boards.list', () => this.filter());
this.filter();
},
filter() {
let bs = this.$store.boards.list;
if(this.boardSearch) bs = bs.filter(b=>(b.title+b.project).toLowerCase().includes(this.boardSearch.toLowerCase()));
if(this.statusFilter) bs = bs.filter(b=>b.status===this.statusFilter);
this.filteredBoards = bs;
}
}
}
// ── Kanban ────────────────────────────────────────────────────────────────────
function kanban() {
return {
columns: [
{id:'pending', label:'Pending', color:'text-gray-400'},
{id:'in_progress', label:'In Progress', color:'text-yellow-400'},
{id:'done', label:'Done', color:'text-green-400'},
{id:'blocked', label:'Blocked', color:'text-red-400'},
],
get board() {
return this.$store.boards.list.find(b=>b.board_id===this.$store.boards.activeId);
},
tasksFor(status) {
return (this.board?.tasks||[]).filter(t=>t.status===status)
.sort((a,b)=>a.number-b.number);
},
openTask(task) { this.$store.boards.taskModal = task; }
}
}
// ── Worklog ───────────────────────────────────────────────────────────────────
function worklogTab() {
return {
context: 'egmont', days: 7, data: null, standup: null, loading: false,
async init() { await this.load(); },
async load() {
this.loading = true; this.standup = null;
try {
const r = await fetch(`/api/v1/worklog?context=${this.context}&days=${this.days}`).then(r=>r.json());
this.data = r;
} catch(e) { this.data = null; }
this.loading = false;
},
async loadStandup() {
this.loading = true; this.data = null;
try {
const r = await fetch(`/api/v1/standup?days=${this.days}&context=${this.context}`).then(r=>r.json());
this.standup = r;
} catch(e) { this.standup = null; }
this.loading = false;
},
renderMd(text) { return marked.parse(String(text||'')); },
renderWorklog(data) {
if(!data) return '';
// worklog returns {commits, summary, ...}
let html = '';
if(data.summary) html += `<div class="mb-4">${marked.parse(data.summary)}</div>`;
const byRepo = data.by_repo || data.commits_by_repo || {};
for(const [repo, commits] of Object.entries(byRepo)) {
html += `<div class="mb-4"><div class="text-indigo-400 font-semibold text-sm mb-1">📦 ${repo}</div><ul class="space-y-0.5">`;
for(const c of (commits||[])) {
html += `<li class="text-xs text-gray-400"><span class="text-gray-600 mr-2">${(c.sha||'').slice(0,7)}</span>${c.message||c}</li>`;
}
html += '</ul></div>';
}
if(!html) html = `<pre class="text-xs text-gray-500 overflow-x-auto">${JSON.stringify(data,null,2)}</pre>`;
return html;
}
}
}
// ── Projects ──────────────────────────────────────────────────────────────────
function projectsTab() {
return {
projects: [], filtered: [], context: 'all', q: '', selected: null, withContextOnly: false,
async init() {
const r = await fetch('/api/v1/projects').then(r=>r.json());
this.projects = r.projects||[];
this.filter();
},
filter() {
let ps = this.projects;
if(this.context!=='all') ps = ps.filter(p=>p.context===this.context);
if(this.withContextOnly) ps = ps.filter(p=>p.work_context);
if(this.q) {
const ql = this.q.toLowerCase();
ps = ps.filter(p=>(p.name+p.path+(p.tags||[]).join(' ')).toLowerCase().includes(ql));
}
this.filtered = ps;
}
}
}
// ── Knowledge ─────────────────────────────────────────────────────────────────
function knowledgeTab() {
return {
subTab: 'docs', items: [], filteredItems: [], q: '',
selectedItem: null, detail: null, loading: false, detailLoading: false,
async init() { await this.loadList(); },
async loadList() {
this.loading = true; this.selectedItem = null; this.detail = null;
try {
let url = {
docs: '/api/v1/knowledge',
howtos: '/api/v1/howtos',
agents: '/api/v1/agents',
skills: '/api/v1/skills',
adrs: '/api/v1/adrs',
memories: '/api/v1/memories',
}[this.subTab];
const r = await fetch(url).then(r=>r.json());
this.items = r.entries||r.adrs||r.memories||r.agents||r.skills||r.howtos||
(r.agents_list ? Object.entries(r.agents_list||{}).map(([n,c])=>({name:n,content:c})) : null)||
this.flattenAgents(r)||[];
} catch(e) { this.items = []; }
this.loading = false;
this.filterList();
},
flattenAgents(r) {
// agents/skills return nested structure
if(r.agents) return Array.isArray(r.agents) ? r.agents : Object.entries(r.agents).map(([k,v])=>({name:k,...(typeof v==='string'?{content:v}:v)}));
if(r.skills) return Array.isArray(r.skills) ? r.skills : Object.entries(r.skills).map(([k,v])=>({name:k,...(typeof v==='string'?{content:v}:v)}));
if(r.files) return r.files;
return null;
},
filterList() {
if(!this.q) { this.filteredItems = this.items; return; }
const ql = this.q.toLowerCase();
this.filteredItems = this.items.filter(i=>
((i.title||i.name||i.storage_filename||'')+' '+(i.category||i.entity_type||'')+(i.tags||[]).join(' ')).toLowerCase().includes(ql)
);
},
async select(item) {
this.selectedItem = item; this.detail = null;
if(['docs','howtos','agents','skills'].includes(this.subTab)) {
this.detailLoading = true;
try {
let url;
if(this.subTab==='docs') url = `/api/v1/knowledge/${item.storage_filename}`;
else if(this.subTab==='howtos') url = `/api/v1/howtos/${item.filename||item.name}`;
else if(this.subTab==='agents') url = `/api/v1/agents/${item.name}`;
else if(this.subTab==='skills') url = `/api/v1/skills/${item.name}`;
if(url) {
const text = await fetch(url).then(r=>r.text());
this.detail = text;
}
} catch(e) { this.detail = null; }
this.detailLoading = false;
}
},
renderMd(text) { return marked.parse(String(text||'')); },
extractContent(raw) {
// If it's JSON, pull out content field
try {
const j = JSON.parse(raw);
return j.content||j.raw||j.definition||JSON.stringify(j,null,2);
} catch { return raw; }
}
}
}
</script>
</body>
</html>