feat: implement ssh forwarding

This commit is contained in:
2024-11-17 18:10:35 +00:00
parent a7933f8b06
commit 45bfbe093a
21 changed files with 1175 additions and 296 deletions

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
@@ -60,9 +61,13 @@ func main() {
log.Fatalln(err)
}
log.Println("syncing all workspaces...")
if err = workspace.SyncAll(context.Background(), services); err != nil {
log.Fatalln(err)
}
apiServer := echo.New()
apiServer.Use(services.Middleware())
apiServer.Use(proxy.Middleware())
apiServer.Use(services.Middleware(), proxy.Middleware())
g := apiServer.Group("/api")
workspace.DefineRoutes(g)
template.DefineRoutes(g)
@@ -92,6 +97,7 @@ func main() {
}
} else {
c.Logger().Error(err)
_ = c.NoContent(http.StatusInternalServerError)
}
}

21
internal/docker/docker.go Normal file
View File

@@ -0,0 +1,21 @@
package docker
import (
"context"
"github.com/docker/docker/api/types"
"github.com/docker/go-connections/nat"
"strconv"
)
// ContainerSSHHostPort returns the port on the host that is exposing the internal ssh port of the given container info
func ContainerSSHHostPort(ctx context.Context, container types.ContainerJSON) int {
ports := container.NetworkSettings.Ports[nat.Port("22/tcp")]
if len(ports) == 0 {
return -1
}
port, err := strconv.Atoi(ports[0].HostPort)
if err != nil {
return -1
}
return port
}

View File

