Konzola: dinamičke task sesije sa PTY per task

- Zamena fiksnih 2 sesija sa taskSessionManager (map po task ID)
- "Pusti" pokreće interaktivni claude u PTY, šalje task prompt
- "Proveri" pokreće review claude sesiju za task u review/
- WS se konektuje na postojeću PTY sesiju po task ID-u
- Konzola stranica dinamički prikazuje terminale za aktivne sesije
- Replay buffer za reconnect na postojeće sesije
- Novi testovi za session manager, prompt buildere, review endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-21 04:32:34 +00:00
parent ac72ca6f52
commit 510b75c0bf
9 changed files with 594 additions and 582 deletions

View File

@ -1,385 +1,161 @@
package server package server
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec" "path/filepath"
"strconv"
"strings"
"sync" "sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// sessionState represents the state of a console session. // taskSession represents a PTY session tied to a specific task.
type sessionState struct { type taskSession struct {
mu sync.Mutex TaskID string
status string // "idle" or "running" Type string // "work" or "review"
cmd *exec.Cmd PTY *consolePTYSession
ptySess *consolePTYSession Started time.Time
execID string
taskID string // which task is being worked on (if any)
history []historyEntry
output []string
listeners map[chan string]bool
} }
// historyEntry represents a command in the session history. // taskSessionResponse is the JSON representation of a task session.
type historyEntry struct { type taskSessionResponse struct {
Command string `json:"command"` TaskID string `json:"task_id"`
ExecID string `json:"exec_id"` Type string `json:"type"`
Timestamp string `json:"timestamp"` Status string `json:"status"` // "running" or "exited"
Status string `json:"status"` // "running", "done", "error", "killed" Started string `json:"started"`
} }
// execRequest is the JSON body for starting a command. // taskSessionManager manages dynamic PTY sessions per task.
type execRequest struct { type taskSessionManager struct {
Cmd string `json:"cmd"` mu sync.RWMutex
Session int `json:"session"` sessions map[string]*taskSession
} }
// execResponse is the JSON response after starting a command. func newTaskSessionManager() *taskSessionManager {
type execResponse struct { return &taskSessionManager{
ExecID string `json:"exec_id"` sessions: make(map[string]*taskSession),
Session int `json:"session"`
}
// sessionStatus represents the status of a session for the API.
type sessionStatus struct {
Session int `json:"session"`
Status string `json:"status"`
TaskID string `json:"task_id,omitempty"`
ExecID string `json:"exec_id,omitempty"`
}
// consoleManager manages the two console sessions.
type consoleManager struct {
sessions [2]*sessionState
mu sync.Mutex
counter int
}
// newConsoleManager creates a new console manager with two idle sessions.
func newConsoleManager() *consoleManager {
return &consoleManager{
sessions: [2]*sessionState{
{status: "idle", listeners: make(map[chan string]bool)},
{status: "idle", listeners: make(map[chan string]bool)},
},
} }
} }
// nextExecID generates a unique execution ID. // sessionKey returns a unique key for a task session.
func (cm *consoleManager) nextExecID() string { func sessionKey(taskID, sessionType string) string {
cm.mu.Lock() if sessionType == "review" {
defer cm.mu.Unlock() return taskID + "-review"
cm.counter++ }
return fmt.Sprintf("exec-%d-%d", time.Now().Unix(), cm.counter) return taskID
} }
// getSession returns a session by index (0 or 1). // startSession spawns an interactive claude PTY and sends the task prompt.
func (cm *consoleManager) getSession(idx int) *sessionState { func (sm *taskSessionManager) startSession(taskID, sessionType, projectDir, prompt string) (*taskSession, error) {
if idx < 0 || idx > 1 { key := sessionKey(taskID, sessionType)
return nil
}
return cm.sessions[idx]
}
// handleConsoleExec starts a command in a session. sm.mu.Lock()
func (s *Server) handleConsoleExec(c *gin.Context) { if _, exists := sm.sessions[key]; exists {
var req execRequest sm.mu.Unlock()
if err := c.ShouldBindJSON(&req); err != nil { return nil, fmt.Errorf("sesija %s već postoji", key)
c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidan JSON: " + err.Error()})
return
} }
sm.mu.Unlock()
if req.Session < 1 || req.Session > 2 { ptySess, err := spawnTaskPTY(projectDir)
c.JSON(http.StatusBadRequest, gin.H{"error": "sesija mora biti 1 ili 2"})
return
}
sessionIdx := req.Session - 1
session := s.console.getSession(sessionIdx)
session.mu.Lock()
if session.status == "running" {
session.mu.Unlock()
c.JSON(http.StatusConflict, gin.H{"error": "sesija je zauzeta"})
return
}
execID := s.console.nextExecID()
session.status = "running"
session.execID = execID
session.output = nil
session.mu.Unlock()
// Add to history
entry := historyEntry{
Command: req.Cmd,
ExecID: execID,
Timestamp: timeNow(),
Status: "running",
}
session.mu.Lock()
session.history = append(session.history, entry)
if len(session.history) > 50 {
session.history = session.history[len(session.history)-50:]
}
session.mu.Unlock()
// Start the command in background
go s.runCommand(session, req.Cmd, execID)
c.JSON(http.StatusOK, execResponse{
ExecID: execID,
Session: req.Session,
})
}
// cleanEnv returns the current environment with CLAUDECODE removed,
// so child claude processes don't inherit the parent's session.
func cleanEnv() []string {
var env []string
for _, e := range os.Environ() {
if !strings.HasPrefix(e, "CLAUDECODE=") {
env = append(env, e)
}
}
return env
}
// runCommand spawns a PTY-backed claude CLI process and monitors it.
func (s *Server) runCommand(session *sessionState, command, execID string) {
ptySess, err := spawnConsolePTY(s.projectRoot(), command)
if err != nil { if err != nil {
log.Printf("PTY spawn error for %s: %v", execID, err) return nil, err
s.finishSession(session, execID, "error")
return
}
log.Printf("PTY spawned for %s (PID %d)", execID, ptySess.Cmd.Process.Pid)
session.mu.Lock()
session.cmd = ptySess.Cmd
session.ptySess = ptySess
session.mu.Unlock()
// Wait for process to exit
<-ptySess.Done()
status := "done"
if ptySess.Cmd.ProcessState != nil && !ptySess.Cmd.ProcessState.Success() {
status = "error"
}
log.Printf("PTY finished for %s (status: %s)", execID, status)
// Note: we do NOT clear session.ptySess here — the WS handler
// needs it for replay buffer even after the process exits.
// It gets replaced when a new command starts.
s.finishSession(session, execID, status)
} }
// sendToSession sends a line to all listeners and stores in output buffer. sess := &taskSession{
func (s *Server) sendToSession(session *sessionState, line string) { TaskID: taskID,
session.mu.Lock() Type: sessionType,
defer session.mu.Unlock() PTY: ptySess,
Started: time.Now(),
}
session.output = append(session.output, line) sm.mu.Lock()
sm.sessions[key] = sess
sm.mu.Unlock()
for ch := range session.listeners { log.Printf("Session[%s]: started (PID %d)", key, ptySess.Cmd.Process.Pid)
// Send the task prompt after claude initializes
go func() {
subID := fmt.Sprintf("init-%d", time.Now().UnixNano())
ch := ptySess.Subscribe(subID)
timer := time.NewTimer(30 * time.Second)
select { select {
case ch <- line: case <-ch:
default: // Claude is alive and producing output
} case <-timer.C:
} log.Printf("Session[%s]: timeout waiting for claude to start", key)
} case <-ptySess.Done():
log.Printf("Session[%s]: claude exited before producing output", key)
// finishSession marks a session as idle and notifies listeners. ptySess.Unsubscribe(subID)
func (s *Server) finishSession(session *sessionState, execID, status string) {
session.mu.Lock()
defer session.mu.Unlock()
session.status = "idle"
session.cmd = nil
// Update history entry status
for i := len(session.history) - 1; i >= 0; i-- {
if session.history[i].ExecID == execID {
session.history[i].Status = status
break
}
}
// Notify listeners that stream is done
for ch := range session.listeners {
select {
case ch <- "[DONE]":
default:
}
}
}
// handleConsoleStream serves an SSE stream for a command execution.
func (s *Server) handleConsoleStream(c *gin.Context) {
execID := c.Param("id")
// Find which session has this exec ID
var session *sessionState
for i := 0; i < 2; i++ {
sess := s.console.getSession(i)
sess.mu.Lock()
if sess.execID == execID {
session = sess
sess.mu.Unlock()
break
}
sess.mu.Unlock()
}
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"})
return return
} }
timer.Stop()
ptySess.Unsubscribe(subID)
// Set SSE headers // Let claude fully render its welcome screen
c.Header("Content-Type", "text/event-stream") time.Sleep(2 * time.Second)
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
// Create listener channel // Type the prompt
ch := make(chan string, 100) log.Printf("Session[%s]: sending prompt (%d bytes)", key, len(prompt))
ptySess.WriteInput([]byte(prompt + "\n"))
session.mu.Lock()
// Send buffered output first
for _, line := range session.output {
fmt.Fprintf(c.Writer, "data: %s\n\n", line)
}
c.Writer.Flush()
// If already done, send done event and return
if session.status == "idle" && session.execID == execID {
session.mu.Unlock()
fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n")
c.Writer.Flush()
return
}
session.listeners[ch] = true
session.mu.Unlock()
// Clean up on disconnect
defer func() {
session.mu.Lock()
delete(session.listeners, ch)
session.mu.Unlock()
}() }()
notify := c.Request.Context().Done() return sess, nil
}
for { // getSessionByKey returns a session by its full key.
func (sm *taskSessionManager) getSessionByKey(key string) *taskSession {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.sessions[key]
}
// listSessions returns all active sessions.
func (sm *taskSessionManager) listSessions() []taskSessionResponse {
sm.mu.RLock()
defer sm.mu.RUnlock()
result := make([]taskSessionResponse, 0, len(sm.sessions))
for _, sess := range sm.sessions {
status := "running"
select { select {
case <-notify: case <-sess.PTY.Done():
return status = "exited"
case line := <-ch: default:
if line == "[DONE]" {
fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n")
c.Writer.Flush()
return
}
fmt.Fprintf(c.Writer, "data: %s\n\n", line)
c.Writer.Flush()
}
}
} }
// handleConsoleKill kills the running process in a session. result = append(result, taskSessionResponse{
func (s *Server) handleConsoleKill(c *gin.Context) { TaskID: sess.TaskID,
sessionNum, err := strconv.Atoi(c.Param("session")) Type: sess.Type,
if err != nil || sessionNum < 1 || sessionNum > 2 { Status: status,
c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidna sesija"}) Started: sess.Started.Format("15:04:05"),
return })
}
return result
} }
session := s.console.getSession(sessionNum - 1) // killSession terminates a session and removes it.
func (sm *taskSessionManager) killSession(taskID, sessionType string) bool {
key := sessionKey(taskID, sessionType)
session.mu.Lock() sm.mu.Lock()
defer session.mu.Unlock() sess, exists := sm.sessions[key]
if exists {
delete(sm.sessions, key)
}
sm.mu.Unlock()
if session.status != "running" { if !exists {
c.JSON(http.StatusOK, gin.H{"status": "idle", "message": "sesija nije aktivna"}) return false
return
} }
// Close PTY session if it exists log.Printf("Session[%s]: killed", key)
if session.ptySess != nil { sess.PTY.Close()
session.ptySess.Close() return true
session.ptySess = nil
} else if session.cmd != nil && session.cmd.Process != nil {
session.cmd.Process.Kill()
}
// Update history
for i := len(session.history) - 1; i >= 0; i-- {
if session.history[i].ExecID == session.execID {
session.history[i].Status = "killed"
break
}
}
session.status = "idle"
session.cmd = nil
c.JSON(http.StatusOK, gin.H{"status": "killed"})
}
// handleConsoleSessions returns the status of both sessions.
func (s *Server) handleConsoleSessions(c *gin.Context) {
statuses := make([]sessionStatus, 2)
for i := 0; i < 2; i++ {
sess := s.console.getSession(i)
sess.mu.Lock()
statuses[i] = sessionStatus{
Session: i + 1,
Status: sess.status,
TaskID: sess.taskID,
ExecID: sess.execID,
}
sess.mu.Unlock()
}
c.JSON(http.StatusOK, statuses)
}
// handleConsoleHistory returns command history for a session.
func (s *Server) handleConsoleHistory(c *gin.Context) {
sessionNum, err := strconv.Atoi(c.Param("session"))
if err != nil || sessionNum < 1 || sessionNum > 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidna sesija"})
return
}
session := s.console.getSession(sessionNum - 1)
session.mu.Lock()
history := make([]historyEntry, len(session.history))
copy(history, session.history)
session.mu.Unlock()
data, _ := json.Marshal(history)
c.Header("Content-Type", "application/json")
c.String(http.StatusOK, string(data))
}
// timeNow returns the current time formatted as HH:MM:SS.
func timeNow() string {
return time.Now().Format("15:04:05")
} }
// handleConsolePage serves the console HTML page. // handleConsolePage serves the console HTML page.
@ -387,3 +163,106 @@ func (s *Server) handleConsolePage(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, renderConsolePage()) c.String(http.StatusOK, renderConsolePage())
} }
// handleConsoleSessions returns all active task sessions as JSON.
func (s *Server) handleConsoleSessions(c *gin.Context) {
sessions := s.console.listSessions()
c.JSON(http.StatusOK, sessions)
}
// handleConsoleKill kills a task session.
func (s *Server) handleConsoleKill(c *gin.Context) {
taskID := c.Param("taskID")
sessionType := c.DefaultQuery("type", "work")
if s.console.killSession(taskID, sessionType) {
c.JSON(http.StatusOK, gin.H{"status": "killed", "task": taskID})
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"})
}
}
// buildWorkPrompt builds the prompt for a work session.
func buildWorkPrompt(taskID string, taskContent []byte) string {
return fmt.Sprintf(`Radiš na tasku %s. Evo sadržaj taska:
%s
PRAVILA:
1. Pročitaj agents/coder/CLAUDE.md za pravila kodiranja
2. Kod piši u code/ folderu
3. Svi testovi moraju proći: go test ./... -count=1
4. Build mora proći: go build ./...
5. go vet ./... mora proći
6. Commituj sa porukom: %s: Opis na srpskom
7. Napiši izveštaj u TASKS/reports/%s-report.md
8. Premesti task fajl: mv TASKS/active/%s.md TASKS/review/%s.md
9. Kada sve završiš, reci "GOTOVO"`, taskID, string(taskContent), taskID, taskID, taskID, taskID)
}
// buildReviewPrompt builds the prompt for a review session.
func buildReviewPrompt(taskID string, taskContent, reportContent []byte) string {
prompt := fmt.Sprintf(`Pregledaj task %s. Evo sadržaj taska:
%s
`, taskID, string(taskContent))
if len(reportContent) > 0 {
prompt += fmt.Sprintf(`
Izveštaj agenta:
%s
`, string(reportContent))
}
prompt += fmt.Sprintf(`
KORACI PREGLEDA:
1. Pročitaj agents/checker/CLAUDE.md za pravila
2. Proveri da li je kod napisan prema zadatku
3. Pokreni testove: go test ./... -count=1
4. Pokreni build: go build ./...
5. Pokreni vet: go vet ./...
6. Ako je SVE u redu:
- Premesti task: mv TASKS/review/%s.md TASKS/done/%s.md
- Reci "ODOBRENO"
7. Ako NIJE u redu:
- Zapiši šta treba popraviti u task fajl
- Premesti task: mv TASKS/review/%s.md TASKS/active/%s.md
- Reci "VRAĆENO NA DORADU"`, taskID, taskID, taskID, taskID)
return prompt
}
// handleReviewTask launches a review claude session for a task.
func (s *Server) handleReviewTask(c *gin.Context) {
id := c.Param("id")
// Read task content
taskPath := filepath.Join(s.Config.TasksDir, "review", id+".md")
taskContent, err := os.ReadFile(taskPath)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task nije pronađen u review/"})
return
}
// Read report if exists
reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md")
reportContent, _ := os.ReadFile(reportPath)
// Build review prompt
prompt := buildReviewPrompt(id, taskContent, reportContent)
// Start review session
_, err = s.console.startSession(id, "review", s.projectRoot(), prompt)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "started", "task": id, "type": "review"})
}
// timeNow returns the current time formatted as HH:MM:SS.
func timeNow() string {
return time.Now().Format("15:04:05")
}

