feat: add supervisor prototype with embedded frontend

This commit is contained in:
root
2026-03-09 19:15:53 +01:00
parent 96c4ce1697
commit 84de557052
56 changed files with 4044 additions and 10 deletions

View 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"})
}

View 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()})
}

View 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},
}
}