feat: implement port forwarding
This commit is contained in:
59
.idea/remote-targets.xml
generated
Normal file
59
.idea/remote-targets.xml
generated
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RemoteTargetsManager">
|
||||||
|
<targets>
|
||||||
|
<target name="golang:latest" type="docker" uuid="ff82964d-5e49-4486-ae62-9c1eccece008">
|
||||||
|
<config>
|
||||||
|
<option name="targetPlatform">
|
||||||
|
<TargetPlatform />
|
||||||
|
</option>
|
||||||
|
<option name="buildNotPull" value="false" />
|
||||||
|
<option name="pullImageConfig">
|
||||||
|
<PullImageConfig>
|
||||||
|
<option name="tagToPull" value="golang:latest" />
|
||||||
|
</PullImageConfig>
|
||||||
|
</option>
|
||||||
|
</config>
|
||||||
|
<ContributedStateBase type="GoLanguageRuntime">
|
||||||
|
<config>
|
||||||
|
<option name="compiledExecutablesVolume">
|
||||||
|
<VolumeState>
|
||||||
|
<option name="targetSpecificBits">
|
||||||
|
<map>
|
||||||
|
<entry key="mountAsVolume" value="false" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</VolumeState>
|
||||||
|
</option>
|
||||||
|
<option name="goPath" value="/go" />
|
||||||
|
<option name="goRoot" value="/usr/local/go/bin/go" />
|
||||||
|
<option name="goVersion" value="go1.23.3 linux/arm64" />
|
||||||
|
<option name="projectSourcesVolume">
|
||||||
|
<VolumeState>
|
||||||
|
<option name="targetSpecificBits">
|
||||||
|
<map>
|
||||||
|
<entry key="mountAsVolume" value="false" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</VolumeState>
|
||||||
|
</option>
|
||||||
|
</config>
|
||||||
|
</ContributedStateBase>
|
||||||
|
</target>
|
||||||
|
<target name="kenneth@lycoris:22" type="ssh/sftp" uuid="043b7802-728f-4d8c-874e-eb426ab4c67d">
|
||||||
|
<config>
|
||||||
|
<option name="projectRootOnTarget" value="/home/kenneth/dev/tesseract" />
|
||||||
|
<option name="serverName" value="kenneth@lycoris:22 password" />
|
||||||
|
<option name="useRsync" value="true" />
|
||||||
|
</config>
|
||||||
|
<ContributedStateBase type="GoLanguageRuntime">
|
||||||
|
<config>
|
||||||
|
<option name="goPath" value="/home/kenneth/go" />
|
||||||
|
<option name="goRoot" value="/usr/bin/go" />
|
||||||
|
<option name="goVersion" value="go1.22.7 linux/amd64" />
|
||||||
|
</config>
|
||||||
|
</ContributedStateBase>
|
||||||
|
</target>
|
||||||
|
</targets>
|
||||||
|
</component>
|
||||||
|
</project>
|
@@ -49,7 +49,7 @@ func main() {
|
|||||||
log.Fatalln(err)
|
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) {
|
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
2
go.mod
2
go.mod
@@ -4,6 +4,7 @@ go 1.22.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/docker/docker v27.3.1+incompatible
|
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/golang-migrate/migrate/v4 v4.18.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/labstack/echo/v4 v4.12.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/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/distribution/reference v0.6.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/docker/go-units v0.5.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
|
@@ -3,7 +3,7 @@ package migration
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"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/file"
|
||||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
@@ -27,7 +27,10 @@ CREATE TABLE IF NOT EXISTS template_files
|
|||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
content BLOB 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
|
CREATE TABLE IF NOT EXISTS template_images
|
||||||
@@ -36,7 +39,10 @@ CREATE TABLE IF NOT EXISTS template_images
|
|||||||
image_tag TEXT NOT NULL,
|
image_tag TEXT NOT NULL,
|
||||||
image_id 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
|
CREATE TABLE IF NOT EXISTS port_mappings
|
||||||
@@ -45,5 +51,8 @@ CREATE TABLE IF NOT EXISTS port_mappings
|
|||||||
container_port INTEGER NOT NULL,
|
container_port INTEGER NOT NULL,
|
||||||
subdomain TEXT,
|
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
|
||||||
)
|
)
|
@@ -48,6 +48,10 @@ func (p *ReverseProxy) AddEntry(subdomain string, url *url.URL) {
|
|||||||
p.httpProxies[subdomain] = proxy
|
p.httpProxies[subdomain] = proxy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *ReverseProxy) RemoveEntry(subdomain string) {
|
||||||
|
delete(p.httpProxies, subdomain)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *ReverseProxy) shouldHandleRequest(c echo.Context) bool {
|
func (p *ReverseProxy) shouldHandleRequest(c echo.Context) bool {
|
||||||
h := strings.Replace(p.hostName, ".", "\\.", -1)
|
h := strings.Replace(p.hostName, ".", "\\.", -1)
|
||||||
reg, err := regexp.Compile(".*\\." + h)
|
reg, err := regexp.Compile(".*\\." + h)
|
||||||
@@ -83,7 +87,8 @@ func (p *ReverseProxy) handleRequest(c echo.Context) error {
|
|||||||
return echo.NewHTTPError(http.StatusNotFound)
|
return echo.NewHTTPError(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy, ok := p.httpProxies[subdomain]
|
first := strings.Split(subdomain, ".")[0]
|
||||||
|
proxy, ok := p.httpProxies[first]
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusNotFound)
|
return echo.NewHTTPError(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
@@ -49,7 +49,7 @@ func createDockerTemplate(ctx context.Context, tx bun.Tx, opts createTemplateOpt
|
|||||||
FilePath: "README.md",
|
FilePath: "README.md",
|
||||||
Content: make([]byte, 0),
|
Content: make([]byte, 0),
|
||||||
}
|
}
|
||||||
files := []templateFile{dockerfile, readme}
|
files := []*templateFile{&dockerfile, &readme}
|
||||||
|
|
||||||
if err = tx.NewInsert().Model(&t).Returning("*").Scan(ctx); err != nil {
|
if err = tx.NewInsert().Model(&t).Returning("*").Scan(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -59,6 +59,8 @@ func createDockerTemplate(ctx context.Context, tx bun.Tx, opts createTemplateOpt
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Files = files
|
||||||
|
|
||||||
return &t, nil
|
return &t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -17,6 +17,8 @@ type updateWorkspaceRequestBody struct {
|
|||||||
PortMappings []portMapping `json:"ports"`
|
PortMappings []portMapping `json:"ports"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keyCurrentWorkspace = "currentWorkspace"
|
||||||
|
|
||||||
func fetchAllWorkspaces(c echo.Context) error {
|
func fetchAllWorkspaces(c echo.Context) error {
|
||||||
mgr := workspaceManagerFrom(c)
|
mgr := workspaceManagerFrom(c)
|
||||||
workspaces, err := mgr.findAllWorkspaces(c.Request().Context())
|
workspaces, err := mgr.findAllWorkspaces(c.Request().Context())
|
||||||
@@ -26,28 +28,44 @@ func fetchAllWorkspaces(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, workspaces)
|
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 {
|
func updateOrCreateWorkspace(c echo.Context) error {
|
||||||
workspaceName := c.Param("workspaceName")
|
workspace := currentWorkspace(c)
|
||||||
if workspaceName == "" {
|
if workspace == nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound)
|
return createWorkspace(c, c.Param("workspaceName"))
|
||||||
}
|
}
|
||||||
|
return updateWorkspace(c, workspace)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createWorkspace(c echo.Context, workspaceName string) error {
|
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)
|
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()
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
var body updateWorkspaceRequestBody
|
var body updateWorkspaceRequestBody
|
||||||
@@ -83,14 +101,6 @@ func updateWorkspace(c echo.Context, workspaceName string) error {
|
|||||||
|
|
||||||
mgr := workspaceManagerFrom(c)
|
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) {
|
switch status(body.Status) {
|
||||||
case statusStopped:
|
case statusStopped:
|
||||||
if err = mgr.stopWorkspace(ctx, workspace); err != nil {
|
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 {
|
func deleteWorkspace(c echo.Context) error {
|
||||||
workspaceName := c.Param("workspaceName")
|
workspace := currentWorkspace(c)
|
||||||
if workspaceName == "" {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
mgr := workspaceManagerFrom(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) {
|
if errors.Is(err, errWorkspaceNotFound) {
|
||||||
return echo.NewHTTPError(http.StatusNotFound)
|
return echo.NewHTTPError(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
@@ -130,3 +136,31 @@ func deleteWorkspace(c echo.Context) error {
|
|||||||
|
|
||||||
return c.NoContent(http.StatusOK)
|
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)
|
||||||
|
}
|
||||||
|
@@ -8,6 +8,7 @@ import (
|
|||||||
func DefineRoutes(g *echo.Group, services service.Services) {
|
func DefineRoutes(g *echo.Group, services service.Services) {
|
||||||
g.Use(newWorkspaceManagerMiddleware(services))
|
g.Use(newWorkspaceManagerMiddleware(services))
|
||||||
g.GET("/workspaces", fetchAllWorkspaces)
|
g.GET("/workspaces", fetchAllWorkspaces)
|
||||||
g.POST("/workspaces/:workspaceName", updateOrCreateWorkspace)
|
g.POST("/workspaces/:workspaceName", updateOrCreateWorkspace, currentWorkspaceMiddleware(true))
|
||||||
g.DELETE("/workspaces/:workspaceName", deleteWorkspace)
|
g.DELETE("/workspaces/:workspaceName", deleteWorkspace, currentWorkspaceMiddleware(false))
|
||||||
|
g.DELETE("/workspaces/:workspaceName/forwarded-ports/:portName", deleteWorkspacePortMapping, currentWorkspaceMiddleware(false))
|
||||||
}
|
}
|
||||||
|
@@ -45,7 +45,7 @@ type portMapping struct {
|
|||||||
ContainerPort int `json:"port"`
|
ContainerPort int `json:"port"`
|
||||||
Subdomain string `json:"subdomain"`
|
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.
|
// 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 {
|
if err = errors.Join(errs...); err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.NewDelete().Model(&deletedWorkspaces).WherePK().Exec(ctx)
|
if len(deletedWorkspaces) > 0 {
|
||||||
if err != nil {
|
_, err = tx.NewDelete().Model(&deletedWorkspaces).WherePK().Exec(ctx)
|
||||||
_ = tx.Rollback()
|
if err != nil {
|
||||||
return err
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
|
@@ -38,7 +38,7 @@ var errWorkspaceNotFound = errors.New("workspace not found")
|
|||||||
|
|
||||||
func (mgr workspaceManager) findAllWorkspaces(ctx context.Context) ([]workspace, error) {
|
func (mgr workspaceManager) findAllWorkspaces(ctx context.Context) ([]workspace, error) {
|
||||||
var workspaces []workspace
|
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 err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return make([]workspace, 0), nil
|
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) {
|
func (mgr workspaceManager) findWorkspace(ctx context.Context, name string) (*workspace, error) {
|
||||||
var w workspace
|
var w workspace
|
||||||
err := mgr.db.NewSelect().Model(&w).
|
err := mgr.db.NewSelect().Model(&w).
|
||||||
|
Relation("PortMappings").
|
||||||
Where("name = ?", name).
|
Where("name = ?", name).
|
||||||
Scan(ctx)
|
Scan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -214,24 +215,12 @@ func (mgr workspaceManager) createWorkspace(ctx context.Context, opts createWork
|
|||||||
return &w, nil
|
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)
|
tx, err := mgr.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
inspect, err := mgr.dockerClient.ContainerInspect(ctx, workspace.ContainerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
@@ -252,7 +241,7 @@ func (mgr workspaceManager) deleteWorkspace(ctx context.Context, name string) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
res, err := tx.NewDelete().
|
res, err := tx.NewDelete().
|
||||||
Model(&workspace).
|
Model(workspace).
|
||||||
WherePK().
|
WherePK().
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -357,3 +346,29 @@ func (mgr workspaceManager) addPortMappings(ctx context.Context, workspace *work
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
BIN
web/bun.lockb
BIN
web/bun.lockb
Binary file not shown.
@@ -30,6 +30,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.1.1",
|
"@radix-ui/react-switch": "^1.1.1",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
"@replit/codemirror-vim": "^6.2.1",
|
"@replit/codemirror-vim": "^6.2.1",
|
||||||
|
53
web/src/components/ui/tabs.tsx
Normal file
53
web/src/components/ui/tabs.tsx
Normal file
@@ -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<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
@@ -183,7 +183,10 @@ function useAddWorkspacePort() {
|
|||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (err: unknown) {}
|
setStatus({ type: "ok" });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setStatus({ type: "error", error });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[mutate],
|
[mutate],
|
||||||
);
|
);
|
||||||
|
62
web/src/workspaces/workspace-info-dialog.tsx
Normal file
62
web/src/workspaces/workspace-info-dialog.tsx
Normal file
@@ -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 (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{workspace.name}</DialogTitle>
|
||||||
|
<DialogDescription>{workspace.imageTag}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="ssh">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="ssh">SSH Information</TabsTrigger>
|
||||||
|
<TabsTrigger value="ports">Forwarded Ports</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="ssh">
|
||||||
|
<SshTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="ports">
|
||||||
|
<PortInfoTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabContainer({ children }: React.PropsWithChildren) {
|
||||||
|
return <div className="pt-4">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SshTab() {
|
||||||
|
const workspace = useContext(WorkspaceTableRowContext);
|
||||||
|
|
||||||
|
if (!workspace.sshPort) {
|
||||||
|
return (
|
||||||
|
<p>SSH server is not running in this workspace, so SSH is unavailable.</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabContainer>
|
||||||
|
<p className="text-sm text-muted-foreground">SSH Port</p>
|
||||||
|
<pre>{workspace.sshPort}</pre>
|
||||||
|
<p className="text-sm text-muted-foreground mt-4">Command</p>
|
||||||
|
<pre>
|
||||||
|
ssh -p {workspace.sshPort} testuser@{import.meta.env.VITE_HOST_NAME}
|
||||||
|
</pre>
|
||||||
|
</TabContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WorkspaceInfoDialog };
|
194
web/src/workspaces/workspace-port-info-tab.tsx
Normal file
194
web/src/workspaces/workspace-port-info-tab.tsx
Normal file
@@ -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<PortInfoTabStore>()((set) => ({
|
||||||
|
isAddingPort: false,
|
||||||
|
setIsAddingPort: (isAddingPort) => set({ isAddingPort }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function PortInfoTab() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Subdomain</TableHead>
|
||||||
|
<TableHead>Port</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<PortInfoTableBody />
|
||||||
|
</Table>
|
||||||
|
<AddPortButton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PortInfoTableBody() {
|
||||||
|
const workspace = useContext(WorkspaceTableRowContext);
|
||||||
|
const ports = workspace.ports ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableBody>
|
||||||
|
{ports.map(({ port, subdomain }) => (
|
||||||
|
<TableRow key={subdomain}>
|
||||||
|
<TableCell className="py-0">{subdomain}</TableCell>
|
||||||
|
<TableCell className="py-0">{port}</TableCell>
|
||||||
|
<TableCell className="p-0 text-right">
|
||||||
|
<DeletePortMappingButton />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
<NewPortMappingRow />
|
||||||
|
</TableBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddPortButton() {
|
||||||
|
const isAddingPort = useStore((state) => state.isAddingPort);
|
||||||
|
const setIsAddingPort = useStore((state) => state.setIsAddingPort);
|
||||||
|
|
||||||
|
if (isAddingPort) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => setIsAddingPort(true)}
|
||||||
|
>
|
||||||
|
Add port
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<typeof NewPortMappingForm>) {
|
||||||
|
await addWorkspacePort(workspace.name, [
|
||||||
|
{
|
||||||
|
subdomain: values.subdomain,
|
||||||
|
port: values.port,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setIsAddingPort(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(submitForm)} id={formId}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="subdomain"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="subdomain" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="port"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
className="[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
style={{ MozAppearance: "textfield" }}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={65535}
|
||||||
|
placeholder="8080"
|
||||||
|
form={formId}
|
||||||
|
{...field}
|
||||||
|
onChange={(value) =>
|
||||||
|
field.onChange(value.currentTarget.valueAsNumber)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-0">
|
||||||
|
<Button
|
||||||
|
form={formId}
|
||||||
|
disabled={status.type === "loading"}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsAddingPort(false)}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
form={formId}
|
||||||
|
disabled={status.type === "loading"}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
{status.type === "loading" ? <LoadingSpinner /> : <Check />}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeletePortMappingButton() {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PortInfoTab };
|
@@ -46,6 +46,8 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { WorkspaceInfoDialog } from "./workspace-info-dialog";
|
||||||
|
|
||||||
const WorkspaceTableRowContext = createContext<Workspace>(
|
const WorkspaceTableRowContext = createContext<Workspace>(
|
||||||
null as unknown as Workspace,
|
null as unknown as Workspace,
|
||||||
@@ -230,51 +232,58 @@ function DeleteWorkspaceButton({ workspace }: { workspace: Workspace }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="outline" size="icon" onClick={_deleteWorkspace}>
|
<Button variant="destructive" size="icon" onClick={_deleteWorkspace}>
|
||||||
{status.type === "loading" ? (
|
{status.type === "loading" ? <LoadingSpinner /> : <Trash2 />}
|
||||||
<LoadingSpinner />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="text-destructive" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceInfoButton() {
|
function WorkspaceInfoButton() {
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Dialog>
|
||||||
<PopoverTrigger>
|
<DialogTrigger>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon">
|
||||||
<Info />
|
<Info />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</DialogTrigger>
|
||||||
<PopoverContent>
|
<WorkspaceInfoDialog />
|
||||||
<WorkspaceInfoPopoverContent />
|
</Dialog>
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceInfoPopoverContent() {
|
function WorkspaceInfoPopoverContent() {
|
||||||
const workspace = useContext(WorkspaceTableRowContext);
|
const workspace = useContext(WorkspaceTableRowContext);
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="flex flex-col">
|
||||||
{workspace.sshPort ? (
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<>
|
{workspace.sshPort ? (
|
||||||
<div className="col-span-2">
|
<>
|
||||||
<p>SSH Port</p>
|
<div className="col-span-2">
|
||||||
</div>
|
<p>SSH Port</p>
|
||||||
<p className="text-right">{workspace.sshPort}</p>
|
</div>
|
||||||
</>
|
<p className="text-right">{workspace.sshPort}</p>
|
||||||
) : null}
|
</>
|
||||||
{workspace?.ports?.map(({ port, subdomain }) => (
|
) : null}
|
||||||
<Fragment key={port}>
|
</div>
|
||||||
<div className="col-span-2">
|
<hr className="my-2" />
|
||||||
<p>{subdomain}</p>
|
<p className="text-sm text-muted-foreground col-span-3 mb-1">
|
||||||
</div>
|
Forwarded ports
|
||||||
<p className="text-right">{port}</p>
|
</p>
|
||||||
</Fragment>
|
<div className="grid grid-cols-3 gap-2">
|
||||||
))}
|
{workspace?.ports?.map(({ port, subdomain }) => (
|
||||||
|
<Fragment key={port}>
|
||||||
|
<div className="col-span-2 flex items-center">
|
||||||
|
<p>{subdomain}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-right">{port}</p>
|
||||||
|
<Button variant="destructive" size="icon">
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<PortEntry />
|
<PortEntry />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -314,7 +323,7 @@ function PortEntry() {
|
|||||||
if (!isAddingPort) {
|
if (!isAddingPort) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className="col-span-3"
|
className="col-span-3 mt-4"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onAddPortButtonClick}
|
onClick={onAddPortButtonClick}
|
||||||
@@ -337,9 +346,8 @@ function PortEntry() {
|
|||||||
name="portName"
|
name="portName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
<FormLabel>Subdomain</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="web-app" {...field} />
|
<Input placeholder="Subdomain" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -350,7 +358,6 @@ function PortEntry() {
|
|||||||
name="port"
|
name="port"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-1">
|
<FormItem className="col-span-1">
|
||||||
<FormLabel>Port</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
className="[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
@@ -361,6 +368,9 @@ function PortEntry() {
|
|||||||
max={65535}
|
max={65535}
|
||||||
placeholder="8080"
|
placeholder="8080"
|
||||||
{...field}
|
{...field}
|
||||||
|
onChange={(value) =>
|
||||||
|
field.onChange(value.currentTarget.valueAsNumber)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -390,4 +400,4 @@ function PortEntry() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { WorkspaceTable };
|
export { WorkspaceTable, WorkspaceTableRowContext };
|
||||||
|
Reference in New Issue
Block a user