View File

@ -79,19 +79,20 @@ type consolePTYSession struct {
lastActive time.Time lastActive time.Time
} }
// spawnConsolePTY starts a new claude CLI in a PTY for the console. // spawnTaskPTY starts an interactive claude CLI with auto-permissions in a PTY.
func spawnConsolePTY(projectDir, prompt string) (*consolePTYSession, error) { // Used for task work and review sessions. The prompt is sent after startup.
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt) func spawnTaskPTY(projectDir string) (*consolePTYSession, error) {
cmd := exec.Command("claude", "--permission-mode", "dontAsk")
cmd.Dir = projectDir cmd.Dir = projectDir
cmd.Env = cleanEnvForPTY() cmd.Env = cleanEnvForPTY()
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 120}) ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 50, Cols: 180})
if err != nil { if err != nil {
return nil, fmt.Errorf("start pty: %w", err) return nil, fmt.Errorf("start pty: %w", err)
} }
sess := &consolePTYSession{ sess := &consolePTYSession{
ID: fmt.Sprintf("pty-%d", time.Now().UnixNano()), ID: fmt.Sprintf("task-%d", time.Now().UnixNano()),
Ptmx: ptmx, Ptmx: ptmx,
Cmd: cmd, Cmd: cmd,
buffer: NewRingBuffer(outputBufferSize), buffer: NewRingBuffer(outputBufferSize),

View File

@ -4,6 +4,7 @@ package server
import ( import (
"fmt" "fmt"
"io/fs" "io/fs"
"log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -22,7 +23,7 @@ import (
type Server struct { type Server struct {
Config *config.Config Config *config.Config
Router *gin.Engine Router *gin.Engine
console *consoleManager console *taskSessionManager
events *eventBroker events *eventBroker
chatMu sync.RWMutex chatMu sync.RWMutex
chats map[string]*chatState chats map[string]*chatState
@ -73,7 +74,7 @@ func New(cfg *config.Config) *Server {
s := &Server{ s := &Server{
Config: cfg, Config: cfg,
Router: router, Router: router,
console: newConsoleManager(), console: newTaskSessionManager(),
events: newEventBroker(cfg.TasksDir), events: newEventBroker(cfg.TasksDir),
chats: make(map[string]*chatState), chats: make(map[string]*chatState),
} }
@ -106,6 +107,7 @@ func (s *Server) setupRoutes() {
s.Router.GET("/task/:id", s.handleTaskDetail) s.Router.GET("/task/:id", s.handleTaskDetail)
s.Router.POST("/task/:id/move", s.handleMoveTask) s.Router.POST("/task/:id/move", s.handleMoveTask)
s.Router.POST("/task/:id/run", s.handleRunTask) s.Router.POST("/task/:id/run", s.handleRunTask)
s.Router.POST("/task/:id/review", s.handleReviewTask)
s.Router.GET("/report/:id", s.handleReport) s.Router.GET("/report/:id", s.handleReport)
// SSE events // SSE events
@ -116,12 +118,9 @@ func (s *Server) setupRoutes() {
// Console routes // Console routes
s.Router.GET("/console", s.handleConsolePage) s.Router.GET("/console", s.handleConsolePage)
s.Router.POST("/console/exec", s.handleConsoleExec)
s.Router.GET("/console/stream/:id", s.handleConsoleStream)
s.Router.POST("/console/kill/:session", s.handleConsoleKill)
s.Router.GET("/console/sessions", s.handleConsoleSessions) s.Router.GET("/console/sessions", s.handleConsoleSessions)
s.Router.GET("/console/history/:session", s.handleConsoleHistory) s.Router.POST("/console/kill/:taskID", s.handleConsoleKill)
s.Router.GET("/console/ws/:session", s.handleConsoleWS) s.Router.GET("/console/ws/:key", s.handleConsoleWS)
// Logs route // Logs route
s.Router.GET("/api/logs/tail", s.handleLogsTail) s.Router.GET("/api/logs/tail", s.handleLogsTail)
@ -359,9 +358,19 @@ func (s *Server) handleRunTask(c *gin.Context) {
taskPath := filepath.Join(s.Config.TasksDir, "active", id+".md") taskPath := filepath.Join(s.Config.TasksDir, "active", id+".md")
appendTimestamp(taskPath, "Pokrenut (→active)") appendTimestamp(taskPath, "Pokrenut (→active)")
// Read task content and spawn a claude work session
taskContent, _ := os.ReadFile(taskPath)
prompt := buildWorkPrompt(id, taskContent)
_, err = s.console.startSession(id, "work", s.projectRoot(), prompt)
if err != nil {
log.Printf("Warning: session start failed for %s: %v", id, err)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": "started", "status": "started",
"task": id, "task": id,
"session": id,
}) })
} }

View File

@ -1098,15 +1098,15 @@ func TestConsolePage(t *testing.T) {
} }
body := w.Body.String() body := w.Body.String()
if !containsStr(body, "Sesija 1") { if !containsStr(body, "KAOS") {
t.Error("expected 'Sesija 1' in console page") t.Error("expected 'KAOS' in console page")
} }
if !containsStr(body, "Sesija 2") { if !containsStr(body, "Konzola") {
t.Error("expected 'Sesija 2' in console page") t.Error("expected 'Konzola' in console page")
} }
} }
func TestConsoleSessions(t *testing.T) { func TestConsoleSessions_Empty(t *testing.T) {
srv := setupTestServer(t) srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/console/sessions", nil) req := httptest.NewRequest(http.MethodGet, "/console/sessions", nil)
@ -1117,115 +1117,25 @@ func TestConsoleSessions(t *testing.T) {
t.Fatalf("expected 200, got %d", w.Code) t.Fatalf("expected 200, got %d", w.Code)
} }
var statuses []sessionStatus var sessions []taskSessionResponse
if err := json.Unmarshal(w.Body.Bytes(), &statuses); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &sessions); err != nil {
t.Fatalf("invalid JSON: %v", err) t.Fatalf("invalid JSON: %v", err)
} }
if len(statuses) != 2 { if len(sessions) != 0 {
t.Fatalf("expected 2 sessions, got %d", len(statuses)) t.Fatalf("expected 0 sessions, got %d", len(sessions))
}
if statuses[0].Status != "idle" || statuses[1].Status != "idle" {
t.Error("expected both sessions idle")
} }
} }
func TestConsoleExec_InvalidSession(t *testing.T) { func TestConsoleKill_NotFound(t *testing.T) {
srv := setupTestServer(t) srv := setupTestServer(t)
body := `{"cmd":"status","session":3}` req := httptest.NewRequest(http.MethodPost, "/console/kill/T99", nil)
req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req) srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest { if w.Code != http.StatusNotFound {
t.Fatalf("expected 400 for invalid session, got %d", w.Code) t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestConsoleExec_ValidRequest(t *testing.T) {
srv := setupTestServer(t)
body := `{"cmd":"echo test","session":1}`
req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp execResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp.ExecID == "" {
t.Error("expected non-empty exec ID")
}
if resp.Session != 1 {
t.Errorf("expected session 1, got %d", resp.Session)
}
}
func TestConsoleKill_IdleSession(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/console/kill/1", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestConsoleHistory_Empty(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/console/history/1", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var history []historyEntry
if err := json.Unmarshal(w.Body.Bytes(), &history); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(history) != 0 {
t.Errorf("expected empty history, got %d entries", len(history))
}
}
func TestConsoleHistory_AfterExec(t *testing.T) {
srv := setupTestServer(t)
// Execute a command first
body := `{"cmd":"test command","session":2}`
req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
// Check history
req2 := httptest.NewRequest(http.MethodGet, "/console/history/2", nil)
w2 := httptest.NewRecorder()
srv.Router.ServeHTTP(w2, req2)
var history []historyEntry
json.Unmarshal(w2.Body.Bytes(), &history)
if len(history) != 1 {
t.Fatalf("expected 1 history entry, got %d", len(history))
}
if history[0].Command != "test command" {
t.Errorf("expected 'test command', got %s", history[0].Command)
} }
} }
@ -1635,7 +1545,7 @@ func TestConsolePage_ToolbarAbovePanels(t *testing.T) {
} }
} }
func TestConsolePage_HasSessionToggle(t *testing.T) { func TestConsolePage_HasDynamicSessions(t *testing.T) {
srv := setupTestServer(t) srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/console", nil) req := httptest.NewRequest(http.MethodGet, "/console", nil)
@ -1643,11 +1553,11 @@ func TestConsolePage_HasSessionToggle(t *testing.T) {
srv.Router.ServeHTTP(w, req) srv.Router.ServeHTTP(w, req)
body := w.Body.String() body := w.Body.String()
if !containsStr(body, "togglePanel2") { if !containsStr(body, "refreshSessions") {
t.Error("expected togglePanel2 button in console page") t.Error("expected refreshSessions function in console page")
} }
if !containsStr(body, `+ Sesija 2`) { if !containsStr(body, "/console/sessions") {
t.Error("expected '+ Sesija 2' toggle button") t.Error("expected /console/sessions API call")
} }
} }
@ -2070,15 +1980,15 @@ func TestConsolePage_HasWebSocket(t *testing.T) {
srv.Router.ServeHTTP(w, req) srv.Router.ServeHTTP(w, req)
body := w.Body.String() body := w.Body.String()
if !containsStr(body, "/console/ws/") { if !containsStr(body, "console/ws/") {
t.Error("expected WebSocket URL /console/ws/ in console page") t.Error("expected WebSocket URL console/ws/ in console page")
} }
if !containsStr(body, "new WebSocket") { if !containsStr(body, "new WebSocket") {
t.Error("expected WebSocket constructor in console page") t.Error("expected WebSocket constructor in console page")
} }
} }
func TestConsolePage_HasTerminalContainers(t *testing.T) { func TestConsolePage_HasEmptyState(t *testing.T) {
srv := setupTestServer(t) srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/console", nil) req := httptest.NewRequest(http.MethodGet, "/console", nil)
@ -2086,14 +1996,14 @@ func TestConsolePage_HasTerminalContainers(t *testing.T) {
srv.Router.ServeHTTP(w, req) srv.Router.ServeHTTP(w, req)
body := w.Body.String() body := w.Body.String()
if !containsStr(body, `id="terminal-1"`) { if !containsStr(body, "empty-state") {
t.Error("expected terminal-1 container") t.Error("expected empty-state element")
} }
if !containsStr(body, `id="terminal-2"`) { if !containsStr(body, "Pusti") {
t.Error("expected terminal-2 container") t.Error("expected 'Pusti' instruction in empty state")
} }
if !containsStr(body, "console-terminal") { if !containsStr(body, "console-terminal") {
t.Error("expected console-terminal class") t.Error("expected console-terminal class in JS code")
} }
} }
@ -2128,3 +2038,128 @@ func TestConsolePage_HasResizeHandler(t *testing.T) {
t.Error("expected resize message type in WebSocket handler") t.Error("expected resize message type in WebSocket handler")
} }
} }
// ── Task session manager tests ──────────────────────
func TestSessionKey(t *testing.T) {
if got := sessionKey("T01", "work"); got != "T01" {
t.Errorf("expected T01, got %s", got)
}
if got := sessionKey("T01", "review"); got != "T01-review" {
t.Errorf("expected T01-review, got %s", got)
}
}
func TestTaskSessionManager_ListEmpty(t *testing.T) {
sm := newTaskSessionManager()
sessions := sm.listSessions()
if len(sessions) != 0 {
t.Errorf("expected 0 sessions, got %d", len(sessions))
}
}
func TestTaskSessionManager_KillNotFound(t *testing.T) {
sm := newTaskSessionManager()
if sm.killSession("T99", "work") {
t.Error("expected false for non-existent session")
}
}
func TestTaskSessionManager_GetNotFound(t *testing.T) {
sm := newTaskSessionManager()
if sm.getSessionByKey("T99") != nil {
t.Error("expected nil for non-existent session")
}
}
func TestBuildWorkPrompt(t *testing.T) {
prompt := buildWorkPrompt("T08", []byte("# T08: Test task\n\nOpis."))
if !containsStr(prompt, "T08") {
t.Error("expected task ID in prompt")
}
if !containsStr(prompt, "Test task") {
t.Error("expected task content in prompt")
}
if !containsStr(prompt, "agents/coder/CLAUDE.md") {
t.Error("expected coder CLAUDE.md reference")
}
if !containsStr(prompt, "go test") {
t.Error("expected test instruction")
}
if !containsStr(prompt, "report") {
t.Error("expected report instruction")
}
}
func TestBuildReviewPrompt(t *testing.T) {
prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), []byte("# Report\nSve ok."))
if !containsStr(prompt, "T08") {
t.Error("expected task ID in review prompt")
}
if !containsStr(prompt, "Sve ok") {
t.Error("expected report content in review prompt")
}
if !containsStr(prompt, "agents/checker/CLAUDE.md") {
t.Error("expected checker CLAUDE.md reference")
}
}
func TestBuildReviewPrompt_NoReport(t *testing.T) {
prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), nil)
if !containsStr(prompt, "T08") {
t.Error("expected task ID")
}
if containsStr(prompt, "Izveštaj agenta") {
t.Error("should not include report section when no report")
}
}
func TestReviewTask_NotFound(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/task/T99/review", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestConsolePage_HasKillButton(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/console", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "killSession") {
t.Error("expected killSession function in console page")
}
}
func TestTaskDetail_HasProveriButton(t *testing.T) {
srv := setupTestServer(t)
// Put T08 in review
os.Rename(
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
filepath.Join(srv.Config.TasksDir, "review", "T08.md"),
)
req := httptest.NewRequest(http.MethodGet, "/task/T08", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "Proveri") {
t.Error("expected 'Proveri' button for review task")
}
if !containsStr(body, "/review") {
t.Error("expected /review endpoint in Proveri button")
}
}

