feat: add supervisor prototype with embedded frontend
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user