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 4ccdf3e..441d30c 100755 Binary files a/web/bun.lockb and b/web/bun.lockb differ 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 (