Konzola: xterm.js + WebSocket + PTY real-time terminal
- Nova pty_session.go: RingBuffer, consolePTYSession, spawnConsolePTY - Nova ws.go: WebSocket handler za PTY bidirekcioni I/O - console.go: koristi consolePTYSession umesto starih pipe-ova - console.html: xterm.js 5.5.0 CDN, FitAddon, WebLinksAddon - Podrška za resize, binarni podaci, replay buffer (1MB) - 8 novih testova (RingBuffer + xterm konzola) — ukupno 179 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7cce5e99c7
commit
c970cb2419
@ -16,6 +16,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
|||||||
@ -30,6 +30,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
|||||||
@ -19,6 +19,7 @@ type sessionState struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
status string // "idle" or "running"
|
status string // "idle" or "running"
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
|
ptySess *consolePTYSession
|
||||||
execID string
|
execID string
|
||||||
taskID string // which task is being worked on (if any)
|
taskID string // which task is being worked on (if any)
|
||||||
history []historyEntry
|
history []historyEntry
|
||||||
@ -152,36 +153,30 @@ func cleanEnv() []string {
|
|||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCommand executes a command in a PTY and streams output to listeners.
|
// runCommand spawns a PTY-backed claude CLI process and monitors it.
|
||||||
func (s *Server) runCommand(session *sessionState, command, execID string) {
|
func (s *Server) runCommand(session *sessionState, command, execID string) {
|
||||||
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", command)
|
ptySess, err := spawnConsolePTY(s.projectRoot(), command)
|
||||||
cmd.Dir = s.projectRoot()
|
|
||||||
cmd.Env = cleanEnv()
|
|
||||||
|
|
||||||
ptmx, err := startPTY(cmd)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.sendToSession(session, "[greška pri pokretanju: "+err.Error()+"]")
|
|
||||||
s.finishSession(session, execID, "error")
|
s.finishSession(session, execID, "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer ptmx.Close()
|
|
||||||
|
|
||||||
session.mu.Lock()
|
session.mu.Lock()
|
||||||
session.cmd = cmd
|
session.cmd = ptySess.Cmd
|
||||||
|
session.ptySess = ptySess
|
||||||
session.mu.Unlock()
|
session.mu.Unlock()
|
||||||
|
|
||||||
// Read PTY output and send to session
|
// Wait for process to exit
|
||||||
readPTY(ptmx, func(line string) {
|
<-ptySess.Done()
|
||||||
s.sendToSession(session, line)
|
|
||||||
})
|
|
||||||
|
|
||||||
err = cmd.Wait()
|
|
||||||
status := "done"
|
status := "done"
|
||||||
if err != nil {
|
if ptySess.Cmd.ProcessState != nil && !ptySess.Cmd.ProcessState.Success() {
|
||||||
if _, ok := err.(*exec.ExitError); ok {
|
|
||||||
status = "error"
|
status = "error"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
session.mu.Lock()
|
||||||
|
session.ptySess = nil
|
||||||
|
session.mu.Unlock()
|
||||||
|
|
||||||
s.finishSession(session, execID, status)
|
s.finishSession(session, execID, status)
|
||||||
}
|
}
|
||||||
@ -197,7 +192,6 @@ func (s *Server) sendToSession(session *sessionState, line string) {
|
|||||||
select {
|
select {
|
||||||
case ch <- line:
|
case ch <- line:
|
||||||
default:
|
default:
|
||||||
// Skip if channel is full
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -313,12 +307,16 @@ func (s *Server) handleConsoleKill(c *gin.Context) {
|
|||||||
session.mu.Lock()
|
session.mu.Lock()
|
||||||
defer session.mu.Unlock()
|
defer session.mu.Unlock()
|
||||||
|
|
||||||
if session.status != "running" || session.cmd == nil {
|
if session.status != "running" {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "idle", "message": "sesija nije aktivna"})
|
c.JSON(http.StatusOK, gin.H{"status": "idle", "message": "sesija nije aktivna"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.cmd.Process != nil {
|
// Close PTY session if it exists
|
||||||
|
if session.ptySess != nil {
|
||||||
|
session.ptySess.Close()
|
||||||
|
session.ptySess = nil
|
||||||
|
} else if session.cmd != nil && session.cmd.Process != nil {
|
||||||
session.cmd.Process.Kill()
|
session.cmd.Process.Kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
215
code/internal/server/pty_session.go
Normal file
215
code/internal/server/pty_session.go
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
outputBufferSize = 1024 * 1024 // 1MB ring buffer for replay
|
||||||
|
)
|
||||||
|
|
||||||
|
// RingBuffer is a fixed-size circular buffer for terminal output.
|
||||||
|
type RingBuffer struct {
|
||||||
|
data []byte
|
||||||
|
size int
|
||||||
|
pos int
|
||||||
|
full bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRingBuffer creates a new ring buffer with the given size.
|
||||||
|
func NewRingBuffer(size int) *RingBuffer {
|
||||||
|
return &RingBuffer{data: make([]byte, size), size: size}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write appends data to the ring buffer.
|
||||||
|
func (rb *RingBuffer) Write(p []byte) {
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
|
for _, b := range p {
|
||||||
|
rb.data[rb.pos] = b
|
||||||
|
rb.pos++
|
||||||
|
if rb.pos >= rb.size {
|
||||||
|
rb.pos = 0
|
||||||
|
rb.full = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes returns the buffer contents in correct order.
|
||||||
|
func (rb *RingBuffer) Bytes() []byte {
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
|
if !rb.full {
|
||||||
|
result := make([]byte, rb.pos)
|
||||||
|
copy(result, rb.data[:rb.pos])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result := make([]byte, rb.size)
|
||||||
|
n := copy(result, rb.data[rb.pos:])
|
||||||
|
copy(result[n:], rb.data[:rb.pos])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the buffer.
|
||||||
|
func (rb *RingBuffer) Reset() {
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
|
rb.pos = 0
|
||||||
|
rb.full = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// consolePTYSession manages a single claude CLI running in a pseudo-terminal.
|
||||||
|
type consolePTYSession struct {
|
||||||
|
ID string
|
||||||
|
Ptmx *os.File
|
||||||
|
Cmd *exec.Cmd
|
||||||
|
buffer *RingBuffer
|
||||||
|
subscribers map[string]chan []byte
|
||||||
|
mu sync.Mutex
|
||||||
|
done chan struct{}
|
||||||
|
lastActive time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnConsolePTY starts a new claude CLI in a PTY for the console.
|
||||||
|
func spawnConsolePTY(projectDir, prompt string) (*consolePTYSession, error) {
|
||||||
|
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt)
|
||||||
|
cmd.Dir = projectDir
|
||||||
|
cmd.Env = cleanEnvForPTY()
|
||||||
|
|
||||||
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 120})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("start pty: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := &consolePTYSession{
|
||||||
|
ID: fmt.Sprintf("pty-%d", time.Now().UnixNano()),
|
||||||
|
Ptmx: ptmx,
|
||||||
|
Cmd: cmd,
|
||||||
|
buffer: NewRingBuffer(outputBufferSize),
|
||||||
|
subscribers: make(map[string]chan []byte),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
lastActive: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
go sess.readLoop()
|
||||||
|
go sess.waitExit()
|
||||||
|
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop reads PTY output, writes to ring buffer, and forwards to subscribers.
|
||||||
|
func (s *consolePTYSession) readLoop() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := s.Ptmx.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := make([]byte, n)
|
||||||
|
copy(data, buf[:n])
|
||||||
|
|
||||||
|
s.buffer.Write(data)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastActive = time.Now()
|
||||||
|
for _, ch := range s.subscribers {
|
||||||
|
select {
|
||||||
|
case ch <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitExit waits for the CLI process to exit and signals done.
|
||||||
|
func (s *consolePTYSession) waitExit() {
|
||||||
|
if s.Cmd.Process != nil {
|
||||||
|
s.Cmd.Wait()
|
||||||
|
}
|
||||||
|
close(s.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe adds a subscriber for PTY output.
|
||||||
|
func (s *consolePTYSession) Subscribe(id string) chan []byte {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
ch := make(chan []byte, 256)
|
||||||
|
s.subscribers[id] = ch
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a subscriber.
|
||||||
|
func (s *consolePTYSession) Unsubscribe(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if ch, ok := s.subscribers[id]; ok {
|
||||||
|
close(ch)
|
||||||
|
delete(s.subscribers, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize changes the PTY terminal size.
|
||||||
|
func (s *consolePTYSession) Resize(rows, cols uint16) error {
|
||||||
|
return pty.Setsize(s.Ptmx, &pty.Winsize{Rows: rows, Cols: cols})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteInput sends keyboard input to the PTY.
|
||||||
|
func (s *consolePTYSession) WriteInput(data []byte) (int, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastActive = time.Now()
|
||||||
|
s.mu.Unlock()
|
||||||
|
return s.Ptmx.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBuffer returns the ring buffer contents for replay.
|
||||||
|
func (s *consolePTYSession) GetBuffer() []byte {
|
||||||
|
return s.buffer.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done returns a channel that closes when the process exits.
|
||||||
|
func (s *consolePTYSession) Done() <-chan struct{} {
|
||||||
|
return s.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the PTY session.
|
||||||
|
func (s *consolePTYSession) Close() {
|
||||||
|
s.mu.Lock()
|
||||||
|
for id, ch := range s.subscribers {
|
||||||
|
close(ch)
|
||||||
|
delete(s.subscribers, id)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.Ptmx.Close()
|
||||||
|
if s.Cmd.Process != nil {
|
||||||
|
s.Cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanEnvForPTY returns environment with proper terminal settings.
|
||||||
|
func cleanEnvForPTY() []string {
|
||||||
|
var env []string
|
||||||
|
for _, e := range os.Environ() {
|
||||||
|
if strings.HasPrefix(e, "CLAUDECODE=") ||
|
||||||
|
strings.HasPrefix(e, "CLAUDE_CODE_ENTRYPOINT=") ||
|
||||||
|
strings.HasPrefix(e, "TERM=") ||
|
||||||
|
strings.HasPrefix(e, "COLORTERM=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
env = append(env, e)
|
||||||
|
}
|
||||||
|
env = append(env, "TERM=xterm-256color", "COLORTERM=truecolor")
|
||||||
|
return env
|
||||||
|
}
|
||||||
@ -121,6 +121,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.Router.POST("/console/kill/:session", s.handleConsoleKill)
|
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.GET("/console/history/:session", s.handleConsoleHistory)
|
||||||
|
s.Router.GET("/console/ws/:session", s.handleConsoleWS)
|
||||||
|
|
||||||
// Docs routes
|
// Docs routes
|
||||||
s.Router.GET("/docs", s.handleDocsList)
|
s.Router.GET("/docs", s.handleDocsList)
|
||||||
|
|||||||
@ -2008,3 +2008,126 @@ func findStr(s, substr string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── RingBuffer tests ────────────────────────────────
|
||||||
|
|
||||||
|
func TestRingBuffer_WriteAndRead(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(16)
|
||||||
|
rb.Write([]byte("hello"))
|
||||||
|
|
||||||
|
got := rb.Bytes()
|
||||||
|
if string(got) != "hello" {
|
||||||
|
t.Errorf("expected 'hello', got '%s'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRingBuffer_Overflow(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(8)
|
||||||
|
rb.Write([]byte("abcdefgh")) // exactly fills
|
||||||
|
rb.Write([]byte("ij")) // wraps around
|
||||||
|
|
||||||
|
got := rb.Bytes()
|
||||||
|
// Should contain the last 8 bytes: "cdefghij"
|
||||||
|
if string(got) != "cdefghij" {
|
||||||
|
t.Errorf("expected 'cdefghij', got '%s'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRingBuffer_Reset(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(16)
|
||||||
|
rb.Write([]byte("test"))
|
||||||
|
rb.Reset()
|
||||||
|
|
||||||
|
got := rb.Bytes()
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("expected empty after reset, got %d bytes", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── xterm.js console page tests ─────────────────────
|
||||||
|
|
||||||
|
func TestConsolePage_HasXtermJS(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, "xterm.min.js") {
|
||||||
|
t.Error("expected xterm.min.js CDN link in console page")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "addon-fit") {
|
||||||
|
t.Error("expected addon-fit CDN link in console page")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "xterm.css") {
|
||||||
|
t.Error("expected xterm.css CDN link in console page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_HasWebSocket(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, "/console/ws/") {
|
||||||
|
t.Error("expected WebSocket URL /console/ws/ in console page")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "new WebSocket") {
|
||||||
|
t.Error("expected WebSocket constructor in console page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_HasTerminalContainers(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, `id="terminal-1"`) {
|
||||||
|
t.Error("expected terminal-1 container")
|
||||||
|
}
|
||||||
|
if !containsStr(body, `id="terminal-2"`) {
|
||||||
|
t.Error("expected terminal-2 container")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "console-terminal") {
|
||||||
|
t.Error("expected console-terminal class")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_HasBinaryMessageSupport(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, "arraybuffer") {
|
||||||
|
t.Error("expected arraybuffer binary type for WebSocket")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "Uint8Array") {
|
||||||
|
t.Error("expected Uint8Array handling for binary messages")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_HasResizeHandler(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, "fitAddon.fit()") {
|
||||||
|
t.Error("expected fitAddon.fit() for resize handling")
|
||||||
|
}
|
||||||
|
if !containsStr(body, `'resize'`) {
|
||||||
|
t.Error("expected resize message type in WebSocket handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
146
code/internal/server/ws.go
Normal file
146
code/internal/server/ws.go
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var wsUpgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 4096,
|
||||||
|
WriteBufferSize: 4096,
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsResizeMsg is sent from the browser when the terminal size changes.
|
||||||
|
type wsResizeMsg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Cols uint16 `json:"cols"`
|
||||||
|
Rows uint16 `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConsoleWS handles WebSocket connections for console PTY sessions.
|
||||||
|
func (s *Server) handleConsoleWS(c *gin.Context) {
|
||||||
|
sessionNum := c.Param("session")
|
||||||
|
if sessionNum != "1" && sessionNum != "2" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sesija mora biti 1 ili 2"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WebSocket upgrade error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
idx := 0
|
||||||
|
if sessionNum == "2" {
|
||||||
|
idx = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
session := s.console.getSession(idx)
|
||||||
|
|
||||||
|
// Wait for PTY session to be available (it gets set when a command is executed)
|
||||||
|
session.mu.Lock()
|
||||||
|
ptySess := session.ptySess
|
||||||
|
session.mu.Unlock()
|
||||||
|
|
||||||
|
if ptySess == nil {
|
||||||
|
// No active PTY — send message and wait
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte("\r\n\033[33m[Nema aktivne sesije. Pokrenite komandu.]\033[0m\r\n"))
|
||||||
|
|
||||||
|
// Poll for session to start
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
session.mu.Lock()
|
||||||
|
ptySess = session.ptySess
|
||||||
|
session.mu.Unlock()
|
||||||
|
if ptySess != nil {
|
||||||
|
goto connected
|
||||||
|
}
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connected:
|
||||||
|
subID := fmt.Sprintf("ws-%d", time.Now().UnixNano())
|
||||||
|
outputCh := ptySess.Subscribe(subID)
|
||||||
|
defer ptySess.Unsubscribe(subID)
|
||||||
|
|
||||||
|
// Send buffered output for reconnect
|
||||||
|
buffered := ptySess.GetBuffer()
|
||||||
|
if len(buffered) > 0 {
|
||||||
|
conn.WriteMessage(websocket.BinaryMessage, buffered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialized write channel
|
||||||
|
writeCh := make(chan []byte, 256)
|
||||||
|
writeDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(writeDone)
|
||||||
|
for data := range writeCh {
|
||||||
|
if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// PTY output → WebSocket
|
||||||
|
go func() {
|
||||||
|
for data := range outputCh {
|
||||||
|
select {
|
||||||
|
case writeCh <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Watch for process exit
|
||||||
|
go func() {
|
||||||
|
<-ptySess.Done()
|
||||||
|
select {
|
||||||
|
case writeCh <- []byte("\r\n\033[33m[Sesija završena]\033[0m\r\n"):
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// WebSocket → PTY (read pump)
|
||||||
|
for {
|
||||||
|
_, msg, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||||
|
log.Printf("WebSocket read error: %v", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for resize message
|
||||||
|
var resize wsResizeMsg
|
||||||
|
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 {
|
||||||
|
log.Printf("PTY resize error: %v", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular keyboard input → PTY
|
||||||
|
if _, err := ptySess.WriteInput(msg); err != nil {
|
||||||
|
log.Printf("PTY write error: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(writeCh)
|
||||||
|
<-writeDone
|
||||||
|
}
|
||||||
@ -1,23 +1,115 @@
|
|||||||
|
/* === CSS varijable — teme === */
|
||||||
|
:root,
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-page: #1a1a2e;
|
||||||
|
--bg-panel: #16213e;
|
||||||
|
--bg-deep: #111;
|
||||||
|
--bg-hover: #0f3460;
|
||||||
|
--border: #0f3460;
|
||||||
|
--border-light: #333;
|
||||||
|
--border-disabled: #444;
|
||||||
|
--accent: #e94560;
|
||||||
|
--success: #4ecca3;
|
||||||
|
--info: #6ec6ff;
|
||||||
|
--warning: #ffd93d;
|
||||||
|
--text: #eee;
|
||||||
|
--text-light: #ddd;
|
||||||
|
--text-secondary: #aaa;
|
||||||
|
--text-muted: #888;
|
||||||
|
--text-dim: #666;
|
||||||
|
--text-disabled: #555;
|
||||||
|
--text-on-color: #1a1a2e;
|
||||||
|
--overlay: rgba(0,0,0,0.6);
|
||||||
|
--shadow: rgba(0,0,0,0.4);
|
||||||
|
--drag-over: rgba(15, 52, 96, 0.3);
|
||||||
|
--accent-shadow: rgba(233, 69, 96, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-page: #f0f2f5;
|
||||||
|
--bg-panel: #ffffff;
|
||||||
|
--bg-deep: #f5f6fa;
|
||||||
|
--bg-hover: #e0e5ed;
|
||||||
|
--border: #c9d1db;
|
||||||
|
--border-light: #d8dee6;
|
||||||
|
--border-disabled: #ccc;
|
||||||
|
--accent: #d63851;
|
||||||
|
--success: #1a8a6a;
|
||||||
|
--info: #2b7dbd;
|
||||||
|
--warning: #b8860b;
|
||||||
|
--text: #1e293b;
|
||||||
|
--text-light: #334155;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-dim: #94a3b8;
|
||||||
|
--text-disabled: #a0aec0;
|
||||||
|
--text-on-color: #ffffff;
|
||||||
|
--overlay: rgba(0,0,0,0.35);
|
||||||
|
--shadow: rgba(0,0,0,0.12);
|
||||||
|
--drag-over: rgba(59, 130, 246, 0.1);
|
||||||
|
--accent-shadow: rgba(214, 56, 81, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Reset === */
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
background: #1a1a2e;
|
background: var(--bg-page);
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Header === */
|
||||||
.header {
|
.header {
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
background: #16213e;
|
background: var(--bg-panel);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 2px solid #0f3460;
|
border-bottom: 2px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 { font-size: 1.4em; }
|
.header h1 { font-size: 1.4em; }
|
||||||
.header .version { color: #888; font-size: 0.9em; }
|
.header .version { color: var(--text-muted); font-size: 0.9em; }
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Theme toggle === */
|
||||||
|
.theme-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Board (Kanban) === */
|
||||||
.board {
|
.board {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
@ -27,7 +119,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
background: #16213e;
|
background: var(--bg-panel);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
@ -37,21 +129,22 @@ body {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
border-bottom: 2px solid #0f3460;
|
border-bottom: 2px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-count {
|
.column-count {
|
||||||
background: #0f3460;
|
background: var(--bg-hover);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Task cards === */
|
||||||
.task-card {
|
.task-card {
|
||||||
background: #1a1a2e;
|
background: var(--bg-page);
|
||||||
border: 1px solid #333;
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@ -60,13 +153,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-card:hover {
|
.task-card:hover {
|
||||||
border-color: #e94560;
|
border-color: var(--accent);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-id {
|
.task-id {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #e94560;
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-title {
|
.task-title {
|
||||||
@ -77,16 +170,16 @@ body {
|
|||||||
.task-meta {
|
.task-meta {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-deps {
|
.task-deps {
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: #666;
|
color: var(--text-dim);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Task detail overlay (modal) */
|
/* === Task detail modal === */
|
||||||
#task-detail {
|
#task-detail {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -95,7 +188,7 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: var(--overlay);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@ -105,8 +198,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-inner {
|
.detail-inner {
|
||||||
background: #16213e;
|
background: var(--bg-panel);
|
||||||
border: 1px solid #0f3460;
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
width: 700px;
|
width: 700px;
|
||||||
@ -122,14 +215,14 @@ body {
|
|||||||
top: 12px;
|
top: 12px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
font-size: 1.4em;
|
font-size: 1.4em;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-close:hover { color: #e94560; }
|
.detail-close:hover { color: var(--accent); }
|
||||||
|
|
||||||
.detail-meta { margin-top: 12px; font-size: 0.9em; color: #aaa; }
|
.detail-meta { margin-top: 12px; font-size: 0.9em; color: var(--text-secondary); }
|
||||||
.detail-meta p { margin-bottom: 4px; }
|
.detail-meta p { margin-bottom: 4px; }
|
||||||
|
|
||||||
.detail-actions {
|
.detail-actions {
|
||||||
@ -139,23 +232,33 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Buttons === */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #333;
|
border: 1px solid var(--border-light);
|
||||||
background: #1a1a2e;
|
background: var(--bg-page);
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover { background: #0f3460; }
|
.btn:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.btn-move { border-color: var(--accent); }
|
||||||
|
|
||||||
.btn-move { border-color: #e94560; }
|
|
||||||
/* Task action buttons */
|
|
||||||
.task-action {
|
.task-action {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@ -166,27 +269,27 @@ body {
|
|||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-run { border-color: #4ecca3; color: #4ecca3; }
|
.btn-run { border-color: var(--success); color: var(--success); }
|
||||||
.btn-run:hover { background: #4ecca3; color: #1a1a2e; }
|
.btn-run:hover { background: var(--success); color: var(--text-on-color); }
|
||||||
|
|
||||||
.btn-review { border-color: #6ec6ff; color: #6ec6ff; }
|
.btn-review { border-color: var(--info); color: var(--info); }
|
||||||
.btn-review:hover { background: #6ec6ff; color: #1a1a2e; }
|
.btn-review:hover { background: var(--info); color: var(--text-on-color); }
|
||||||
|
|
||||||
.btn-approve { border-color: #ffd93d; color: #ffd93d; }
|
.btn-approve { border-color: var(--warning); color: var(--warning); }
|
||||||
.btn-approve:hover { background: #ffd93d; color: #1a1a2e; }
|
.btn-approve:hover { background: var(--warning); color: var(--text-on-color); }
|
||||||
|
|
||||||
.btn-report { border-color: #888; color: #888; }
|
.btn-report { border-color: var(--text-muted); color: var(--text-muted); }
|
||||||
.btn-report:hover { background: #888; color: #1a1a2e; }
|
.btn-report:hover { background: var(--text-muted); color: var(--text-on-color); }
|
||||||
|
|
||||||
.btn-blocked {
|
.btn-blocked {
|
||||||
border-color: #444;
|
border-color: var(--border-disabled);
|
||||||
color: #555;
|
color: var(--text-disabled);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-running {
|
.btn-running {
|
||||||
border-color: #e94560;
|
border-color: var(--accent);
|
||||||
color: #e94560;
|
color: var(--accent);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
animation: pulse 1.5s ease infinite;
|
animation: pulse 1.5s ease infinite;
|
||||||
}
|
}
|
||||||
@ -195,32 +298,28 @@ body {
|
|||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.5; }
|
50% { opacity: 0.5; }
|
||||||
}
|
}
|
||||||
.btn-success { border-color: #4ecca3; color: #4ecca3; }
|
|
||||||
.btn-success:hover { background: #4ecca3; color: #1a1a2e; }
|
|
||||||
|
|
||||||
.detail-content {
|
.btn-success { border-color: var(--success); color: var(--success); }
|
||||||
margin-top: 16px;
|
.btn-success:hover { background: var(--success); color: var(--text-on-color); }
|
||||||
padding: 12px;
|
|
||||||
background: #111;
|
.btn-active {
|
||||||
border-radius: 6px;
|
background: var(--bg-hover);
|
||||||
max-height: 60vh;
|
border-color: var(--accent);
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sortable column-tasks container */
|
/* === Sortable / Drag & Drop === */
|
||||||
.column-tasks {
|
.column-tasks {
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drag & Drop styles */
|
|
||||||
.task-ghost {
|
.task-ghost {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
border: 2px dashed #e94560;
|
border: 2px dashed var(--accent);
|
||||||
background: #0f3460;
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-chosen {
|
.task-chosen {
|
||||||
box-shadow: 0 4px 16px rgba(233, 69, 96, 0.3);
|
box-shadow: 0 4px 16px var(--accent-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-drag {
|
.task-drag {
|
||||||
@ -228,27 +327,26 @@ body {
|
|||||||
transform: rotate(2deg);
|
transform: rotate(2deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drop zone highlight */
|
|
||||||
.column-tasks.sortable-drag-over {
|
.column-tasks.sortable-drag-over {
|
||||||
background: rgba(15, 52, 96, 0.3);
|
background: var(--drag-over);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Flash animations */
|
/* === Flash animations === */
|
||||||
@keyframes flash-success {
|
@keyframes flash-success {
|
||||||
0% { background: #4ecca3; }
|
0% { background: var(--success); }
|
||||||
100% { background: #1a1a2e; }
|
100% { background: var(--bg-page); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flash-error {
|
@keyframes flash-error {
|
||||||
0% { background: #e94560; }
|
0% { background: var(--accent); }
|
||||||
100% { background: #1a1a2e; }
|
100% { background: var(--bg-page); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.flash-success { animation: flash-success 0.5s ease; }
|
.flash-success { animation: flash-success 0.5s ease; }
|
||||||
.flash-error { animation: flash-error 0.5s ease; }
|
.flash-error { animation: flash-error 0.5s ease; }
|
||||||
|
|
||||||
/* Toast notifications */
|
/* === Toast === */
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
@ -267,32 +365,25 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast-success {
|
.toast-success {
|
||||||
background: #4ecca3;
|
background: var(--success);
|
||||||
color: #1a1a2e;
|
color: var(--text-on-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-error {
|
.toast-error {
|
||||||
background: #e94560;
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header right section */
|
/* === Search === */
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search */
|
|
||||||
.search-wrapper {
|
.search-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-wrapper input {
|
.search-wrapper input {
|
||||||
background: #1a1a2e;
|
background: var(--bg-page);
|
||||||
border: 1px solid #333;
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
width: 220px;
|
width: 220px;
|
||||||
@ -301,7 +392,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-wrapper input:focus {
|
.search-wrapper input:focus {
|
||||||
border-color: #e94560;
|
border-color: var(--accent);
|
||||||
width: 300px;
|
width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,9 +403,9 @@ body {
|
|||||||
width: 400px;
|
width: 400px;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #16213e;
|
background: var(--bg-panel);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
box-shadow: 0 8px 24px var(--shadow);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
@ -327,8 +418,8 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
border-bottom: 1px solid #0f3460;
|
border-bottom: 1px solid var(--border);
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,7 +428,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-result:hover {
|
.search-result:hover {
|
||||||
background: #0f3460;
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-result-header {
|
.search-result-header {
|
||||||
@ -357,19 +448,19 @@ body {
|
|||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #0f3460;
|
background: var(--bg-hover);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-status-done { color: #4ecca3; }
|
.search-status-done { color: var(--success); }
|
||||||
.search-status-active { color: #e94560; }
|
.search-status-active { color: var(--accent); }
|
||||||
.search-status-review { color: #ffd93d; }
|
.search-status-review { color: var(--warning); }
|
||||||
.search-status-ready { color: #6ec6ff; }
|
.search-status-ready { color: var(--info); }
|
||||||
.search-status-backlog { color: #888; }
|
.search-status-backlog { color: var(--text-muted); }
|
||||||
|
|
||||||
.search-snippet {
|
.search-snippet {
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -380,23 +471,18 @@ body {
|
|||||||
.search-empty {
|
.search-empty {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Navigation */
|
/* === Navigation === */
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-active {
|
/* === Docs === */
|
||||||
background: #0f3460;
|
|
||||||
border-color: #e94560;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Docs — full height */
|
|
||||||
.docs-container {
|
.docs-container {
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
height: calc(100vh - 60px);
|
height: calc(100vh - 60px);
|
||||||
@ -414,7 +500,7 @@ body {
|
|||||||
|
|
||||||
.docs-sidebar h2 {
|
.docs-sidebar h2 {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
color: #e94560;
|
color: var(--accent);
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -429,9 +515,9 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: #16213e;
|
background: var(--bg-panel);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
@ -439,7 +525,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.doc-item:hover {
|
.doc-item:hover {
|
||||||
background: #0f3460;
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-icon { font-size: 1em; }
|
.doc-icon { font-size: 1em; }
|
||||||
@ -450,7 +536,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.docs-breadcrumbs a {
|
.docs-breadcrumbs a {
|
||||||
color: #e94560;
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,7 +545,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-sep {
|
.breadcrumb-sep {
|
||||||
color: #555;
|
color: var(--text-dim);
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -473,7 +559,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.docs-content {
|
.docs-content {
|
||||||
background: #16213e;
|
background: var(--bg-panel);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
@ -481,24 +567,24 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.docs-content h1, .docs-content h2, .docs-content h3 {
|
.docs-content h1, .docs-content h2, .docs-content h3 {
|
||||||
color: #e94560;
|
color: var(--accent);
|
||||||
margin-top: 1.2em;
|
margin-top: 1.2em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-content h1 { font-size: 1.5em; border-bottom: 1px solid #333; padding-bottom: 8px; }
|
.docs-content h1 { font-size: 1.5em; border-bottom: 1px solid var(--border-light); padding-bottom: 8px; }
|
||||||
.docs-content h2 { font-size: 1.2em; }
|
.docs-content h2 { font-size: 1.2em; }
|
||||||
.docs-content h3 { font-size: 1.05em; }
|
.docs-content h3 { font-size: 1.05em; }
|
||||||
|
|
||||||
.docs-content a {
|
.docs-content a {
|
||||||
color: #4ecca3;
|
color: var(--success);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-content a:hover { text-decoration: underline; }
|
.docs-content a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.docs-content code {
|
.docs-content code {
|
||||||
background: #1a1a2e;
|
background: var(--bg-page);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
@ -506,7 +592,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.docs-content pre {
|
.docs-content pre {
|
||||||
background: #1a1a2e;
|
background: var(--bg-page);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@ -525,13 +611,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.docs-content th, .docs-content td {
|
.docs-content th, .docs-content td {
|
||||||
border: 1px solid #333;
|
border: 1px solid var(--border-light);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-content th {
|
.docs-content th {
|
||||||
background: #0f3460;
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-content ul, .docs-content ol {
|
.docs-content ul, .docs-content ol {
|
||||||
@ -542,19 +628,19 @@ body {
|
|||||||
.docs-content li { margin: 4px 0; }
|
.docs-content li { margin: 4px 0; }
|
||||||
|
|
||||||
.docs-content blockquote {
|
.docs-content blockquote {
|
||||||
border-left: 3px solid #e94560;
|
border-left: 3px solid var(--accent);
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
color: #aaa;
|
color: var(--text-secondary);
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-content hr {
|
.docs-content hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #333;
|
border-top: 1px solid var(--border-light);
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Console — fullscreen */
|
/* === Console === */
|
||||||
.console-container {
|
.console-container {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
height: calc(100vh - 60px);
|
height: calc(100vh - 60px);
|
||||||
@ -574,7 +660,7 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #16213e;
|
background: var(--bg-panel);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@ -584,7 +670,7 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-bottom: 1px solid #0f3460;
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -592,66 +678,49 @@ body {
|
|||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #0f3460;
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-idle { color: #888; }
|
.session-idle { color: var(--text-muted); }
|
||||||
.session-running { color: #4ecca3; }
|
.session-running { color: var(--success); }
|
||||||
|
|
||||||
.btn-kill {
|
.btn-kill {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
border-color: #e94560;
|
border-color: var(--accent);
|
||||||
color: #e94560;
|
color: var(--accent);
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-output {
|
.console-terminal {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-terminal .xterm {
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-terminal .xterm-viewport {
|
||||||
overflow-y: auto;
|
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 {
|
.console-input-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-top: 1px solid #0f3460;
|
border-top: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-input {
|
.console-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #1a1a2e;
|
background: var(--bg-page);
|
||||||
border: 1px solid #333;
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
@ -659,7 +728,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.console-input:focus {
|
.console-input:focus {
|
||||||
border-color: #e94560;
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-input:disabled {
|
.console-input:disabled {
|
||||||
@ -673,7 +742,7 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Submit / Prijava */
|
/* === Submit / Prijava === */
|
||||||
.submit-container {
|
.submit-container {
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@ -690,19 +759,19 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-mode {
|
.btn-mode {
|
||||||
border-color: #333;
|
border-color: var(--border-light);
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-mode.active {
|
.btn-mode.active {
|
||||||
border-color: #e94560;
|
border-color: var(--accent);
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
background: #0f3460;
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-mode h2 {
|
.submit-mode h2 {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
color: #e94560;
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
@ -713,15 +782,15 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
color: #aaa;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #1a1a2e;
|
background: var(--bg-page);
|
||||||
border: 1px solid #333;
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
outline: none;
|
outline: none;
|
||||||
@ -730,7 +799,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus {
|
.form-input:focus {
|
||||||
border-color: #e94560;
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea.form-input {
|
textarea.form-input {
|
||||||
@ -748,7 +817,7 @@ textarea.form-input {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-msg {
|
.submit-msg {
|
||||||
@ -760,16 +829,16 @@ textarea.form-input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.submit-success {
|
.submit-success {
|
||||||
background: #4ecca3;
|
background: var(--success);
|
||||||
color: #1a1a2e;
|
color: var(--text-on-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-error {
|
.submit-error {
|
||||||
background: #e94560;
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chat (operator mode) */
|
/* === Chat (operator mode) === */
|
||||||
#mode-operator {
|
#mode-operator {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -780,7 +849,7 @@ textarea.form-input {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #111;
|
background: var(--bg-deep);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
@ -799,11 +868,11 @@ textarea.form-input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-user {
|
.chat-user {
|
||||||
color: #6ec6ff;
|
color: var(--info);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-bot {
|
.chat-bot {
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-text {
|
.chat-text {
|
||||||
@ -816,7 +885,12 @@ textarea.form-input {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* === Placeholder text === */
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Responsive === */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.board { grid-template-columns: repeat(3, 1fr); }
|
.board { grid-template-columns: repeat(3, 1fr); }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,13 +5,21 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>KAOS — Konzola</title>
|
<title>KAOS — Konzola</title>
|
||||||
|
<script>(function(){var m=localStorage.getItem('kaos-theme')||'dark',t=m;if(m==='auto'){t=window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}document.documentElement.setAttribute('data-theme',t)})()</script>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/theme.js"></script>
|
||||||
</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">
|
||||||
|
<button class="theme-btn" data-theme-mode="light" onclick="setTheme('light')" title="Svetla tema">☀️</button>
|
||||||
|
<button class="theme-btn" data-theme-mode="dark" onclick="setTheme('dark')" title="Tamna tema">🌙</button>
|
||||||
|
<button class="theme-btn" data-theme-mode="auto" onclick="setTheme('auto')" title="Sistemska tema">🔄</button>
|
||||||
|
</div>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/" class="btn">Kanban</a>
|
<a href="/" class="btn">Kanban</a>
|
||||||
<a href="/docs" class="btn">Dokumenti</a>
|
<a href="/docs" class="btn">Dokumenti</a>
|
||||||
@ -33,9 +41,9 @@
|
|||||||
<span class="session-status" id="status-1">idle</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>
|
<button class="btn btn-kill" id="kill-1" onclick="killSession(1)" style="display:none">Prekini</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="console-output" id="output-1"></div>
|
<div class="console-terminal" id="terminal-1"></div>
|
||||||
<div class="console-input-row">
|
<div class="console-input-row">
|
||||||
<input type="text" id="input-1" class="console-input" placeholder="Komanda..." onkeydown="handleKey(event, 1)" autocomplete="off">
|
<input type="text" id="input-1" class="console-input" placeholder="Komanda za claude..." onkeydown="handleKey(event, 1)" autocomplete="off">
|
||||||
<button class="btn btn-move" onclick="sendCommand(1)">⏎</button>
|
<button class="btn btn-move" onclick="sendCommand(1)">⏎</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -46,19 +54,142 @@
|
|||||||
<span class="session-status" id="status-2">idle</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>
|
<button class="btn btn-kill" id="kill-2" onclick="killSession(2)" style="display:none">Prekini</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="console-output" id="output-2"></div>
|
<div class="console-terminal" id="terminal-2"></div>
|
||||||
<div class="console-input-row">
|
<div class="console-input-row">
|
||||||
<input type="text" id="input-2" class="console-input" placeholder="Komanda..." onkeydown="handleKey(event, 2)" autocomplete="off">
|
<input type="text" id="input-2" class="console-input" placeholder="Komanda za claude..." onkeydown="handleKey(event, 2)" autocomplete="off">
|
||||||
<button class="btn btn-move" onclick="sendCommand(2)">⏎</button>
|
<button class="btn btn-move" onclick="sendCommand(2)">⏎</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
// ── Terminal themes ──────────────────────────────────
|
||||||
|
var TERM_THEMES = {
|
||||||
|
dark: {
|
||||||
|
background: '#111', foreground: '#e0e0e0', cursor: '#e94560', cursorAccent: '#111',
|
||||||
|
selectionBackground: 'rgba(233,69,96,0.3)',
|
||||||
|
black: '#111', red: '#f44336', green: '#4caf50', yellow: '#ff9800',
|
||||||
|
blue: '#2196f3', magenta: '#e94560', cyan: '#00bcd4', white: '#e0e0e0',
|
||||||
|
brightBlack: '#6c6c80', brightRed: '#ff6b81', brightGreen: '#66bb6a', brightYellow: '#ffb74d',
|
||||||
|
brightBlue: '#64b5f6', brightMagenta: '#ff6b81', brightCyan: '#4dd0e1', brightWhite: '#ffffff'
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
background: '#f5f6fa', foreground: '#1e293b', cursor: '#d63851', cursorAccent: '#f5f6fa',
|
||||||
|
selectionBackground: 'rgba(214,56,81,0.15)',
|
||||||
|
black: '#1e293b', red: '#dc322f', green: '#859900', yellow: '#b58900',
|
||||||
|
blue: '#268bd2', magenta: '#d63851', cyan: '#2aa198', white: '#eee8d5',
|
||||||
|
brightBlack: '#586e75', brightRed: '#cb4b16', brightGreen: '#586e75', brightYellow: '#657b83',
|
||||||
|
brightBlue: '#839496', brightMagenta: '#6c71c4', brightCyan: '#93a1a1', brightWhite: '#002b36'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTermTheme() {
|
||||||
|
var t = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||||
|
return TERM_THEMES[t] || TERM_THEMES.dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session state ────────────────────────────────────
|
||||||
|
var sessions = [{}, {}];
|
||||||
var historyIdx = [0, 0];
|
var historyIdx = [0, 0];
|
||||||
var cmdHistory = [[], []];
|
var cmdHistory = [[], []];
|
||||||
|
|
||||||
|
function initTerminal(idx) {
|
||||||
|
var num = idx + 1;
|
||||||
|
var containerEl = document.getElementById('terminal-' + num);
|
||||||
|
var theme = getTermTheme();
|
||||||
|
|
||||||
|
var term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: 'block',
|
||||||
|
cursorInactiveStyle: 'outline',
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
|
||||||
|
theme: theme,
|
||||||
|
allowProposedApi: true,
|
||||||
|
scrollback: 10000,
|
||||||
|
convertEol: false,
|
||||||
|
drawBoldTextInBrightColors: true
|
||||||
|
});
|
||||||
|
|
||||||
|
var fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.loadAddon(new WebLinksAddon.WebLinksAddon());
|
||||||
|
term.open(containerEl);
|
||||||
|
|
||||||
|
// Keyboard input → WebSocket
|
||||||
|
term.onData(function(data) {
|
||||||
|
var ws = sessions[idx].ws;
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize → WebSocket
|
||||||
|
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() { fitAddon.fit(); }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSocket connection ─────────────────────────────
|
||||||
|
function connectWS(idx) {
|
||||||
|
var num = idx + 1;
|
||||||
|
var sess = sessions[idx];
|
||||||
|
|
||||||
|
if (sess.ws) {
|
||||||
|
sess.ws.close();
|
||||||
|
sess.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
var url = proto + '//' + location.host + '/console/ws/' + num;
|
||||||
|
var ws = new WebSocket(url);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
ws.onopen = function() {
|
||||||
|
setSessionUI(num, 'running');
|
||||||
|
// Send initial size
|
||||||
|
var term = sess.term;
|
||||||
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||||
|
term.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function(event) {
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
sess.term.write(new Uint8Array(event.data));
|
||||||
|
} else {
|
||||||
|
sess.term.write(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = function() {
|
||||||
|
sess.ws = null;
|
||||||
|
setSessionUI(num, 'idle');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = function() {
|
||||||
|
sess.ws = null;
|
||||||
|
setSessionUI(num, 'idle');
|
||||||
|
};
|
||||||
|
|
||||||
|
sess.ws = ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command handling ─────────────────────────────────
|
||||||
function handleKey(e, session) {
|
function handleKey(e, session) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
sendCommand(session);
|
sendCommand(session);
|
||||||
@ -87,11 +218,11 @@ function sendCommand(session) {
|
|||||||
var idx = session - 1;
|
var idx = session - 1;
|
||||||
cmdHistory[idx].push(cmd);
|
cmdHistory[idx].push(cmd);
|
||||||
historyIdx[idx] = cmdHistory[idx].length;
|
historyIdx[idx] = cmdHistory[idx].length;
|
||||||
|
|
||||||
var output = document.getElementById('output-' + session);
|
|
||||||
output.innerHTML += '<div class="console-cmd">> ' + escapeHtml(cmd) + '</div>';
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
|
||||||
|
// Clear terminal for new command
|
||||||
|
sessions[idx].term.clear();
|
||||||
|
|
||||||
setSessionUI(session, 'running');
|
setSessionUI(session, 'running');
|
||||||
|
|
||||||
fetch('/console/exec', {
|
fetch('/console/exec', {
|
||||||
@ -102,7 +233,7 @@ function sendCommand(session) {
|
|||||||
.then(function(resp) {
|
.then(function(resp) {
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
return resp.json().then(function(data) {
|
return resp.json().then(function(data) {
|
||||||
output.innerHTML += '<div class="console-error">' + escapeHtml(data.error) + '</div>';
|
sessions[idx].term.write('\r\n\x1b[31m' + data.error + '\x1b[0m\r\n');
|
||||||
setSessionUI(session, 'idle');
|
setSessionUI(session, 'idle');
|
||||||
throw new Error(data.error);
|
throw new Error(data.error);
|
||||||
});
|
});
|
||||||
@ -111,40 +242,23 @@ function sendCommand(session) {
|
|||||||
})
|
})
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
streamOutput(session, data.exec_id);
|
// Connect WebSocket to the PTY session
|
||||||
|
connectWS(idx);
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
setSessionUI(session, 'idle');
|
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) {
|
function killSession(session) {
|
||||||
fetch('/console/kill/' + session, {method: 'POST'})
|
fetch('/console/kill/' + session, {method: 'POST'})
|
||||||
.then(function() {
|
.then(function() {
|
||||||
var output = document.getElementById('output-' + session);
|
var idx = session - 1;
|
||||||
output.innerHTML += '<div class="console-error">--- prekinuto ---</div>';
|
sessions[idx].term.write('\r\n\x1b[33m--- prekinuto ---\x1b[0m\r\n');
|
||||||
|
if (sessions[idx].ws) {
|
||||||
|
sessions[idx].ws.close();
|
||||||
|
sessions[idx].ws = null;
|
||||||
|
}
|
||||||
setSessionUI(session, 'idle');
|
setSessionUI(session, 'idle');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -153,7 +267,6 @@ function setSessionUI(session, status) {
|
|||||||
document.getElementById('status-' + session).textContent = status;
|
document.getElementById('status-' + session).textContent = status;
|
||||||
document.getElementById('status-' + session).className = 'session-status session-' + status;
|
document.getElementById('status-' + session).className = 'session-status session-' + status;
|
||||||
document.getElementById('kill-' + session).style.display = status === 'running' ? 'inline-block' : 'none';
|
document.getElementById('kill-' + session).style.display = status === 'running' ? 'inline-block' : 'none';
|
||||||
document.getElementById('input-' + session).disabled = status === 'running';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePanel2() {
|
function togglePanel2() {
|
||||||
@ -162,17 +275,44 @@ function togglePanel2() {
|
|||||||
if (panel.style.display === 'none') {
|
if (panel.style.display === 'none') {
|
||||||
panel.style.display = 'flex';
|
panel.style.display = 'flex';
|
||||||
btn.textContent = '- Sesija 2';
|
btn.textContent = '- Sesija 2';
|
||||||
|
// Initialize terminal 2 if not yet done
|
||||||
|
if (!sessions[1].term) {
|
||||||
|
initTerminal(1);
|
||||||
|
} else {
|
||||||
|
sessions[1].fitAddon.fit();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
panel.style.display = 'none';
|
panel.style.display = 'none';
|
||||||
btn.textContent = '+ Sesija 2';
|
btn.textContent = '+ Sesija 2';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
// ── Theme sync ───────────────────────────────────────
|
||||||
var div = document.createElement('div');
|
var origSetTheme = window.setTheme;
|
||||||
div.textContent = text;
|
window.setTheme = function(mode) {
|
||||||
return div.innerHTML;
|
if (origSetTheme) origSetTheme(mode);
|
||||||
|
// Update terminal themes after a tick
|
||||||
|
setTimeout(function() {
|
||||||
|
var theme = getTermTheme();
|
||||||
|
for (var i = 0; i < 2; i++) {
|
||||||
|
if (sessions[i].term) {
|
||||||
|
sessions[i].term.options.theme = theme;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Window resize ────────────────────────────────────
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
for (var i = 0; i < 2; i++) {
|
||||||
|
if (sessions[i].fitAddon) {
|
||||||
|
sessions[i].fitAddon.fit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Initialize ───────────────────────────────────────
|
||||||
|
initTerminal(0);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user