View File

@ -12,6 +12,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -19,6 +20,26 @@ import (
"github.com/dal/kaos/internal/supervisor" "github.com/dal/kaos/internal/supervisor"
) )
// chatCounter generates unique chat IDs.
var chatCounter atomic.Int64
func nextChatID() string {
chatCounter.Add(1)
return fmt.Sprintf("chat-%d-%d", time.Now().Unix(), chatCounter.Load())
}
// cleanEnv returns the current environment with CLAUDECODE removed,
// so child claude processes don't inherit the parent's session.
func cleanEnv() []string {
var env []string
for _, e := range os.Environ() {
if !strings.HasPrefix(e, "CLAUDECODE=") {
env = append(env, e)
}
}
return env
}
// chatState manages an operator chat session backed by a claude CLI process. // chatState manages an operator chat session backed by a claude CLI process.
type chatState struct { type chatState struct {
mu sync.Mutex mu sync.Mutex
@ -136,7 +157,7 @@ func (s *Server) handleChatSubmit(c *gin.Context) {
string(claudeMD), context, req.Message) string(claudeMD), context, req.Message)
// Create chat session // Create chat session
chatID := s.console.nextExecID() chatID := nextChatID()
chat := &chatState{ chat := &chatState{
id: chatID, id: chatID,
listeners: make(map[chan string]bool), listeners: make(map[chan string]bool),

View File

@ -2,10 +2,8 @@ package server
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@ -24,37 +22,40 @@ type wsResizeMsg struct {
Rows uint16 `json:"rows"` Rows uint16 `json:"rows"`
} }
// handleConsoleWS handles WebSocket connections for console terminals. // handleConsoleWS handles WebSocket connections for task console terminals.
// Each connection gets its own independent shell PTY session. // Each connection attaches to an existing PTY session by task key.
func (s *Server) handleConsoleWS(c *gin.Context) { func (s *Server) handleConsoleWS(c *gin.Context) {
sessionNum := c.Param("session") key := c.Param("key") // e.g., "T08" or "T08-review"
if sessionNum != "1" && sessionNum != "2" {
c.JSON(http.StatusBadRequest, gin.H{"error": "sesija mora biti 1 ili 2"}) sess := s.console.getSessionByKey(key)
if sess == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"})
return return
} }
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
log.Printf("WebSocket upgrade error: %v", err) log.Printf("WS[%s]: upgrade error: %v", key, err)
return return
} }
defer conn.Close() defer conn.Close()
log.Printf("WS[%s]: connected, spawning shell", sessionNum) log.Printf("WS[%s]: connected", key)
// Spawn a fresh interactive shell for this connection ptySess := sess.PTY
ptySess, err := spawnShellPTY(s.projectRoot())
if err != nil { // Send replay buffer so the user sees existing output
log.Printf("WS[%s]: shell spawn error: %v", sessionNum, err) replay := ptySess.GetBuffer()
conn.WriteMessage(websocket.TextMessage, if len(replay) > 0 {
[]byte(fmt.Sprintf("\r\n\033[31m[Greška: %v]\033[0m\r\n", err))) if err := conn.WriteMessage(websocket.BinaryMessage, replay); err != nil {
log.Printf("WS[%s]: replay write error: %v", key, err)
return return
} }
defer ptySess.Close() log.Printf("WS[%s]: replayed %d bytes", key, len(replay))
}
log.Printf("WS[%s]: shell started (PID %d)", sessionNum, ptySess.Cmd.Process.Pid) // Subscribe to new PTY output
subID := key + "-ws"
subID := fmt.Sprintf("ws-%d", time.Now().UnixNano())
outputCh := ptySess.Subscribe(subID) outputCh := ptySess.Subscribe(subID)
defer ptySess.Unsubscribe(subID) defer ptySess.Unsubscribe(subID)
@ -87,7 +88,7 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
go func() { go func() {
select { select {
case <-ptySess.Done(): case <-ptySess.Done():
log.Printf("WS[%s]: shell exited", sessionNum) log.Printf("WS[%s]: process exited", key)
conn.WriteMessage(websocket.CloseMessage, conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done")) websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"))
case <-stopCh: case <-stopCh:
@ -99,7 +100,7 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
_, msg, err := conn.ReadMessage() _, msg, err := conn.ReadMessage()
if err != nil { if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Printf("WS[%s]: read error: %v", sessionNum, err) log.Printf("WS[%s]: read error: %v", key, err)
} }
break break
} }
@ -108,14 +109,14 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
var resize wsResizeMsg var resize wsResizeMsg
if json.Unmarshal(msg, &resize) == nil && resize.Type == "resize" && resize.Cols > 0 && resize.Rows > 0 { if json.Unmarshal(msg, &resize) == nil && resize.Type == "resize" && resize.Cols > 0 && resize.Rows > 0 {
if err := ptySess.Resize(resize.Rows, resize.Cols); err != nil { if err := ptySess.Resize(resize.Rows, resize.Cols); err != nil {
log.Printf("WS[%s]: resize error: %v", sessionNum, err) log.Printf("WS[%s]: resize error: %v", key, err)
} }
continue continue
} }
// Regular keyboard input → PTY // Regular keyboard input → PTY
if _, err := ptySess.WriteInput(msg); err != nil { if _, err := ptySess.WriteInput(msg); err != nil {
log.Printf("WS[%s]: write error: %v", sessionNum, err) log.Printf("WS[%s]: write error: %v", key, err)
break break
} }
} }
@ -123,5 +124,5 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
close(stopCh) close(stopCh)
close(writeCh) close(writeCh)
<-writeDone <-writeDone
log.Printf("WS[%s]: disconnected", sessionNum) log.Printf("WS[%s]: disconnected", key)
} }

