diff --git a/docs/screenshots/build-dialog.png b/docs/screenshots/build-dialog.png new file mode 100644 index 0000000..1e37a5f Binary files /dev/null and b/docs/screenshots/build-dialog.png differ diff --git a/docs/screenshots/build-output-panel.png b/docs/screenshots/build-output-panel.png new file mode 100644 index 0000000..f2d5fd5 Binary files /dev/null and b/docs/screenshots/build-output-panel.png differ diff --git a/docs/screenshots/template-editor.png b/docs/screenshots/template-editor.png new file mode 100644 index 0000000..556fe75 Binary files /dev/null and b/docs/screenshots/template-editor.png differ diff --git a/docs/screenshots/workspace-dialog.png b/docs/screenshots/workspace-dialog.png new file mode 100644 index 0000000..5662cd1 Binary files /dev/null and b/docs/screenshots/workspace-dialog.png differ diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 7034835..c62f363 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -6,6 +6,7 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/go-connections/nat" "strconv" + "strings" ) // ContainerSSHHostPort returns the port on the host that is exposing the internal ssh port of the given container info @@ -33,3 +34,8 @@ func ContainerHostPort(ctx context.Context, container types.ContainerJSON, port } return port } + +// CleanErrorMessage removes unnecessary parts in a docker sdk error message, such as "Error response from daemon:" +func CleanErrorMessage(msg string) string { + return strings.Replace(msg, "Error response from daemon: ", "", 1) +} diff --git a/internal/template/template_manager.go b/internal/template/template_manager.go index b7181ee..5bce8e6 100644 --- a/internal/template/template_manager.go +++ b/internal/template/template_manager.go @@ -14,7 +14,7 @@ import ( "github.com/docker/docker/errdefs" "github.com/google/uuid" "github.com/uptrace/bun" - "strings" + "tesseract/internal/docker" "time" ) @@ -259,7 +259,7 @@ func (mgr *templateManager) buildTemplate(ctx context.Context, template *templat // the docker sdk returns an error message that looks like: // "Error response from daemon: dockerfile parse error on line 1: unknown instruction: FR (did you mean FROM?)" // we don't want the "error response..." part because it is meaningless - message: strings.Replace(err.Error(), "Error response from daemon: ", "", 1), + message: docker.CleanErrorMessage(err.Error()), } } return nil, err diff --git a/internal/workspace/errors.go b/internal/workspace/errors.go new file mode 100644 index 0000000..506cf81 --- /dev/null +++ b/internal/workspace/errors.go @@ -0,0 +1,9 @@ +package workspace + +type errWorkspaceExists struct { + message string +} + +func (err *errWorkspaceExists) Error() string { + return err.message +} diff --git a/internal/workspace/http_handlers.go b/internal/workspace/http_handlers.go index 887ef43..c86e3f0 100644 --- a/internal/workspace/http_handlers.go +++ b/internal/workspace/http_handlers.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/labstack/echo/v4" "net/http" + "tesseract/internal/apierror" ) type createWorkspaceRequestBody struct { @@ -86,6 +87,12 @@ func createWorkspace(c echo.Context, workspaceName string) error { if errors.Is(err, errImageNotFound) { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no image with id %v exists", body.ImageID)) } + + var errWorkspaceExists *errWorkspaceExists + if errors.As(err, &errWorkspaceExists) { + return apierror.New(http.StatusBadRequest, "WORKSPACE_EXISTS", errWorkspaceExists.message) + } + return err } diff --git a/internal/workspace/workspace_manager.go b/internal/workspace/workspace_manager.go index f8045dd..1850542 100644 --- a/internal/workspace/workspace_manager.go +++ b/internal/workspace/workspace_manager.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" + "github.com/docker/docker/errdefs" "github.com/docker/go-connections/nat" "github.com/google/uuid" "github.com/uptrace/bun" @@ -174,6 +175,11 @@ func (mgr workspaceManager) createWorkspace(ctx context.Context, opts createWork res, err := mgr.dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, opts.name) if err != nil { + if errdefs.IsConflict(err) { + return nil, &errWorkspaceExists{ + message: docker.CleanErrorMessage(err.Error()), + } + } return nil, err } diff --git a/web/src/api.ts b/web/src/api.ts index 1ff2fe0..12ea5b9 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -6,6 +6,7 @@ interface ApiErrorResponse { } const API_ERROR_BAD_TEMPLATE = "BAD_TEMPLATE"; +const API_ERROR_WORKSPACE_EXISTS = "WORKSPACE_EXISTS"; enum ApiError { NotFound = "NOT_FOUND", @@ -45,5 +46,11 @@ function isApiErrorResponse(error: unknown): error is ApiErrorResponse { ); } -export { API_ERROR_BAD_TEMPLATE, ApiError, fetchApi, isApiErrorResponse }; +export { + API_ERROR_BAD_TEMPLATE, + API_ERROR_WORKSPACE_EXISTS, + ApiError, + fetchApi, + isApiErrorResponse, +}; export type { ApiErrorResponse }; diff --git a/web/src/templates/template-editor.tsx b/web/src/templates/template-editor.tsx index 4dd3ba0..09d1c95 100644 --- a/web/src/templates/template-editor.tsx +++ b/web/src/templates/template-editor.tsx @@ -15,7 +15,7 @@ import { import { cn } from "@/lib/utils"; import { Link, useRouter } from "@tanstack/react-router"; import { ArrowLeft, ChevronDown, ChevronUp, Loader2 } from "lucide-react"; -import { useEffect, useId, useRef } from "react"; +import { useEffect, useRef } from "react"; import { useStore } from "zustand"; import { useTemplate, useTemplateFile, useUpdateTemplateFile } from "./api"; import { templateEditorRoute } from "./routes"; diff --git a/web/src/workspaces/new-workspace-dialog.tsx b/web/src/workspaces/new-workspace-dialog.tsx index dc5daa9..f0f9f97 100644 --- a/web/src/workspaces/new-workspace-dialog.tsx +++ b/web/src/workspaces/new-workspace-dialog.tsx @@ -30,6 +30,7 @@ import { nonempty, object, pattern, string, type Infer } from "superstruct"; import { useCreateWorkspace, useWorkspaceRuntimes } from "./api"; import type { TemplateImage } from "@/templates/types"; import type { WorkspaceRuntime } from "./types"; +import { API_ERROR_WORKSPACE_EXISTS, isApiErrorResponse } from "@/api"; interface NewWorkspaceDialogProps { onCreateSuccess: () => void; @@ -131,28 +132,47 @@ function NewWorkspaceForm({ useEffect(() => { switch (status.type) { case "error": - toast({ - variant: "destructive", - title: "Failed to create the workspace.", - action: ( - { - formRef.current?.requestSubmit(); - }} - altText="Try again" - > - Try again - - ), - }); + if (isApiErrorResponse(status.error)) { + let toastTitle = ""; + switch (status.error.code) { + case API_ERROR_WORKSPACE_EXISTS: + toastTitle = "Workspace already exists."; + break; + default: + toastTitle = "Failed to create the workspace."; + break; + } + toast({ + variant: "destructive", + title: toastTitle, + description: status.error.error, + }); + } else { + toast({ + variant: "destructive", + title: "Failed to create the workspace.", + description: "Unknown error", + action: ( + { + formRef.current?.requestSubmit(); + }} + altText="Try again" + > + Try again + + ), + }); + } break; + case "ok": onCreateSuccess(); break; default: break; } - }, [status.type, toast, onCreateSuccess]); + }, [status, toast, onCreateSuccess]); async function onSubmit(values: Infer) { await createWorkspace({