From cacf66067eb2a0fa8bb95c108b600effeae0e9e4 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Thu, 28 Nov 2024 19:17:37 +0000 Subject: [PATCH] feat: implement port forwarding --- .idea/remote-targets.xml | 59 ++++++ cmd/tesseract/main.go | 2 +- go.mod | 2 +- internal/migration/migration.go | 2 +- internal/migration/sql/1_initial.up.sql | 15 +- internal/reverseproxy/proxy.go | 7 +- internal/template/docker_template.go | 4 +- internal/workspace/http_handlers.go | 104 ++++++---- internal/workspace/routes.go | 5 +- internal/workspace/workspace.go | 14 +- internal/workspace/workspace_manager.go | 45 ++-- web/bun.lockb | Bin 187108 -> 187836 bytes web/package.json | 1 + web/src/components/ui/tabs.tsx | 53 +++++ web/src/workspaces/api.ts | 5 +- web/src/workspaces/workspace-info-dialog.tsx | 62 ++++++ .../workspaces/workspace-port-info-tab.tsx | 194 ++++++++++++++++++ web/src/workspaces/workspace-table.tsx | 80 ++++---- 18 files changed, 553 insertions(+), 101 deletions(-) create mode 100644 .idea/remote-targets.xml create mode 100644 web/src/components/ui/tabs.tsx create mode 100644 web/src/workspaces/workspace-info-dialog.tsx create mode 100644 web/src/workspaces/workspace-port-info-tab.tsx diff --git a/.idea/remote-targets.xml b/.idea/remote-targets.xml new file mode 100644 index 0000000..5805918 --- /dev/null +++ b/.idea/remote-targets.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cmd/tesseract/main.go b/cmd/tesseract/main.go index ace5c21..ea54dc1 100644 --- a/cmd/tesseract/main.go +++ b/cmd/tesseract/main.go @@ -49,7 +49,7 @@ func main() { log.Fatalln(err) } - err = migration.Up(fmt.Sprintf("sqlite3://%s", config.DatabasePath)) + err = migration.Up(fmt.Sprintf("sqlite://%s", config.DatabasePath)) if err != nil && !errors.Is(err, migrate.ErrNoChange) { log.Fatalln(err) } diff --git a/go.mod b/go.mod index 6df8d71..cb41316 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.0 require ( github.com/docker/docker v27.3.1+incompatible + github.com/docker/go-connections v0.5.0 github.com/golang-migrate/migrate/v4 v4.18.1 github.com/google/uuid v1.6.0 github.com/labstack/echo/v4 v4.12.0 @@ -19,7 +20,6 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/containerd/log v0.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect diff --git a/internal/migration/migration.go b/internal/migration/migration.go index a1b0f83..4562ee2 100644 --- a/internal/migration/migration.go +++ b/internal/migration/migration.go @@ -3,7 +3,7 @@ package migration import ( "embed" "github.com/golang-migrate/migrate/v4" - _ "github.com/golang-migrate/migrate/v4/database/sqlite3" + _ "github.com/golang-migrate/migrate/v4/database/sqlite" _ "github.com/golang-migrate/migrate/v4/source/file" "github.com/golang-migrate/migrate/v4/source/iofs" _ "modernc.org/sqlite" diff --git a/internal/migration/sql/1_initial.up.sql b/internal/migration/sql/1_initial.up.sql index 21b28b1..458fa11 100644 --- a/internal/migration/sql/1_initial.up.sql +++ b/internal/migration/sql/1_initial.up.sql @@ -27,7 +27,10 @@ CREATE TABLE IF NOT EXISTS template_files file_path TEXT NOT NULL, content BLOB NOT NULL, - CONSTRAINT pk_template_files PRIMARY KEY (template_id, file_path) + CONSTRAINT pk_template_files PRIMARY KEY (template_id, file_path), + CONSTRAINT fk_template_template_files FOREIGN KEY (template_id) REFERENCES templates (id) + ON UPDATE CASCADE + ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS template_images @@ -36,7 +39,10 @@ CREATE TABLE IF NOT EXISTS template_images image_tag TEXT NOT NULL, image_id TEXT NOT NULL, - CONSTRAINT pk_template_images PRIMARY KEY (template_id, image_tag, image_id) + CONSTRAINT pk_template_images PRIMARY KEY (template_id, image_tag, image_id), + CONSTRAINT fk_template_template_images FOREIGN KEY (template_id) REFERENCES templates (id) + ON UPDATE CASCADE + ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS port_mappings @@ -45,5 +51,8 @@ CREATE TABLE IF NOT EXISTS port_mappings container_port INTEGER NOT NULL, subdomain TEXT, - CONSTRAINT pk_port_mappings PRIMARY KEY (workspace_id, container_port, subdomain) + CONSTRAINT pk_port_mappings PRIMARY KEY (workspace_id, container_port, subdomain), + CONSTRAINT fk_workspace_port_mappings FOREIGN KEY (workspace_id) REFERENCES workspaces (id) + ON UPDATE CASCADE + ON DELETE CASCADE ) \ No newline at end of file diff --git a/internal/reverseproxy/proxy.go b/internal/reverseproxy/proxy.go index 6a36c92..c0c6f62 100644 --- a/internal/reverseproxy/proxy.go +++ b/internal/reverseproxy/proxy.go @@ -48,6 +48,10 @@ func (p *ReverseProxy) AddEntry(subdomain string, url *url.URL) { p.httpProxies[subdomain] = proxy } +func (p *ReverseProxy) RemoveEntry(subdomain string) { + delete(p.httpProxies, subdomain) +} + func (p *ReverseProxy) shouldHandleRequest(c echo.Context) bool { h := strings.Replace(p.hostName, ".", "\\.", -1) reg, err := regexp.Compile(".*\\." + h) @@ -83,7 +87,8 @@ func (p *ReverseProxy) handleRequest(c echo.Context) error { return echo.NewHTTPError(http.StatusNotFound) } - proxy, ok := p.httpProxies[subdomain] + first := strings.Split(subdomain, ".")[0] + proxy, ok := p.httpProxies[first] if !ok { return echo.NewHTTPError(http.StatusNotFound) } diff --git a/internal/template/docker_template.go b/internal/template/docker_template.go index df75bd7..cbeb1fa 100644 --- a/internal/template/docker_template.go +++ b/internal/template/docker_template.go @@ -49,7 +49,7 @@ func createDockerTemplate(ctx context.Context, tx bun.Tx, opts createTemplateOpt FilePath: "README.md", Content: make([]byte, 0), } - files := []templateFile{dockerfile, readme} + files := []*templateFile{&dockerfile, &readme} if err = tx.NewInsert().Model(&t).Returning("*").Scan(ctx); err != nil { return nil, err @@ -59,6 +59,8 @@ func createDockerTemplate(ctx context.Context, tx bun.Tx, opts createTemplateOpt return nil, err } + t.Files = files + return &t, nil } diff --git a/internal/workspace/http_handlers.go b/internal/workspace/http_handlers.go index a71346d..cb4874d 100644 --- a/internal/workspace/http_handlers.go +++ b/internal/workspace/http_handlers.go @@ -17,6 +17,8 @@ type updateWorkspaceRequestBody struct { PortMappings []portMapping `json:"ports"` } +const keyCurrentWorkspace = "currentWorkspace" + func fetchAllWorkspaces(c echo.Context) error { mgr := workspaceManagerFrom(c) workspaces, err := mgr.findAllWorkspaces(c.Request().Context()) @@ -26,28 +28,44 @@ func fetchAllWorkspaces(c echo.Context) error { return c.JSON(http.StatusOK, workspaces) } +func currentWorkspace(c echo.Context) *workspace { + return c.Get(keyCurrentWorkspace).(*workspace) +} + +func currentWorkspaceMiddleware(ignoreMissing bool) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + workspaceName := c.Param("workspaceName") + if workspaceName == "" || !workspaceNameRegex.MatchString(workspaceName) { + return echo.NewHTTPError(http.StatusNotFound) + } + + mgr := workspaceManagerFrom(c) + workspace, err := mgr.findWorkspace(c.Request().Context(), workspaceName) + if err != nil { + if errors.Is(err, errWorkspaceNotFound) { + if ignoreMissing { + c.Set(keyCurrentWorkspace, nil) + } else { + return echo.NewHTTPError(http.StatusNotFound) + } + } else { + return err + } + } + c.Set(keyCurrentWorkspace, workspace) + + return next(c) + } + } +} + func updateOrCreateWorkspace(c echo.Context) error { - workspaceName := c.Param("workspaceName") - if workspaceName == "" { - return echo.NewHTTPError(http.StatusNotFound) + workspace := currentWorkspace(c) + if workspace == nil { + return createWorkspace(c, c.Param("workspaceName")) } - - if !workspaceNameRegex.MatchString(workspaceName) { - return echo.NewHTTPError(http.StatusNotFound) - } - - ctx := c.Request().Context() - mgr := workspaceManagerFrom(c) - - exists, err := mgr.hasWorkspace(ctx, workspaceName) - if err != nil { - return err - } - if !exists { - return createWorkspace(c, workspaceName) - } - - return updateWorkspace(c, workspaceName) + return updateWorkspace(c, workspace) } func createWorkspace(c echo.Context, workspaceName string) error { @@ -72,7 +90,7 @@ func createWorkspace(c echo.Context, workspaceName string) error { return c.JSON(http.StatusOK, w) } -func updateWorkspace(c echo.Context, workspaceName string) error { +func updateWorkspace(c echo.Context, workspace *workspace) error { ctx := c.Request().Context() var body updateWorkspaceRequestBody @@ -83,14 +101,6 @@ func updateWorkspace(c echo.Context, workspaceName string) error { mgr := workspaceManagerFrom(c) - workspace, err := mgr.findWorkspace(ctx, workspaceName) - if err != nil { - if errors.Is(err, errWorkspaceNotFound) { - return echo.NewHTTPError(http.StatusNotFound) - } - return err - } - switch status(body.Status) { case statusStopped: if err = mgr.stopWorkspace(ctx, workspace); err != nil { @@ -115,13 +125,9 @@ func updateWorkspace(c echo.Context, workspaceName string) error { } func deleteWorkspace(c echo.Context) error { - workspaceName := c.Param("workspaceName") - if workspaceName == "" { - return echo.NewHTTPError(http.StatusNotFound) - } - + workspace := currentWorkspace(c) mgr := workspaceManagerFrom(c) - if err := mgr.deleteWorkspace(c.Request().Context(), workspaceName); err != nil { + if err := mgr.deleteWorkspace(c.Request().Context(), workspace); err != nil { if errors.Is(err, errWorkspaceNotFound) { return echo.NewHTTPError(http.StatusNotFound) } @@ -130,3 +136,31 @@ func deleteWorkspace(c echo.Context) error { return c.NoContent(http.StatusOK) } + +func deleteWorkspacePortMapping(c echo.Context) error { + workspace := currentWorkspace(c) + mgr := workspaceManagerFrom(c) + + portName := c.Param("portName") + if portName == "" { + return echo.NewHTTPError(http.StatusNotFound) + } + + var portMapping *portMapping + for _, m := range workspace.PortMappings { + if m.Subdomain == portName { + portMapping = &m + break + } + } + if portMapping == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + err := mgr.deletePortMapping(c.Request().Context(), workspace, portMapping) + if err != nil { + return err + } + + return c.NoContent(http.StatusOK) +} diff --git a/internal/workspace/routes.go b/internal/workspace/routes.go index 70bc6e3..f6480eb 100644 --- a/internal/workspace/routes.go +++ b/internal/workspace/routes.go @@ -8,6 +8,7 @@ import ( func DefineRoutes(g *echo.Group, services service.Services) { g.Use(newWorkspaceManagerMiddleware(services)) g.GET("/workspaces", fetchAllWorkspaces) - g.POST("/workspaces/:workspaceName", updateOrCreateWorkspace) - g.DELETE("/workspaces/:workspaceName", deleteWorkspace) + g.POST("/workspaces/:workspaceName", updateOrCreateWorkspace, currentWorkspaceMiddleware(true)) + g.DELETE("/workspaces/:workspaceName", deleteWorkspace, currentWorkspaceMiddleware(false)) + g.DELETE("/workspaces/:workspaceName/forwarded-ports/:portName", deleteWorkspacePortMapping, currentWorkspaceMiddleware(false)) } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index f18689a..a231e9b 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -45,7 +45,7 @@ type portMapping struct { ContainerPort int `json:"port"` Subdomain string `json:"subdomain"` - Workspace workspace `bun:"rel:belongs-to,join:workspace_id=id"` + Workspace workspace `bun:"rel:belongs-to,join:workspace_id=id" json:"-"` } // status represents the status of a workspace. @@ -121,15 +121,19 @@ func SyncAll(ctx context.Context, services service.Services) error { }() } + wg.Wait() + if err = errors.Join(errs...); err != nil { _ = tx.Rollback() return err } - _, err = tx.NewDelete().Model(&deletedWorkspaces).WherePK().Exec(ctx) - if err != nil { - _ = tx.Rollback() - return err + if len(deletedWorkspaces) > 0 { + _, err = tx.NewDelete().Model(&deletedWorkspaces).WherePK().Exec(ctx) + if err != nil { + _ = tx.Rollback() + return err + } } if err = tx.Commit(); err != nil { diff --git a/internal/workspace/workspace_manager.go b/internal/workspace/workspace_manager.go index a42b01a..a45a733 100644 --- a/internal/workspace/workspace_manager.go +++ b/internal/workspace/workspace_manager.go @@ -38,7 +38,7 @@ var errWorkspaceNotFound = errors.New("workspace not found") func (mgr workspaceManager) findAllWorkspaces(ctx context.Context) ([]workspace, error) { var workspaces []workspace - err := mgr.db.NewSelect().Model(&workspaces).Scan(ctx) + err := mgr.db.NewSelect().Model(&workspaces).Relation("PortMappings").Scan(ctx) if err != nil { if errors.Is(err, sql.ErrNoRows) { return make([]workspace, 0), nil @@ -100,6 +100,7 @@ func (mgr workspaceManager) findAllWorkspaces(ctx context.Context) ([]workspace, func (mgr workspaceManager) findWorkspace(ctx context.Context, name string) (*workspace, error) { var w workspace err := mgr.db.NewSelect().Model(&w). + Relation("PortMappings"). Where("name = ?", name). Scan(ctx) if err != nil { @@ -214,24 +215,12 @@ func (mgr workspaceManager) createWorkspace(ctx context.Context, opts createWork return &w, nil } -func (mgr workspaceManager) deleteWorkspace(ctx context.Context, name string) error { +func (mgr workspaceManager) deleteWorkspace(ctx context.Context, workspace *workspace) error { tx, err := mgr.db.BeginTx(ctx, nil) if err != nil { return err } - var workspace workspace - if err = tx.NewSelect(). - Model(&workspace). - Where("name = ?", name). - Scan(ctx); err != nil { - _ = tx.Rollback() - if errors.Is(err, sql.ErrNoRows) { - return errWorkspaceNotFound - } - return err - } - inspect, err := mgr.dockerClient.ContainerInspect(ctx, workspace.ContainerID) if err != nil { _ = tx.Rollback() @@ -252,7 +241,7 @@ func (mgr workspaceManager) deleteWorkspace(ctx context.Context, name string) er } res, err := tx.NewDelete(). - Model(&workspace). + Model(workspace). WherePK(). Exec(ctx) if err != nil { @@ -357,3 +346,29 @@ func (mgr workspaceManager) addPortMappings(ctx context.Context, workspace *work return nil } + +func (mgr workspaceManager) deletePortMapping(ctx context.Context, workspace *workspace, portMapping *portMapping) error { + tx, err := mgr.db.BeginTx(ctx, nil) + if err != nil { + return err + } + + _, err = tx.NewDelete().Model(portMapping). + Where("workspace_id = ?", workspace.ID). + Where("subdomain = ?", portMapping.Subdomain). + Where("container_port = ?", portMapping.ContainerPort). + Exec(ctx) + if err != nil { + _ = tx.Rollback() + return err + } + + if err = tx.Commit(); err != nil { + _ = tx.Rollback() + return err + } + + mgr.reverseProxy.RemoveEntry(portMapping.Subdomain) + + return nil +} diff --git a/web/bun.lockb b/web/bun.lockb index 4ccdf3e82f190a3fde8accb99cd0004473a2c8c8..441d30c97c990064b98280f8c81a5ec8444061d6 100755 GIT binary patch delta 30780 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 diff --git a/web/package.json b/web/package.json index 00c0f9b..04b9da9 100644 --- a/web/package.json +++ b/web/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", "@replit/codemirror-vim": "^6.2.1", diff --git a/web/src/components/ui/tabs.tsx b/web/src/components/ui/tabs.tsx new file mode 100644 index 0000000..85d83be --- /dev/null +++ b/web/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/web/src/workspaces/api.ts b/web/src/workspaces/api.ts index 342192f..96d5722 100644 --- a/web/src/workspaces/api.ts +++ b/web/src/workspaces/api.ts @@ -183,7 +183,10 @@ function useAddWorkspacePort() { throwOnError: true, }, ); - } catch (err: unknown) {} + setStatus({ type: "ok" }); + } catch (error: unknown) { + setStatus({ type: "error", error }); + } }, [mutate], ); diff --git a/web/src/workspaces/workspace-info-dialog.tsx b/web/src/workspaces/workspace-info-dialog.tsx new file mode 100644 index 0000000..9b3dfc4 --- /dev/null +++ b/web/src/workspaces/workspace-info-dialog.tsx @@ -0,0 +1,62 @@ +import { + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useContext } from "react"; +import { WorkspaceTableRowContext } from "./workspace-table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PortInfoTab } from "./workspace-port-info-tab"; + +function WorkspaceInfoDialog() { + const workspace = useContext(WorkspaceTableRowContext); + + return ( + + + {workspace.name} + {workspace.imageTag} + + + + SSH Information + Forwarded Ports + + + + + + + + + + ); +} + +function TabContainer({ children }: React.PropsWithChildren) { + return
{children}
; +} + +function SshTab() { + const workspace = useContext(WorkspaceTableRowContext); + + if (!workspace.sshPort) { + return ( +

SSH server is not running in this workspace, so SSH is unavailable.

+ ); + } + + return ( + +

SSH Port

+
{workspace.sshPort}
+

Command

+
+				ssh -p {workspace.sshPort} testuser@{import.meta.env.VITE_HOST_NAME}
+			
+
+ ); +} + +export { WorkspaceInfoDialog }; diff --git a/web/src/workspaces/workspace-port-info-tab.tsx b/web/src/workspaces/workspace-port-info-tab.tsx new file mode 100644 index 0000000..3e24972 --- /dev/null +++ b/web/src/workspaces/workspace-port-info-tab.tsx @@ -0,0 +1,194 @@ +import { Button } from "@/components/ui/button"; +import { FormField, FormItem, FormControl, Form } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, + Table, +} from "@/components/ui/table"; +import { superstructResolver } from "@hookform/resolvers/superstruct"; +import { Check, Trash2, X } from "lucide-react"; +import { useContext, useId } from "react"; +import { useForm } from "react-hook-form"; +import { object, pattern, string, size, number, type Infer } from "superstruct"; +import { WorkspaceTableRowContext } from "./workspace-table"; +import { create } from "zustand"; +import { useAddWorkspacePort } from "./api"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; + +interface PortInfoTabStore { + isAddingPort: boolean; + setIsAddingPort: (isAddingPort: boolean) => void; +} + +const useStore = create()((set) => ({ + isAddingPort: false, + setIsAddingPort: (isAddingPort) => set({ isAddingPort }), +})); + +function PortInfoTab() { + return ( + <> + + + + Subdomain + Port + + + +
+ + + ); +} + +function PortInfoTableBody() { + const workspace = useContext(WorkspaceTableRowContext); + const ports = workspace.ports ?? []; + + return ( + + {ports.map(({ port, subdomain }) => ( + + {subdomain} + {port} + + + + + ))} + + + ); +} + +function AddPortButton() { + const isAddingPort = useStore((state) => state.isAddingPort); + const setIsAddingPort = useStore((state) => state.setIsAddingPort); + + if (isAddingPort) { + return null; + } + + return ( + + ); +} + +const NewPortMappingForm = object({ + subdomain: pattern(string(), /^[\w-]+$/), + port: size(number(), 1, 65536), +}); + +function NewPortMappingRow() { + const { addWorkspacePort, status } = useAddWorkspacePort(); + const workspace = useContext(WorkspaceTableRowContext); + const isAddingPort = useStore((state) => state.isAddingPort); + const setIsAddingPort = useStore((state) => state.setIsAddingPort); + const formId = useId(); + const form = useForm({ + resolver: superstructResolver(NewPortMappingForm), + disabled: status.type === "loading", + defaultValues: { + subdomain: "", + port: 3000, + }, + }); + + if (!isAddingPort) { + return null; + } + + async function submitForm(values: Infer) { + await addWorkspacePort(workspace.name, [ + { + subdomain: values.subdomain, + port: values.port, + }, + ]); + setIsAddingPort(false); + } + + return ( + + +
+ + ( + + + + + + )} + /> + + +
+ + ( + + field.onChange(value.currentTarget.valueAsNumber) + } + /> + )} + /> + + + + + +
+ ); +} + +function DeletePortMappingButton() { + return ( + + ); +} + +export { PortInfoTab }; diff --git a/web/src/workspaces/workspace-table.tsx b/web/src/workspaces/workspace-table.tsx index cb73339..64377dd 100644 --- a/web/src/workspaces/workspace-table.tsx +++ b/web/src/workspaces/workspace-table.tsx @@ -46,6 +46,8 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { WorkspaceInfoDialog } from "./workspace-info-dialog"; const WorkspaceTableRowContext = createContext( null as unknown as Workspace, @@ -230,51 +232,58 @@ function DeleteWorkspaceButton({ workspace }: { workspace: Workspace }) { } return ( - ); } function WorkspaceInfoButton() { return ( - - + + - - - - - + + + ); } function WorkspaceInfoPopoverContent() { const workspace = useContext(WorkspaceTableRowContext); return ( -
- {workspace.sshPort ? ( - <> -
-

SSH Port

-
-

{workspace.sshPort}

- - ) : null} - {workspace?.ports?.map(({ port, subdomain }) => ( - -
-

{subdomain}

-
-

{port}

-
- ))} +
+
+ {workspace.sshPort ? ( + <> +
+

SSH Port

+
+

{workspace.sshPort}

+ + ) : null} +
+
+

+ Forwarded ports +

+
+ {workspace?.ports?.map(({ port, subdomain }) => ( + +
+

{subdomain}

+
+
+

{port}

+ +
+
+ ))} +
); @@ -314,7 +323,7 @@ function PortEntry() { if (!isAddingPort) { return (