feat: implement port forwarding

This commit is contained in:
2024-11-28 19:17:37 +00:00
parent 6e6fb06351
commit cacf66067e
18 changed files with 553 additions and 101 deletions

59
.idea/remote-targets.xml generated Normal file
View 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>

View File

@@ -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)
}

2
go.mod
View File

@@ -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

View File

@@ -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"

View File

@@ -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
)

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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 updateOrCreateWorkspace(c echo.Context) error {
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 == "" {
if workspaceName == "" || !workspaceNameRegex.MatchString(workspaceName) {
return echo.NewHTTPError(http.StatusNotFound)
}
if !workspaceNameRegex.MatchString(workspaceName) {
return echo.NewHTTPError(http.StatusNotFound)
}
ctx := c.Request().Context()
mgr := workspaceManagerFrom(c)
exists, err := mgr.hasWorkspace(ctx, workspaceName)
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
}
if !exists {
return createWorkspace(c, workspaceName)
}
c.Set(keyCurrentWorkspace, workspace)
return updateWorkspace(c, workspaceName)
return next(c)
}
}
}
func updateOrCreateWorkspace(c echo.Context) error {
workspace := currentWorkspace(c)
if workspace == nil {
return createWorkspace(c, c.Param("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)
}

View File

@@ -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))
}

View File

@@ -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,16 +121,20 @@ func SyncAll(ctx context.Context, services service.Services) error {
}()
}
wg.Wait()
if err = errors.Join(errs...); 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 {
_ = tx.Rollback()

View File

@@ -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
}

Binary file not shown.

View File

@@ -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",

View 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 }

View File

@@ -183,7 +183,10 @@ function useAddWorkspacePort() {
throwOnError: true,
},
);
} catch (err: unknown) {}
setStatus({ type: "ok" });
} catch (error: unknown) {
setStatus({ type: "error", error });
}
},
[mutate],
);

View 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 };

View 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 };

View File

@@ -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<Workspace>(
null as unknown as Workspace,
@@ -230,35 +232,30 @@ function DeleteWorkspaceButton({ workspace }: { workspace: Workspace }) {
}
return (
<Button variant="outline" size="icon" onClick={_deleteWorkspace}>
{status.type === "loading" ? (
<LoadingSpinner />
) : (
<Trash2 className="text-destructive" />
)}
<Button variant="destructive" size="icon" onClick={_deleteWorkspace}>
{status.type === "loading" ? <LoadingSpinner /> : <Trash2 />}
</Button>
);
}
function WorkspaceInfoButton() {
return (
<Popover>
<PopoverTrigger>
<Dialog>
<DialogTrigger>
<Button variant="outline" size="icon">
<Info />
</Button>
</PopoverTrigger>
<PopoverContent>
<WorkspaceInfoPopoverContent />
</PopoverContent>
</Popover>
</DialogTrigger>
<WorkspaceInfoDialog />
</Dialog>
);
}
function WorkspaceInfoPopoverContent() {
const workspace = useContext(WorkspaceTableRowContext);
return (
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col">
<div className="grid grid-cols-3 gap-2">
{workspace.sshPort ? (
<>
<div className="col-span-2">
@@ -267,14 +264,26 @@ function WorkspaceInfoPopoverContent() {
<p className="text-right">{workspace.sshPort}</p>
</>
) : null}
</div>
<hr className="my-2" />
<p className="text-sm text-muted-foreground col-span-3 mb-1">
Forwarded ports
</p>
<div className="grid grid-cols-3 gap-2">
{workspace?.ports?.map(({ port, subdomain }) => (
<Fragment key={port}>
<div className="col-span-2">
<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 />
</div>
);
@@ -314,7 +323,7 @@ function PortEntry() {
if (!isAddingPort) {
return (
<Button
className="col-span-3"
className="col-span-3 mt-4"
variant="secondary"
size="sm"
onClick={onAddPortButtonClick}
@@ -337,9 +346,8 @@ function PortEntry() {
name="portName"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Subdomain</FormLabel>
<FormControl>
<Input placeholder="web-app" {...field} />
<Input placeholder="Subdomain" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -350,7 +358,6 @@ function PortEntry() {
name="port"
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Port</FormLabel>
<FormControl>
<Input
className="[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
@@ -361,6 +368,9 @@ function PortEntry() {
max={65535}
placeholder="8080"
{...field}
onChange={(value) =>
field.onChange(value.currentTarget.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
@@ -390,4 +400,4 @@ function PortEntry() {
);
}
export { WorkspaceTable };
export { WorkspaceTable, WorkspaceTableRowContext };