View File

@ -13,7 +13,7 @@
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<h1>🔧 KAOS Dashboard</h1> <h1>KAOS Dashboard</h1>
<div class="header-right"> <div class="header-right">
<div class="theme-toggle"> <div class="theme-toggle">
<button class="theme-btn" data-theme-mode="light" onclick="setTheme('light')" title="Svetla tema">☀️</button> <button class="theme-btn" data-theme-mode="light" onclick="setTheme('light')" title="Svetla tema">☀️</button>
@ -31,24 +31,11 @@
<div class="console-container"> <div class="console-container">
<div class="console-toolbar"> <div class="console-toolbar">
<button class="btn" id="toggle-panel" onclick="togglePanel2()">+ Sesija 2</button> <span id="session-info">Sesije: 0</span>
</div> </div>
<div class="console-panels" id="panels">
<div class="console-panels"> <div class="console-empty" id="empty-state">
<div class="console-panel" id="panel-1"> <p>Nema aktivnih sesija. Kliknite "Pusti" na tasku da pokrenete rad.</p>
<div class="console-panel-header">
<span>🔧 Sesija 1</span>
<span class="session-status session-running" id="status-1">connected</span>
</div>
<div class="console-terminal" id="terminal-1"></div>
</div>
<div class="console-panel" id="panel-2" style="display:none">
<div class="console-panel-header">
<span>🔧 Sesija 2</span>
<span class="session-status" id="status-2">idle</span>
</div>
<div class="console-terminal" id="terminal-2"></div>
</div> </div>
</div> </div>
</div> </div>
@ -83,13 +70,38 @@ function getTermTheme() {
} }
// ── Session state ──────────────────────────────────── // ── Session state ────────────────────────────────────
var sessions = [{}, {}]; var terminals = {};
function sessionKey(sess) {
return sess.type === 'review' ? sess.task_id + '-review' : sess.task_id;
}
function createTerminal(sess) {
var key = sessionKey(sess);
if (terminals[key]) return;
document.getElementById('empty-state').style.display = 'none';
var panel = document.createElement('div');
panel.className = 'console-panel';
panel.id = 'panel-' + key;
var header = document.createElement('div');
header.className = 'console-panel-header';
var label = sess.task_id + (sess.type === 'review' ? ' [pregled]' : ' [rad]');
header.innerHTML = '<span>' + label + '</span>' +
'<span class="session-status session-running" id="status-' + key + '">' + sess.status + '</span>' +
'<button class="btn btn-sm" onclick="killSession(\'' + sess.task_id + '\', \'' + sess.type + '\')">Ugasi</button>';
var termDiv = document.createElement('div');
termDiv.className = 'console-terminal';
termDiv.id = 'terminal-' + key;
panel.appendChild(header);
panel.appendChild(termDiv);
document.getElementById('panels').appendChild(panel);
function initTerminal(idx) {
var num = idx + 1;
var containerEl = document.getElementById('terminal-' + num);
var theme = getTermTheme(); var theme = getTermTheme();
var term = new Terminal({ var term = new Terminal({
cursorBlink: true, cursorBlink: true,
cursorStyle: 'block', cursorStyle: 'block',
@ -106,41 +118,22 @@ function initTerminal(idx) {
var fitAddon = new FitAddon.FitAddon(); var fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon.WebLinksAddon()); term.loadAddon(new WebLinksAddon.WebLinksAddon());
term.open(containerEl); term.open(termDiv);
// Keyboard input → WebSocket terminals[key] = { term: term, fitAddon: fitAddon, ws: null };
term.onData(function(data) {
var ws = sessions[idx].ws;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Resize → WebSocket termDiv.addEventListener('click', function() { term.focus(); });
term.onResize(function(size) {
var ws = sessions[idx].ws;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
}
});
containerEl.addEventListener('click', function() { term.focus(); });
sessions[idx].term = term;
sessions[idx].fitAddon = fitAddon;
sessions[idx].ws = null;
setTimeout(function() { setTimeout(function() {
fitAddon.fit(); fitAddon.fit();
// Auto-connect WebSocket immediately connectWS(key, term);
connectWS(idx);
}, 100); }, 100);
} }
// ── WebSocket connection ───────────────────────────── // ── WebSocket connection ─────────────────────────────
function connectWS(idx) { function connectWS(key, term) {
var num = idx + 1; var sess = terminals[key];
var sess = sessions[idx]; if (!sess) return;
if (sess.ws) { if (sess.ws) {
sess.ws.close(); sess.ws.close();
@ -148,62 +141,95 @@ function connectWS(idx) {
} }
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var url = proto + '//' + location.host + '/console/ws/' + num; var url = proto + '//' + location.host + '/console/ws/' + key;
var ws = new WebSocket(url); var ws = new WebSocket(url);
ws.binaryType = 'arraybuffer'; ws.binaryType = 'arraybuffer';
ws.onopen = function() { ws.onopen = function() {
setSessionUI(num, 'connected'); var el = document.getElementById('status-' + key);
var term = sess.term; if (el) { el.textContent = 'connected'; el.className = 'session-status session-running'; }
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
term.focus(); term.focus();
}; };
ws.onmessage = function(event) { ws.onmessage = function(event) {
if (event.data instanceof ArrayBuffer) { if (event.data instanceof ArrayBuffer) {
sess.term.write(new Uint8Array(event.data)); term.write(new Uint8Array(event.data));
} else { } else {
sess.term.write(event.data); term.write(event.data);
} }
}; };
ws.onclose = function() { ws.onclose = function() {
sess.ws = null; sess.ws = null;
setSessionUI(num, 'disconnected'); var el = document.getElementById('status-' + key);
if (el) { el.textContent = 'disconnected'; el.className = 'session-status'; }
}; };
ws.onerror = function() { ws.onerror = function() {
sess.ws = null; sess.ws = null;
setSessionUI(num, 'error');
}; };
// Keyboard input → WebSocket
term.onData(function(data) {
if (sess.ws && sess.ws.readyState === WebSocket.OPEN) {
sess.ws.send(data);
}
});
// Resize → WebSocket
term.onResize(function(size) {
if (sess.ws && sess.ws.readyState === WebSocket.OPEN) {
sess.ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
}
});
sess.ws = ws; sess.ws = ws;
} }
// ── UI helpers ─────────────────────────────────────── // ── Session management ───────────────────────────────
function setSessionUI(session, status) { function killSession(taskID, type) {
var el = document.getElementById('status-' + session); fetch('/console/kill/' + taskID + '?type=' + type, { method: 'POST' })
el.textContent = status; .then(function(resp) { return resp.json(); })
el.className = 'session-status'; .then(function() { refreshSessions(); });
if (status === 'connected') el.className += ' session-running';
} }
function togglePanel2() { function refreshSessions() {
var panel = document.getElementById('panel-2'); fetch('/console/sessions')
var btn = document.getElementById('toggle-panel'); .then(function(resp) { return resp.json(); })
if (panel.style.display === 'none') { .then(function(sessions) {
panel.style.display = 'flex'; var info = document.getElementById('session-info');
btn.textContent = '- Sesija 2'; info.textContent = 'Sesije: ' + sessions.length;
if (!sessions[1].term) {
initTerminal(1); if (sessions.length === 0) {
document.getElementById('empty-state').style.display = 'block';
} else { } else {
sessions[1].fitAddon.fit(); document.getElementById('empty-state').style.display = 'none';
if (!sessions[1].ws) connectWS(1);
} }
} else {
panel.style.display = 'none'; var currentKeys = {};
btn.textContent = '+ Sesija 2'; sessions.forEach(function(sess) {
var key = sessionKey(sess);
currentKeys[key] = true;
createTerminal(sess);
var el = document.getElementById('status-' + key);
if (el && sess.status === 'exited') {
el.textContent = 'finished';
el.className = 'session-status session-done';
} }
});
// Remove panels for sessions that no longer exist
Object.keys(terminals).forEach(function(key) {
if (!currentKeys[key]) {
var panel = document.getElementById('panel-' + key);
if (panel) panel.remove();
if (terminals[key].ws) terminals[key].ws.close();
delete terminals[key];
}
});
});
} }
// ── Theme sync ─────────────────────────────────────── // ── Theme sync ───────────────────────────────────────
@ -212,25 +238,26 @@ window.setTheme = function(mode) {
if (origSetTheme) origSetTheme(mode); if (origSetTheme) origSetTheme(mode);
setTimeout(function() { setTimeout(function() {
var theme = getTermTheme(); var theme = getTermTheme();
for (var i = 0; i < 2; i++) { Object.keys(terminals).forEach(function(key) {
if (sessions[i].term) { if (terminals[key].term) {
sessions[i].term.options.theme = theme; terminals[key].term.options.theme = theme;
}
} }
});
}, 50); }, 50);
}; };
// ── Window resize ──────────────────────────────────── // ── Window resize ────────────────────────────────────
window.addEventListener('resize', function() { window.addEventListener('resize', function() {
for (var i = 0; i < 2; i++) { Object.keys(terminals).forEach(function(key) {
if (sessions[i].fitAddon) { if (terminals[key].fitAddon) {
sessions[i].fitAddon.fit(); terminals[key].fitAddon.fit();
}
} }
}); });
});
// ── Initialize ─────────────────────────────────────── // ── Initialize ───────────────────────────────────────
initTerminal(0); refreshSessions();
setInterval(refreshSessions, 5000);
</script> </script>
</body> </body>
</html> </html>

View File

@ -34,6 +34,7 @@
<a href="/docs" class="btn">Dokumenti</a> <a href="/docs" class="btn">Dokumenti</a>
<a href="/console" class="btn">Konzola</a> <a href="/console" class="btn">Konzola</a>
<a href="/submit" class="btn">Prijava</a> <a href="/submit" class="btn">Prijava</a>
<a href="#" class="btn" onclick="showLogs(); return false;">Logovi</a>
</nav> </nav>
</div> </div>
</div> </div>
@ -70,6 +71,22 @@ document.getElementById('task-detail').addEventListener('click', function(e) {
} }
}); });
function showLogs() {
fetch('/api/logs/tail')
.then(function(resp) { return resp.text(); })
.then(function(text) {
var el = document.getElementById('task-detail');
el.innerHTML = '<div class="detail-overlay"><div class="detail-header"><h2>Poslednji logovi</h2><button class="detail-close" onclick="closeDetail()">&times;</button></div><div class="detail-body"><pre style="white-space:pre-wrap;font-size:13px;line-height:1.5">' + escapeHtml(text) + '</pre></div></div>';
el.classList.add('active');
});
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(msg, type) { function showToast(msg, type) {
var toast = document.getElementById('toast'); var toast = document.getElementById('toast');
toast.textContent = msg; toast.textContent = msg;
@ -79,18 +96,39 @@ function showToast(msg, type) {
}, 2000); }, 2000);
} }
// Handle "Proveri" button response
document.body.addEventListener('htmx:afterRequest', function(e) {
if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/review/)) {
var xhr = e.detail.xhr;
if (xhr.status === 200) {
showToast('Pregled pokrenut', 'success');
setTimeout(function() { window.location.href = '/console'; }, 800);
} else {
try {
var data = JSON.parse(xhr.responseText);
showToast(data.error || 'Greška', 'error');
} catch(ex) {
showToast('Greška', 'error');
}
}
}
});
// Handle "Pusti" button response // Handle "Pusti" button response
document.body.addEventListener('htmx:afterRequest', function(e) { document.body.addEventListener('htmx:afterRequest', function(e) {
if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/run/)) { if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/run/)) {
var xhr = e.detail.xhr; var xhr = e.detail.xhr;
if (xhr.status === 200) { if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText); var data = JSON.parse(xhr.responseText);
showToast(data.exec_id ? 'Pokrenuto u sesiji ' + data.session : 'Pokrenuto', 'success'); showToast('Pokrenuto: ' + (data.task || ''), 'success');
// Redirect to console after short delay so user can see output
setTimeout(function() { window.location.href = '/console'; }, 800); setTimeout(function() { window.location.href = '/console'; }, 800);
} else { } else {
try {
var data = JSON.parse(xhr.responseText); var data = JSON.parse(xhr.responseText);
showToast(data.error || 'Greška', 'error'); showToast(data.error || 'Greška', 'error');
} catch(ex) {
showToast('Greška', 'error');
}
} }
htmx.ajax('GET', '/', {target: '#board', swap: 'outerHTML', select: '#board'}); htmx.ajax('GET', '/', {target: '#board', swap: 'outerHTML', select: '#board'});
} }

