diff --git a/tavern/app.go b/tavern/app.go index 3dea2d8d0..1f83f5d1f 100644 --- a/tavern/app.go +++ b/tavern/app.go @@ -45,8 +45,8 @@ import ( tavernmcp "realm.pub/tavern/internal/mcp" "realm.pub/tavern/internal/portals" "realm.pub/tavern/internal/portals/mux" - "realm.pub/tavern/internal/portals/ssh" "realm.pub/tavern/internal/portals/pty" + "realm.pub/tavern/internal/portals/ssh" "realm.pub/tavern/internal/redirectors" "realm.pub/tavern/internal/scheduler" "realm.pub/tavern/internal/secrets" @@ -313,13 +313,8 @@ func NewServer(ctx context.Context, options ...func(*Config)) (*Server, error) { // Configure Request Logging httpLogger := log.New(os.Stderr, "[HTTP] ", log.Flags()) - // Configure Shell Muxes - wsShellMux, grpcShellMux := cfg.NewShellMuxes(ctx) - go func() { - if err := wsShellMux.Start(ctx); err != nil { - slog.ErrorContext(ctx, "websocket shell mux stopped", "err", err) - } - }() + // Configure Shell Mux + grpcShellMux := cfg.NewGRPCShellMux(ctx) go func() { if err := grpcShellMux.Start(ctx); err != nil { slog.ErrorContext(ctx, "grpc shell mux stopped", "err", err) @@ -493,14 +488,8 @@ func NewServer(ctx context.Context, options ...func(*Config)) (*Server, error) { Handler: cdn.NewUploadHandler(client), }, "/shell/ws": tavernhttp.Endpoint{ - Handler: stream.NewShellHandler(client, wsShellMux), - }, - "/shellv2/ws": tavernhttp.Endpoint{ Handler: tavernshell.NewHandler(client, portalMux), }, - "/shell/ping": tavernhttp.Endpoint{ - Handler: stream.NewPingHandler(client, wsShellMux), - }, "/portals/ssh/ws": tavernhttp.Endpoint{ Handler: ssh.NewHandler(client, portalMux), }, diff --git a/tavern/config.go b/tavern/config.go index a42ea2a0e..f3cac7ca5 100644 --- a/tavern/config.go +++ b/tavern/config.go @@ -257,24 +257,22 @@ func (cfg *Config) NewPortalMux(ctx context.Context) *mux.Mux { return mux.New(mux.WithPubSubClient(client), mux.WithSubscriberBufferSize(subBufferSize)) } -// NewShellMuxes configures two stream.Mux instances for shell i/o. -// The wsMux will be used by websockets to subscribe to shell output and publish new input. +// NewGRPCShellMux configures a stream.Mux instance for shell i/o. // The grpcMux will be used by gRPC to subscribe to shell input and publish new output. -func (cfg *Config) NewShellMuxes(ctx context.Context) (wsMux *stream.Mux, grpcMux *stream.Mux) { +func (cfg *Config) NewGRPCShellMux(ctx context.Context) (grpcMux *stream.Mux) { var ( projectID = EnvGCPProjectID.String() gcpTopicPrefix = fmt.Sprintf("gcppubsub://projects/%s/topics/", projectID) topicShellInput = EnvPubSubTopicShellInput.String() topicShellOutput = EnvPubSubTopicShellOutput.String() subShellInput = EnvPubSubSubscriptionShellInput.String() - subShellOutput = EnvPubSubSubscriptionShellOutput.String() ) // For GCP, messages for a "Subscription" are load-balanced across all of the "Subscribers" to that same "Subscription" // This means we must make a new "Subscription" in GCP for each instance of tavern to ensure they all receive the // appropriate input/output from shells. For more information, see the information here: // https://cloud.google.com/pubsub/docs/pubsub-basics#choose_a_publish_and_subscribe_pattern - if strings.HasPrefix(subShellInput, "gcppubsub://") && strings.HasPrefix(subShellOutput, "gcppubsub://") { + if strings.HasPrefix(subShellInput, "gcppubsub://") { if projectID == "" { log.Fatalf("[FATAL] must set value for %q when using gcppubsub:// in configuration", EnvGCPProjectID.Key) } @@ -316,13 +314,10 @@ func (cfg *Config) NewShellMuxes(ctx context.Context) (wsMux *stream.Mux, grpcMu } shellInputTopicID := strings.TrimPrefix(topicShellInput, gcpTopicPrefix) - shellOutputTopicID := strings.TrimPrefix(topicShellOutput, gcpTopicPrefix) // Overwrite env var specification with newly created GCP PubSub Subscriptions subShellInput = fmt.Sprintf("gcppubsub://projects/%s/subscriptions/%s", projectID, createGCPSubscription(ctx, shellInputTopicID)) slog.DebugContext(ctx, "created GCP PubSub subscription for shell input", "subscription_name", subShellInput) - subShellOutput = fmt.Sprintf("gcppubsub://projects/%s/subscriptions/%s", projectID, createGCPSubscription(ctx, shellOutputTopicID)) - slog.DebugContext(ctx, "created GCP PubSub subscription for shell output", "subscription_name", subShellOutput) // Start a goroutine to publish noop messages on an interval. // This reduces cold-start latency for GCP PubSub which can improve shell user experience. @@ -341,16 +336,8 @@ func (cfg *Config) NewShellMuxes(ctx context.Context) (wsMux *stream.Mux, grpcMu log.Fatalf("[FATAL] Failed to connect to pubsub topic (%q): %v", topicShellOutput, err) } - slog.DebugContext(ctx, "opening GCP PubSub subscription for shell output", "subscription_name", subShellOutput) - subOutput, err := pubsub.OpenSubscription(ctx, subShellOutput) - if err != nil { - log.Fatalf("[FATAL] Failed to connect to pubsub subscription (%q): %v", subShellOutput, err) - } - - pubInput, err := pubsub.OpenTopic(ctx, topicShellInput) - if err != nil { - log.Fatalf("[FATAL] Failed to connect to pubsub topic (%q): %v", topicShellInput, err) - } + // Make sure the topic is created before we try to subscribe to it in memory + _, _ = pubsub.OpenTopic(ctx, topicShellInput) slog.DebugContext(ctx, "opening GCP PubSub subscription for shell input", "subscription_name", subShellInput) subInput, err := pubsub.OpenSubscription(ctx, subShellInput) @@ -358,7 +345,6 @@ func (cfg *Config) NewShellMuxes(ctx context.Context) (wsMux *stream.Mux, grpcMu log.Fatalf("[FATAL] Failed to connect to pubsub subscription (%q): %v", subShellInput, err) } - wsMux = stream.NewMux(pubInput, subOutput) grpcMux = stream.NewMux(pubOutput, subInput) return } diff --git a/tavern/internal/http/stream/websocket.go b/tavern/internal/http/stream/websocket.go deleted file mode 100644 index df388f039..000000000 --- a/tavern/internal/http/stream/websocket.go +++ /dev/null @@ -1,396 +0,0 @@ -package stream - -import ( - "context" - "log/slog" - "net/http" - "strconv" - "sync" - "time" - - "github.com/gorilla/websocket" - "gocloud.dev/pubsub" - "realm.pub/tavern/internal/auth" - "realm.pub/tavern/internal/ent" - "realm.pub/tavern/internal/ent/shell" - "realm.pub/tavern/internal/ent/user" -) - -const ( - // Time allowed to write a message to the peer. - writeWait = 10 * time.Second - - // Time allowed to read the next pong message from the peer. - pongWait = 60 * time.Second - - // Send pings to peer with this period. Must be less than pongWait. - pingPeriod = (pongWait * 9) / 10 - - // Maximum message size allowed from peer. - maxMessageSize = 256 * 1024 // 256KB -) - -type connector struct { - *Stream - mux *Mux - ws *websocket.Conn - kind string -} - -// WriteToWebsocket will read messages from the Mux and write them to the underlying websocket. -func (c *connector) WriteToWebsocket(ctx context.Context) { - defer c.ws.Close() - - // Register with mux to receive messages - c.mux.Register(c.Stream) - defer c.mux.Unregister(c.Stream) - - // Keep Alive - ticker := time.NewTicker(pingPeriod) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - c.ws.WriteMessage(websocket.CloseMessage, []byte{}) - return - case message, ok := <-c.Messages(): - c.ws.SetWriteDeadline(time.Now().Add(writeWait)) - if !ok { - // The mux closed the channel. - c.ws.WriteMessage(websocket.CloseMessage, []byte{}) - return - } - - // Check if stream has closed - hasClosed, ok := message.Metadata[MetadataStreamClose] - if ok && hasClosed != "" { - // The producer ended the stream. - slog.DebugContext(ctx, "websocket closed due to producer ending stream", - "stream_id", c.Stream.id, - "stream_order_key", c.Stream.orderKey, - ) - c.ws.WriteMessage(websocket.CloseMessage, []byte{}) - return - } - - // Filter by kind - kind := message.Metadata[MetadataMsgKind] - if kind == "" { - kind = "data" - } - if kind != c.kind { - continue - } - - w, err := c.ws.NextWriter(websocket.BinaryMessage) - if err != nil { - return - } - if _, err := w.Write(message.Body); err != nil { - slog.ErrorContext(ctx, "failed to write message from producer to websocket", - "stream_id", c.Stream.id, - "stream_order_key", c.Stream.orderKey, - "error", err, - ) - } - - // Flush queued messages to the current websocket message. - n := len(c.Messages()) - for i := 0; i < n; i++ { - additionalMsg := <-c.Messages() - - // Filter additional messages too - kind := additionalMsg.Metadata[MetadataMsgKind] - if kind == "" { - kind = "data" - } - if kind != c.kind { - continue - } - - if _, err := w.Write(additionalMsg.Body); err != nil { - slog.ErrorContext(ctx, "failed to write additional message from producer to websocket", - "stream_id", c.Stream.id, - "stream_order_key", c.Stream.orderKey, - "error", err, - ) - } - } - - if err := w.Close(); err != nil { - return - } - case <-ticker.C: - c.ws.SetWriteDeadline(time.Now().Add(writeWait)) - if err := c.ws.WriteMessage(websocket.PingMessage, nil); err != nil { - return - } - } - } -} - -// ReadFromWebsocket will read messages from the underlying websocket and send them to the configured Mux. -func (c *connector) ReadFromWebsocket(ctx context.Context) { - defer c.ws.Close() - - // Configure connection info - c.ws.SetReadLimit(maxMessageSize) - c.ws.SetReadDeadline(time.Now().Add(pongWait)) - c.ws.SetPongHandler(func(string) error { - c.ws.SetReadDeadline(time.Now().Add(pongWait)) - return nil - }) - - for { - select { - case <-ctx.Done(): - return - default: - _, message, err := c.ws.ReadMessage() - if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - slog.ErrorContext(ctx, "websocket closed unexpectedly", - "stream_id", c.Stream.id, - "stream_order_key", c.Stream.orderKey, - "error", err, - ) - } - return - } - - msgLen := len(message) - if err := c.Stream.SendMessage(ctx, &pubsub.Message{ - Body: message, - Metadata: map[string]string{ - metadataID: c.id, - MetadataMsgKind: c.kind, - }, - }, c.mux); err != nil { - slog.ErrorContext(ctx, "websocket failed to publish message", - "stream_id", c.Stream.id, - "stream_order_key", c.Stream.orderKey, - "msg_len", msgLen, - "error", err, - ) - return - } - } - } -} - -func manageActiveUser(ctx context.Context, done <-chan struct{}, graph *ent.Client, shellID int, userID int) { - defer func() { - slog.DebugContext(ctx, "websocket checking user activity for shell before removal", "user_id", userID, "shell_id", shellID) - - wasAdded, err := graph.Shell.Query(). - Where(shell.ID(shellID)). - QueryActiveUsers(). - Where(user.ID(userID)). - Exist(ctx) - if err != nil { - slog.ErrorContext(ctx, "websocket failed to check user activity for shell", "err", err, "user_id", userID, "shell_id", shellID) - return - } - if !wasAdded { - return - } - - if _, err := graph.Shell.UpdateOneID(shellID). - RemoveActiveUserIDs(userID). - Save(ctx); err != nil { - slog.ErrorContext(ctx, "websocket failed to remove inactive user from shell", "err", err, "user_id", userID, "shell_id", shellID) - return - } - }() - - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - case <-done: - return - case <-ticker.C: - // Handle case where user has multiple shells open - alreadyAdded, err := graph.Shell.Query(). - Where(shell.ID(shellID)). - QueryActiveUsers(). - Where(user.ID(userID)). - Exist(ctx) - if err != nil { - slog.ErrorContext(ctx, "websocket failed to check user activity for shell", "err", err, "user_id", userID, "shell_id", shellID) - continue - } - if alreadyAdded { - continue - } - - if _, err := graph.Shell.UpdateOneID(shellID). - AddActiveUserIDs(userID). - Save(ctx); err != nil { - slog.ErrorContext(ctx, "websocket failed to add active user to shell", "err", err, "user_id", userID, "shell_id", shellID) - } - } - } - -} - -// NewShellHandler provides an HTTP handler which handles a websocket for shell io. -// It requires a query param "shell_id" be specified (must be an integer). -// This ID represents which Shell ent the websocket will connect to. -func NewShellHandler(graph *ent.Client, mux *Mux) http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Load Authenticated User - authUser := auth.UserFromContext(ctx) - var ( - authUserName = "unknown" - authUserID = 0 - ) - if authUser != nil { - authUserID = authUser.ID - authUserName = authUser.Name - } - - // Parse Shell ID - shellIDStr := r.URL.Query().Get("shell_id") - if shellIDStr == "" { - http.Error(w, "must provide integer value for 'shell_id'", http.StatusBadRequest) - return - } - shellID, err := strconv.Atoi(shellIDStr) - if err != nil { - http.Error(w, "invalid 'shell_id' provided, must be integer", http.StatusBadRequest) - return - } - - // Load Shell - revShell, err := graph.Shell.Query(). - Where(shell.ID(shellID)). - Select(shell.FieldClosedAt). - Only(ctx) - if err != nil { - if ent.IsNotFound(err) { - http.Error(w, "shell not found", http.StatusNotFound) - } else { - slog.ErrorContext(ctx, "websocket failed to load shell", "err", err, "shell_id", shellID, "user_id", authUserID, "user_name", authUserName) - http.Error(w, "failed to load shell", http.StatusInternalServerError) - } - return - } - - // Track Active User - var activeUserWG sync.WaitGroup - activeUserDoneCh := make(chan struct{}) - if authUser != nil { - activeUserWG.Add(1) - go func(ctx context.Context, shellID, userID int) { - defer activeUserWG.Done() - manageActiveUser(ctx, activeUserDoneCh, graph, shellID, userID) - }(ctx, revShell.ID, authUser.ID) - } - - // Prevent opening closed shells - if !revShell.ClosedAt.IsZero() { - http.Error(w, "shell already closed", http.StatusBadRequest) - return - } - - // Start Websocket - slog.InfoContext(ctx, "new shell websocket connection", "shell_id", shellID, "user_id", authUserID, "user_name", authUserName) - ws, err := upgrader.Upgrade(w, r, nil) - if err != nil { - slog.ErrorContext(ctx, "websocket failed to upgrade connection", "err", err, "shell_id", shellID, "user_id", authUserID, "user_name", authUserName) - return - } - defer slog.InfoContext(ctx, "websocket shell connection closed", "shell_id", shellID, "user_id", authUserID, "user_name", authUserName) - - // Initialize Stream - stream := New(shellIDStr) - - // Create Connector - conn := &connector{ - Stream: stream, - mux: mux, - ws: ws, - kind: "data", - } - - // Read & Write - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - conn.ReadFromWebsocket(ctx) - }() - go func() { - defer wg.Done() - conn.WriteToWebsocket(ctx) - }() - - wg.Wait() - activeUserDoneCh <- struct{}{} - activeUserWG.Wait() - }) -} - -// NewPingHandler provides an HTTP handler which handles a websocket for latency pings. -// It requires a query param "shell_id" be specified (must be an integer). -func NewPingHandler(graph *ent.Client, mux *Mux) http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Parse Shell ID - shellIDStr := r.URL.Query().Get("shell_id") - if shellIDStr == "" { - http.Error(w, "must provide integer value for 'shell_id'", http.StatusBadRequest) - return - } - shellID, err := strconv.Atoi(shellIDStr) - if err != nil { - http.Error(w, "invalid 'shell_id' provided, must be integer", http.StatusBadRequest) - return - } - - // Check if shell exists (optional, but good for consistency) - exists, err := graph.Shell.Query().Where(shell.ID(shellID)).Exist(ctx) - if err != nil || !exists { - http.Error(w, "shell not found", http.StatusNotFound) - return - } - - // Start Websocket - ws, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - - // Initialize Stream - stream := New(shellIDStr) - - // Create Connector - conn := &connector{ - Stream: stream, - mux: mux, - ws: ws, - kind: "ping", - } - - // Read & Write - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - conn.ReadFromWebsocket(ctx) - }() - go func() { - defer wg.Done() - conn.WriteToWebsocket(ctx) - }() - - wg.Wait() - }) -} diff --git a/tavern/internal/http/stream/websocket_test.go b/tavern/internal/http/stream/websocket_test.go deleted file mode 100644 index b4bbff661..000000000 --- a/tavern/internal/http/stream/websocket_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package stream_test - -import ( - "context" - "net/http/httptest" - "strconv" - "strings" - "testing" - "time" - - "github.com/gorilla/websocket" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gocloud.dev/pubsub" - _ "gocloud.dev/pubsub/mempubsub" - "realm.pub/tavern/internal/c2/c2pb" - "realm.pub/tavern/internal/ent/enttest" - "realm.pub/tavern/internal/http/stream" - - _ "github.com/mattn/go-sqlite3" -) - -func TestNewShellHandler(t *testing.T) { - // Setup Ent Client - graph := enttest.OpenTempDB(t) - defer graph.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Topic for messages going TO the websocket (server -> shell) - outputTopic, err := pubsub.OpenTopic(ctx, "mem://websocket-output") - require.NoError(t, err) - defer outputTopic.Shutdown(ctx) - outputSub, err := pubsub.OpenSubscription(ctx, "mem://websocket-output") - require.NoError(t, err) - defer outputSub.Shutdown(ctx) - - // Topic for messages coming FROM the websocket (shell -> server) - inputTopic, err := pubsub.OpenTopic(ctx, "mem://websocket-input") - require.NoError(t, err) - defer inputTopic.Shutdown(ctx) - inputSub, err := pubsub.OpenSubscription(ctx, "mem://websocket-input") - require.NoError(t, err) - defer inputSub.Shutdown(ctx) - - // Create a test user - user, err := graph.User.Create().SetName("test-user").SetOauthID("test-oauth-id").SetPhotoURL("http://example.com/photo.jpg").Save(ctx) - require.NoError(t, err) - - // Create a test host - host, err := graph.Host.Create().SetIdentifier("test-host").SetPlatform(c2pb.Host_PLATFORM_LINUX).Save(ctx) - require.NoError(t, err) - - // Create a test beacon - beacon, err := graph.Beacon.Create().SetHost(host).SetTransport(c2pb.Transport_TRANSPORT_UNSPECIFIED).Save(ctx) - require.NoError(t, err) - - // Create a test tome - tome, err := graph.Tome.Create(). - SetName("test-tome"). - SetDescription("test-description"). - SetAuthor("test-author"). - SetEldritch("test-eldritch"). - SetUploader(user). - Save(ctx) - require.NoError(t, err) - - // Create a test quest - quest, err := graph.Quest.Create().SetName("test-quest").SetTome(tome).SetCreator(user).Save(ctx) - require.NoError(t, err) - - // Create a test task - task, err := graph.Task.Create().SetQuest(quest).SetBeacon(beacon).Save(ctx) - require.NoError(t, err) - - // Create Mux with the correct topics - mux := stream.NewMux(inputTopic, outputSub) - go mux.Start(ctx) - - // Create a test shell - shell, err := graph.Shell.Create().SetData([]byte("test-data")).SetOwner(user).SetTask(task).SetBeacon(beacon).Save(ctx) - require.NoError(t, err) - - // Create a test server - handler := stream.NewShellHandler(graph, mux) - server := httptest.NewServer(handler) - defer server.Close() - - // Create a websocket client - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "?shell_id=" + strconv.Itoa(shell.ID) - ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - require.NoError(t, err) - defer ws.Close() - - // Test writing to the websocket (server -> shell) - testMessage := []byte("hello from server") - err = outputTopic.Send(ctx, &pubsub.Message{ - Body: testMessage, - Metadata: map[string]string{"id": strconv.Itoa(shell.ID)}, - }) - require.NoError(t, err) - - _, p, err := ws.ReadMessage() - assert.NoError(t, err) - - assert.Equal(t, testMessage, p) - - // Test reading from the websocket (shell -> server) - // Client sends raw bytes - readMessage := []byte("hello from shell") - err = ws.WriteMessage(websocket.TextMessage, readMessage) - require.NoError(t, err) - - // Now, we expect the message on the input subscription - msg, err := inputSub.Receive(ctx) - require.NoError(t, err, "timed out waiting for message from websocket") - - // The body sent to pubsub should be the raw bytes - assert.Equal(t, readMessage, msg.Body) - assert.Equal(t, "data", msg.Metadata[stream.MetadataMsgKind]) - msg.Ack() -} diff --git a/tavern/internal/www/.gitignore b/tavern/internal/www/.gitignore index 6b8682554..c13679d26 100644 --- a/tavern/internal/www/.gitignore +++ b/tavern/internal/www/.gitignore @@ -22,3 +22,4 @@ npm_start.log # Allow build files !/build +build/ diff --git a/tavern/internal/www/src/App.tsx b/tavern/internal/www/src/App.tsx index 72fa1803d..1bc2899f3 100644 --- a/tavern/internal/www/src/App.tsx +++ b/tavern/internal/www/src/App.tsx @@ -9,8 +9,7 @@ import 'react-virtualized/styles.css'; import { AuthorizationContextProvider } from "./context/AuthorizationContext"; import HostDetails from "./pages/host-details/HostDetails"; import { Dashboard } from "./pages/dashboard"; -import Shell from "./pages/shell/Shell"; -import ShellV2 from "./pages/shellv2"; +import Shell from "./pages/shell"; import { UserPreferencesContextProvider } from "./context/UserPreferences"; import { AdminPortal } from "./pages/admin/AdminPortal"; import Assets from "./pages/assets/Assets"; @@ -63,10 +62,6 @@ const router = createBrowserRouter([ path: "assets", element: , }, - { - path: "shells/:shellId", - element: , - }, { path: "admin", element: , @@ -78,8 +73,8 @@ const router = createBrowserRouter([ ] }, { - path: "shellv2/:shellId", - element: , + path: "shell/:shellId", + element: , }, ]); diff --git a/tavern/internal/www/src/components/create-shell-button/CreateShellButton.tsx b/tavern/internal/www/src/components/create-shell-button/CreateShellButton.tsx index 809faaf60..083ef1937 100644 --- a/tavern/internal/www/src/components/create-shell-button/CreateShellButton.tsx +++ b/tavern/internal/www/src/components/create-shell-button/CreateShellButton.tsx @@ -87,7 +87,7 @@ export const CreateShellButton: React.FC = ({ hostId, be const [createShell, { loading: mutationLoading }] = useMutation(CREATE_SHELL_MUTATION, { onCompleted: (data) => { const shellId = data.createShell.id; - window.open(`/shellv2/${shellId}`, '_blank'); + window.open(`/shell/${shellId}`, '_blank'); }, onError: (error) => { toast({ diff --git a/tavern/internal/www/src/components/create-shell-button/CreateShellButtonWindowOpen.test.tsx b/tavern/internal/www/src/components/create-shell-button/CreateShellButtonWindowOpen.test.tsx index 357f88ba0..7e1b70fd8 100644 --- a/tavern/internal/www/src/components/create-shell-button/CreateShellButtonWindowOpen.test.tsx +++ b/tavern/internal/www/src/components/create-shell-button/CreateShellButtonWindowOpen.test.tsx @@ -78,7 +78,7 @@ test('CreateShellButton opens new tab on click', async () => { // Wait for window.open to have been called await waitFor(() => { - expect(openMock).toHaveBeenCalledWith(`/shellv2/${mockShellId}`, '_blank'); + expect(openMock).toHaveBeenCalledWith(`/shell/${mockShellId}`, '_blank'); }); vi.unstubAllGlobals(); diff --git a/tavern/internal/www/src/components/task-card/components/TaskMenu/useOpenShell.ts b/tavern/internal/www/src/components/task-card/components/TaskMenu/useOpenShell.ts index 8cb650648..04fcc5619 100644 --- a/tavern/internal/www/src/components/task-card/components/TaskMenu/useOpenShell.ts +++ b/tavern/internal/www/src/components/task-card/components/TaskMenu/useOpenShell.ts @@ -17,7 +17,7 @@ export function useOpenShell(beaconId: string) { const [createShell, { loading }] = useMutation(CREATE_SHELL_MUTATION, { onCompleted: (data) => { const shellId = data.createShell.id; - window.open(`/shellv2/${shellId}`, "_blank"); + window.open(`/shell/${shellId}`, "_blank"); }, onError: (error) => { toast({ diff --git a/tavern/internal/www/src/lib/browser-adapter.ts b/tavern/internal/www/src/lib/browser-adapter.ts index 9692385de..eb3fe5bd9 100644 --- a/tavern/internal/www/src/lib/browser-adapter.ts +++ b/tavern/internal/www/src/lib/browser-adapter.ts @@ -1,4 +1,4 @@ -import { WebsocketMessage, WebsocketMessageKind } from "../pages/shellv2/websocket"; +import { WebsocketMessage, WebsocketMessageKind } from "../pages/shell/websocket"; export interface ExecutionResult { status: "complete" | "incomplete" | "error" | "meta"; diff --git a/tavern/internal/www/src/pages/host-details/shell-tab/ShellsTable.tsx b/tavern/internal/www/src/pages/host-details/shell-tab/ShellsTable.tsx index 20d9a876e..423eb6359 100644 --- a/tavern/internal/www/src/pages/host-details/shell-tab/ShellsTable.tsx +++ b/tavern/internal/www/src/pages/host-details/shell-tab/ShellsTable.tsx @@ -135,7 +135,7 @@ export const ShellsTable = ({ shellIds, hasMore = false, onLoadMore }: ShellsTab buttonVariant="ghost" onClick={(e) => { e.stopPropagation(); - navigate(`/shellv2/${shell.id}`); + navigate(`/shell/${shell.id}`); }} > diff --git a/tavern/internal/www/src/pages/shell/Shell.tsx b/tavern/internal/www/src/pages/shell/Shell.tsx deleted file mode 100644 index 09bf84c1b..000000000 --- a/tavern/internal/www/src/pages/shell/Shell.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { Terminal } from "@xterm/xterm"; -import { AttachAddon } from 'xterm-addon-attach'; -import { useState, useEffect, useRef } from 'react'; -import { useParams } from "react-router-dom"; -import { useToast } from "@chakra-ui/react"; -import '@xterm/xterm/css/xterm.css'; -import { EmptyState, EmptyStateType } from "../../components/tavern-base-ui/EmptyState"; -import Button from "../../components/tavern-base-ui/button/Button"; -import Badge from "../../components/tavern-base-ui/badge/Badge"; -import Breadcrumbs from "../../components/Breadcrumbs"; - -const Shell = () => { - const { shellId } = useParams(); - const toast = useToast(); - - const [wsIsOpen, setWsIsOpen] = useState(false); - const [latency, setLatency] = useState(null); - const ws = useRef(null); - const pingWs = useRef(null); - const termRef = useRef(null); - if (termRef.current === null) { - termRef.current = new Terminal(); - } - - // Setup Shell WebSocket - useEffect(() => { - if (!ws.current) { - const scheme = window.location.protocol === "https:" ? 'wss' : 'ws'; - const socket = new WebSocket(`${scheme}://${window.location.host}/shell/ws?shell_id=${shellId}`); - - socket.onopen = (e) => { - setWsIsOpen(true); - toast({ - title: 'Shell Connected', - description: 'Only output after your connection is displayed, so you may need to enter a newline to see the prompt', - status: 'success', - duration: 6000, - isClosable: true, - }) - const attachAddon = new AttachAddon(socket); - termRef.current?.loadAddon(attachAddon); - }; - socket.onerror = (e) => { - toast({ - title: 'Shell Connection Error', - description: `Something went wrong with the underlying connection to the shell (${e.type})`, - status: 'error', - duration: 6000, - isClosable: true, - }) - } - socket.onclose = (e) => { - setWsIsOpen(false); - toast({ - title: 'Shell Closed', - description: `Your shell connection has been closed, however the shell may still be available (${e.type})`, - status: 'info', - duration: 6000, - isClosable: true, - }) - } - - ws.current = socket; - } - - // Cleanup - return () => { - if (ws.current) { - ws.current.close(); - ws.current = null; - } - } - }, [shellId]); - - // Setup Ping WebSocket and Loop - useEffect(() => { - if (!pingWs.current) { - const scheme = window.location.protocol === "https:" ? 'wss' : 'ws'; - const socket = new WebSocket(`${scheme}://${window.location.host}/shell/ping?shell_id=${shellId}`); - socket.binaryType = 'arraybuffer'; - - socket.onmessage = (ev) => { - // We expect the payload to be the timestamp we sent - // It comes back as binary (ArrayBuffer) because the backend writes BinaryMessage - try { - const dec = new TextDecoder("utf-8"); - let sentAtStr = ""; - if (ev.data instanceof ArrayBuffer) { - sentAtStr = dec.decode(ev.data); - } else if (typeof ev.data === "string") { - sentAtStr = ev.data; - } - - const sentAt = parseInt(sentAtStr); - if (!isNaN(sentAt)) { - const now = Date.now(); - setLatency(now - sentAt); - } - } catch (e) { - console.error("Failed to parse ping response", e); - } - }; - - pingWs.current = socket; - } - - const timer = setInterval(() => { - if (pingWs.current && pingWs.current.readyState === WebSocket.OPEN) { - // Send current timestamp as string/bytes - const now = Date.now().toString(); - pingWs.current.send(now); - } - }, 2000); - - return () => { - clearInterval(timer); - if (pingWs.current) { - pingWs.current.close(); - pingWs.current = null; - } - }; - }, [shellId]); - - const renderTerminal = (div: HTMLDivElement) => { if (div) { termRef.current?.open(div); } }; - - //TODO: Expand to fetch active users for this page - return ( - <> - - - - - Shell for id:{shellId} - BETA FEATURE - {latency !== null && ( - - {latency}ms - - )} - - Start by clicking inside the terminal, you may need to enter a newline to see the terminal prompt. - - - - Report a bug - - - - - { - wsIsOpen ? - : - - } - > - ); -} -export default Shell; diff --git a/tavern/internal/www/src/pages/shellv2/components/DocTooltip.tsx b/tavern/internal/www/src/pages/shell/components/DocTooltip.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/components/DocTooltip.tsx rename to tavern/internal/www/src/pages/shell/components/DocTooltip.tsx diff --git a/tavern/internal/www/src/pages/shellv2/components/PtyTerminal.tsx b/tavern/internal/www/src/pages/shell/components/PtyTerminal.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/components/PtyTerminal.tsx rename to tavern/internal/www/src/pages/shell/components/PtyTerminal.tsx diff --git a/tavern/internal/www/src/pages/shellv2/components/ShellActionsMenu.tsx b/tavern/internal/www/src/pages/shell/components/ShellActionsMenu.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/components/ShellActionsMenu.tsx rename to tavern/internal/www/src/pages/shell/components/ShellActionsMenu.tsx diff --git a/tavern/internal/www/src/pages/shellv2/components/ShellCompletions.tsx b/tavern/internal/www/src/pages/shell/components/ShellCompletions.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/components/ShellCompletions.tsx rename to tavern/internal/www/src/pages/shell/components/ShellCompletions.tsx diff --git a/tavern/internal/www/src/pages/shellv2/components/ShellHeader.tsx b/tavern/internal/www/src/pages/shell/components/ShellHeader.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/components/ShellHeader.tsx rename to tavern/internal/www/src/pages/shell/components/ShellHeader.tsx diff --git a/tavern/internal/www/src/pages/shellv2/components/ShellStatusBar.tsx b/tavern/internal/www/src/pages/shell/components/ShellStatusBar.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/components/ShellStatusBar.tsx rename to tavern/internal/www/src/pages/shell/components/ShellStatusBar.tsx diff --git a/tavern/internal/www/src/pages/shellv2/components/ShellTerminal.tsx b/tavern/internal/www/src/pages/shell/components/ShellTerminal.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/components/ShellTerminal.tsx rename to tavern/internal/www/src/pages/shell/components/ShellTerminal.tsx diff --git a/tavern/internal/www/src/pages/shellv2/components/SshConnectionModal.tsx b/tavern/internal/www/src/pages/shell/components/SshConnectionModal.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/components/SshConnectionModal.tsx rename to tavern/internal/www/src/pages/shell/components/SshConnectionModal.tsx diff --git a/tavern/internal/www/src/pages/shellv2/components/SshTerminal.tsx b/tavern/internal/www/src/pages/shell/components/SshTerminal.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/components/SshTerminal.tsx rename to tavern/internal/www/src/pages/shell/components/SshTerminal.tsx diff --git a/tavern/internal/www/src/pages/shellv2/components/__tests__/ShellHeader.test.tsx b/tavern/internal/www/src/pages/shell/components/__tests__/ShellHeader.test.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/components/__tests__/ShellHeader.test.tsx rename to tavern/internal/www/src/pages/shell/components/__tests__/ShellHeader.test.tsx diff --git a/tavern/internal/www/src/pages/shellv2/components/__tests__/ShellStatusBar.test.tsx b/tavern/internal/www/src/pages/shell/components/__tests__/ShellStatusBar.test.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/components/__tests__/ShellStatusBar.test.tsx rename to tavern/internal/www/src/pages/shell/components/__tests__/ShellStatusBar.test.tsx diff --git a/tavern/internal/www/src/pages/shellv2/graphql.ts b/tavern/internal/www/src/pages/shell/graphql.ts similarity index 100% rename from tavern/internal/www/src/pages/shellv2/graphql.ts rename to tavern/internal/www/src/pages/shell/graphql.ts diff --git a/tavern/internal/www/src/pages/shellv2/hooks/shellUtils.test.ts b/tavern/internal/www/src/pages/shell/hooks/shellUtils.test.ts similarity index 100% rename from tavern/internal/www/src/pages/shellv2/hooks/shellUtils.test.ts rename to tavern/internal/www/src/pages/shell/hooks/shellUtils.test.ts diff --git a/tavern/internal/www/src/pages/shellv2/hooks/shellUtils.ts b/tavern/internal/www/src/pages/shell/hooks/shellUtils.ts similarity index 100% rename from tavern/internal/www/src/pages/shellv2/hooks/shellUtils.ts rename to tavern/internal/www/src/pages/shell/hooks/shellUtils.ts diff --git a/tavern/internal/www/src/pages/shellv2/hooks/useCallbackTimer.test.ts b/tavern/internal/www/src/pages/shell/hooks/useCallbackTimer.test.ts similarity index 100% rename from tavern/internal/www/src/pages/shellv2/hooks/useCallbackTimer.test.ts rename to tavern/internal/www/src/pages/shell/hooks/useCallbackTimer.test.ts diff --git a/tavern/internal/www/src/pages/shellv2/hooks/useCallbackTimer.ts b/tavern/internal/www/src/pages/shell/hooks/useCallbackTimer.ts similarity index 100% rename from tavern/internal/www/src/pages/shellv2/hooks/useCallbackTimer.ts rename to tavern/internal/www/src/pages/shell/hooks/useCallbackTimer.ts diff --git a/tavern/internal/www/src/pages/shellv2/hooks/useShellData.ts b/tavern/internal/www/src/pages/shell/hooks/useShellData.ts similarity index 100% rename from tavern/internal/www/src/pages/shellv2/hooks/useShellData.ts rename to tavern/internal/www/src/pages/shell/hooks/useShellData.ts diff --git a/tavern/internal/www/src/pages/shellv2/hooks/useShellTerminal.ts b/tavern/internal/www/src/pages/shell/hooks/useShellTerminal.ts similarity index 99% rename from tavern/internal/www/src/pages/shellv2/hooks/useShellTerminal.ts rename to tavern/internal/www/src/pages/shell/hooks/useShellTerminal.ts index 3d94fa513..d73fa7a8f 100644 --- a/tavern/internal/www/src/pages/shellv2/hooks/useShellTerminal.ts +++ b/tavern/internal/www/src/pages/shell/hooks/useShellTerminal.ts @@ -543,7 +543,7 @@ export const useShellTerminal = ( }; const scheme = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${scheme}://${window.location.host}/shellv2/ws?shell_id=${shellId}`; + const url = `${scheme}://${window.location.host}/shell/ws?shell_id=${shellId}`; adapter.current = new BrowserWasmAdapter( url, diff --git a/tavern/internal/www/src/pages/shellv2/hooks/useTabHotkeys.test.ts b/tavern/internal/www/src/pages/shell/hooks/useTabHotkeys.test.ts similarity index 100% rename from tavern/internal/www/src/pages/shellv2/hooks/useTabHotkeys.test.ts rename to tavern/internal/www/src/pages/shell/hooks/useTabHotkeys.test.ts diff --git a/tavern/internal/www/src/pages/shellv2/hooks/useTabHotkeys.ts b/tavern/internal/www/src/pages/shell/hooks/useTabHotkeys.ts similarity index 100% rename from tavern/internal/www/src/pages/shellv2/hooks/useTabHotkeys.ts rename to tavern/internal/www/src/pages/shell/hooks/useTabHotkeys.ts diff --git a/tavern/internal/www/src/pages/shellv2/index.tsx b/tavern/internal/www/src/pages/shell/index.tsx similarity index 100% rename from tavern/internal/www/src/pages/shellv2/index.tsx rename to tavern/internal/www/src/pages/shell/index.tsx diff --git a/tavern/internal/www/src/pages/shellv2/websocket.ts b/tavern/internal/www/src/pages/shell/websocket.ts similarity index 100% rename from tavern/internal/www/src/pages/shellv2/websocket.ts rename to tavern/internal/www/src/pages/shell/websocket.ts
Start by clicking inside the terminal, you may need to enter a newline to see the terminal prompt.