Files
DevOpsDash/app/templates/dashboard.html
Henrik Jess Nielsen 8905dd2040
All checks were successful
Build and Deploy DevOpsDash / build-image (push) Successful in 8s
fix(ui): handle available_agents/available_skills response keys and nested agent/skill content
- Use r.available_agents and r.available_skills field names from MCP proxy
- Fix extractContent to unwrap agents[0].content / skills[0].content
- Fix Alpine x-for key to include item.name for agents/skills
- Fix selected state comparison to handle name-keyed items
2026-05-09 17:13:10 +02:00

674 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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.name||item.title">
<button @click="select(item)"
class="w-full text-left px-3 py-2 rounded-lg transition-colors text-sm"
:class="(selectedItem?.id&&selectedItem?.id===item.id)||(selectedItem?.storage_filename&&selectedItem?.storage_filename===item.storage_filename)||(selectedItem?.name&&selectedItem?.name===item.name)?'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.files||
r.available_agents||r.available_skills||
r.agents||r.skills||r.howtos||
this.flattenFilesOrHowtos(r)||[];
} catch(e) { this.items = []; }
this.loading = false;
this.filterList();
},
flattenFilesOrHowtos(r) {
if(r.files && Array.isArray(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);
// agents/skills return {agents:[{content:...}]} or {skills:[{content:...}]}
if(j.agents && j.agents[0]) return j.agents[0].content||JSON.stringify(j,null,2);
if(j.skills && j.skills[0]) return j.skills[0].content||JSON.stringify(j,null,2);
return j.content||j.raw||j.definition||JSON.stringify(j,null,2);
} catch { return raw; }
}
}
}
</script>
</body>
</html>