@@ -9,6 +9,7 @@ import (
type Config struct {
DatabasePath string `json:"databasePath"`
TemplateDirectoryPath string `json:"templateDirectoryPath"`
HostKeyDirectoryPath string `json:"hostKeyDirectoryPath"`
HostName string `json:"hostName"`
}
@@ -29,5 +30,10 @@ func ReadConfigFrom(reader io.Reader) (Config, error) {
return Config{}, err
}
config.HostKeyDirectoryPath, err = filepath.Abs(config.HostKeyDirectoryPath)
if err != nil {
return Config{}, err
}
return config, nil
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/uptrace/bun/extra/bundebug"
_ "modernc.org/sqlite"
"net/http"
"tesseract/internal/sshproxy"
)
const (
@@ -19,7 +20,7 @@ const (
keyDockerClient = "dockerClient"
keyDB = "db"
keyConfig = "config"
keyMelody = "melody"
keySSHProxy = "sshProxy"
)
type Services struct {
@@ -27,6 +28,7 @@ type Services struct {
DockerClient *client.Client
Database *bun.DB
Config Config
SSHProxy *sshproxy.SSHProxy
Melody *melody.Melody
}
@@ -42,6 +44,10 @@ func Database(c echo.Context) *bun.DB {
return c.Get(keyDB).(*bun.DB)
}
func SSHProxy(c echo.Context) *sshproxy.SSHProxy {
return c.Get(keySSHProxy).(*sshproxy.SSHProxy)
}
func Initialize(config Config) (Services, error) {
hc := &http.Client{}
@@ -57,12 +63,15 @@ func Initialize(config Config) (Services, error) {
bundb := bun.NewDB(db, sqlitedialect.New())
bundb.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
sshProxy := sshproxy.New()
return Services{
HTTPClient: hc,
DockerClient: docker,
Database: bundb,
Config: config,
Melody: melody.New(),
SSHProxy: sshProxy,
}, nil
}
@@ -73,6 +82,7 @@ func (s Services) Middleware() echo.MiddlewareFunc {
c.Set(keyDockerClient, s.DockerClient)
c.Set(keyDB, s.Database)
c.Set(keyConfig, s.Config)
c.Set(keySSHProxy, s.SSHProxy)
return next(c)
}
}

View File

@@ -0,0 +1,77 @@
package sshproxy
import (
"fmt"
"io"
"net"
"sync"
)
type proxyConnection struct {
internalPort int
externalPort int
listener net.Listener
}
func newProxyConnection(toPort int) (*proxyConnection, error) {
l, err := net.Listen("tcp", ":0")
if err != nil {
return nil, err
}
externalPort := l.Addr().(*net.TCPAddr).Port
return &proxyConnection{
internalPort: toPort,
externalPort: externalPort,
listener: l,
}, nil
}
func (c *proxyConnection) start() {
for {
conn, err := c.listener.Accept()
if err != nil {
fmt.Printf("error accepting connection at %v: %v\n", c.listener.Addr(), err)
}
go c.forwardConnectionToSSH(conn)
}
}
func (c *proxyConnection) forwardConnectionToSSH(conn net.Conn) {
containerConn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", c.internalPort))
if err != nil {
fmt.Printf("error connecting to container ssh at port %d\n", c.internalPort)
return
}
defer containerConn.Close()
var wg sync.WaitGroup
defer wg.Wait()
wg.Add(1)
go func() {
defer wg.Done()
defer conn.Close()
for {
_, err := io.Copy(conn, containerConn)
if err != nil {
fmt.Println("read remote conn err", err)
break
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
defer conn.Close()
for {
_, err := io.Copy(containerConn, conn)
if err != nil {
fmt.Println("write remote conn err", err)
break
}
}
}()
}

View File

@@ -0,0 +1,37 @@
package sshproxy
type SSHProxy struct {
// internalPorts maps internal docker ssh ports to the corresponding external ssh ports
// that users use to ssh into workspaces
internalPorts map[int]int
connections map[int]*proxyConnection
}
func New() *SSHProxy {
return &SSHProxy{
internalPorts: map[int]int{},
connections: map[int]*proxyConnection{},
}
}
func (p *SSHProxy) NewProxyEntryTo(toPort int) error {
c, err := newProxyConnection(toPort)
if err != nil {
return err
}
go c.start()
p.connections[toPort] = c
p.internalPorts[toPort] = c.externalPort
return nil
}
func (p *SSHProxy) FindExternalPort(internalPort int) int {
if port, ok := p.internalPorts[internalPort]; ok {
return port
}
return -1
}

View File

@@ -0,0 +1,55 @@
package workspace
import (
"context"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"io"
)
type spawnedShell struct {
io.Reader
io.Writer
execID string
}
func stopContainer(ctx context.Context, docker *client.Client, containerID string) error {
return docker.ContainerStop(ctx, containerID, container.StopOptions{})
}
func startContainer(ctx context.Context, docker *client.Client, containerID string) error {
return docker.ContainerStart(ctx, containerID, container.StartOptions{})
}
func deleteContainer(ctx context.Context, docker *client.Client, containerID string) error {
return docker.ContainerRemove(ctx, containerID, container.RemoveOptions{
RemoveVolumes: true,
})
}
func inspectContainer(ctx context.Context, docker *client.Client, containerID string) (types.ContainerJSON, error) {
return docker.ContainerInspect(ctx, containerID)
}
func spawnNewShell(ctx context.Context, docker *client.Client, containerID string) (*spawnedShell, error) {
res, err := docker.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Tty: true,
Detach: true,
})
if err != nil {
return nil, err
}
attached, err := docker.ContainerExecAttach(ctx, res.ID, container.ExecAttachOptions{})
if err != nil {
return nil, err
}
return &spawnedShell{
Reader: attached.Reader,
Writer: attached.Conn,
execID: res.ID,
}, nil
}

View File

@@ -5,9 +5,13 @@ import (
"encoding/json"
"errors"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"net/http"
"strconv"
"sync"
docker2 "tesseract/internal/docker"
"tesseract/internal/service"
"tesseract/internal/template"
"time"
@@ -17,8 +21,13 @@ type createWorkspaceRequestBody struct {
ImageID string `json:"imageId"`
}
type updateWorkspaceRequestBody struct {
Status string `json:"status"`
}
func fetchAllWorkspaces(c echo.Context) error {
db := service.Database(c)
ctx := c.Request().Context()
var workspaces []workspace
err := db.NewSelect().Model(&workspaces).Scan(c.Request().Context())
@@ -33,10 +42,56 @@ func fetchAllWorkspaces(c echo.Context) error {
return c.JSON(http.StatusOK, make([]workspace, 0))
}
docker := service.DockerClient(c)
sshProxy := service.SSHProxy(c)
var wg sync.WaitGroup
var mu sync.Mutex
var errs []error
for i, w := range workspaces {
wg.Add(1)
i, w := i, w
go func() {
defer wg.Done()
inspect, err := docker.ContainerInspect(ctx, w.ContainerID)
if err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
} else {
switch inspect.State.Status {
case "running":
workspaces[i].Status = statusRunning
case "exited":
workspaces[i].Status = statusStopped
case "paused":
workspaces[i].Status = statusPaused
case "restarting":
workspaces[i].Status = statusRestarting
default:
workspaces[i].Status = statusUnknown
}
if internalPort := docker2.ContainerSSHHostPort(ctx, inspect); internalPort > 0 {
if port := sshProxy.FindExternalPort(internalPort); port > 0 {
workspaces[i].SSHPort = port
}
}
}
}()
}
wg.Wait()
if err = errors.Join(errs...); err != nil {
return err
}
return c.JSON(http.StatusOK, workspaces)
}
func createWorkspace(c echo.Context) error {
func updateOrCreateWorkspace(c echo.Context) error {
workspaceName := c.Param("workspaceName")
if workspaceName == "" {
return echo.NewHTTPError(http.StatusNotFound)
@@ -46,15 +101,55 @@ func createWorkspace(c echo.Context) error {
return echo.NewHTTPError(http.StatusNotFound)
}
body := createWorkspaceRequestBody{}
err := json.NewDecoder(c.Request().Body).Decode(&body)
db := service.Database(c)
ctx := c.Request().Context()
var w workspace
err := db.NewSelect().Model(&w).
Where("name = ?", workspaceName).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return createWorkspace(c, workspaceName)
}
return err
}
var body updateWorkspaceRequestBody
if err = json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
return err
}
docker := service.DockerClient(c)
switch status(body.Status) {
case statusStopped:
if err = stopContainer(ctx, docker, workspaceName); err != nil {
return err
}
w.Status = statusStopped
break
case statusRunning:
if err = startContainer(ctx, docker, workspaceName); err != nil {
return err
}
w.Status = statusRunning
break
}
return c.JSON(http.StatusOK, w)
}
func createWorkspace(c echo.Context, workspaceName string) error {
var body createWorkspaceRequestBody
if err := json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
db := service.Database(c)
ctx := c.Request().Context()
tx, err := db.BeginTx(c.Request().Context(), nil)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
@@ -72,18 +167,53 @@ func createWorkspace(c echo.Context) error {
docker := service.DockerClient(c)
res, err := docker.ContainerCreate(c.Request().Context(), &container.Config{
containerSSHPort := nat.Port("22/tcp")
containerConfig := &container.Config{
Tty: true,
Image: img.ImageID,
}, nil, nil, nil, workspaceName)
ExposedPorts: nat.PortSet{
containerSSHPort: {},
},
}
hostConfig := &container.HostConfig{
PortBindings: nat.PortMap{
containerSSHPort: {
{"127.0.0.1", ""},
},
},
}
res, err := docker.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, workspaceName)
if err != nil {
return err
}
err = docker.ContainerStart(c.Request().Context(), res.ID, container.StartOptions{})
err = docker.ContainerStart(ctx, res.ID, container.StartOptions{})
if err != nil {
return err
}
inspect, err := docker.ContainerInspect(ctx, res.ID)
if err != nil {
return err
}
ports := inspect.NetworkSettings.Ports[containerSSHPort]
if len(ports) == 0 {
return errors.New("failed to bind ssh port for container")
}
sshProxy := service.SSHProxy(c)
hostPort, err := strconv.Atoi(ports[0].HostPort)
if err != nil {
return err
}
if err = sshProxy.NewProxyEntryTo(hostPort); err != nil {
return err
}
id, err := uuid.NewV7()
if err != nil {
return err
@@ -95,6 +225,8 @@ func createWorkspace(c echo.Context) error {
ContainerID: res.ID,
ImageTag: img.ImageTag,
CreatedAt: time.Now().Format(time.RFC3339),
SSHPort: hostPort,
Status: statusRunning,
}
_, err = tx.NewInsert().Model(&w).Exec(c.Request().Context())
if err != nil {
@@ -108,3 +240,75 @@ func createWorkspace(c echo.Context) error {
return c.JSON(http.StatusOK, w)
}
func deleteWorkspace(c echo.Context) error {
workspaceName := c.Param("workspaceName")
if workspaceName == "" {
return echo.NewHTTPError(http.StatusNotFound)
}
db := service.Database(c)
docker := service.DockerClient(c)
ctx := c.Request().Context()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound)
}
var w workspace
if err = tx.NewSelect().Model(&w).Scan(ctx); err != nil {
_ = tx.Rollback()
return echo.NewHTTPError(http.StatusNotFound)
}
inspect, err := inspectContainer(ctx, docker, w.ContainerID)
if err != nil {
_ = tx.Rollback()
return err
}
if inspect.State.Running {
if err = stopContainer(ctx, docker, w.ContainerID); err != nil {
_ = tx.Rollback()
return err
}
}
if err = deleteContainer(ctx, docker, w.ContainerID); err != nil {
return err
}
res, err := tx.NewDelete().
Table("workspaces").
Where("name = ?", workspaceName).
Exec(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound)
}
return err
}
count, err := res.RowsAffected()
if err != nil {
_ = tx.Rollback()
return err
}
if count == 0 {
_ = tx.Rollback()
return echo.NewHTTPError(http.StatusNotFound)
}
if count != 1 {
_ = tx.Rollback()
return echo.NewHTTPError(http.StatusNotFound)
}
if err = tx.Commit(); err != nil {
_ = tx.Rollback()
return err
}
return c.NoContent(http.StatusOK)
}

