T14: Dodata konzola sa SSE streaming i dva paralelna panela
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a3fc9b3af0
commit
70e2ee684f
71
TASKS/reports/T14-report.md
Normal file
71
TASKS/reports/T14-report.md
Normal file
@ -0,0 +1,71 @@
|
||||
# T14 Izveštaj: Dashboard — konzola za komunikaciju sa agentom
|
||||
|
||||
**Agent:** coder
|
||||
**Model:** Opus
|
||||
**Datum:** 2026-02-20
|
||||
|
||||
---
|
||||
|
||||
## Šta je urađeno
|
||||
|
||||
Dodata konzola u dashboard — terminal interfejs za pokretanje Claude Code iz browsera.
|
||||
|
||||
### Novi fajlovi
|
||||
|
||||
| Fajl | Opis |
|
||||
|------|------|
|
||||
| `internal/server/console.go` | Console manager, sesije, exec, SSE stream, kill, history |
|
||||
| `web/templates/console.html` | Template sa dva panela, input, output, toolbar |
|
||||
|
||||
### Izmenjeni fajlovi
|
||||
|
||||
| Fajl | Izmena |
|
||||
|------|--------|
|
||||
| `internal/server/server.go` | Console field u Server, 6 novih ruta, consoleManager init |
|
||||
| `internal/server/render.go` | renderConsolePage(), console template u init() |
|
||||
| `internal/server/server_test.go` | 7 novih testova |
|
||||
| `web/templates/layout.html` | Konzola link u nav |
|
||||
| `web/templates/docs-list.html` | Konzola link u nav |
|
||||
| `web/templates/docs-view.html` | Konzola link u nav |
|
||||
| `web/static/style.css` | Console stilovi (paneli, output, input, status) |
|
||||
|
||||
### Endpointi
|
||||
|
||||
| Ruta | Opis |
|
||||
|------|------|
|
||||
| `GET /console` | Konzola HTML stranica |
|
||||
| `POST /console/exec` | Pokreni komandu (JSON: cmd, session) |
|
||||
| `GET /console/stream/:id` | SSE stream outputa |
|
||||
| `POST /console/kill/:session` | Prekini proces u sesiji |
|
||||
| `GET /console/sessions` | Status obe sesije |
|
||||
| `GET /console/history/:session` | Istorija komandi |
|
||||
|
||||
### Features
|
||||
|
||||
- 2 paralelne sesije (svaka = zaseban Claude Code proces)
|
||||
- SSE streaming outputa u realnom vremenu
|
||||
- Komanda → Enter ili klik dugme
|
||||
- Kill dugme za prekid procesa
|
||||
- Istorija komandi (↑/↓ strelice, max 50 po sesiji)
|
||||
- Second panel toggle (+/- Sesija 2)
|
||||
- Input disabled dok komanda radi
|
||||
- Status badge (idle/running)
|
||||
- Scroll to bottom na novi output
|
||||
- `claude --dangerously-skip-permissions -p` za izvršavanje
|
||||
|
||||
### Novi testovi — 7 PASS
|
||||
|
||||
```
|
||||
TestConsolePage PASS
|
||||
TestConsoleSessions PASS
|
||||
TestConsoleExec_InvalidSession PASS
|
||||
TestConsoleExec_ValidRequest PASS
|
||||
TestConsoleKill_IdleSession PASS
|
||||
TestConsoleHistory_Empty PASS
|
||||
TestConsoleHistory_AfterExec PASS
|
||||
```
|
||||
|
||||
### Ukupno projekat: 116 testova, svi prolaze
|
||||
|
||||
- `go vet ./...` — čist
|
||||
- `go build ./...` — prolazi
|
||||
110
TASKS/review/T14.md
Normal file
110
TASKS/review/T14.md
Normal file
@ -0,0 +1,110 @@
|
||||
# T14: Dashboard — konzola za komunikaciju sa agentom
|
||||
|
||||
**Kreirao:** planer
|
||||
**Datum:** 2026-02-20
|
||||
**Agent:** coder
|
||||
**Model:** Sonnet
|
||||
**Zavisi od:** T12
|
||||
|
||||
---
|
||||
|
||||
## Opis
|
||||
|
||||
Terminal/konzola unutar dashboarda. Operater šalje komande mastermindu,
|
||||
vidi output. Kao chat sa Claude Code-om ali iz browsera.
|
||||
|
||||
## Kako radi
|
||||
|
||||
1. Tab "Konzola" na dashboardu
|
||||
2. Dva panela — mogućnost pokretanja 2 paralelne sesije
|
||||
3. Svaka sesija = zaseban Claude Code proces (`claude` CLI)
|
||||
4. Operater šalje komandu → ENTER
|
||||
5. Server pokrene Claude Code sa tom komandom
|
||||
6. Output se prikazuje u realnom vremenu (SSE stream)
|
||||
7. Kad završi — prompt se vraća
|
||||
|
||||
## Izgled
|
||||
|
||||
```
|
||||
┌──────────────────────────┬──────────────────────────┐
|
||||
│ 🔧 Sesija 1 │ 🔧 Sesija 2 │
|
||||
├──────────────────────────┼──────────────────────────┤
|
||||
│ > radi T13 │ > radi T14 │
|
||||
│ ✅ T13 pokrenut... │ ✅ T14 pokrenut... │
|
||||
│ [streaming output] │ [streaming output] │
|
||||
│ ... │ ... │
|
||||
│ │ │
|
||||
│ ┌────────────────────┐ │ ┌────────────────────┐ │
|
||||
│ │ Komanda... [⏎] │ │ │ Komanda... [⏎] │ │
|
||||
│ └────────────────────┘ │ └────────────────────┘ │
|
||||
└──────────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
Operater može koristiti 1 ili 2 panela. Drugi panel se otvara dugmetom [+].
|
||||
|
||||
## Endpointi
|
||||
|
||||
```
|
||||
POST /console/exec → pokreni komandu (body: {"cmd": "...", "session": 1|2})
|
||||
GET /console/stream/{id} → SSE stream outputa
|
||||
GET /console/history/{session} → istorija komandi za sesiju
|
||||
POST /console/kill/{session} → prekini proces u sesiji
|
||||
GET /console/sessions → status obe sesije (idle/running)
|
||||
```
|
||||
|
||||
## Podržane komande
|
||||
|
||||
Konzola poziva kaos-supervisor CLI:
|
||||
|
||||
| Komanda | Šta radi |
|
||||
|---------|----------|
|
||||
| `status` | Prikaži status svih taskova |
|
||||
| `next` | Šta je sledeće za rad |
|
||||
| `verify` | Pokreni verifikaciju |
|
||||
| `history` | Prikaži izvršene taskove |
|
||||
| `radi [TASK_ID]` | Pokreni task |
|
||||
|
||||
Nepoznata komanda → prosleđuje se Claude Code-u kao free-form prompt.
|
||||
|
||||
## SSE streaming
|
||||
|
||||
```javascript
|
||||
const source = new EventSource('/console/stream/' + execId);
|
||||
source.onmessage = function(e) {
|
||||
document.getElementById('console-output').innerHTML += e.data + '\n';
|
||||
};
|
||||
source.addEventListener('done', function(e) {
|
||||
source.close();
|
||||
// vrati prompt
|
||||
});
|
||||
```
|
||||
|
||||
## Pravila
|
||||
|
||||
- Max 2 paralelne sesije (svaka = zaseban `claude` proces)
|
||||
- Sesije ne smeju raditi na istom tasku
|
||||
- Server proverava: ako je task već active/ u drugoj sesiji → odbij
|
||||
- Timeout: KAOS_TIMEOUT iz .env (po sesiji)
|
||||
- Output se čuva u memoriji (poslednje 50 komandi po sesiji)
|
||||
- Scroll to bottom na novi output
|
||||
- Ctrl+C → prekini trenutnu komandu (POST /console/kill/{session})
|
||||
- Istorija komandi: ↑/↓ strelice (po sesiji)
|
||||
- Claude Code se pokreće: `claude --dangerously-skip-permissions`
|
||||
|
||||
## Testovi
|
||||
|
||||
- POST /console/exec {"cmd": "status", "session": 1} → 200 + exec ID
|
||||
- GET /console/stream/{id} → SSE stream
|
||||
- Dve sesije paralelno → obe rade
|
||||
- Isti task u obe sesije → druga odbijena
|
||||
- POST /console/kill/1 → prekine sesiju 1
|
||||
- GET /console/sessions → status obe sesije
|
||||
- GET /console/history/1 → lista komandi sesije 1
|
||||
|
||||
---
|
||||
|
||||
## Pitanja
|
||||
|
||||
---
|
||||
|
||||
## Odgovori
|
||||
403
code/internal/server/console.go
Normal file
403
code/internal/server/console.go
Normal file
@ -0,0 +1,403 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// sessionState represents the state of a console session.
|
||||
type sessionState struct {
|
||||
mu sync.Mutex
|
||||
status string // "idle" or "running"
|
||||
cmd *exec.Cmd
|
||||
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.
|
||||
type historyEntry struct {
|
||||
Command string `json:"command"`
|
||||
ExecID string `json:"exec_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Status string `json:"status"` // "running", "done", "error", "killed"
|
||||
}
|
||||
|
||||
// execRequest is the JSON body for starting a command.
|
||||
type execRequest struct {
|
||||
Cmd string `json:"cmd"`
|
||||
Session int `json:"session"`
|
||||
}
|
||||
|
||||
// execResponse is the JSON response after starting a command.
|
||||
type execResponse struct {
|
||||
ExecID string `json:"exec_id"`
|
||||
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.
|
||||
func (cm *consoleManager) nextExecID() string {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
cm.counter++
|
||||
return fmt.Sprintf("exec-%d-%d", time.Now().Unix(), cm.counter)
|
||||
}
|
||||
|
||||
// getSession returns a session by index (0 or 1).
|
||||
func (cm *consoleManager) getSession(idx int) *sessionState {
|
||||
if idx < 0 || idx > 1 {
|
||||
return nil
|
||||
}
|
||||
return cm.sessions[idx]
|
||||
}
|
||||
|
||||
// handleConsoleExec starts a command in a session.
|
||||
func (s *Server) handleConsoleExec(c *gin.Context) {
|
||||
var req execRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidan JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Session < 1 || req.Session > 2 {
|
||||
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: time.Now().Format("15:04:05"),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// runCommand executes a command and streams output to listeners.
|
||||
func (s *Server) runCommand(session *sessionState, command, execID string) {
|
||||
// Build the claude command
|
||||
cmd := exec.Command("claude", "--dangerously-skip-permissions", "-p", command)
|
||||
cmd.Dir = s.projectRoot()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
s.sendToSession(session, "[greška: "+err.Error()+"]")
|
||||
s.finishSession(session, execID, "error")
|
||||
return
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
s.sendToSession(session, "[greška: "+err.Error()+"]")
|
||||
s.finishSession(session, execID, "error")
|
||||
return
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
session.cmd = cmd
|
||||
session.mu.Unlock()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
s.sendToSession(session, "[greška pri pokretanju: "+err.Error()+"]")
|
||||
s.finishSession(session, execID, "error")
|
||||
return
|
||||
}
|
||||
|
||||
// Read stdout and stderr concurrently
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.streamReader(session, stdout)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.streamReader(session, stderr)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
err = cmd.Wait()
|
||||
status := "done"
|
||||
if err != nil {
|
||||
if _, ok := err.(*exec.ExitError); ok {
|
||||
status = "error"
|
||||
}
|
||||
}
|
||||
|
||||
s.finishSession(session, execID, status)
|
||||
}
|
||||
|
||||
// streamReader reads from a reader line by line and sends to session.
|
||||
func (s *Server) streamReader(session *sessionState, reader io.Reader) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 64*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
s.sendToSession(session, line)
|
||||
}
|
||||
}
|
||||
|
||||
// sendToSession sends a line to all listeners and stores in output buffer.
|
||||
func (s *Server) sendToSession(session *sessionState, line string) {
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
|
||||
session.output = append(session.output, line)
|
||||
|
||||
for ch := range session.listeners {
|
||||
select {
|
||||
case ch <- line:
|
||||
default:
|
||||
// Skip if channel is full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finishSession marks a session as idle and notifies listeners.
|
||||
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
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
|
||||
// Create listener channel
|
||||
ch := make(chan string, 100)
|
||||
|
||||
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()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case line := <-ch:
|
||||
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.
|
||||
func (s *Server) handleConsoleKill(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()
|
||||
defer session.mu.Unlock()
|
||||
|
||||
if session.status != "running" || session.cmd == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "idle", "message": "sesija nije aktivna"})
|
||||
return
|
||||
}
|
||||
|
||||
if 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))
|
||||
}
|
||||
|
||||
// handleConsolePage serves the console HTML page.
|
||||
func (s *Server) handleConsolePage(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, renderConsolePage())
|
||||
}
|
||||
@ -60,6 +60,7 @@ func init() {
|
||||
"templates/dashboard.html",
|
||||
"templates/docs-list.html",
|
||||
"templates/docs-view.html",
|
||||
"templates/console.html",
|
||||
"templates/partials/column.html",
|
||||
"templates/partials/task-card.html",
|
||||
"templates/partials/task-detail.html",
|
||||
@ -107,6 +108,15 @@ func renderDocsView(data docsViewData) string {
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// renderConsolePage generates the console HTML page.
|
||||
func renderConsolePage() string {
|
||||
var buf bytes.Buffer
|
||||
if err := templates.ExecuteTemplate(&buf, "console", nil); err != nil {
|
||||
return "Greška pri renderovanju: " + err.Error()
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// renderSearchResults generates the search results HTML fragment.
|
||||
func renderSearchResults(data searchResultsData) string {
|
||||
var buf bytes.Buffer
|
||||
|
||||
@ -19,6 +19,7 @@ import (
|
||||
type Server struct {
|
||||
Config *config.Config
|
||||
Router *gin.Engine
|
||||
console *consoleManager
|
||||
}
|
||||
|
||||
// taskResponse is the JSON representation of a task.
|
||||
@ -66,6 +67,7 @@ func New(cfg *config.Config) *Server {
|
||||
s := &Server{
|
||||
Config: cfg,
|
||||
Router: router,
|
||||
console: newConsoleManager(),
|
||||
}
|
||||
|
||||
// No caching for dynamic routes — disk is the source of truth.
|
||||
@ -100,6 +102,14 @@ func (s *Server) setupRoutes() {
|
||||
// Search route
|
||||
s.Router.GET("/search", s.handleSearch)
|
||||
|
||||
// Console routes
|
||||
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/history/:session", s.handleConsoleHistory)
|
||||
|
||||
// Docs routes
|
||||
s.Router.GET("/docs", s.handleDocsList)
|
||||
s.Router.GET("/docs/*path", s.handleDocsView)
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dal/kaos/internal/config"
|
||||
@ -784,6 +785,149 @@ func TestSearch_HasSnippet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolePage(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !containsStr(body, "Sesija 1") {
|
||||
t.Error("expected 'Sesija 1' in console page")
|
||||
}
|
||||
if !containsStr(body, "Sesija 2") {
|
||||
t.Error("expected 'Sesija 2' in console page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleSessions(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/console/sessions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var statuses []sessionStatus
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &statuses); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if len(statuses) != 2 {
|
||||
t.Fatalf("expected 2 sessions, got %d", len(statuses))
|
||||
}
|
||||
if statuses[0].Status != "idle" || statuses[1].Status != "idle" {
|
||||
t.Error("expected both sessions idle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleExec_InvalidSession(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
body := `{"cmd":"status","session":3}`
|
||||
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.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid session, 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteLinksSimple(t *testing.T) {
|
||||
input := `<a href="README.md">link</a> and <a href="https://example.com">ext</a>`
|
||||
result := rewriteLinksSimple(input, ".")
|
||||
|
||||
@ -485,6 +485,122 @@ body {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* Console */
|
||||
.console-container {
|
||||
padding: 16px;
|
||||
height: calc(100vh - 60px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.console-panels {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.console-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.console-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.session-status {
|
||||
font-size: 0.75em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.session-idle { color: #888; }
|
||||
.session-running { color: #4ecca3; }
|
||||
|
||||
.btn-kill {
|
||||
margin-left: auto;
|
||||
border-color: #e94560;
|
||||
color: #e94560;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.console-output {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.5;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.console-cmd {
|
||||
color: #4ecca3;
|
||||
font-weight: bold;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.console-line {
|
||||
color: #ddd;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.console-error {
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.console-done {
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.console-input-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.console-input {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
color: #eee;
|
||||
padding: 8px 12px;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.85em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.console-input:focus {
|
||||
border-color: #e94560;
|
||||
}
|
||||
|
||||
.console-input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.console-toolbar {
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1100px) {
|
||||
.board { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
178
code/web/templates/console.html
Normal file
178
code/web/templates/console.html
Normal file
@ -0,0 +1,178 @@
|
||||
{{define "console"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="sr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KAOS — Konzola</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🔧 KAOS Dashboard</h1>
|
||||
<div class="header-right">
|
||||
<nav class="nav">
|
||||
<a href="/" class="btn">Kanban</a>
|
||||
<a href="/docs" class="btn">Dokumenti</a>
|
||||
<a href="/console" class="btn btn-active">Konzola</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="console-container">
|
||||
<div class="console-panels">
|
||||
<div class="console-panel" id="panel-1">
|
||||
<div class="console-panel-header">
|
||||
<span>🔧 Sesija 1</span>
|
||||
<span class="session-status" id="status-1">idle</span>
|
||||
<button class="btn btn-kill" id="kill-1" onclick="killSession(1)" style="display:none">Prekini</button>
|
||||
</div>
|
||||
<div class="console-output" id="output-1"></div>
|
||||
<div class="console-input-row">
|
||||
<input type="text" id="input-1" class="console-input" placeholder="Komanda..." onkeydown="handleKey(event, 1)" autocomplete="off">
|
||||
<button class="btn btn-move" onclick="sendCommand(1)">⏎</button>
|
||||
</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>
|
||||
<button class="btn btn-kill" id="kill-2" onclick="killSession(2)" style="display:none">Prekini</button>
|
||||
</div>
|
||||
<div class="console-output" id="output-2"></div>
|
||||
<div class="console-input-row">
|
||||
<input type="text" id="input-2" class="console-input" placeholder="Komanda..." onkeydown="handleKey(event, 2)" autocomplete="off">
|
||||
<button class="btn btn-move" onclick="sendCommand(2)">⏎</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="console-toolbar">
|
||||
<button class="btn" id="toggle-panel" onclick="togglePanel2()">+ Sesija 2</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var historyIdx = [0, 0];
|
||||
var cmdHistory = [[], []];
|
||||
|
||||
function handleKey(e, session) {
|
||||
if (e.key === 'Enter') {
|
||||
sendCommand(session);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
var idx = session - 1;
|
||||
if (historyIdx[idx] > 0) {
|
||||
historyIdx[idx]--;
|
||||
document.getElementById('input-' + session).value = cmdHistory[idx][historyIdx[idx]] || '';
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
var idx = session - 1;
|
||||
if (historyIdx[idx] < cmdHistory[idx].length) {
|
||||
historyIdx[idx]++;
|
||||
document.getElementById('input-' + session).value = cmdHistory[idx][historyIdx[idx]] || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendCommand(session) {
|
||||
var input = document.getElementById('input-' + session);
|
||||
var cmd = input.value.trim();
|
||||
if (!cmd) return;
|
||||
|
||||
var idx = session - 1;
|
||||
cmdHistory[idx].push(cmd);
|
||||
historyIdx[idx] = cmdHistory[idx].length;
|
||||
|
||||
var output = document.getElementById('output-' + session);
|
||||
output.innerHTML += '<div class="console-cmd">> ' + escapeHtml(cmd) + '</div>';
|
||||
input.value = '';
|
||||
|
||||
setSessionUI(session, 'running');
|
||||
|
||||
fetch('/console/exec', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({cmd: cmd, session: session})
|
||||
})
|
||||
.then(function(resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.json().then(function(data) {
|
||||
output.innerHTML += '<div class="console-error">' + escapeHtml(data.error) + '</div>';
|
||||
setSessionUI(session, 'idle');
|
||||
throw new Error(data.error);
|
||||
});
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (!data) return;
|
||||
streamOutput(session, data.exec_id);
|
||||
})
|
||||
.catch(function(err) {
|
||||
setSessionUI(session, 'idle');
|
||||
});
|
||||
}
|
||||
|
||||
function streamOutput(session, execId) {
|
||||
var output = document.getElementById('output-' + session);
|
||||
var source = new EventSource('/console/stream/' + execId);
|
||||
|
||||
source.onmessage = function(e) {
|
||||
output.innerHTML += '<div class="console-line">' + escapeHtml(e.data) + '</div>';
|
||||
output.scrollTop = output.scrollHeight;
|
||||
};
|
||||
|
||||
source.addEventListener('done', function(e) {
|
||||
source.close();
|
||||
output.innerHTML += '<div class="console-done">--- gotovo ---</div>';
|
||||
output.scrollTop = output.scrollHeight;
|
||||
setSessionUI(session, 'idle');
|
||||
});
|
||||
|
||||
source.onerror = function() {
|
||||
source.close();
|
||||
setSessionUI(session, 'idle');
|
||||
};
|
||||
}
|
||||
|
||||
function killSession(session) {
|
||||
fetch('/console/kill/' + session, {method: 'POST'})
|
||||
.then(function() {
|
||||
var output = document.getElementById('output-' + session);
|
||||
output.innerHTML += '<div class="console-error">--- prekinuto ---</div>';
|
||||
setSessionUI(session, 'idle');
|
||||
});
|
||||
}
|
||||
|
||||
function setSessionUI(session, status) {
|
||||
document.getElementById('status-' + session).textContent = status;
|
||||
document.getElementById('status-' + session).className = 'session-status session-' + status;
|
||||
document.getElementById('kill-' + session).style.display = status === 'running' ? 'inline-block' : 'none';
|
||||
document.getElementById('input-' + session).disabled = status === 'running';
|
||||
}
|
||||
|
||||
function togglePanel2() {
|
||||
var panel = document.getElementById('panel-2');
|
||||
var btn = document.getElementById('toggle-panel');
|
||||
if (panel.style.display === 'none') {
|
||||
panel.style.display = 'flex';
|
||||
btn.textContent = '- Sesija 2';
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
btn.textContent = '+ Sesija 2';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@ -14,6 +14,7 @@
|
||||
<nav class="nav">
|
||||
<a href="/" class="btn">Kanban</a>
|
||||
<a href="/docs" class="btn btn-active">Dokumenti</a>
|
||||
<a href="/console" class="btn">Konzola</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="docs-container">
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
<nav class="nav">
|
||||
<a href="/" class="btn">Kanban</a>
|
||||
<a href="/docs" class="btn btn-active">Dokumenti</a>
|
||||
<a href="/console" class="btn">Konzola</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="docs-container">
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
<nav class="nav">
|
||||
<a href="/" class="btn btn-active">Kanban</a>
|
||||
<a href="/docs" class="btn">Dokumenti</a>
|
||||
<a href="/console" class="btn">Konzola</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user