mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat: interactive terminal — WS-bridged docker exec PTY (#46)
GET /services/:id/terminal upgrades to a WebSocket attached to a `docker exec` PTY in the service's running replica. JSON frames both ways: stdin/resize out, stdout/exit/error in. Shell probe tries each candidate (bash→sh) since `command -v` needs a shell to run; distroless images get a clean "no shell" error instead of an OCI failure dump. - docker.Client gains ExecAttach + ExecResize wrappers - Frontend: xterm.js replaces the one-off console section — live PTY, fit-addon resizes forwarded over the socket - CLI: `containr shell <id>` bridges the local TTY (raw mode, SIGWINCH resize forwarding); `containr exec` keeps the one-off command path - Access mirrors service logs: project manage scope required Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
5eac4a382b
commit
612520bbff
13 files changed
+570
-75
No files matched your search
+6
-3
@@ -307,9 +307,12 @@ and wired refs, one click or one CLI call.
|
|||||||
UI at `/operations` (Operate nav) with live refetch; `containr
|
UI at `/operations` (Operate nav) with live refetch; `containr
|
||||||
operations` CLI + `containr_operations_get` MCP. Remaining: agent
|
operations` CLI + `containr_operations_get` MCP. Remaining: agent
|
||||||
commands once node fleet lands; flush/stuck-job tools.
|
commands once node fleet lands; flush/stuck-job tools.
|
||||||
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
|
- [x] **Interactive terminal** — `GET /services/:id/terminal` upgrades
|
||||||
`nodes/:id/terminal` via agent (dflow's wetty without the extra
|
to a WebSocket bridged to a `docker exec` PTY (bash→sh fallback,
|
||||||
container).
|
`TERM=xterm-256color`, resize frames). xterm.js Console section on the
|
||||||
|
service page; `containr shell <id>` bridges a local TTY; distroless
|
||||||
|
images get a clean "no shell" error. Remaining: `nodes/:id/terminal`
|
||||||
|
via agent.
|
||||||
- [x] **Team invites** — `POST /admin/invites` returns a one-time
|
- [x] **Team invites** — `POST /admin/invites` returns a one-time
|
||||||
link (sha256-hashed token at rest, optional email binding, TTL);
|
link (sha256-hashed token at rest, optional email binding, TTL);
|
||||||
public `GET /auth/invites/:token` validates and
|
public `GET /auth/invites/:token` validates and
|
||||||
|
|||||||
@@ -388,6 +388,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||||
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
|
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
|
||||||
|
|
||||||
|
// Interactive terminal — WebSocket → docker exec pty.
|
||||||
|
authed.GET("/services/:id/terminal", handleServiceTerminal)
|
||||||
|
|
||||||
// Environment variables — secrets, owner/admin only.
|
// Environment variables — secrets, owner/admin only.
|
||||||
authed.GET("/services/:id/variables", handleGetVariables)
|
authed.GET("/services/:id/variables", handleGetVariables)
|
||||||
authed.PUT("/services/:id/variables", handleUpdateVariables)
|
authed.PUT("/services/:id/variables", handleUpdateVariables)
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/docker"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Interactive terminal: a WebSocket bridged to a `docker exec` pty in the
|
||||||
|
// service's container. Protocol is JSON text frames both ways:
|
||||||
|
// client → server: {"type":"stdin","data":"..."} | {"type":"resize","cols":N,"rows":N}
|
||||||
|
// server → client: {"type":"stdout","data":"..."} | {"type":"exit","code":N} | {"type":"error","message":"..."}
|
||||||
|
// Shell preference: $SHELL, then bash, then sh.
|
||||||
|
|
||||||
|
type terminalFrame struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data string `json:"data,omitempty"`
|
||||||
|
Cols uint `json:"cols,omitempty"`
|
||||||
|
Rows uint `json:"rows,omitempty"`
|
||||||
|
Code int `json:"code,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleServiceTerminal(c *gin.Context) {
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
serviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid service id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID, found := serviceProjectID(db, serviceID)
|
||||||
|
if !found {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "service not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, allowed := projectManageAccess(c, db, projectID); !allowed {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "service not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dockerClient, _ := c.Get("docker_client")
|
||||||
|
client, ok := dockerClient.(*docker.Client)
|
||||||
|
if !ok || client == nil {
|
||||||
|
respondError(c, http.StatusServiceUnavailable, "DOCKER_UNAVAILABLE", "Docker is not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
containers, err := client.ListContainersFiltered(ctx, filters.NewArgs(
|
||||||
|
filters.Arg("label", "containr.service="+serviceID.String()),
|
||||||
|
), false)
|
||||||
|
if err != nil || len(containers) == 0 {
|
||||||
|
respondError(c, http.StatusConflict, "NO_CONTAINER", "service has no running container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Prefer a running container. With several matches, deterministically
|
||||||
|
// pick the first `containr-<service>-<n>` replica by name — that is the
|
||||||
|
// actual workload naming; auxiliary containers get ignored.
|
||||||
|
target := ""
|
||||||
|
sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] })
|
||||||
|
for _, ctr := range containers {
|
||||||
|
if ctr.State == "running" && strings.HasPrefix(ctr.Names[0], "/containr-") {
|
||||||
|
target = ctr.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
for _, ctr := range containers {
|
||||||
|
if ctr.State == "running" {
|
||||||
|
target = ctr.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
target = containers[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
send := func(f terminalFrame) bool {
|
||||||
|
return conn.WriteJSON(f) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a shell that actually exists — distroless images may have none.
|
||||||
|
// `command -v` can't probe without a shell, so start each candidate and
|
||||||
|
// watch for the OCI "no such file" failure.
|
||||||
|
var stream types.HijackedResponse
|
||||||
|
var execID string
|
||||||
|
attached := false
|
||||||
|
for _, candidate := range []string{"/bin/bash", "/bin/sh", "sh"} {
|
||||||
|
exec, err := client.ExecCreate(ctx, target, docker.ExecConfig{
|
||||||
|
Cmd: []string{candidate},
|
||||||
|
AttachStdin: true,
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
Tty: true,
|
||||||
|
Env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, err := client.ExecAttach(ctx, exec.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if execStartFailed(ctx, client, exec.ID, s) {
|
||||||
|
s.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stream, execID, attached = s, exec.ID, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !attached {
|
||||||
|
send(terminalFrame{Type: "error", Message: "no shell available in this container image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
// The client sends a resize frame with its real size right after
|
||||||
|
// connecting; start at a sane default meanwhile.
|
||||||
|
_ = client.ExecResize(ctx, execID, 80, 24)
|
||||||
|
|
||||||
|
var wsWrite sync.Mutex
|
||||||
|
writeJSON := func(f terminalFrame) {
|
||||||
|
wsWrite.Lock()
|
||||||
|
defer wsWrite.Unlock()
|
||||||
|
_ = conn.WriteJSON(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container → client.
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
for {
|
||||||
|
n, err := stream.Reader.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
writeJSON(terminalFrame{Type: "stdout", Data: string(buf[:n])})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
log.Printf("terminal: exec stream ended: %v", err)
|
||||||
|
}
|
||||||
|
inspect, ierr := client.ExecInspect(context.Background(), execID)
|
||||||
|
code := 0
|
||||||
|
if ierr == nil {
|
||||||
|
code = inspect.ExitCode
|
||||||
|
}
|
||||||
|
writeJSON(terminalFrame{Type: "exit", Code: code})
|
||||||
|
_ = conn.WriteControl(websocket.CloseMessage,
|
||||||
|
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session ended"),
|
||||||
|
time.Now().Add(time.Second))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Client → container.
|
||||||
|
for {
|
||||||
|
_, raw, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var f terminalFrame
|
||||||
|
if err := json.Unmarshal(raw, &f); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch f.Type {
|
||||||
|
case "stdin":
|
||||||
|
if _, err := stream.Conn.Write([]byte(f.Data)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "resize":
|
||||||
|
_ = client.ExecResize(ctx, execID, f.Cols, f.Rows)
|
||||||
|
case "ping":
|
||||||
|
writeJSON(terminalFrame{Type: "pong"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// execStartFailed reports whether an attached exec failed to launch
|
||||||
|
// (missing binary → non-zero exit, not running). The OCI failure takes a
|
||||||
|
// moment to surface, so a short grace period elapses before judging.
|
||||||
|
func execStartFailed(ctx context.Context, client *docker.Client, execID string, _ types.HijackedResponse) bool {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return true
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
inspect, err := client.ExecInspect(ctx, execID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !inspect.Running && inspect.ExitCode != 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShellCmd opens an interactive shell inside a service's running container.
|
||||||
|
var ShellCmd = &cobra.Command{
|
||||||
|
Use: "shell <service-id>",
|
||||||
|
Short: "Interactive shell inside the service container",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
serviceID := args[0]
|
||||||
|
cli, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(cli.BaseURL, "/")
|
||||||
|
wsURL := strings.Replace(base, "http", "ws", 1) + "/services/" + serviceID + "/terminal"
|
||||||
|
|
||||||
|
header := http.Header{}
|
||||||
|
header.Set("Authorization", "Bearer "+cli.Token)
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("terminal: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
fd := int(os.Stdin.Fd())
|
||||||
|
raw := term.IsTerminal(fd)
|
||||||
|
var oldState *term.State
|
||||||
|
if raw {
|
||||||
|
oldState, err = term.MakeRaw(fd)
|
||||||
|
if err == nil {
|
||||||
|
defer term.Restore(fd, oldState)
|
||||||
|
}
|
||||||
|
if w, h, err := term.GetSize(fd); err == nil {
|
||||||
|
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
|
||||||
|
}
|
||||||
|
// Forward SIGWINCH resizes.
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGWINCH)
|
||||||
|
defer signal.Stop(sigCh)
|
||||||
|
go func() {
|
||||||
|
for range sigCh {
|
||||||
|
if w, h, err := term.GetSize(fd); err == nil {
|
||||||
|
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
// stdin → ws
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := os.Stdin.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if werr := conn.WriteJSON(map[string]interface{}{
|
||||||
|
"type": "stdin", "data": string(buf[:n]),
|
||||||
|
}); werr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// ws → stdout
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
for {
|
||||||
|
var frame struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := conn.ReadJSON(&frame); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch frame.Type {
|
||||||
|
case "stdout":
|
||||||
|
_, _ = os.Stdout.WriteString(frame.Data)
|
||||||
|
case "exit":
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\n[session ended — exit %d]\r\n", frame.Code)
|
||||||
|
return
|
||||||
|
case "error":
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\nerror: %s\r\n", frame.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(24 * time.Hour):
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(commands.WebhooksCmd)
|
rootCmd.AddCommand(commands.WebhooksCmd)
|
||||||
rootCmd.AddCommand(commands.OperationsCmd)
|
rootCmd.AddCommand(commands.OperationsCmd)
|
||||||
rootCmd.AddCommand(commands.ActivityCmd)
|
rootCmd.AddCommand(commands.ActivityCmd)
|
||||||
|
rootCmd.AddCommand(commands.ShellCmd)
|
||||||
rootCmd.AddCommand(commands.BannersCmd)
|
rootCmd.AddCommand(commands.BannersCmd)
|
||||||
rootCmd.AddCommand(commands.InvitesCmd)
|
rootCmd.AddCommand(commands.InvitesCmd)
|
||||||
rootCmd.AddCommand(commands.UpCmd)
|
rootCmd.AddCommand(commands.UpCmd)
|
||||||
|
|||||||
@@ -516,6 +516,21 @@ func (c *Client) ExecRun(ctx context.Context, containerID string, cmd []string)
|
|||||||
return buf.String(), inspect.ExitCode, nil
|
return buf.String(), inspect.ExitCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecAttach attaches to a running exec instance, returning the hijacked
|
||||||
|
// bidirectional stream. With Tty enabled the stream carries raw pty data
|
||||||
|
// (no stdcopy multiplexing).
|
||||||
|
func (c *Client) ExecAttach(ctx context.Context, execID string) (types.HijackedResponse, error) {
|
||||||
|
return c.cli.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecResize resizes the pty of a running exec instance.
|
||||||
|
func (c *Client) ExecResize(ctx context.Context, execID string, cols, rows uint) error {
|
||||||
|
return c.cli.ContainerExecResize(ctx, execID, container.ResizeOptions{
|
||||||
|
Height: rows,
|
||||||
|
Width: cols,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetImageInfo returns information about a Docker image
|
// GetImageInfo returns information about a Docker image
|
||||||
func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) {
|
func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) {
|
||||||
images, err := c.cli.ImageList(ctx, image.ListOptions{})
|
images, err := c.cli.ImageList(ctx, image.ListOptions{})
|
||||||
|
|||||||
Generated
+17
@@ -33,6 +33,8 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
@@ -4262,6 +4264,21 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@xyflow/react": {
|
"node_modules/@xyflow/react": {
|
||||||
"version": "12.10.1",
|
"version": "12.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { getApiBaseUrl } from '@/lib/api-client';
|
||||||
|
|
||||||
|
type Frame = {
|
||||||
|
type: 'stdin' | 'resize' | 'stdout' | 'exit' | 'error' | 'ping' | 'pong';
|
||||||
|
data?: string;
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interactive shell: WebSocket → docker exec pty in the service container.
|
||||||
|
export function ServiceTerminal({ serviceId }: { serviceId: string }) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [status, setStatus] = useState<'connecting' | 'live' | 'ended' | 'error'>('connecting');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hostRef.current) return;
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
|
||||||
|
fontSize: 13,
|
||||||
|
cursorBlink: true,
|
||||||
|
convertEol: false,
|
||||||
|
theme: {
|
||||||
|
background: '#0a0a0c',
|
||||||
|
foreground: '#d4d4d8',
|
||||||
|
cursor: '#a1a1aa',
|
||||||
|
selectionBackground: '#3f3f46',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(hostRef.current);
|
||||||
|
fit.fit();
|
||||||
|
|
||||||
|
const wsUrl = getApiBaseUrl().replace(/^http/, 'ws');
|
||||||
|
const ws = new WebSocket(`${wsUrl}/services/${serviceId}/terminal`);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setStatus('live');
|
||||||
|
const { cols, rows } = term;
|
||||||
|
const frame: Frame = { type: 'resize', cols, rows };
|
||||||
|
ws.send(JSON.stringify(frame));
|
||||||
|
};
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let f: Frame;
|
||||||
|
try {
|
||||||
|
f = JSON.parse(ev.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (f.type === 'stdout' && f.data) term.write(f.data);
|
||||||
|
else if (f.type === 'exit') {
|
||||||
|
setStatus('ended');
|
||||||
|
term.write(`\r\n\x1b[90m[session ended — exit ${f.code ?? 0}]\x1b[0m\r\n`);
|
||||||
|
} else if (f.type === 'error') {
|
||||||
|
setStatus('error');
|
||||||
|
term.write(`\r\n\x1b[31m${f.message ?? 'terminal error'}\x1b[0m\r\n`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => setStatus((s) => (s === 'live' ? s : 'error'));
|
||||||
|
ws.onclose = () => setStatus((s) => (s === 'error' ? s : 'ended'));
|
||||||
|
|
||||||
|
const dataSub = term.onData((data) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'stdin', data } satisfies Frame));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
fit.fit();
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
const { cols, rows } = term;
|
||||||
|
ws.send(JSON.stringify({ type: 'resize', cols, rows } satisfies Frame));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(hostRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
dataSub.dispose();
|
||||||
|
ws.close();
|
||||||
|
term.dispose();
|
||||||
|
};
|
||||||
|
}, [serviceId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{status === 'connecting' && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#0a0a0c]/80 rounded-[var(--radius-md)]">
|
||||||
|
<Loader2 size={18} className="animate-spin text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden p-2"
|
||||||
|
style={{ background: '#0a0a0c', height: 420 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
deleteScalingPolicy,
|
deleteScalingPolicy,
|
||||||
getServiceScalingState,
|
getServiceScalingState,
|
||||||
manualScaleService,
|
manualScaleService,
|
||||||
execInService,
|
|
||||||
rollbackDeployment,
|
rollbackDeployment,
|
||||||
cancelDeployment,
|
cancelDeployment,
|
||||||
updateServiceVariables,
|
updateServiceVariables,
|
||||||
@@ -59,6 +58,7 @@ import { getCurrentUserProfile } from '@/lib/api-client';
|
|||||||
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
||||||
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
|
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
|
||||||
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
|
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
|
||||||
|
import { ServiceTerminal } from '@/features/service/components/ServiceTerminal';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -338,21 +338,6 @@ export function ServiceDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Console ---
|
|
||||||
const [consoleInput, setConsoleInput] = useState('');
|
|
||||||
const [consoleHistory, setConsoleHistory] = useState<
|
|
||||||
Array<{ command: string; output: string; exitCode: number; error: string }>
|
|
||||||
>([]);
|
|
||||||
const execMutation = useMutation({
|
|
||||||
mutationFn: (command: string) => execInService(serviceId, command),
|
|
||||||
onSuccess: (result, command) => {
|
|
||||||
setConsoleHistory((prev) => [
|
|
||||||
{ command, output: result.output ?? '', exitCode: result.exit_code ?? 0, error: result.error ?? '' },
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
setConsoleInput('');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const triggerCronMutation = useMutation({
|
const triggerCronMutation = useMutation({
|
||||||
mutationFn: (id: string) => triggerCronJob(id),
|
mutationFn: (id: string) => triggerCronJob(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -1927,64 +1912,10 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Console</h2>
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Console</h2>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">One-off commands via docker exec — 30s limit, 64KB output cap</p>
|
<p className="text-sm text-[var(--text-secondary)]">Interactive shell inside the service container</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ServiceTerminal serviceId={serviceId} />
|
||||||
<form
|
|
||||||
className="flex items-center gap-2 mb-4"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const cmd = consoleInput.trim();
|
|
||||||
if (cmd && !execMutation.isPending) execMutation.mutate(cmd);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-mono text-[var(--accent-primary)]">$</span>
|
|
||||||
<input
|
|
||||||
value={consoleInput}
|
|
||||||
onChange={(e) => setConsoleInput(e.target.value)}
|
|
||||||
placeholder="e.g. env | sort | head -20"
|
|
||||||
disabled={execMutation.isPending}
|
|
||||||
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={execMutation.isPending || !consoleInput.trim()}
|
|
||||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{execMutation.isPending ? 'Running…' : 'Run'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{execMutation.isError && (
|
|
||||||
<p className="mb-4 text-xs text-[var(--error)]">
|
|
||||||
{(execMutation.error as Error)?.message ?? 'Command failed'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{consoleHistory.length === 0 ? (
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">
|
|
||||||
Commands run inside the service's running container as its default user. No interactive shell — stdout/stderr is captured and returned.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{consoleHistory.map((entry, i) => (
|
|
||||||
<div key={i} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-3">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<p className="text-xs font-mono text-[var(--text-primary)]">$ {entry.command}</p>
|
|
||||||
<span className={`text-xs font-mono ${entry.exitCode === 0 && !entry.error ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
|
||||||
{entry.error ? 'error' : `exit ${entry.exitCode}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{(entry.output || entry.error) && (
|
|
||||||
<pre className="text-xs font-mono text-[var(--text-secondary)] whitespace-pre-wrap break-all max-h-64 overflow-y-auto">
|
|
||||||
{entry.output}{entry.error ? `\n${entry.error}` : ''}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1056,6 +1056,66 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/services/{id}/terminal": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Interactive terminal (WebSocket)
|
||||||
|
* @description Upgrades to a WebSocket bridged to a `docker exec` PTY in the service's running container (bash if present, else sh). Cookie/bearer auth as usual. Client→server JSON frames: `{"type":"stdin","data":"..."}`, `{"type":"resize","cols":N,"rows":N}`. Server→client frames: `{"type":"stdout","data":"..."}`, `{"type":"exit","code":N}`, `{"type":"error","message":"..."}`.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Switching protocols — WebSocket session established */
|
||||||
|
101: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Service not found or not visible */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Service has no running container */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Docker unavailable */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/internal/wake/{id}": {
|
"/internal/wake/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -720,6 +720,35 @@ paths:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
/services/{id}/terminal:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Interactive terminal (WebSocket)
|
||||||
|
description: >-
|
||||||
|
Upgrades to a WebSocket bridged to a `docker exec` PTY in the
|
||||||
|
service's running container (bash if present, else sh).
|
||||||
|
Cookie/bearer auth as usual.
|
||||||
|
Client→server JSON frames: `{"type":"stdin","data":"..."}`,
|
||||||
|
`{"type":"resize","cols":N,"rows":N}`.
|
||||||
|
Server→client frames: `{"type":"stdout","data":"..."}`,
|
||||||
|
`{"type":"exit","code":N}`, `{"type":"error","message":"..."}`.
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'101':
|
||||||
|
description: Switching protocols — WebSocket session established
|
||||||
|
'404':
|
||||||
|
description: Service not found or not visible
|
||||||
|
'409':
|
||||||
|
description: Service has no running container
|
||||||
|
'503':
|
||||||
|
description: Docker unavailable
|
||||||
|
|
||||||
/internal/wake/{id}:
|
/internal/wake/{id}:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ operations live ops view: active deploys, queue depth,
|
|||||||
24h failures, cron runs, backups
|
24h failures, cron runs, backups
|
||||||
activity enriched activity feed; --severity/--category/
|
activity enriched activity feed; --severity/--category/
|
||||||
--resource/--project/--limit filters
|
--resource/--project/--limit filters
|
||||||
|
shell <service-id> interactive PTY shell inside the service
|
||||||
|
container (bash→sh fallback; resizes follow)
|
||||||
banners list|create|update|delete announcement banners (admin);
|
banners list|create|update|delete announcement banners (admin);
|
||||||
active banners print on `auth status`
|
active banners print on `auth status`
|
||||||
invites list|create|revoke team invite links (admin); create
|
invites list|create|revoke team invite links (admin); create
|
||||||
|
|||||||
Reference in new issue
Block a user