feat: add supervisor prototype with embedded frontend
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Build outputs
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# Frontend generated artifacts
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
|
||||||
|
# Embedded frontend build output (keep lightweight placeholder only)
|
||||||
|
internal/static/dist/assets/
|
||||||
|
|
||||||
|
# OS/editor noise
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
48
AGENTS.md
Normal file
48
AGENTS.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
- `cmd/supervisor/`: Go entrypoint (`main.go`) for the single executable.
|
||||||
|
- `internal/`: backend application code.
|
||||||
|
- `internal/httpserver/`: HTTP routes, middleware, REST and WebSocket handlers.
|
||||||
|
- `internal/session/`: PTY process runtime, session lifecycle, buffering, and manager logic.
|
||||||
|
- `internal/static/`: embedded frontend assets (`go:embed` serves `internal/static/dist`).
|
||||||
|
- `web/`: Vue 3 + TypeScript frontend (Vite, Pinia, Vue Router, xterm).
|
||||||
|
- `scripts/`: build helpers for frontend and full build pipeline.
|
||||||
|
- `bin/`: compiled artifacts (generated).
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `make frontend-build`: installs web dependencies, runs Vite production build, copies `web/dist` to `internal/static/dist`.
|
||||||
|
- `make backend-build`: builds Go binary at `bin/supervisor`.
|
||||||
|
- `make build`: full build (frontend + backend).
|
||||||
|
- `make run`: builds everything and starts the app.
|
||||||
|
- `cd web && npm run dev`: frontend dev server with proxy to backend (`:8080`).
|
||||||
|
- `go test ./...`: run backend unit tests (when tests are present).
|
||||||
|
|
||||||
|
Do not edit `internal/static/dist` by hand; it is generated from `web/dist` by `make frontend-build`.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- Go: format with `gofmt`; package names lowercase; exported symbols in `CamelCase`; errors as `err` with wrapped context.
|
||||||
|
- Vue/TS: components in `PascalCase.vue`, stores and API modules in `camelCase.ts`.
|
||||||
|
- Keep handlers thin: validation + transport only; put lifecycle/process logic in `internal/session`.
|
||||||
|
- Prefer explicit JSON DTOs over ad-hoc maps except small error responses.
|
||||||
|
- Keep session state rules centralized in `internal/session/manager.go` (example: only non-running sessions can be deleted).
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
- Backend tests use Go’s standard `testing` package.
|
||||||
|
- Place tests alongside code as `*_test.go` (example: `internal/session/manager_test.go`).
|
||||||
|
- Prioritize tests for session lifecycle (`create/start/input/resize/stop`) and WebSocket message handling.
|
||||||
|
- Frontend automated tests are not configured yet; if added, keep them under `web/src/**/__tests__`.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
- Existing history uses short, direct commit messages (often imperative). Keep subject concise, one logical change per commit.
|
||||||
|
- Recommended pattern: `area: action` (example: `session: handle resize messages`).
|
||||||
|
- PRs should include a summary of behavior changes.
|
||||||
|
- List impacted paths and manual verification commands.
|
||||||
|
- Add screenshots/GIFs for UI changes.
|
||||||
|
- Link the related issue/task when available.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
- Default bind is `:8080`; override with `SUPERVISOR_ADDR`.
|
||||||
|
- Do not commit secrets or environment-specific credentials.
|
||||||
|
- PTY commands are user-supplied; keep future changes mindful of command execution risk.
|
||||||
|
- Session deletion endpoint is `DELETE /api/sessions/{id}`; preserve current API semantics (`409` for running sessions, `204` on success).
|
||||||
12
Makefile
Normal file
12
Makefile
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
.PHONY: frontend-build backend-build build run
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
./scripts/build-frontend.sh
|
||||||
|
|
||||||
|
backend-build:
|
||||||
|
go build -o ./bin/supervisor ./cmd/supervisor
|
||||||
|
|
||||||
|
build: frontend-build backend-build
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./bin/supervisor
|
||||||
129
README.md
129
README.md
@ -1,14 +1,123 @@
|
|||||||
# Supervisor
|
# Supervisor Prototype
|
||||||
|
|
||||||
Supervisor is a lightweight multi-agent orchestration tool for LLM workflows.
|
Supervisor je prvý funkčný prototyp ako jedna Go aplikácia s embednutým Vue frontendom.
|
||||||
|
|
||||||
It runs multiple AI agents under the control of a central supervisor that plans tasks,
|
## Čo projekt robí
|
||||||
delegates them to worker agents, and evaluates their results. The system provides
|
|
||||||
a web interface to observe, control, and intervene in agent workflows in real time.
|
|
||||||
|
|
||||||
Designed for development workflows, Supervisor allows agents to analyze code,
|
- spúšťa paralelné externé konzolové procesy v PTY session
|
||||||
generate patches, run tools, review changes, and collaborate on complex tasks
|
- session bežia ďalej nezávisle od pripojeného browsera
|
||||||
while keeping a human in the loop when needed.
|
- streamuje live terminál výstup cez WebSocket
|
||||||
|
- prijíma input z klávesnice (xterm.js) aj programový input cez HTTP API
|
||||||
|
- drží metadata a scrollback output v in-memory store
|
||||||
|
- podporuje odstránenie ukončených session (status `stopped`, `exited`, `error`)
|
||||||
|
|
||||||
The core is written in Go and intended to run inside an isolated environment
|
## Architektúra
|
||||||
(such as a VM or container) with secure access through a web interface.
|
|
||||||
|
- `cmd/supervisor/main.go`: entrypoint
|
||||||
|
- `internal/app`: zostavenie aplikácie, wiring komponentov
|
||||||
|
- `internal/httpserver`: čistý `net/http` router, middleware, API + WS handlery
|
||||||
|
- `internal/session`: PTY procesy, lifecycle session, output buffer, subscribe mechanizmus
|
||||||
|
- `internal/store/memory`: in-memory persistencia metadata session
|
||||||
|
- `internal/supervisor`: skeleton orchestration vrstvy pre budúce supervisor/worker rozšírenie
|
||||||
|
- `web/`: samostatný Vue 3 + Vite frontend
|
||||||
|
- `internal/static`: embedded statické assets servované zo zabudovaného FS
|
||||||
|
|
||||||
|
## API endpointy
|
||||||
|
|
||||||
|
- `GET /healthz`
|
||||||
|
- `GET /api/sessions`
|
||||||
|
- `POST /api/sessions`
|
||||||
|
- `GET /api/sessions/{id}`
|
||||||
|
- `POST /api/sessions/{id}/input`
|
||||||
|
- `POST /api/sessions/{id}/stop`
|
||||||
|
- `DELETE /api/sessions/{id}`
|
||||||
|
- `GET /ws/sessions/{id}`
|
||||||
|
|
||||||
|
Poznámka k mazaniu:
|
||||||
|
|
||||||
|
- bežiacu session (`running`) nie je možné odstrániť (`409 Conflict`)
|
||||||
|
- neexistujúca session vracia `404`
|
||||||
|
- úspešné odstránenie vracia `204 No Content`
|
||||||
|
|
||||||
|
### Session model
|
||||||
|
|
||||||
|
Každá session obsahuje:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `name`
|
||||||
|
- `agentId`
|
||||||
|
- `command`
|
||||||
|
- `status`
|
||||||
|
- `createdAt`
|
||||||
|
- `startedAt`
|
||||||
|
- `exitedAt`
|
||||||
|
- `exitCode`
|
||||||
|
|
||||||
|
## WebSocket protokol
|
||||||
|
|
||||||
|
JSON envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "terminal.input | terminal.output | terminal.resize | session.status | error",
|
||||||
|
"session": "session-id",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Použité payloady:
|
||||||
|
|
||||||
|
- `terminal.input`: `{ "data": "..." }`
|
||||||
|
- `terminal.output`: `{ "data": "..." }`
|
||||||
|
- `terminal.resize`: `{ "cols": 120, "rows": 30 }`
|
||||||
|
- `session.status`: `{ "status": "running", "exitCode": null }`
|
||||||
|
- `error`: `{ "message": "..." }`
|
||||||
|
|
||||||
|
## Frontend poznámky
|
||||||
|
|
||||||
|
- layout má globálny reset okrajov (`body { margin: 0; }`)
|
||||||
|
- v sidebare je pri ukončených session tlačidlo `Remove`
|
||||||
|
- pri odstránení aktuálne otvorenej session UI presmeruje na dashboard
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Predpoklady:
|
||||||
|
|
||||||
|
- Go (1.23+)
|
||||||
|
- Node.js + npm
|
||||||
|
|
||||||
|
Frontend build do `web/dist` a následne sa skopíruje do `internal/static/dist` pre `go:embed`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make frontend-build
|
||||||
|
make backend-build
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spustenie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
alebo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/supervisor
|
||||||
|
```
|
||||||
|
|
||||||
|
Default adresa: `:8080`.
|
||||||
|
|
||||||
|
Konfigurácia:
|
||||||
|
|
||||||
|
- `SUPERVISOR_ADDR` (napr. `:9090`)
|
||||||
|
|
||||||
|
## Budúce rozšírenie
|
||||||
|
|
||||||
|
Štruktúra je pripravená na doplnenie:
|
||||||
|
|
||||||
|
- supervisor orchestration manager
|
||||||
|
- worker agents
|
||||||
|
- workflow runs
|
||||||
|
- audit log
|
||||||
|
- persistent store (DB)
|
||||||
|
|||||||
27
cmd/supervisor/main.go
Normal file
27
cmd/supervisor/main.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"supervisor/internal/app"
|
||||||
|
"supervisor/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
application, err := app.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize app: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if err := application.Run(ctx); err != nil {
|
||||||
|
log.Fatalf("application error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
go.mod
Normal file
8
go.mod
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
module supervisor
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/creack/pty v1.1.24
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
)
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
75
internal/app/app.go
Normal file
75
internal/app/app.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"supervisor/internal/config"
|
||||||
|
"supervisor/internal/httpserver"
|
||||||
|
"supervisor/internal/session"
|
||||||
|
"supervisor/internal/store/memory"
|
||||||
|
"supervisor/internal/supervisor"
|
||||||
|
"supervisor/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
cfg config.Config
|
||||||
|
logger *log.Logger
|
||||||
|
httpServer *http.Server
|
||||||
|
SessionManager *session.Manager
|
||||||
|
Supervisor *supervisor.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.Config) (*App, error) {
|
||||||
|
logger := util.NewLogger()
|
||||||
|
memStore := memory.NewStore()
|
||||||
|
sessionManager := session.NewManager(memStore, nil)
|
||||||
|
supervisorManager := supervisor.NewManager()
|
||||||
|
|
||||||
|
router, err := httpserver.NewRouter(httpserver.Dependencies{
|
||||||
|
Logger: logger,
|
||||||
|
Manager: sessionManager,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build router: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.Addr,
|
||||||
|
Handler: router,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &App{
|
||||||
|
cfg: cfg,
|
||||||
|
logger: logger,
|
||||||
|
httpServer: srv,
|
||||||
|
SessionManager: sessionManager,
|
||||||
|
Supervisor: supervisorManager,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Run(ctx context.Context) error {
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
a.logger.Printf("HTTP server listening on %s", a.cfg.Addr)
|
||||||
|
errCh <- a.httpServer.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = a.httpServer.Shutdown(shutdownCtx)
|
||||||
|
return nil
|
||||||
|
case err := <-errCh:
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
15
internal/config/config.go
Normal file
15
internal/config/config.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() Config {
|
||||||
|
addr := os.Getenv("SUPERVISOR_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8080"
|
||||||
|
}
|
||||||
|
return Config{Addr: addr}
|
||||||
|
}
|
||||||
10
internal/domain/agent.go
Normal file
10
internal/domain/agent.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Agent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
32
internal/domain/event.go
Normal file
32
internal/domain/event.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventTerminalOutput EventType = "terminal.output"
|
||||||
|
EventSessionStatus EventType = "session.status"
|
||||||
|
EventError EventType = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
SessionID string `json:"session"`
|
||||||
|
Payload any `json:"payload"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminalOutputEvent carries raw terminal bytes as UTF-8 text.
|
||||||
|
type TerminalOutputEvent struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionStatusEvent struct {
|
||||||
|
Status SessionStatus `json:"status"`
|
||||||
|
ExitCode *int `json:"exitCode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorEvent struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
20
internal/domain/run.go
Normal file
20
internal/domain/run.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type RunStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RunStatusPlanned RunStatus = "planned"
|
||||||
|
RunStatusRunning RunStatus = "running"
|
||||||
|
RunStatusDone RunStatus = "done"
|
||||||
|
RunStatusFailed RunStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Run struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
WorkflowID string `json:"workflowId"`
|
||||||
|
RequestedBy string `json:"requestedBy"`
|
||||||
|
Status RunStatus `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
25
internal/domain/session.go
Normal file
25
internal/domain/session.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type SessionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionStatusCreated SessionStatus = "created"
|
||||||
|
SessionStatusRunning SessionStatus = "running"
|
||||||
|
SessionStatusStopped SessionStatus = "stopped"
|
||||||
|
SessionStatusExited SessionStatus = "exited"
|
||||||
|
SessionStatusError SessionStatus = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AgentID string `json:"agentId"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Status SessionStatus `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||||
|
ExitedAt *time.Time `json:"exitedAt,omitempty"`
|
||||||
|
ExitCode *int `json:"exitCode,omitempty"`
|
||||||
|
}
|
||||||
13
internal/httpserver/handlers/health.go
Normal file
13
internal/httpserver/handlers/health.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HealthHandler struct{}
|
||||||
|
|
||||||
|
func (h HealthHandler) Handle(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
131
internal/httpserver/handlers/sessions.go
Normal file
131
internal/httpserver/handlers/sessions.go
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"supervisor/internal/domain"
|
||||||
|
"supervisor/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionService interface {
|
||||||
|
CreateSession(ctx context.Context, params session.CreateSessionParams) (domain.Session, error)
|
||||||
|
StartSession(ctx context.Context, id string) error
|
||||||
|
StopSession(ctx context.Context, id string) error
|
||||||
|
DeleteSession(ctx context.Context, id string) error
|
||||||
|
ListSessions(ctx context.Context) ([]domain.Session, error)
|
||||||
|
GetSession(ctx context.Context, id string) (domain.Session, error)
|
||||||
|
WriteInput(ctx context.Context, id string, input string) error
|
||||||
|
Resize(ctx context.Context, id string, cols, rows int) error
|
||||||
|
Subscribe(id string) (<-chan domain.Event, func(), error)
|
||||||
|
Scrollback(id string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionsHandler struct {
|
||||||
|
manager SessionService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionsHandler(manager SessionService) *SessionsHandler {
|
||||||
|
return &SessionsHandler{manager: manager}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SessionsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessions, err := h.manager.ListSessions(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SessionsHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req session.CreateSessionParams
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err := h.manager.CreateSession(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.manager.StartSession(r.Context(), created.ID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current, err := h.manager.GetSession(r.Context(), created.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SessionsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
item, err := h.manager.GetSession(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, session.ErrSessionNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SessionsHandler) Input(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
var req session.InputRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.manager.WriteInput(r.Context(), id, req.Input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SessionsHandler) Stop(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if err := h.manager.StopSession(r.Context(), id); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.manager.GetSession(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SessionsHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if err := h.manager.DeleteSession(r.Context(), id); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, session.ErrSessionNotFound):
|
||||||
|
writeError(w, http.StatusNotFound, err)
|
||||||
|
case errors.Is(err, session.ErrSessionRunning):
|
||||||
|
writeError(w, http.StatusConflict, err)
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, code int, err error) {
|
||||||
|
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
155
internal/httpserver/handlers/ws_terminal.go
Normal file
155
internal/httpserver/handlers/ws_terminal.go
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"supervisor/internal/domain"
|
||||||
|
"supervisor/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WSTerminalHandler struct {
|
||||||
|
manager SessionService
|
||||||
|
upgrader websocket.Upgrader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWSTerminalHandler(manager SessionService) *WSTerminalHandler {
|
||||||
|
return &WSTerminalHandler{
|
||||||
|
manager: manager,
|
||||||
|
upgrader: websocket.Upgrader{
|
||||||
|
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WSTerminalHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := r.PathValue("id")
|
||||||
|
current, err := h.manager.GetSession(r.Context(), sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := h.upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
events, cancel, err := h.manager.Subscribe(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.WriteJSON(errorEnvelope(sessionID, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := conn.WriteJSON(map[string]any{
|
||||||
|
"type": string(domain.EventSessionStatus),
|
||||||
|
"session": sessionID,
|
||||||
|
"payload": ws.SessionStatusPayload{Status: string(current.Status), ExitCode: current.ExitCode},
|
||||||
|
}); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollback, _ := h.manager.Scrollback(sessionID)
|
||||||
|
if len(scrollback) > 0 {
|
||||||
|
if err := conn.WriteJSON(map[string]any{
|
||||||
|
"type": string(domain.EventTerminalOutput),
|
||||||
|
"session": sessionID,
|
||||||
|
"payload": ws.TerminalOutputPayload{Data: string(scrollback)},
|
||||||
|
}); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := context.WithCancel(r.Context())
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
readErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
readErr <- h.readLoop(ctx, conn, sessionID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-readErr:
|
||||||
|
if err == nil || websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = conn.WriteJSON(errorEnvelope(sessionID, err.Error()))
|
||||||
|
return
|
||||||
|
case event, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := conn.WriteJSON(toEnvelope(event)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WSTerminalHandler) readLoop(ctx context.Context, conn *websocket.Conn, sessionID string) error {
|
||||||
|
conn.SetReadLimit(1 << 20)
|
||||||
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
_, msg, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope ws.Envelope
|
||||||
|
if err := json.Unmarshal(msg, &envelope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch envelope.Type {
|
||||||
|
case "terminal.input":
|
||||||
|
var payload ws.TerminalInputPayload
|
||||||
|
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := h.manager.WriteInput(ctx, sessionID, payload.Data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "terminal.resize":
|
||||||
|
var payload ws.TerminalResizePayload
|
||||||
|
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := h.manager.Resize(ctx, sessionID, payload.Cols, payload.Rows); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported message type: %s", envelope.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toEnvelope(event domain.Event) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": string(event.Type),
|
||||||
|
"session": event.SessionID,
|
||||||
|
"payload": event.Payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorEnvelope(sessionID string, message string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "error",
|
||||||
|
"session": sessionID,
|
||||||
|
"payload": ws.ErrorPayload{Message: message},
|
||||||
|
}
|
||||||
|
}
|
||||||
27
internal/httpserver/middleware.go
Normal file
27
internal/httpserver/middleware.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoggingMiddleware(logger *log.Logger, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
started := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
logger.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(started))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecoverMiddleware(logger *log.Logger, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
logger.Printf("panic: %v", rec)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
42
internal/httpserver/router.go
Normal file
42
internal/httpserver/router.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"supervisor/internal/httpserver/handlers"
|
||||||
|
"supervisor/internal/static"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Dependencies struct {
|
||||||
|
Logger *log.Logger
|
||||||
|
Manager handlers.SessionService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(deps Dependencies) (http.Handler, error) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
healthHandler := handlers.HealthHandler{}
|
||||||
|
sessions := handlers.NewSessionsHandler(deps.Manager)
|
||||||
|
wsHandler := handlers.NewWSTerminalHandler(deps.Manager)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /healthz", healthHandler.Handle)
|
||||||
|
mux.HandleFunc("GET /api/sessions", sessions.List)
|
||||||
|
mux.HandleFunc("POST /api/sessions", sessions.Create)
|
||||||
|
mux.HandleFunc("GET /api/sessions/{id}", sessions.Get)
|
||||||
|
mux.HandleFunc("POST /api/sessions/{id}/input", sessions.Input)
|
||||||
|
mux.HandleFunc("POST /api/sessions/{id}/stop", sessions.Stop)
|
||||||
|
mux.HandleFunc("DELETE /api/sessions/{id}", sessions.Delete)
|
||||||
|
mux.HandleFunc("GET /ws/sessions/{id}", wsHandler.Handle)
|
||||||
|
|
||||||
|
staticHandler, err := static.NewSPAHandler()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mux.Handle("/", staticHandler)
|
||||||
|
|
||||||
|
var root http.Handler = mux
|
||||||
|
root = RecoverMiddleware(deps.Logger, root)
|
||||||
|
root = LoggingMiddleware(deps.Logger, root)
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
158
internal/session/manager.go
Normal file
158
internal/session/manager.go
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"supervisor/internal/domain"
|
||||||
|
"supervisor/internal/store"
|
||||||
|
"supervisor/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrSessionNotFound = errors.New("session not found")
|
||||||
|
var ErrSessionRunning = errors.New("session is running")
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
store store.SessionStore
|
||||||
|
factory PTYFactory
|
||||||
|
runtimes map[string]*Session
|
||||||
|
scrollbackLimit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(store store.SessionStore, factory PTYFactory) *Manager {
|
||||||
|
if factory == nil {
|
||||||
|
factory = DefaultPTYFactory{}
|
||||||
|
}
|
||||||
|
return &Manager{
|
||||||
|
store: store,
|
||||||
|
factory: factory,
|
||||||
|
runtimes: make(map[string]*Session),
|
||||||
|
scrollbackLimit: 512 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) CreateSession(ctx context.Context, params CreateSessionParams) (domain.Session, error) {
|
||||||
|
command := params.Command
|
||||||
|
if command == "" {
|
||||||
|
command = "bash"
|
||||||
|
}
|
||||||
|
s := domain.Session{
|
||||||
|
ID: util.NewID("sess"),
|
||||||
|
Name: params.Name,
|
||||||
|
AgentID: params.AgentID,
|
||||||
|
Command: command,
|
||||||
|
Status: domain.SessionStatusCreated,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if s.Name == "" {
|
||||||
|
s.Name = s.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewSession(s, m.scrollbackLimit, func(session domain.Session) {
|
||||||
|
_ = m.store.Upsert(context.Background(), session)
|
||||||
|
})
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.runtimes[s.ID] = runtime
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if err := m.store.Upsert(ctx, s); err != nil {
|
||||||
|
return domain.Session{}, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) StartSession(_ context.Context, id string) error {
|
||||||
|
runtime, err := m.runtimeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runtime.Start(m.factory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) StopSession(_ context.Context, id string) error {
|
||||||
|
runtime, err := m.runtimeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runtime.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ListSessions(ctx context.Context) ([]domain.Session, error) {
|
||||||
|
return m.store.List(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetSession(ctx context.Context, id string) (domain.Session, error) {
|
||||||
|
s, ok, err := m.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Session{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return domain.Session{}, ErrSessionNotFound
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) WriteInput(_ context.Context, id string, input string) error {
|
||||||
|
runtime, err := m.runtimeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runtime.WriteInput(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Subscribe(id string) (<-chan domain.Event, func(), error) {
|
||||||
|
runtime, err := m.runtimeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
_, ch, cancel := runtime.Subscribe()
|
||||||
|
return ch, cancel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Resize(_ context.Context, id string, cols, rows int) error {
|
||||||
|
runtime, err := m.runtimeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runtime.Resize(cols, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Scrollback(id string) ([]byte, error) {
|
||||||
|
runtime, err := m.runtimeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return runtime.Scrollback(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) DeleteSession(ctx context.Context, id string) error {
|
||||||
|
runtime, err := m.runtimeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := runtime.Snapshot()
|
||||||
|
if snapshot.Status == domain.SessionStatusRunning {
|
||||||
|
return ErrSessionRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.runtimes, id)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return m.store.Delete(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) runtimeByID(id string) (*Session, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
runtime, ok := m.runtimes[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrSessionNotFound
|
||||||
|
}
|
||||||
|
return runtime, nil
|
||||||
|
}
|
||||||
20
internal/session/models.go
Normal file
20
internal/session/models.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import "supervisor/internal/domain"
|
||||||
|
|
||||||
|
type CreateSessionParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
AgentID string `json:"agentId"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputRequest struct {
|
||||||
|
Input string `json:"input"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResizeRequest struct {
|
||||||
|
Cols int `json:"cols"`
|
||||||
|
Rows int `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionSummary = domain.Session
|
||||||
53
internal/session/process.go
Normal file
53
internal/session/process.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DefaultPTYFactory struct{}
|
||||||
|
|
||||||
|
type shellProcess struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
pty *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f DefaultPTYFactory) Start(command string) (PTYProcess, error) {
|
||||||
|
cmd := exec.Command("bash", "-lc", command)
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
ptmx, err := pty.Start(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &shellProcess{cmd: cmd, pty: ptmx}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *shellProcess) Read(b []byte) (int, error) {
|
||||||
|
return p.pty.Read(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *shellProcess) Write(b []byte) (int, error) {
|
||||||
|
return p.pty.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *shellProcess) Close() error {
|
||||||
|
return p.pty.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *shellProcess) Wait() error {
|
||||||
|
return p.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *shellProcess) Resize(cols, rows uint16) error {
|
||||||
|
return pty.Setsize(p.pty, &pty.Winsize{Cols: cols, Rows: rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *shellProcess) SignalStop() error {
|
||||||
|
if p.cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
}
|
||||||
14
internal/session/pty.go
Normal file
14
internal/session/pty.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import "io"
|
||||||
|
|
||||||
|
type PTYProcess interface {
|
||||||
|
io.ReadWriteCloser
|
||||||
|
Wait() error
|
||||||
|
Resize(cols, rows uint16) error
|
||||||
|
SignalStop() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type PTYFactory interface {
|
||||||
|
Start(command string) (PTYProcess, error)
|
||||||
|
}
|
||||||
273
internal/session/session.go
Normal file
273
internal/session/session.go
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"supervisor/internal/domain"
|
||||||
|
"supervisor/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StateChangeFn func(domain.Session)
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
meta domain.Session
|
||||||
|
process PTYProcess
|
||||||
|
subscribers map[string]chan domain.Event
|
||||||
|
scrollback []byte
|
||||||
|
scrollbackSize int
|
||||||
|
onStateChange StateChangeFn
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSession(meta domain.Session, scrollbackSize int, onStateChange StateChangeFn) *Session {
|
||||||
|
if scrollbackSize <= 0 {
|
||||||
|
scrollbackSize = 256 * 1024
|
||||||
|
}
|
||||||
|
return &Session{
|
||||||
|
meta: meta,
|
||||||
|
subscribers: make(map[string]chan domain.Event),
|
||||||
|
scrollbackSize: scrollbackSize,
|
||||||
|
onStateChange: onStateChange,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Snapshot() domain.Session {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Scrollback() []byte {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
cpy := make([]byte, len(s.scrollback))
|
||||||
|
copy(cpy, s.scrollback)
|
||||||
|
return cpy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Start(factory PTYFactory) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.meta.Status == domain.SessionStatusRunning {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.meta.Command == "" {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return errors.New("empty command")
|
||||||
|
}
|
||||||
|
proc, err := factory.Start(s.meta.Command)
|
||||||
|
if err != nil {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
s.meta.Status = domain.SessionStatusError
|
||||||
|
s.meta.ExitedAt = &now
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.emitStateChange()
|
||||||
|
s.publish(domain.Event{
|
||||||
|
Type: domain.EventError,
|
||||||
|
SessionID: s.meta.ID,
|
||||||
|
Payload: domain.ErrorEvent{
|
||||||
|
Message: err.Error(),
|
||||||
|
},
|
||||||
|
At: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
s.process = proc
|
||||||
|
s.meta.Status = domain.SessionStatusRunning
|
||||||
|
s.meta.StartedAt = &now
|
||||||
|
s.meta.ExitedAt = nil
|
||||||
|
s.meta.ExitCode = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.emitStateChange()
|
||||||
|
s.publishStatus()
|
||||||
|
|
||||||
|
go s.readLoop(proc)
|
||||||
|
go s.waitLoop(proc)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Stop() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.process == nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
proc := s.process
|
||||||
|
if s.meta.Status == domain.SessionStatusRunning {
|
||||||
|
s.meta.Status = domain.SessionStatusStopped
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.emitStateChange()
|
||||||
|
s.publishStatus()
|
||||||
|
return proc.SignalStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) WriteInput(input string) error {
|
||||||
|
s.mu.RLock()
|
||||||
|
proc := s.process
|
||||||
|
status := s.meta.Status
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if proc == nil || status != domain.SessionStatusRunning {
|
||||||
|
return errors.New("session is not running")
|
||||||
|
}
|
||||||
|
_, err := proc.Write([]byte(input))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Resize(cols, rows int) error {
|
||||||
|
if cols <= 0 || rows <= 0 {
|
||||||
|
return errors.New("invalid terminal size")
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
proc := s.process
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if proc == nil {
|
||||||
|
return errors.New("session has no process")
|
||||||
|
}
|
||||||
|
return proc.Resize(uint16(cols), uint16(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Subscribe() (string, <-chan domain.Event, func()) {
|
||||||
|
id := util.NewID("sub")
|
||||||
|
ch := make(chan domain.Event, 128)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.subscribers[id] = ch
|
||||||
|
s.mu.Unlock()
|
||||||
|
cancel := func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
sub, ok := s.subscribers[id]
|
||||||
|
if ok {
|
||||||
|
delete(s.subscribers, id)
|
||||||
|
close(sub)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
return id, ch, cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) readLoop(proc PTYProcess) {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := proc.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunk := append([]byte(nil), buf[:n]...)
|
||||||
|
s.appendScrollback(chunk)
|
||||||
|
s.publish(domain.Event{
|
||||||
|
Type: domain.EventTerminalOutput,
|
||||||
|
SessionID: s.Snapshot().ID,
|
||||||
|
Payload: domain.TerminalOutputEvent{
|
||||||
|
Data: string(chunk),
|
||||||
|
},
|
||||||
|
At: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, io.EOF) {
|
||||||
|
s.publish(domain.Event{
|
||||||
|
Type: domain.EventError,
|
||||||
|
SessionID: s.Snapshot().ID,
|
||||||
|
Payload: domain.ErrorEvent{Message: err.Error()},
|
||||||
|
At: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) waitLoop(proc PTYProcess) {
|
||||||
|
err := proc.Wait()
|
||||||
|
_ = proc.Close()
|
||||||
|
|
||||||
|
var exitCode *int
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
code := exitErr.ExitCode()
|
||||||
|
exitCode = &code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
code := 0
|
||||||
|
exitCode = &code
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.meta.Status != domain.SessionStatusError {
|
||||||
|
s.meta.Status = domain.SessionStatusExited
|
||||||
|
}
|
||||||
|
s.meta.ExitedAt = &now
|
||||||
|
s.meta.ExitCode = exitCode
|
||||||
|
s.process = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.emitStateChange()
|
||||||
|
s.publishStatus()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.publish(domain.Event{
|
||||||
|
Type: domain.EventError,
|
||||||
|
SessionID: s.Snapshot().ID,
|
||||||
|
Payload: domain.ErrorEvent{Message: err.Error()},
|
||||||
|
At: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) appendScrollback(data []byte) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if len(data) >= s.scrollbackSize {
|
||||||
|
s.scrollback = append([]byte(nil), data[len(data)-s.scrollbackSize:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.scrollback = append(s.scrollback, data...)
|
||||||
|
if len(s.scrollback) > s.scrollbackSize {
|
||||||
|
extra := len(s.scrollback) - s.scrollbackSize
|
||||||
|
s.scrollback = append([]byte(nil), s.scrollback[extra:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) publishStatus() {
|
||||||
|
snap := s.Snapshot()
|
||||||
|
s.publish(domain.Event{
|
||||||
|
Type: domain.EventSessionStatus,
|
||||||
|
SessionID: snap.ID,
|
||||||
|
Payload: domain.SessionStatusEvent{
|
||||||
|
Status: snap.Status,
|
||||||
|
ExitCode: snap.ExitCode,
|
||||||
|
},
|
||||||
|
At: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) publish(event domain.Event) {
|
||||||
|
s.mu.RLock()
|
||||||
|
subs := make([]chan domain.Event, 0, len(s.subscribers))
|
||||||
|
for _, ch := range s.subscribers {
|
||||||
|
subs = append(subs, ch)
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, ch := range subs {
|
||||||
|
select {
|
||||||
|
case ch <- event:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) emitStateChange() {
|
||||||
|
if s.onStateChange == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.onStateChange(s.Snapshot())
|
||||||
|
}
|
||||||
10
internal/static/dist/index.html
vendored
Normal file
10
internal/static/dist/index.html
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Supervisor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Frontend not built yet. Run make frontend-build.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
internal/static/embed.go
Normal file
7
internal/static/embed.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package static
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// DistFS contains built frontend assets copied into internal/static/dist.
|
||||||
|
//go:embed dist dist/*
|
||||||
|
var DistFS embed.FS
|
||||||
52
internal/static/serve.go
Normal file
52
internal/static/serve.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SPAHandler struct {
|
||||||
|
assets fs.FS
|
||||||
|
files http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSPAHandler() (http.Handler, error) {
|
||||||
|
sub, err := fs.Sub(DistFS, "dist")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SPAHandler{
|
||||||
|
assets: sub,
|
||||||
|
files: http.FileServer(http.FS(sub)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SPAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requested := path.Clean(r.URL.Path)
|
||||||
|
if requested == "." || requested == "/" {
|
||||||
|
h.serveIndex(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidate := requested[1:]
|
||||||
|
if candidate == "" {
|
||||||
|
h.serveIndex(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file, err := h.assets.Open(candidate); err == nil {
|
||||||
|
_ = file.Close()
|
||||||
|
h.files.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.serveIndex(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SPAHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
index, err := fs.ReadFile(h.assets, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "index.html not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write(index)
|
||||||
|
}
|
||||||
51
internal/store/memory/store.go
Normal file
51
internal/store/memory/store.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"supervisor/internal/domain"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]domain.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore() *Store {
|
||||||
|
return &Store{sessions: make(map[string]domain.Session)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Upsert(_ context.Context, session domain.Session) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sessions[session.ID] = session
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Get(_ context.Context, id string) (domain.Session, bool, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
session, ok := s.sessions[id]
|
||||||
|
return session, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) List(_ context.Context) ([]domain.Session, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
items := make([]domain.Session, 0, len(s.sessions))
|
||||||
|
for _, session := range s.sessions {
|
||||||
|
items = append(items, session)
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].CreatedAt.After(items[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Delete(_ context.Context, id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.sessions, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
13
internal/store/types.go
Normal file
13
internal/store/types.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"supervisor/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionStore interface {
|
||||||
|
Upsert(ctx context.Context, session domain.Session) error
|
||||||
|
Get(ctx context.Context, id string) (domain.Session, bool, error)
|
||||||
|
List(ctx context.Context) ([]domain.Session, error)
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
}
|
||||||
13
internal/supervisor/manager.go
Normal file
13
internal/supervisor/manager.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package supervisor
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// Manager is a future extension point for high-level orchestration.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
assignments map[string]AgentAssignment
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager() *Manager {
|
||||||
|
return &Manager{assignments: make(map[string]AgentAssignment)}
|
||||||
|
}
|
||||||
9
internal/supervisor/models.go
Normal file
9
internal/supervisor/models.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package supervisor
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type AgentAssignment struct {
|
||||||
|
AgentID string `json:"agentId"`
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
AssignedAt time.Time `json:"assignedAt"`
|
||||||
|
}
|
||||||
12
internal/util/ids.go
Normal file
12
internal/util/ids.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewID(prefix string) string {
|
||||||
|
buf := make([]byte, 8)
|
||||||
|
_, _ = rand.Read(buf)
|
||||||
|
return prefix + "_" + hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
10
internal/util/logger.go
Normal file
10
internal/util/logger.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewLogger() *log.Logger {
|
||||||
|
return log.New(os.Stdout, "[supervisor] ", log.LstdFlags|log.Lmicroseconds)
|
||||||
|
}
|
||||||
31
internal/ws/messages.go
Normal file
31
internal/ws/messages.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
type Envelope struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Session string `json:"session"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TerminalInputPayload struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TerminalOutputPayload struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TerminalResizePayload struct {
|
||||||
|
Cols int `json:"cols"`
|
||||||
|
Rows int `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionStatusPayload struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode *int `json:"exitCode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorPayload struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
12
scripts/build-all.sh
Executable file
12
scripts/build-all.sh
Executable file
@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
"$ROOT_DIR/scripts/build-frontend.sh"
|
||||||
|
|
||||||
|
mkdir -p "$ROOT_DIR/bin"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
go build -o "$ROOT_DIR/bin/supervisor" ./cmd/supervisor
|
||||||
|
|
||||||
|
echo "Built binary: $ROOT_DIR/bin/supervisor"
|
||||||
16
scripts/build-frontend.sh
Executable file
16
scripts/build-frontend.sh
Executable file
@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
WEB_DIR="$ROOT_DIR/web"
|
||||||
|
EMBED_DIST_DIR="$ROOT_DIR/internal/static/dist"
|
||||||
|
|
||||||
|
cd "$WEB_DIR"
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
rm -rf "$EMBED_DIST_DIR"
|
||||||
|
mkdir -p "$EMBED_DIST_DIR"
|
||||||
|
cp -R "$WEB_DIR/dist/." "$EMBED_DIST_DIR/"
|
||||||
|
|
||||||
|
echo "Frontend built into $WEB_DIR/dist and copied to $EMBED_DIST_DIR"
|
||||||
23
web/index.html
Normal file
23
web/index.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Supervisor</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1591
web/package-lock.json
generated
Normal file
1591
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
web/package.json
Normal file
24
web/package.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "supervisor-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"pinia": "^2.3.1",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
web/src/App.vue
Normal file
7
web/src/App.vue
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<AppShell />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AppShell from './components/layout/AppShell.vue'
|
||||||
|
</script>
|
||||||
6
web/src/api/http.ts
Normal file
6
web/src/api/http.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const http = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
47
web/src/api/sessions.ts
Normal file
47
web/src/api/sessions.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
|
||||||
|
export interface SessionDto {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
agentId: string
|
||||||
|
command: string
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
startedAt?: string
|
||||||
|
exitedAt?: string
|
||||||
|
exitCode?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionRequest {
|
||||||
|
name: string
|
||||||
|
agentId: string
|
||||||
|
command: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSessions(): Promise<SessionDto[]> {
|
||||||
|
const { data } = await http.get<SessionDto[]>('/sessions')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(payload: CreateSessionRequest): Promise<SessionDto> {
|
||||||
|
const { data } = await http.post<SessionDto>('/sessions', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(id: string): Promise<SessionDto> {
|
||||||
|
const { data } = await http.get<SessionDto>(`/sessions/${id}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postSessionInput(id: string, input: string): Promise<void> {
|
||||||
|
await http.post(`/sessions/${id}/input`, { input })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopSession(id: string): Promise<SessionDto> {
|
||||||
|
const { data } = await http.post<SessionDto>(`/sessions/${id}/stop`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(id: string): Promise<void> {
|
||||||
|
await http.delete(`/sessions/${id}`)
|
||||||
|
}
|
||||||
59
web/src/api/ws.ts
Normal file
59
web/src/api/ws.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
export interface WSEnvelope<T = unknown> {
|
||||||
|
type: string
|
||||||
|
session: string
|
||||||
|
payload: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TerminalSocket {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sessionId: string,
|
||||||
|
private readonly onMessage: (msg: WSEnvelope) => void,
|
||||||
|
private readonly onError?: (error: Event) => void,
|
||||||
|
private readonly onClose?: () => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
const url = `${protocol}://${window.location.host}/ws/sessions/${this.sessionId}`
|
||||||
|
this.ws = new WebSocket(url)
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
this.onMessage(JSON.parse(event.data) as WSEnvelope)
|
||||||
|
} catch {
|
||||||
|
// ignore malformed payloads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.ws.onerror = (event) => this.onError?.(event)
|
||||||
|
this.ws.onclose = () => this.onClose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
sendInput(data: string): void {
|
||||||
|
this.send({
|
||||||
|
type: 'terminal.input',
|
||||||
|
session: this.sessionId,
|
||||||
|
payload: { data },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sendResize(cols: number, rows: number): void {
|
||||||
|
this.send({
|
||||||
|
type: 'terminal.resize',
|
||||||
|
session: this.sessionId,
|
||||||
|
payload: { cols, rows },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.ws?.close()
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(payload: WSEnvelope): void {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.ws.send(JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
54
web/src/components/layout/AppShell.vue
Normal file
54
web/src/components/layout/AppShell.vue
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<div class="shell">
|
||||||
|
<Sidebar class="sidebar" />
|
||||||
|
<section class="main">
|
||||||
|
<Topbar />
|
||||||
|
<div class="content">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
import Sidebar from './Sidebar.vue'
|
||||||
|
import Topbar from './Topbar.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #0b1117;
|
||||||
|
color: #d8e3ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid #223143;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 64px 1fr;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #223143;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
33
web/src/components/layout/Sidebar.vue
Normal file
33
web/src/components/layout/Sidebar.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="sidebar-wrap">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Supervisor</h1>
|
||||||
|
<small>sessions</small>
|
||||||
|
</div>
|
||||||
|
<SessionList />
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import SessionList from '../sessions/SessionList.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar-wrap {
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
background: linear-gradient(180deg, #101922 0%, #0d141c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header small {
|
||||||
|
color: #7b95b0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
33
web/src/components/layout/Topbar.vue
Normal file
33
web/src/components/layout/Topbar.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="title">Runtime Console</div>
|
||||||
|
<RouterLink class="btn" to="/">Dashboard</RouterLink>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #101922;
|
||||||
|
border-bottom: 1px solid #223143;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
color: #d8e3ed;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid #39506a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
71
web/src/components/sessions/SessionCard.vue
Normal file
71
web/src/components/sessions/SessionCard.vue
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<article class="card" :class="{ active }" @click="$emit('select', session.id)">
|
||||||
|
<div class="top">
|
||||||
|
<strong>{{ session.name || session.id }}</strong>
|
||||||
|
<button v-if="canRemove" class="remove" @click.stop="$emit('remove', session.id)">Remove</button>
|
||||||
|
</div>
|
||||||
|
<p>{{ session.command }}</p>
|
||||||
|
<small>{{ session.status }}</small>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { SessionDto } from '../../api/sessions'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
session: SessionDto
|
||||||
|
active: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'select', id: string): void
|
||||||
|
(e: 'remove', id: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const removableStatuses = new Set(['stopped', 'exited', 'error'])
|
||||||
|
const canRemove = computed(() => removableStatuses.has(props.session.status))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
border: 1px solid #253549;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #111a24;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.active {
|
||||||
|
border-color: #63a0ff;
|
||||||
|
background: #1a2532;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
border: 1px solid #4f637a;
|
||||||
|
background: #203040;
|
||||||
|
color: #e5eef5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
margin: 8px 0;
|
||||||
|
color: #9bb2c8;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card small {
|
||||||
|
color: #73d0a9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
57
web/src/components/sessions/SessionList.vue
Normal file
57
web/src/components/sessions/SessionList.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="list">
|
||||||
|
<button class="refresh" @click="store.fetchSessions">Refresh</button>
|
||||||
|
<SessionCard
|
||||||
|
v-for="item in store.sessions"
|
||||||
|
:key="item.id"
|
||||||
|
:session="item"
|
||||||
|
:active="item.id === store.activeSessionId"
|
||||||
|
@select="openSession"
|
||||||
|
@remove="removeSession"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useSessionsStore } from '../../stores/sessions'
|
||||||
|
import SessionCard from './SessionCard.vue'
|
||||||
|
|
||||||
|
const store = useSessionsStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchSessions()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openSession(id: string): void {
|
||||||
|
store.setActiveSession(id)
|
||||||
|
router.push(`/sessions/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSession(id: string): Promise<void> {
|
||||||
|
await store.removeSession(id)
|
||||||
|
if (route.params.id === id) {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: start;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh {
|
||||||
|
border: 1px solid #2f4054;
|
||||||
|
background: #162331;
|
||||||
|
color: #c6d4e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
51
web/src/components/terminals/TerminalTabs.vue
Normal file
51
web/src/components/terminals/TerminalTabs.vue
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
v-for="item in sessions"
|
||||||
|
:key="item.id"
|
||||||
|
class="tab"
|
||||||
|
:class="{ active: item.id === activeId }"
|
||||||
|
@click="$emit('select', item.id)"
|
||||||
|
>
|
||||||
|
{{ item.name || item.id }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SessionDto } from '../../api/sessions'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
sessions: SessionDto[]
|
||||||
|
activeId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'select', id: string): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #223143;
|
||||||
|
background: #0f1922;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
border: 1px solid #2f4155;
|
||||||
|
background: #152230;
|
||||||
|
color: #c9d6e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
border-color: #6ca7ff;
|
||||||
|
background: #213245;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
131
web/src/components/terminals/XTermView.vue
Normal file
131
web/src/components/terminals/XTermView.vue
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="container" class="terminal" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { TerminalSocket, type WSEnvelope } from '../../api/ws'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sessionId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const container = ref<HTMLDivElement | null>(null)
|
||||||
|
let terminal: Terminal | null = null
|
||||||
|
let fitAddon: FitAddon | null = null
|
||||||
|
let socket: TerminalSocket | null = null
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function initTerminal(): void {
|
||||||
|
if (!container.value || terminal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 14,
|
||||||
|
scrollback: 5000,
|
||||||
|
convertEol: true,
|
||||||
|
theme: {
|
||||||
|
background: '#05090e',
|
||||||
|
foreground: '#d6dfeb',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
fitAddon = new FitAddon()
|
||||||
|
terminal.loadAddon(fitAddon)
|
||||||
|
terminal.open(container.value)
|
||||||
|
fitAddon.fit()
|
||||||
|
|
||||||
|
terminal.onData((data: string) => {
|
||||||
|
socket?.sendInput(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSocket(sessionId: string): void {
|
||||||
|
socket?.close()
|
||||||
|
terminal?.reset()
|
||||||
|
|
||||||
|
socket = new TerminalSocket(
|
||||||
|
sessionId,
|
||||||
|
(message: WSEnvelope) => {
|
||||||
|
if (!terminal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'terminal.output') {
|
||||||
|
const payload = message.payload as { data?: string }
|
||||||
|
if (payload.data) {
|
||||||
|
terminal.write(payload.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'session.status') {
|
||||||
|
const payload = message.payload as { status?: string }
|
||||||
|
if (payload.status) {
|
||||||
|
terminal.writeln(`\r\n[status] ${payload.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'error') {
|
||||||
|
const payload = message.payload as { message?: string }
|
||||||
|
if (payload.message) {
|
||||||
|
terminal.writeln(`\r\n[error] ${payload.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
terminal?.writeln('\r\n[ws] websocket error')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
socket.connect()
|
||||||
|
window.setTimeout(() => sendResize(), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendResize(): void {
|
||||||
|
if (!terminal || !fitAddon) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fitAddon.fit()
|
||||||
|
socket?.sendResize(terminal.cols, terminal.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initTerminal()
|
||||||
|
connectSocket(props.sessionId)
|
||||||
|
|
||||||
|
if (container.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
sendResize()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(container.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.sessionId,
|
||||||
|
(sessionId) => {
|
||||||
|
if (!terminal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectSocket(sessionId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
socket?.close()
|
||||||
|
terminal?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.terminal {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
10
web/src/main.ts
Normal file
10
web/src/main.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
22
web/src/router/index.ts
Normal file
22
web/src/router/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import DashboardView from '../views/DashboardView.vue'
|
||||||
|
import SessionView from '../views/SessionView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: DashboardView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/sessions/:id',
|
||||||
|
name: 'session',
|
||||||
|
component: SessionView,
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
91
web/src/stores/sessions.ts
Normal file
91
web/src/stores/sessions.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
deleteSession,
|
||||||
|
getSession,
|
||||||
|
listSessions,
|
||||||
|
postSessionInput,
|
||||||
|
stopSession,
|
||||||
|
type CreateSessionRequest,
|
||||||
|
type SessionDto,
|
||||||
|
} from '../api/sessions'
|
||||||
|
|
||||||
|
interface SessionsState {
|
||||||
|
sessions: SessionDto[]
|
||||||
|
activeSessionId: string | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSessionsStore = defineStore('sessions', {
|
||||||
|
state: (): SessionsState => ({
|
||||||
|
sessions: [],
|
||||||
|
activeSessionId: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
activeSession(state): SessionDto | undefined {
|
||||||
|
return state.sessions.find((item) => item.id === state.activeSessionId)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async fetchSessions() {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
this.sessions = await listSessions()
|
||||||
|
} catch (err) {
|
||||||
|
this.error = String(err)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async createSession(payload: CreateSessionRequest) {
|
||||||
|
const created = await createSession(payload)
|
||||||
|
this.sessions = [created, ...this.sessions.filter((item) => item.id !== created.id)]
|
||||||
|
this.activeSessionId = created.id
|
||||||
|
return created
|
||||||
|
},
|
||||||
|
async loadSession(id: string) {
|
||||||
|
const current = await getSession(id)
|
||||||
|
const idx = this.sessions.findIndex((item) => item.id === id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.sessions.splice(idx, 1, current)
|
||||||
|
} else {
|
||||||
|
this.sessions.unshift(current)
|
||||||
|
}
|
||||||
|
this.activeSessionId = id
|
||||||
|
return current
|
||||||
|
},
|
||||||
|
setActiveSession(id: string) {
|
||||||
|
this.activeSessionId = id
|
||||||
|
},
|
||||||
|
async stopSession(id: string) {
|
||||||
|
const updated = await stopSession(id)
|
||||||
|
const idx = this.sessions.findIndex((item) => item.id === id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.sessions.splice(idx, 1, updated)
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
},
|
||||||
|
async sendInput(id: string, input: string) {
|
||||||
|
await postSessionInput(id, input)
|
||||||
|
},
|
||||||
|
async removeSession(id: string) {
|
||||||
|
await deleteSession(id)
|
||||||
|
this.sessions = this.sessions.filter((item) => item.id !== id)
|
||||||
|
if (this.activeSessionId === id) {
|
||||||
|
this.activeSessionId = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
upsertSession(session: SessionDto) {
|
||||||
|
const idx = this.sessions.findIndex((item) => item.id === session.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.sessions.splice(idx, 1, { ...this.sessions[idx], ...session })
|
||||||
|
} else {
|
||||||
|
this.sessions.unshift(session)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
76
web/src/views/DashboardView.vue
Normal file
76
web/src/views/DashboardView.vue
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<section class="dashboard">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
|
||||||
|
<form class="create" @submit.prevent="onCreate">
|
||||||
|
<input v-model="form.name" type="text" placeholder="Session name" />
|
||||||
|
<input v-model="form.agentId" type="text" placeholder="Agent ID (optional)" />
|
||||||
|
<input v-model="form.command" type="text" placeholder="Command, e.g. bash" />
|
||||||
|
<button type="submit">Create Session</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<p>Total sessions: {{ store.sessions.length }}</p>
|
||||||
|
<p>Running: {{ runningCount }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useSessionsStore } from '../stores/sessions'
|
||||||
|
|
||||||
|
const store = useSessionsStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
agentId: '',
|
||||||
|
command: 'bash',
|
||||||
|
})
|
||||||
|
|
||||||
|
const runningCount = computed(() => {
|
||||||
|
return store.sessions.filter((item) => item.status === 'running').length
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchSessions()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onCreate(): Promise<void> {
|
||||||
|
const session = await store.createSession({
|
||||||
|
name: form.name,
|
||||||
|
agentId: form.agentId,
|
||||||
|
command: form.command,
|
||||||
|
})
|
||||||
|
router.push(`/sessions/${session.id}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
padding: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create input,
|
||||||
|
.create button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #2f4358;
|
||||||
|
background: #121e2a;
|
||||||
|
color: #d7e3ec;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
color: #99adbf;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
90
web/src/views/SessionView.vue
Normal file
90
web/src/views/SessionView.vue
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<section class="view">
|
||||||
|
<TerminalTabs
|
||||||
|
v-if="store.sessions.length > 0"
|
||||||
|
:sessions="store.sessions"
|
||||||
|
:active-id="sessionId"
|
||||||
|
@select="onSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="toolbar" v-if="session">
|
||||||
|
<strong>{{ session.name }}</strong>
|
||||||
|
<span>{{ session.command }}</span>
|
||||||
|
<button @click="onStop">Stop</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="terminal-host" v-if="sessionId">
|
||||||
|
<XTermView :session-id="sessionId" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import TerminalTabs from '../components/terminals/TerminalTabs.vue'
|
||||||
|
import XTermView from '../components/terminals/XTermView.vue'
|
||||||
|
import { useSessionsStore } from '../stores/sessions'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useSessionsStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const sessionId = computed(() => props.id)
|
||||||
|
const session = computed(() => store.sessions.find((item) => item.id === sessionId.value))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchSessions()
|
||||||
|
await store.loadSession(sessionId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.id,
|
||||||
|
async (id) => {
|
||||||
|
await store.loadSession(id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function onSelect(id: string): void {
|
||||||
|
store.setActiveSession(id)
|
||||||
|
router.push(`/sessions/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onStop(): Promise<void> {
|
||||||
|
await store.stopSession(sessionId.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto 1fr;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid #223143;
|
||||||
|
color: #c8d7e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button {
|
||||||
|
margin-left: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #4e677f;
|
||||||
|
background: #1d2b3a;
|
||||||
|
color: #e5eef5;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-host {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
web/tsconfig.json
Normal file
16
web/tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
22
web/vite.config.ts
Normal file
22
web/vite.config.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8080',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user