KAOS/code/internal/server/server.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

263 lines
6.4 KiB
Go

// Package server implements the HTTP server and API for the KAOS dashboard.
package server
import (
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/dal/kaos/internal/config"
"github.com/dal/kaos/internal/supervisor"
"github.com/dal/kaos/web"
)
// Server holds the HTTP server state.
type Server struct {
Config *config.Config
Router *gin.Engine
}
// taskResponse is the JSON representation of a task.
type taskResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Agent string `json:"agent"`
Model string `json:"model"`
DependsOn []string `json:"depends_on"`
Description string `json:"description"`
FilePath string `json:"file_path"`
}
// taskDetailResponse includes the raw file content.
type taskDetailResponse struct {
taskResponse
Content string `json:"content"`
}
// validMoveTargets defines allowed destination folders for manual moves.
var validFolders = map[string]bool{
"backlog": true,
"ready": true,
"active": true,
"review": true,
"done": true,
}
// New creates a new Server with all routes configured.
func New(cfg *config.Config) *Server {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(gin.Recovery())
s := &Server{
Config: cfg,
Router: router,
}
s.setupRoutes()
return s
}
// setupRoutes configures all HTTP routes.
func (s *Server) setupRoutes() {
// Embedded static files
staticFS, _ := fs.Sub(web.StaticFS, "static")
s.Router.StaticFS("/static", http.FS(staticFS))
// API routes
s.Router.GET("/api/tasks", s.apiGetTasks)
s.Router.GET("/api/task/:id", s.apiGetTask)
s.Router.POST("/api/task/:id/move", s.apiMoveTask)
// HTML routes
s.Router.GET("/", s.handleDashboard)
s.Router.GET("/task/:id", s.handleTaskDetail)
s.Router.POST("/task/:id/move", s.handleMoveTask)
}
// apiGetTasks returns all tasks as JSON.
func (s *Server) apiGetTasks(c *gin.Context) {
tasks, err := supervisor.ScanTasks(s.Config.TasksDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resp := make([]taskResponse, len(tasks))
for i, t := range tasks {
resp[i] = toTaskResponse(t)
}
c.JSON(http.StatusOK, resp)
}
// apiGetTask returns a single task with its file content as JSON.
func (s *Server) apiGetTask(c *gin.Context) {
id := strings.ToUpper(c.Param("id"))
tasks, err := supervisor.ScanTasks(s.Config.TasksDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
task := supervisor.FindTask(tasks, id)
if task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
content, _ := os.ReadFile(task.FilePath)
resp := taskDetailResponse{
taskResponse: toTaskResponse(*task),
Content: string(content),
}
c.JSON(http.StatusOK, resp)
}
// apiMoveTask moves a task to a different folder.
func (s *Server) apiMoveTask(c *gin.Context) {
id := strings.ToUpper(c.Param("id"))
toFolder := c.Query("to")
if !validFolders[toFolder] {
c.JSON(http.StatusBadRequest, gin.H{"error": "nevažeći folder: " + toFolder})
return
}
tasks, err := supervisor.ScanTasks(s.Config.TasksDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
task := supervisor.FindTask(tasks, id)
if task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if err := supervisor.MoveTask(s.Config.TasksDir, id, task.Status, toFolder); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "moved": id, "to": toFolder})
}
// handleDashboard serves the main dashboard page.
func (s *Server) handleDashboard(c *gin.Context) {
tasks, err := supervisor.ScanTasks(s.Config.TasksDir)
if err != nil {
c.String(http.StatusInternalServerError, "Greška: %v", err)
return
}
columns := groupByStatus(tasks)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, renderDashboard(columns))
}
// handleTaskDetail serves task detail as HTML fragment for HTMX.
func (s *Server) handleTaskDetail(c *gin.Context) {
id := strings.ToUpper(c.Param("id"))
tasks, err := supervisor.ScanTasks(s.Config.TasksDir)
if err != nil {
c.String(http.StatusInternalServerError, "Greška: %v", err)
return
}
task := supervisor.FindTask(tasks, id)
if task == nil {
c.String(http.StatusNotFound, "Task %s nije pronađen", id)
return
}
content, _ := os.ReadFile(task.FilePath)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, renderTaskDetail(*task, string(content)))
}
// handleMoveTask moves a task and returns updated board HTML.
func (s *Server) handleMoveTask(c *gin.Context) {
id := strings.ToUpper(c.Param("id"))
toFolder := c.Query("to")
if !validFolders[toFolder] {
c.String(http.StatusBadRequest, "Nevažeći folder: %s", toFolder)
return
}
tasks, err := supervisor.ScanTasks(s.Config.TasksDir)
if err != nil {
c.String(http.StatusInternalServerError, "Greška: %v", err)
return
}
task := supervisor.FindTask(tasks, id)
if task == nil {
c.String(http.StatusNotFound, "Task %s nije pronađen", id)
return
}
if err := supervisor.MoveTask(s.Config.TasksDir, id, task.Status, toFolder); err != nil {
c.String(http.StatusInternalServerError, "Greška: %v", err)
return
}
// Re-scan and return updated dashboard
s.handleDashboard(c)
}
// Run starts the HTTP server.
func (s *Server) Run() error {
return s.Router.Run(":" + s.Config.Port)
}
// groupByStatus organizes tasks into columns by status folder.
func groupByStatus(tasks []supervisor.Task) map[string][]supervisor.Task {
columns := map[string][]supervisor.Task{
"backlog": {},
"ready": {},
"active": {},
"review": {},
"done": {},
}
for _, t := range tasks {
columns[t.Status] = append(columns[t.Status], t)
}
return columns
}
// reportExists checks if a report file exists for a task.
func reportExists(tasksDir, taskID string) bool {
reportPath := filepath.Join(tasksDir, "reports", taskID+"-report.md")
_, err := os.Stat(reportPath)
return err == nil
}
func toTaskResponse(t supervisor.Task) taskResponse {
deps := t.DependsOn
if deps == nil {
deps = []string{}
}
return taskResponse{
ID: t.ID,
Title: t.Title,
Status: t.Status,
Agent: t.Agent,
Model: t.Model,
DependsOn: deps,
Description: t.Description,
FilePath: t.FilePath,
}
}