View File

@ -10,7 +10,7 @@
</div> </div>
{{if .HasReport}} {{if .HasReport}}
<div class="detail-actions"> <div class="detail-actions">
<a href="/report/{{.Task.ID}}" class="btn" target="_blank">Izvestaj</a> <button class="btn btn-report" hx-get="/report/{{.Task.ID}}" hx-target="#task-detail" hx-swap="innerHTML">Izvestaj</button>
</div> </div>
{{end}} {{end}}
<div class="detail-actions"> <div class="detail-actions">
@ -21,6 +21,7 @@
<button class="btn btn-run" hx-post="/task/{{.Task.ID}}/run" hx-swap="none" onclick="closeDetail()">Pusti</button> <button class="btn btn-run" hx-post="/task/{{.Task.ID}}/run" hx-swap="none" onclick="closeDetail()">Pusti</button>
{{end}} {{end}}
{{if eq .Task.Status "review"}} {{if eq .Task.Status "review"}}
<button class="btn btn-run" hx-post="/task/{{.Task.ID}}/review" hx-swap="none" onclick="closeDetail()">Proveri</button>
<button class="btn btn-success" hx-post="/task/{{.Task.ID}}/move?to=done" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Odobri</button> <button class="btn btn-success" hx-post="/task/{{.Task.ID}}/move?to=done" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Odobri</button>
<button class="btn btn-move" hx-post="/task/{{.Task.ID}}/move?to=ready" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Vrati</button> <button class="btn btn-move" hx-post="/task/{{.Task.ID}}/move?to=ready" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Vrati</button>
{{end}} {{end}}