View File

@@ -6,5 +6,6 @@ import (
func DefineRoutes(g *echo.Group) {
g.GET("/workspaces", fetchAllWorkspaces)
g.POST("/workspaces/:workspaceName", createWorkspace)
g.POST("/workspaces/:workspaceName", updateOrCreateWorkspace)
g.DELETE("/workspaces/:workspaceName", deleteWorkspace)
}

View File

@@ -1,9 +1,17 @@
package workspace
import (
"context"
"database/sql"
"errors"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/google/uuid"
"github.com/uptrace/bun"
"regexp"
"sync"
"tesseract/internal/docker"
"tesseract/internal/service"
)
type workspace struct {
@@ -19,6 +27,100 @@ type workspace struct {
ImageTag string `json:"imageTag"`
CreatedAt string `json:"createdAt"`
SSHPort int `bun:"-" json:"sshPort,omitempty"`
Status status `bun:"-" json:"status"`
}
// status represents the status of a workspace.
type status string
const (
statusRunning status = "running"
statusStopped status = "stopped"
statusPaused status = "paused"
statusRestarting status = "restarting"
statusUnknown status = "unknown"
)
var workspaceNameRegex = regexp.MustCompile("^[\\w-]+$")
func SyncAll(ctx context.Context, services service.Services) error {
tx, err := services.Database.BeginTx(ctx, nil)
if err != nil {
return err
}
var workspaces []workspace
if err = tx.NewSelect().Model(&workspaces).
Column("id", "container_id").
Scan(ctx); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
}
if len(workspaces) == 0 {
return nil
}
var wg sync.WaitGroup
var mu sync.Mutex
var errs []error
var deletedWorkspaces []workspace
for _, w := range workspaces {
w := w
wg.Add(1)
go func() {
var err error
defer wg.Done()
defer func() {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}()
if err = services.DockerClient.ContainerStart(ctx, w.ContainerID, container.StartOptions{}); err != nil {
if client.IsErrNotFound(err) {
err = nil
mu.Lock()
deletedWorkspaces = append(deletedWorkspaces, w)
mu.Unlock()
}
return
}
inspect, err := services.DockerClient.ContainerInspect(ctx, w.ContainerID)
if err != nil {
return
}
internalPort := docker.ContainerSSHHostPort(ctx, inspect)
if internalPort <= 0 {
return
}
err = services.SSHProxy.NewProxyEntryTo(internalPort)
}()
}
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 err = tx.Commit(); err != nil {
_ = tx.Rollback()
return err
}
return nil
}

Binary file not shown.

View File

@@ -25,6 +25,7 @@
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.1",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",

View File

@@ -16,6 +16,7 @@ async function fetchApi(
() => ApiError.Network,
);
if (res.status !== 200) {
console.log(res.status);
switch (res.status) {
case 401:
throw ApiError.BadRequest;

View File

@@ -0,0 +1,7 @@
import { Loader2, type LucideProps } from "lucide-react";
function LoadingSpinner(props: LucideProps) {
return <Loader2 className="animate-spin" {...props} />;
}
export { LoadingSpinner };

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -124,11 +124,8 @@ function _TemplateEditor({
return (
<TemplateEditorStoreContext.Provider value={store.current}>
<SidebarProvider>
<aside>
<EditorSidebar />
</aside>
<div className="flex flex-col w-full">
<EditorSidebar />
<div className="flex flex-col w-full min-w-0">
<EditorTopBar />
<main className="w-full h-full flex flex-col">
<Editor />
@@ -301,7 +298,7 @@ function TemplateBuildOutputPanel() {
return (
<div
className={cn(
"flex flex-col overflow-hidden",
"flex flex-col overflow-hidden w-full",
isBuildOutputVisible ? "h-96" : "",
)}
>

View File

@@ -1,8 +1,8 @@
import { fetchApi } from "@/api";
import useSWR, { useSWRConfig } from "swr";
import type { Workspace } from "./types";
import { WorkspaceStatus, type Workspace } from "./types";
import { useCallback, useState } from "react";
import { QueryStatus } from "@/lib/query";
import type { QueryStatus } from "@/lib/query";
function useWorkspaces() {
return useSWR(
@@ -20,29 +20,143 @@ function useCreateWorkspace() {
async ({
workspaceName,
imageId,
}: { workspaceName: string; imageId: string }): Promise<Workspace> => {
}: {
workspaceName: string;
imageId: string;
}): Promise<Workspace | null> => {
setStatus({ type: "loading" });
try {
const res = await fetchApi(`/workspaces/${workspaceName}`, {
method: "POST",
body: JSON.stringify({ imageId }),
headers: {
"Content-Type": "application/json",
const workspace = await mutate(
"/workspaces",
fetchApi(`/workspaces/${workspaceName}`, {
method: "POST",
body: JSON.stringify({ imageId }),
headers: {
"Content-Type": "application/json",
},
}).then((res): Promise<Workspace> => res.json()),
{
populateCache: (createdWorkspace, workspaces) => [
...workspaces,
createdWorkspace,
],
throwOnError: true,
},
});
const workspace = await res.json();
setStatus({ type: "ok" });
return workspace;
);
return workspace ?? null;
} catch (error: unknown) {
setStatus({ type: "error", error });
return null;
}
},
[],
[mutate],
);
return { createWorkspace, status };
}
export { useWorkspaces, useCreateWorkspace };
function useChangeWorkspaceStatus() {
const [status, setStatus] = useState<QueryStatus>({ type: "idle" });
const { mutate } = useSWRConfig();
const startWorkspace = useCallback(
async (workspaceName: string) => {
setStatus({ type: "loading" });
try {
await mutate(
"/workspaces",
fetchApi(`/workspaces/${workspaceName}`, {
method: "POST",
body: JSON.stringify({ status: WorkspaceStatus.Running }),
headers: {
"Content-Type": "application/json",
},
}).then((res): Promise<Workspace> => res.json()),
{
populateCache: (updatedWorkspace, workspaces) =>
workspaces.map((workspace: Workspace) =>
workspace.containerId === updatedWorkspace.containerId
? updatedWorkspace
: workspace,
),
throwOnError: true,
},
);
setStatus({ type: "ok" });
} catch (error: unknown) {
setStatus({ type: "error", error });
}
},
[mutate],
);
const stopWorkspace = useCallback(
async (workspaceName: string) => {
setStatus({ type: "loading" });
try {
await mutate(
"/workspaces",
fetchApi(`/workspaces/${workspaceName}`, {
method: "POST",
body: JSON.stringify({ status: WorkspaceStatus.Stopped }),
headers: {
"Content-Type": "application/json",
},
}).then((res): Promise<Workspace> => res.json()),
{
populateCache: (updatedWorkspace, workspaces) =>
workspaces.map((workspace: Workspace) =>
workspace.containerId === updatedWorkspace.containerId
? updatedWorkspace
: workspace,
),
throwOnError: true,
},
);
setStatus({ type: "ok" });
} catch (error: unknown) {
setStatus({ type: "error", error });
}
},
[mutate],
);
return { startWorkspace, stopWorkspace, status };
}
function useDeleteWorkspace() {
const [status, setStatus] = useState<QueryStatus>({ type: "idle" });
const { mutate } = useSWRConfig();
const deleteWorkspace = useCallback(
async (workspaceName: string) => {
setStatus({ type: "loading" });
try {
await mutate(
"/workspaces",
fetchApi(`/workspaces/${workspaceName}`, { method: "DELETE" }),
{
populateCache: (_, workspaces) =>
workspaces.filter(
(workspace: Workspace) => workspace.name === workspaceName,
),
throwOnError: true,
},
);
setStatus({ type: "ok" });
} catch (error: unknown) {
setStatus({ type: "error", error });
}
},
[mutate],
);
return { deleteWorkspace, status };
}
export {
useWorkspaces,
useCreateWorkspace,
useChangeWorkspaceStatus,
useDeleteWorkspace,
};

View File

@@ -1,60 +1,14 @@
import { Badge } from "@/components/ui/badge.tsx";
import { MainSidebar } from "@/components/main-sidebar.tsx";
import { Button } from "@/components/ui/button";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { PageHeader } from "@/components/ui/page-header.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import dayjs from "dayjs";
import { Page } from "@/components/ui/page.tsx";
import { SidebarProvider } from "@/components/ui/sidebar.tsx";
import { MainSidebar } from "@/components/main-sidebar.tsx";
import { useCreateWorkspace, useWorkspaces } from "./api";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, Plus } from "lucide-react";
import { nonempty, object, pattern, string, type Infer } from "superstruct";
import { useTemplateImages } from "@/templates/api";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useForm } from "react-hook-form";
import { superstructResolver } from "@hookform/resolvers/superstruct";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { useEffect, useRef } from "react";
import { useToast } from "@/hooks/use-toast";
import { ToastAction } from "@radix-ui/react-toast";
import { Toaster } from "@/components/ui/toaster";
const NewWorkspaceForm = object({
workspaceName: pattern(string(), /^[\w-]+$/),
imageId: nonempty(string()),
});
import { Plus } from "lucide-react";
import { useState } from "react";
import { WorkspaceTable } from "./workspace-table";
import { NewWorkspaceDialog } from "./new-workspace-dialog";
function WorkspaceDashboard() {
return (
@@ -66,225 +20,37 @@ function WorkspaceDashboard() {
<header>
<PageHeader>Workspaces</PageHeader>
</header>
<Dialog>
<main>
<DialogTrigger asChild>
<div className="flex flex-row py-4">
<Button variant="secondary" size="sm">
<Plus /> New workspace
</Button>
</div>
</DialogTrigger>
<WorkspaceTable />
</main>
<NewWorkspaceDialog />
</Dialog>
<Main />
<Toaster />
</Page>
</SidebarProvider>
);
}
function WorkspaceTable() {
const { data: workspaces, isLoading } = useWorkspaces();
function placeholder() {
if (isLoading) {
return (
<div className="w-full py-2 space-y-2">
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
</div>
);
}
if (workspaces?.length === 0) {
return (
<p className="text-center py-2 opacity-80">No workspaces found.</p>
);
}
return null;
}
function Main() {
const [isNewWorkspaceDialogOpen, setIsNewWorkspaceDialogOpen] =
useState(false);
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Image</TableHead>
<TableHead className="w-min">Status</TableHead>
<TableHead className="text-right">Created at</TableHead>
</TableRow>
</TableHeader>
{workspaces ? (
<TableBody>
{workspaces.map((workspace) => (
<TableRow key={workspace.containerId}>
<TableCell>{workspace.name}</TableCell>
<TableCell>{workspace.imageTag}</TableCell>
<TableCell>
<Badge>Running</Badge>
</TableCell>
<TableCell className="text-right">
{dayjs(workspace.createdAt).format("YYYY/MM/DD HH:mm")}
</TableCell>
</TableRow>
))}
</TableBody>
) : null}
</Table>
{placeholder()}
</>
);
}
<Dialog
open={isNewWorkspaceDialogOpen}
onOpenChange={setIsNewWorkspaceDialogOpen}
>
<main>
<DialogTrigger asChild>
<div className="flex flex-row py-4">
<Button variant="secondary" size="sm">
<Plus /> New workspace
</Button>
</div>
</DialogTrigger>
<WorkspaceTable />
</main>
function NewWorkspaceDialog() {
const { data: templateImages, isLoading, error } = useTemplateImages();
const form = useForm({
resolver: superstructResolver(NewWorkspaceForm),
defaultValues: {
workspaceName: "",
imageId: "",
},
});
const { createWorkspace, status } = useCreateWorkspace();
const { toast } = useToast();
const formRef = useRef<HTMLFormElement | null>(null);
useEffect(() => {
if (status.type === "error") {
toast({
variant: "destructive",
title: "Failed to create the workspace.",
action: (
<ToastAction
onClick={() => {
formRef.current?.requestSubmit();
}}
altText="Try again"
>
Try again
</ToastAction>
),
});
}
}, [status.type, toast]);
async function onSubmit(values: Infer<typeof NewWorkspaceForm>) {
await createWorkspace({
workspaceName: values.workspaceName,
imageId: values.imageId,
});
}
function content() {
if (error) {
console.log(error);
return (
<p className="opacity-80">
An error occurred when fetching available options.
</p>
);
}
if (isLoading) {
return (
<div className="w-full flex items-center justify-center">
<Loader2 className="animate-spin" />
</div>
);
}
if (!templateImages) {
return null;
}
if (templateImages.length === 0) {
return (
<>
<p className="opacity-80">
No images found. Create and build a template, and the resulting
image will show up here.
</p>
<Alert>
<AlertTitle>What are images?</AlertTitle>
<AlertDescription>
An image is used to bootstrap a workspace, including the operating
system, the environment, and packages, as specified by a template.
</AlertDescription>
</Alert>
</>
);
}
return (
<Form {...form}>
<form
ref={formRef}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="workspaceName"
render={({ field }) => (
<FormItem>
<FormLabel>Workspace name</FormLabel>
<FormControl>
<Input placeholder="my-workspace" {...field} />
</FormControl>
<FormDescription>
Must only contain alphanumeric characters and "-".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="imageId"
render={({ field }) => (
<FormItem>
<FormLabel>Image for this workspace</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an image" />
</SelectTrigger>
</FormControl>
<SelectContent>
{templateImages.map((image) => (
<SelectItem key={image.imageId} value={image.imageId}>
{image.imageTag}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
);
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>New workspace</DialogTitle>
</DialogHeader>
{content()}
</DialogContent>
<NewWorkspaceDialog
onCreateSuccess={() => setIsNewWorkspaceDialogOpen(false)}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,194 @@
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogHeader } from "@/components/ui/dialog";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { ToastAction } from "@/components/ui/toast";
import { DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useToast } from "@/hooks/use-toast";
import { useTemplateImages } from "@/templates/api";
import { superstructResolver } from "@hookform/resolvers/superstruct";
import { useRef, useCallback, useEffect } from "react";
import { useForm } from "react-hook-form";
import { nonempty, object, pattern, string, type Infer } from "superstruct";
import { useCreateWorkspace } from "./api";
const NewWorkspaceForm = object({
workspaceName: pattern(string(), /^[\w-]+$/),
imageId: nonempty(string()),
});
function NewWorkspaceDialog({
onCreateSuccess,
}: { onCreateSuccess: () => void }) {
const { data: templateImages, isLoading, error } = useTemplateImages();
const form = useForm({
resolver: superstructResolver(NewWorkspaceForm),
defaultValues: {
workspaceName: "",
imageId: "",
},
});
const { createWorkspace, status } = useCreateWorkspace();
const { toast } = useToast();
const formRef = useRef<HTMLFormElement | null>(null);
const _onCreateSuccess = useCallback(onCreateSuccess, []);
useEffect(() => {
switch (status.type) {
case "error":
toast({
variant: "destructive",
title: "Failed to create the workspace.",
action: (
<ToastAction
onClick={() => {
formRef.current?.requestSubmit();
}}
altText="Try again"
>
Try again
</ToastAction>
),
});
break;
case "ok":
_onCreateSuccess();
break;
default:
break;
}
}, [status.type, toast, _onCreateSuccess]);
async function onSubmit(values: Infer<typeof NewWorkspaceForm>) {
await createWorkspace({
workspaceName: values.workspaceName,
imageId: values.imageId,
});
}
function content() {
if (error) {
console.log(error);
return (
<p className="opacity-80">
An error occurred when fetching available options.
</p>
);
}
if (isLoading) {
return (
<div className="w-full flex items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (!templateImages) {
return null;
}
if (templateImages.length === 0) {
return (
<>
<p className="opacity-80">
No images found. Create and build a template, and the resulting
image will show up here.
</p>
<Alert>
<AlertTitle>What are images?</AlertTitle>
<AlertDescription>
An image is used to bootstrap a workspace, including the operating
system, the environment, and packages, as specified by a template.
</AlertDescription>
</Alert>
</>
);
}
return (
<Form {...form}>
<form
ref={formRef}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="workspaceName"
render={({ field }) => (
<FormItem>
<FormLabel>Workspace name</FormLabel>
<FormControl>
<Input placeholder="my-workspace" {...field} />
</FormControl>
<FormDescription>
Must only contain alphanumeric characters and "-".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="imageId"
render={({ field }) => (
<FormItem>
<FormLabel>Image for this workspace</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an image" />
</SelectTrigger>
</FormControl>
<SelectContent>
{templateImages.map((image) => (
<SelectItem key={image.imageId} value={image.imageId}>
{image.imageTag}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
);
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>New workspace</DialogTitle>
</DialogHeader>
{content()}
</DialogContent>
);
}
export { NewWorkspaceDialog };

View File

@@ -1,8 +1,18 @@
enum WorkspaceStatus {
Running = "running",
Stopped = "stopped",
Restarting = "restarting",
Unknown = "unknown",
}
interface Workspace {
name: string;
containerId: string;
imageTag: string;
createdAt: string;
status: WorkspaceStatus;
sshPort?: number;
}
export { WorkspaceStatus };
export type { Workspace };

View File

@@ -0,0 +1,239 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast";
import { StopIcon } from "@radix-ui/react-icons";
import { ToastAction } from "@radix-ui/react-toast";
import dayjs from "dayjs";
import { Info, Loader2, Play, Trash2 } from "lucide-react";
import { useEffect } from "react";
import {
useChangeWorkspaceStatus,
useDeleteWorkspace,
useWorkspaces,
} from "./api";
import { type Workspace, WorkspaceStatus } from "./types";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
function WorkspaceTable() {
const { data: workspaces, isLoading } = useWorkspaces();
function placeholder() {
if (isLoading) {
return (
<div className="w-full py-2 space-y-2">
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
</div>
);
}
if (workspaces?.length === 0) {
return (
<p className="text-center py-2 opacity-80">No workspaces found.</p>
);
}
return null;
}
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Image</TableHead>
<TableHead className="w-min">Status</TableHead>
<TableHead>Created at</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
{workspaces ? (
<TableBody>
{workspaces.map((workspace) => (
<WorkspaceTableRow
key={workspace.containerId}
workspace={workspace}
/>
))}
</TableBody>
) : null}
</Table>
{placeholder()}
</>
);
}
function WorkspaceTableRow({ workspace }: { workspace: Workspace }) {
function statusLabel() {
switch (workspace.status) {
case WorkspaceStatus.Running:
return "Running";
case WorkspaceStatus.Stopped:
return "Stopped";
case WorkspaceStatus.Restarting:
return "Restarting";
case WorkspaceStatus.Unknown:
return "Unknown";
}
}
return (
<TableRow>
<TableCell>{workspace.name}</TableCell>
<TableCell>{workspace.imageTag}</TableCell>
<TableCell>
<Badge>{statusLabel()}</Badge>
</TableCell>
<TableCell>
{dayjs(workspace.createdAt).format("YYYY/MM/DD HH:mm")}
</TableCell>
<TableCell className="flex justify-end space-x-1">
<WorkspaceInfoButton workspace={workspace} />
<WorkspaceStatusButton workspace={workspace} />
<DeleteWorkspaceButton workspace={workspace} />
</TableCell>
</TableRow>
);
}
function WorkspaceStatusButton({ workspace }: { workspace: Workspace }) {
const { toast } = useToast();
const { startWorkspace, stopWorkspace, status } = useChangeWorkspaceStatus();
useEffect(() => {
switch (status.type) {
case "error":
toast({
variant: "destructive",
title: "Failed to change workspace status.",
action: (
<ToastAction onClick={startOrStopWorkspace} altText="Try again">
Try again
</ToastAction>
),
});
break;
}
}, [toast, status.type]);
async function startOrStopWorkspace() {
switch (workspace.status) {
case WorkspaceStatus.Running:
await stopWorkspace(workspace.name);
break;
case WorkspaceStatus.Stopped:
await startWorkspace(workspace.name);
break;
default:
break;
}
}
function statusIcon() {
if (status.type === "loading") {
return <Loader2 className="animate-spin" />;
}
switch (workspace.status) {
case WorkspaceStatus.Running:
return <StopIcon />;
case WorkspaceStatus.Stopped:
return <Play />;
case WorkspaceStatus.Restarting:
case WorkspaceStatus.Unknown:
return null;
}
}
switch (workspace.status) {
case WorkspaceStatus.Running:
case WorkspaceStatus.Stopped:
return (
<Button
variant="outline"
size="icon"
disabled={status.type === "loading"}
onClick={startOrStopWorkspace}
>
{statusIcon()}
</Button>
);
default:
return null;
}
}
function DeleteWorkspaceButton({ workspace }: { workspace: Workspace }) {
const { toast } = useToast();
const { deleteWorkspace, status } = useDeleteWorkspace();
useEffect(() => {
console.log(status.type);
if (status.type === "error") {
toast({
title: `Failed to delete workspace ${workspace.name}.`,
action: (
<ToastAction onClick={_deleteWorkspace} altText="Try again">
Try again
</ToastAction>
),
});
}
}, [toast, status.type, workspace.name]);
async function _deleteWorkspace() {
await deleteWorkspace(workspace.name);
}
return (
<Button variant="outline" size="icon" onClick={_deleteWorkspace}>
{status.type === "loading" ? (
<LoadingSpinner />
) : (
<Trash2 className="text-destructive" />
)}
</Button>
);
}
function WorkspaceInfoButton({ workspace }: { workspace: Workspace }) {
return (
<Popover>
<PopoverTrigger>
<Button variant="outline" size="icon">
<Info />
</Button>
</PopoverTrigger>
<PopoverContent>
<div className="grid grid-cols-3">
{workspace.sshPort ? (
<>
<div className="col-span-2">
<p>SSH Port</p>
</div>
<p className="text-right">{workspace.sshPort}</p>
</>
) : null}
</div>
</PopoverContent>
</Popover>
);
}
export { WorkspaceTable };