KAOS/code/internal/server/render.go
djuka 04ef8e75ef T08: HTTP server + API za taskove
- Gin HTTP server sa dashboard i API endpointima
- JSON API: GET /api/tasks, GET /api/task/:id, POST /api/task/:id/move
- HTML dashboard sa Kanban prikazom (5 kolona)
- HTMX za interaktivnost (klik na task → detalj panel)
- Embedded static fajlovi (htmx.min.js, sortable.min.js)
- Config: dodat KAOS_PORT
- 10 server testova, 77 ukupno — svi prolaze
- Očišćeni duplikati taskova iz v0.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:10:49 +00:00

134 lines
5.1 KiB
Go

package server
import (
"fmt"
"html"
"strings"
"github.com/dal/kaos/internal/supervisor"
)
// statusIcons maps folder names to emoji icons.
var statusIcons = map[string]string{
"backlog": "📦",
"ready": "📋",
"active": "🔄",
"review": "👀",
"done": "✅",
}
// columnOrder defines the display order of columns.
var columnOrder = []string{"backlog", "ready", "active", "review", "done"}
// renderDashboard generates the full dashboard HTML page.
func renderDashboard(columns map[string][]supervisor.Task) string {
var b strings.Builder
b.WriteString(`<!DOCTYPE html>
<html lang="sr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KAOS Dashboard</title>
<script src="/static/htmx.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a2e; color: #eee; }
.header { padding: 16px 24px; background: #16213e; display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #0f3460; }
.header h1 { font-size: 1.4em; }
.header .version { color: #888; font-size: 0.9em; }
.board { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; padding: 16px; min-height: calc(100vh - 60px); }
.column { background: #16213e; border-radius: 8px; padding: 12px; }
.column-header { font-weight: bold; padding: 8px; margin-bottom: 8px; border-bottom: 2px solid #0f3460; display: flex; justify-content: space-between; }
.column-count { background: #0f3460; border-radius: 12px; padding: 2px 8px; font-size: 0.85em; }
.task-card { background: #1a1a2e; border: 1px solid #333; border-radius: 6px; padding: 10px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.2s; }
.task-card:hover { border-color: #e94560; }
.task-id { font-weight: bold; color: #e94560; }
.task-title { margin-top: 4px; font-size: 0.9em; }
.task-meta { margin-top: 6px; font-size: 0.75em; color: #888; }
.task-deps { font-size: 0.75em; color: #666; margin-top: 4px; }
#task-detail { position: fixed; top: 0; right: 0; width: 400px; height: 100vh; background: #16213e; border-left: 2px solid #0f3460; padding: 20px; overflow-y: auto; display: none; z-index: 10; }
#task-detail.active { display: block; }
.detail-close { cursor: pointer; float: right; font-size: 1.2em; color: #888; }
.detail-close:hover { color: #e94560; }
.detail-content { white-space: pre-wrap; font-family: monospace; font-size: 0.85em; margin-top: 16px; line-height: 1.5; }
@media (max-width: 900px) { .board { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 600px) { .board { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<h1>🔧 KAOS Dashboard</h1>
<span class="version">v0.2</span>
</div>
<div class="board" id="board">
`)
for _, col := range columnOrder {
tasks := columns[col]
icon := statusIcons[col]
b.WriteString(fmt.Sprintf(`<div class="column" id="col-%s" data-folder="%s">`, col, col))
b.WriteString(fmt.Sprintf(`<div class="column-header"><span>%s %s</span><span class="column-count">%d</span></div>`,
icon, strings.ToUpper(col), len(tasks)))
for _, t := range tasks {
b.WriteString(renderTaskCard(t))
}
b.WriteString("</div>\n")
}
b.WriteString(`</div>
<div id="task-detail"></div>
<script>
document.body.addEventListener('htmx:afterSwap', function(e) {
if (e.detail.target.id === 'task-detail') {
e.detail.target.classList.add('active');
}
});
function closeDetail() {
document.getElementById('task-detail').classList.remove('active');
document.getElementById('task-detail').innerHTML = '';
}
</script>
</body>
</html>`)
return b.String()
}
// renderTaskCard generates HTML for a single task card.
func renderTaskCard(t supervisor.Task) string {
var b strings.Builder
b.WriteString(fmt.Sprintf(`<div class="task-card" data-id="%s" hx-get="/task/%s" hx-target="#task-detail" hx-swap="innerHTML">`,
t.ID, t.ID))
b.WriteString(fmt.Sprintf(`<div class="task-id">%s</div>`, html.EscapeString(t.ID)))
b.WriteString(fmt.Sprintf(`<div class="task-title">%s</div>`, html.EscapeString(t.Title)))
b.WriteString(fmt.Sprintf(`<div class="task-meta">%s · %s</div>`, html.EscapeString(t.Agent), html.EscapeString(t.Model)))
if len(t.DependsOn) > 0 {
b.WriteString(fmt.Sprintf(`<div class="task-deps">Zavisi od: %s</div>`, html.EscapeString(strings.Join(t.DependsOn, ", "))))
}
b.WriteString("</div>\n")
return b.String()
}
// renderTaskDetail generates HTML fragment for task detail panel.
func renderTaskDetail(t supervisor.Task, content string) string {
var b strings.Builder
b.WriteString(`<span class="detail-close" onclick="closeDetail()">✕</span>`)
b.WriteString(fmt.Sprintf(`<h2>%s: %s</h2>`, html.EscapeString(t.ID), html.EscapeString(t.Title)))
b.WriteString(fmt.Sprintf(`<p>Agent: %s · Model: %s · Status: %s</p>`,
html.EscapeString(t.Agent), html.EscapeString(t.Model), html.EscapeString(t.Status)))
if len(t.DependsOn) > 0 {
b.WriteString(fmt.Sprintf(`<p>Zavisi od: %s</p>`, html.EscapeString(strings.Join(t.DependsOn, ", "))))
}
b.WriteString(fmt.Sprintf(`<div class="detail-content">%s</div>`, html.EscapeString(content)))
return b.String()
}