From dc8b5f769bfbd031dacfa3e7c384cec250231971 Mon Sep 17 00:00:00 2001 From: Mohammed Nafees Date: Fri, 10 Jul 2026 13:22:45 +0200 Subject: [PATCH 1/5] hatchet ui command --- .github/workflows/cli-release.yaml | 10 + Taskfile.yaml | 9 + cmd/hatchet-cli/.goreleaser.yml | 5 + cmd/hatchet-cli/cli/internal/ui/.gitignore | 2 + .../cli/internal/ui/assets/.gitkeep | 0 cmd/hatchet-cli/cli/internal/ui/ui.go | 33 ++ cmd/hatchet-cli/cli/ui.go | 324 ++++++++++++++++++ hack/build/embed-ui.sh | 27 ++ 8 files changed, 410 insertions(+) create mode 100644 cmd/hatchet-cli/cli/internal/ui/.gitignore create mode 100644 cmd/hatchet-cli/cli/internal/ui/assets/.gitkeep create mode 100644 cmd/hatchet-cli/cli/internal/ui/ui.go create mode 100644 cmd/hatchet-cli/cli/ui.go create mode 100755 hack/build/embed-ui.sh diff --git a/.github/workflows/cli-release.yaml b/.github/workflows/cli-release.yaml index b0ee635c03..846cceceda 100644 --- a/.github/workflows/cli-release.yaml +++ b/.github/workflows/cli-release.yaml @@ -48,6 +48,16 @@ jobs: go-version: "1.26" cache: true + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Set up pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + version: 10.16.1 + - name: Install quill (for macOS code signing) run: | curl -sSfL https://raw.githubusercontent.com/anchore/quill/main/install.sh | sh -s -- -b /usr/local/bin diff --git a/Taskfile.yaml b/Taskfile.yaml index 50bceb3eb1..c72604f1cd 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -164,6 +164,15 @@ tasks: start-frontend: cmds: - bash ./hack/dev/start-frontend.sh + embed-cli-ui: + desc: Build the dashboard UI and embed it into the hatchet CLI binary assets. + cmds: + - bash ./hack/build/embed-ui.sh + build-cli: + desc: Build the hatchet CLI with the dashboard UI embedded. + deps: [embed-cli-ui] + cmds: + - go build -o bin/hatchet ./cmd/hatchet-cli write-e2e-env: cmds: - bash ./hack/ci/write-e2e-env.sh diff --git a/cmd/hatchet-cli/.goreleaser.yml b/cmd/hatchet-cli/.goreleaser.yml index 66ed29abe3..f2f8482e0b 100644 --- a/cmd/hatchet-cli/.goreleaser.yml +++ b/cmd/hatchet-cli/.goreleaser.yml @@ -2,6 +2,11 @@ version: 2 project_name: hatchet +# Build the dashboard UI and embed it into the CLI binary before compiling. +before: + hooks: + - bash ../../hack/build/embed-ui.sh + builds: - id: hatchet main: ./main.go diff --git a/cmd/hatchet-cli/cli/internal/ui/.gitignore b/cmd/hatchet-cli/cli/internal/ui/.gitignore new file mode 100644 index 0000000000..8e2cd81b9a --- /dev/null +++ b/cmd/hatchet-cli/cli/internal/ui/.gitignore @@ -0,0 +1,2 @@ +/assets/* +!/assets/.gitkeep diff --git a/cmd/hatchet-cli/cli/internal/ui/assets/.gitkeep b/cmd/hatchet-cli/cli/internal/ui/assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cmd/hatchet-cli/cli/internal/ui/ui.go b/cmd/hatchet-cli/cli/internal/ui/ui.go new file mode 100644 index 0000000000..0bcd529bda --- /dev/null +++ b/cmd/hatchet-cli/cli/internal/ui/ui.go @@ -0,0 +1,33 @@ +// Package ui holds the Hatchet dashboard web bundle that is embedded into the +// CLI binary. During a release build the compiled frontend is copied into the +// assets directory (see hack/build/embed-ui.sh); local `go build` invocations +// ship only a placeholder, in which case Bundled reports false. +package ui + +import ( + "embed" + "io/fs" +) + +//go:embed all:assets +var embedded embed.FS + +// Assets returns the embedded UI bundle rooted at the assets directory. +func Assets() (fs.FS, error) { + return fs.Sub(embedded, "assets") +} + +// Bundled reports whether a real UI build was embedded into this binary. It is +// false for plain `go build` binaries that only carry the placeholder. +func Bundled() bool { + sub, err := Assets() + if err != nil { + return false + } + + if _, err := fs.Stat(sub, "index.html"); err != nil { + return false + } + + return true +} diff --git a/cmd/hatchet-cli/cli/ui.go b/cmd/hatchet-cli/cli/ui.go new file mode 100644 index 0000000000..e5b133a7e4 --- /dev/null +++ b/cmd/hatchet-cli/cli/ui.go @@ -0,0 +1,324 @@ +package cli + +import ( + "context" + "crypto/tls" + "fmt" + "io/fs" + "net" + "net/http" + "net/http/httputil" + "net/url" + "path" + "strings" + "time" + + "github.com/spf13/cobra" + + configcli "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/ui" + "github.com/hatchet-dev/hatchet/pkg/cmdutils" +) + +var uiCmd = &cobra.Command{ + Use: "ui", + Aliases: []string{"web", "dashboard"}, + Short: "Serve the Hatchet dashboard UI locally", + Long: `Serve the Hatchet dashboard UI from the CLI, pointed at an existing Hatchet +instance. The UI is bundled into the CLI binary and proxied to the API server of +your selected profile, so you can browse a self-hosted deployment without +deploying the frontend separately.`, + Example: ` # Serve the UI for your default profile + hatchet ui + + # Serve the UI for a specific profile + hatchet ui --profile production + + # Point the UI at an explicit API server (skips profile resolution) + hatchet ui --api-url http://localhost:8080 + + # Serve on a fixed port without opening a browser + hatchet ui --port 9000 --no-open`, + Run: func(cmd *cobra.Command, args []string) { + runUI(cmd) + }, +} + +func runUI(cmd *cobra.Command) { + apiURLFlag, _ := cmd.Flags().GetString("api-url") + profileFlag, _ := cmd.Flags().GetString("profile") + port, _ := cmd.Flags().GetInt("port") + host, _ := cmd.Flags().GetString("host") + noOpen, _ := cmd.Flags().GetBool("no-open") + + target, insecureSkipVerify, profileName := resolveUITarget(apiURLFlag, profileFlag) + + if !ui.Bundled() { + configcli.Logger.Warnf("This CLI build does not include the dashboard UI. " + + "Use an official release binary, or run 'task embed-cli-ui' before building.") + } + + handler, err := newUIHandler(target, insecureSkipVerify) + if err != nil { + configcli.Logger.Fatalf("could not build UI server: %v", err) + } + + listenPort, err := resolveUIPort(host, port) + if err != nil { + configcli.Logger.Fatalf("%v", err) + } + + addr := fmt.Sprintf("%s:%d", host, listenPort) + localURL := fmt.Sprintf("http://%s:%d", host, listenPort) + + server := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + fmt.Println(uiStartedView(localURL, target.String(), profileName)) + + if !noOpen { + openBrowser(localURL) + } + + interruptCh := cmdutils.InterruptChan() + + select { + case err := <-errCh: + configcli.Logger.Fatalf("UI server failed: %v", err) + case <-interruptCh: + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + } +} + +// resolveUITarget determines the API server the UI should be proxied to. An +// explicit --api-url wins; otherwise the selected profile's API server URL is +// used. +func resolveUITarget(apiURLFlag, profileFlag string) (target *url.URL, insecureSkipVerify bool, profileName string) { + if apiURLFlag != "" { + parsed, err := url.Parse(apiURLFlag) + if err != nil { + configcli.Logger.Fatalf("invalid --api-url '%s': %v", apiURLFlag, err) + } + + return parsed, false, "" + } + + selectedProfile := profileFlag + if selectedProfile == "" { + selectedProfile = selectProfileForm(true) + } + + if selectedProfile == "" { + configcli.Logger.Fatal("no profile selected. Configure a profile with 'hatchet profile' or pass --api-url.") + } + + profile, err := configcli.GetProfile(selectedProfile) + if err != nil { + configcli.Logger.Fatalf("could not get profile '%s': %v", selectedProfile, err) + } + + if profile.ApiServerURL == "" { + configcli.Logger.Fatalf("profile '%s' has no API server URL configured", selectedProfile) + } + + parsed, err := url.Parse(profile.ApiServerURL) + if err != nil { + configcli.Logger.Fatalf("profile '%s' has an invalid API server URL '%s': %v", selectedProfile, profile.ApiServerURL, err) + } + + return parsed, profile.TLSStrategy == "none", selectedProfile +} + +// newUIHandler builds the HTTP handler that serves the embedded UI and proxies +// /api requests to the target Hatchet API server. +func newUIHandler(target *url.URL, insecureSkipVerify bool) (http.Handler, error) { + origin := target.Scheme + "://" + target.Host + + proxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(target) + + // Present the request to the API server as same-origin so cookie/CSRF + // checks pass, since the browser only ever talks to our local origin. + pr.Out.Host = target.Host + if pr.Out.Header.Get("Origin") != "" { + pr.Out.Header.Set("Origin", origin) + } + if pr.Out.Header.Get("Referer") != "" { + pr.Out.Header.Set("Referer", origin+pr.Out.URL.Path) + } + }, + // Rewrite Set-Cookie so session cookies from the (possibly remote, https) + // API server are accepted by the browser against our local http origin. + ModifyResponse: func(resp *http.Response) error { + if cookies := resp.Header["Set-Cookie"]; len(cookies) > 0 { + for i, c := range cookies { + cookies[i] = rewriteSetCookie(c) + } + } + return nil + }, + } + + if insecureSkipVerify { + proxy.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint:gosec // opt-in via profile tlsStrategy=none + } + } + + spa, err := newSPAHandler() + if err != nil { + return nil, err + } + + mux := http.NewServeMux() + mux.Handle("/api/", proxy) + mux.Handle("/", spa) + + return mux, nil +} + +// rewriteSetCookie strips the Domain attribute and the Secure flag so a cookie +// scoped to a remote https host is stored against our local http origin. +func rewriteSetCookie(cookie string) string { + parts := strings.Split(cookie, ";") + out := parts[:0] + + for i, p := range parts { + trimmed := strings.TrimSpace(p) + + // index 0 is the name=value pair, which we always keep. + if i > 0 { + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "domain=") || lower == "secure" { + continue + } + } + + out = append(out, trimmed) + } + + return strings.Join(out, "; ") +} + +// newSPAHandler serves the embedded UI bundle with single-page-app fallback to +// index.html, or a built-in notice when no UI build is bundled. +func newSPAHandler() (http.Handler, error) { + assets, err := ui.Assets() + if err != nil { + return nil, err + } + + if !ui.Bundled() { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write([]byte(uiNotBundledPage)) + }), nil + } + + fileServer := http.FileServer(http.FS(assets)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Frame-Options", "DENY") + + reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/") + if reqPath == "" { + reqPath = "index.html" + } + + if _, err := fs.Stat(assets, reqPath); err != nil { + // Unknown path: fall back to index.html so client-side routing works. + serveIndex(w, assets) + return + } + + if base := path.Base(r.URL.Path); strings.Contains(base, "html") || strings.Contains(base, "js") || base == "." || base == "/" { + w.Header().Set("Cache-Control", "no-cache") + } + + fileServer.ServeHTTP(w, r) + }), nil +} + +func serveIndex(w http.ResponseWriter, assets fs.FS) { + index, err := fs.ReadFile(assets, "index.html") + if err != nil { + http.Error(w, "index.html not found", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(index) +} + +// resolveUIPort returns the port to listen on. A non-zero port is used as-is; +// port 0 auto-detects a free port starting at the default base. +func resolveUIPort(host string, port int) (int, error) { + if port != 0 { + return port, nil + } + + const base = 8080 + + for p := base; p < base+100; p++ { + ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, p)) + if err == nil { + _ = ln.Close() + return p, nil + } + } + + return 0, fmt.Errorf("could not find a free port starting at %d; specify one with --port", base) +} + +func uiStartedView(localURL, targetURL, profileName string) string { + var lines []string + + lines = append(lines, styles.SuccessMessage("Hatchet dashboard is running!")) + lines = append(lines, "") + lines = append(lines, styles.KeyValue("Dashboard", localURL)) + if profileName != "" { + lines = append(lines, styles.KeyValue("Profile", profileName)) + } + lines = append(lines, styles.KeyValue("API server", targetURL)) + lines = append(lines, "") + lines = append(lines, styles.Muted.Render("Press Ctrl+C to stop.")) + + return styles.SuccessBox.Render(strings.Join(lines, "\n")) +} + +const uiNotBundledPage = ` + + Hatchet UI + +

Dashboard UI not bundled

+

This CLI binary was built without the dashboard UI.

+

Use an official release binary, or build the UI into the CLI with + task embed-cli-ui before compiling.

+ +` + +func init() { + rootCmd.AddCommand(uiCmd) + + uiCmd.Flags().StringP("profile", "n", "", "Profile whose API server the UI targets (default: default profile)") + uiCmd.Flags().String("api-url", "", "API server URL to proxy to (overrides the profile's API server URL)") + uiCmd.Flags().IntP("port", "p", 0, "Port to serve the UI on (default: auto-detect starting at 8080)") + uiCmd.Flags().String("host", "localhost", "Host interface to bind the UI server to") + uiCmd.Flags().Bool("no-open", false, "Do not automatically open a browser") +} diff --git a/hack/build/embed-ui.sh b/hack/build/embed-ui.sh new file mode 100755 index 0000000000..8b78338de7 --- /dev/null +++ b/hack/build/embed-ui.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Build the frontend dashboard and copy the compiled bundle into the CLI's +# embedded assets directory so it ships inside the `hatchet` binary. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FRONTEND="$ROOT/frontend/app" +DEST="$ROOT/cmd/hatchet-cli/cli/internal/ui/assets" + +echo "Building frontend in $FRONTEND" +cd "$FRONTEND" +pnpm install --frozen-lockfile +pnpm run build + +echo "Copying bundle into $DEST" +rm -rf "$DEST" +mkdir -p "$DEST" +cp -R "$FRONTEND/dist/." "$DEST/" + +# Drop source maps to keep the embedded bundle (and the binary) small. +find "$DEST" -name '*.map' -delete + +# Keep the placeholder marker so `go:embed all:assets` still compiles from a +# clean checkout after the built assets are removed. +touch "$DEST/.gitkeep" + +echo "Embedded UI bundle ready." From e35aff3d35cd9b61990dac235c2e9e28113752ea Mon Sep 17 00:00:00 2001 From: Mohammed Nafees Date: Fri, 10 Jul 2026 13:26:19 +0200 Subject: [PATCH 2/5] no ui bundle cleanup --- cmd/hatchet-cli/cli/ui.go | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/cmd/hatchet-cli/cli/ui.go b/cmd/hatchet-cli/cli/ui.go index e5b133a7e4..af125840ee 100644 --- a/cmd/hatchet-cli/cli/ui.go +++ b/cmd/hatchet-cli/cli/ui.go @@ -52,13 +52,13 @@ func runUI(cmd *cobra.Command) { host, _ := cmd.Flags().GetString("host") noOpen, _ := cmd.Flags().GetBool("no-open") - target, insecureSkipVerify, profileName := resolveUITarget(apiURLFlag, profileFlag) - if !ui.Bundled() { - configcli.Logger.Warnf("This CLI build does not include the dashboard UI. " + - "Use an official release binary, or run 'task embed-cli-ui' before building.") + configcli.Logger.Fatal("This CLI build does not include the dashboard UI. " + + "Use an official release binary, or run 'task build-cli' to build one with the UI embedded.") } + target, insecureSkipVerify, profileName := resolveUITarget(apiURLFlag, profileFlag) + handler, err := newUIHandler(target, insecureSkipVerify) if err != nil { configcli.Logger.Fatalf("could not build UI server: %v", err) @@ -222,14 +222,6 @@ func newSPAHandler() (http.Handler, error) { return nil, err } - if !ui.Bundled() { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache") - _, _ = w.Write([]byte(uiNotBundledPage)) - }), nil - } - fileServer := http.FileServer(http.FS(assets)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -302,17 +294,6 @@ func uiStartedView(localURL, targetURL, profileName string) string { return styles.SuccessBox.Render(strings.Join(lines, "\n")) } -const uiNotBundledPage = ` - - Hatchet UI - -

Dashboard UI not bundled

-

This CLI binary was built without the dashboard UI.

-

Use an official release binary, or build the UI into the CLI with - task embed-cli-ui before compiling.

- -` - func init() { rootCmd.AddCommand(uiCmd) From 5eb1037321bad247dcb9f17a9dcba2b85c0fe376 Mon Sep 17 00:00:00 2001 From: Mohammed Nafees Date: Fri, 10 Jul 2026 13:36:47 +0200 Subject: [PATCH 3/5] docs --- cmd/hatchet-cli/cli/ui.go | 3 +- frontend/docs/pages/reference/cli/_meta.js | 5 ++++ frontend/docs/pages/reference/cli/index.mdx | 2 ++ frontend/docs/pages/reference/cli/ui.mdx | 33 +++++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 frontend/docs/pages/reference/cli/ui.mdx diff --git a/cmd/hatchet-cli/cli/ui.go b/cmd/hatchet-cli/cli/ui.go index af125840ee..09936de2b8 100644 --- a/cmd/hatchet-cli/cli/ui.go +++ b/cmd/hatchet-cli/cli/ui.go @@ -53,8 +53,7 @@ func runUI(cmd *cobra.Command) { noOpen, _ := cmd.Flags().GetBool("no-open") if !ui.Bundled() { - configcli.Logger.Fatal("This CLI build does not include the dashboard UI. " + - "Use an official release binary, or run 'task build-cli' to build one with the UI embedded.") + configcli.Logger.Fatal("This CLI build does not include the dashboard UI.") } target, insecureSkipVerify, profileName := resolveUITarget(apiURLFlag, profileFlag) diff --git a/frontend/docs/pages/reference/cli/_meta.js b/frontend/docs/pages/reference/cli/_meta.js index 57c211cbed..b95807fc6b 100644 --- a/frontend/docs/pages/reference/cli/_meta.js +++ b/frontend/docs/pages/reference/cli/_meta.js @@ -12,6 +12,11 @@ export default { "running-hatchet-locally": "Running Hatchet Locally", "running-workers-locally": "Running Workers Locally", "triggering-workflows": "Triggering Workflows", + "--dashboard": { + title: "Dashboard", + type: "separator", + }, + ui: "Serving the Dashboard UI", "--tui": { title: "TUI", type: "separator", diff --git a/frontend/docs/pages/reference/cli/index.mdx b/frontend/docs/pages/reference/cli/index.mdx index 14be2559d7..7b496780cb 100644 --- a/frontend/docs/pages/reference/cli/index.mdx +++ b/frontend/docs/pages/reference/cli/index.mdx @@ -18,6 +18,8 @@ The Hatchet CLI is a command-line tool with utilities for running workers locall - **A full built-in TUI**: the [`hatchet tui`](/cli/tui) command lets you interact with your Hatchet deployment through a terminal user interface (TUI) that provides real-time observability into tasks, workflows, workers, and more. +- **Locally served dashboard**: the [`hatchet ui`](/cli/ui) command serves the Hatchet dashboard from the CLI, pointed at a profile's deployment, so you can browse an API-only or self-hosted deployment without deploying the frontend separately. + - **Profiles**: the [`hatchet profile`](/cli/profiles) commands allow you to manage multiple Hatchet instances and tenants with named profiles, making it easy to switch between different environments. ## Installation diff --git a/frontend/docs/pages/reference/cli/ui.mdx b/frontend/docs/pages/reference/cli/ui.mdx new file mode 100644 index 0000000000..d0b5d311c5 --- /dev/null +++ b/frontend/docs/pages/reference/cli/ui.mdx @@ -0,0 +1,33 @@ +import { Callout } from "nextra/components"; + +# Serving the Dashboard UI + +The Hatchet CLI can serve the Hatchet dashboard UI locally, pointed at an existing Hatchet deployment. The frontend assets are bundled into the CLI binary and served on your machine, while API requests are proxied to the deployment configured in your selected profile: + +```sh +hatchet ui +``` + +This opens a browser to the locally served dashboard. It is especially useful for API-only or self-hosted deployments that don't serve a frontend of their own, letting you browse a deployment without actually deploying the dashboard separately. + +## Choosing a target + +By default, `hatchet ui` uses the API server of your default profile (or prompts you to select one). You can target a specific profile or an explicit API server: + +```sh +# Serve the UI for a specific profile +hatchet ui --profile production + +# Point the UI at an explicit API server, skipping profile resolution +hatchet ui --api-url http://localhost:8080 +``` + +## Flags + +| Flag | Description | +| ----------- | -------------------------------------------------------------------------- | +| `--profile` | Profile whose API server the UI targets (defaults to the default profile). | +| `--api-url` | API server URL to proxy to, overriding the profile's API server URL. | +| `--port` | Port to serve the UI on (defaults to auto-detecting from `8080`). | +| `--host` | Host interface to bind the UI server to (defaults to `localhost`). | +| `--no-open` | Do not automatically open a browser. | From e039b62e0723d698769a0115b3553ed94416318e Mon Sep 17 00:00:00 2001 From: Mohammed Nafees Date: Fri, 10 Jul 2026 13:55:57 +0200 Subject: [PATCH 4/5] port race condition --- cmd/hatchet-cli/.goreleaser.yml | 1 - cmd/hatchet-cli/cli/internal/ui/ui.go | 7 ---- cmd/hatchet-cli/cli/ui.go | 50 +++++++++++------------- frontend/docs/pages/reference/cli/ui.mdx | 2 - hack/build/embed-ui.sh | 5 --- 5 files changed, 22 insertions(+), 43 deletions(-) diff --git a/cmd/hatchet-cli/.goreleaser.yml b/cmd/hatchet-cli/.goreleaser.yml index f2f8482e0b..6f0385d48e 100644 --- a/cmd/hatchet-cli/.goreleaser.yml +++ b/cmd/hatchet-cli/.goreleaser.yml @@ -2,7 +2,6 @@ version: 2 project_name: hatchet -# Build the dashboard UI and embed it into the CLI binary before compiling. before: hooks: - bash ../../hack/build/embed-ui.sh diff --git a/cmd/hatchet-cli/cli/internal/ui/ui.go b/cmd/hatchet-cli/cli/internal/ui/ui.go index 0bcd529bda..23ea2f94ad 100644 --- a/cmd/hatchet-cli/cli/internal/ui/ui.go +++ b/cmd/hatchet-cli/cli/internal/ui/ui.go @@ -1,7 +1,3 @@ -// Package ui holds the Hatchet dashboard web bundle that is embedded into the -// CLI binary. During a release build the compiled frontend is copied into the -// assets directory (see hack/build/embed-ui.sh); local `go build` invocations -// ship only a placeholder, in which case Bundled reports false. package ui import ( @@ -12,13 +8,10 @@ import ( //go:embed all:assets var embedded embed.FS -// Assets returns the embedded UI bundle rooted at the assets directory. func Assets() (fs.FS, error) { return fs.Sub(embedded, "assets") } -// Bundled reports whether a real UI build was embedded into this binary. It is -// false for plain `go build` binaries that only carry the placeholder. func Bundled() bool { sub, err := Assets() if err != nil { diff --git a/cmd/hatchet-cli/cli/ui.go b/cmd/hatchet-cli/cli/ui.go index 09936de2b8..5b36b6118d 100644 --- a/cmd/hatchet-cli/cli/ui.go +++ b/cmd/hatchet-cli/cli/ui.go @@ -63,23 +63,21 @@ func runUI(cmd *cobra.Command) { configcli.Logger.Fatalf("could not build UI server: %v", err) } - listenPort, err := resolveUIPort(host, port) + listener, err := listenUI(host, port) if err != nil { configcli.Logger.Fatalf("%v", err) } - addr := fmt.Sprintf("%s:%d", host, listenPort) - localURL := fmt.Sprintf("http://%s:%d", host, listenPort) + localURL := fmt.Sprintf("http://%s:%d", browserHost(host), listener.Addr().(*net.TCPAddr).Port) server := &http.Server{ - Addr: addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, } errCh := make(chan error, 1) go func() { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { errCh <- err } }() @@ -102,9 +100,6 @@ func runUI(cmd *cobra.Command) { } } -// resolveUITarget determines the API server the UI should be proxied to. An -// explicit --api-url wins; otherwise the selected profile's API server URL is -// used. func resolveUITarget(apiURLFlag, profileFlag string) (target *url.URL, insecureSkipVerify bool, profileName string) { if apiURLFlag != "" { parsed, err := url.Parse(apiURLFlag) @@ -141,8 +136,6 @@ func resolveUITarget(apiURLFlag, profileFlag string) (target *url.URL, insecureS return parsed, profile.TLSStrategy == "none", selectedProfile } -// newUIHandler builds the HTTP handler that serves the embedded UI and proxies -// /api requests to the target Hatchet API server. func newUIHandler(target *url.URL, insecureSkipVerify bool) (http.Handler, error) { origin := target.Scheme + "://" + target.Host @@ -150,8 +143,6 @@ func newUIHandler(target *url.URL, insecureSkipVerify bool) (http.Handler, error Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(target) - // Present the request to the API server as same-origin so cookie/CSRF - // checks pass, since the browser only ever talks to our local origin. pr.Out.Host = target.Host if pr.Out.Header.Get("Origin") != "" { pr.Out.Header.Set("Origin", origin) @@ -160,8 +151,6 @@ func newUIHandler(target *url.URL, insecureSkipVerify bool) (http.Handler, error pr.Out.Header.Set("Referer", origin+pr.Out.URL.Path) } }, - // Rewrite Set-Cookie so session cookies from the (possibly remote, https) - // API server are accepted by the browser against our local http origin. ModifyResponse: func(resp *http.Response) error { if cookies := resp.Header["Set-Cookie"]; len(cookies) > 0 { for i, c := range cookies { @@ -174,7 +163,7 @@ func newUIHandler(target *url.URL, insecureSkipVerify bool) (http.Handler, error if insecureSkipVerify { proxy.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint:gosec // opt-in via profile tlsStrategy=none + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint:gosec } } @@ -190,8 +179,6 @@ func newUIHandler(target *url.URL, insecureSkipVerify bool) (http.Handler, error return mux, nil } -// rewriteSetCookie strips the Domain attribute and the Secure flag so a cookie -// scoped to a remote https host is stored against our local http origin. func rewriteSetCookie(cookie string) string { parts := strings.Split(cookie, ";") out := parts[:0] @@ -199,7 +186,6 @@ func rewriteSetCookie(cookie string) string { for i, p := range parts { trimmed := strings.TrimSpace(p) - // index 0 is the name=value pair, which we always keep. if i > 0 { lower := strings.ToLower(trimmed) if strings.HasPrefix(lower, "domain=") || lower == "secure" { @@ -213,8 +199,6 @@ func rewriteSetCookie(cookie string) string { return strings.Join(out, "; ") } -// newSPAHandler serves the embedded UI bundle with single-page-app fallback to -// index.html, or a built-in notice when no UI build is bundled. func newSPAHandler() (http.Handler, error) { assets, err := ui.Assets() if err != nil { @@ -232,7 +216,6 @@ func newSPAHandler() (http.Handler, error) { } if _, err := fs.Stat(assets, reqPath); err != nil { - // Unknown path: fall back to index.html so client-side routing works. serveIndex(w, assets) return } @@ -257,11 +240,14 @@ func serveIndex(w http.ResponseWriter, assets fs.FS) { _, _ = w.Write(index) } -// resolveUIPort returns the port to listen on. A non-zero port is used as-is; -// port 0 auto-detects a free port starting at the default base. -func resolveUIPort(host string, port int) (int, error) { +func listenUI(host string, port int) (net.Listener, error) { if port != 0 { - return port, nil + ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, port)) + if err != nil { + return nil, fmt.Errorf("could not bind to %s:%d: %w", host, port, err) + } + + return ln, nil } const base = 8080 @@ -269,12 +255,20 @@ func resolveUIPort(host string, port int) (int, error) { for p := base; p < base+100; p++ { ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, p)) if err == nil { - _ = ln.Close() - return p, nil + return ln, nil } } - return 0, fmt.Errorf("could not find a free port starting at %d; specify one with --port", base) + return nil, fmt.Errorf("could not find a free port starting at %d; specify one with --port", base) +} + +func browserHost(host string) string { + switch host { + case "", "0.0.0.0", "::", "[::]": + return "localhost" + default: + return host + } } func uiStartedView(localURL, targetURL, profileName string) string { diff --git a/frontend/docs/pages/reference/cli/ui.mdx b/frontend/docs/pages/reference/cli/ui.mdx index d0b5d311c5..547edd2101 100644 --- a/frontend/docs/pages/reference/cli/ui.mdx +++ b/frontend/docs/pages/reference/cli/ui.mdx @@ -1,5 +1,3 @@ -import { Callout } from "nextra/components"; - # Serving the Dashboard UI The Hatchet CLI can serve the Hatchet dashboard UI locally, pointed at an existing Hatchet deployment. The frontend assets are bundled into the CLI binary and served on your machine, while API requests are proxied to the deployment configured in your selected profile: diff --git a/hack/build/embed-ui.sh b/hack/build/embed-ui.sh index 8b78338de7..492eb15d21 100755 --- a/hack/build/embed-ui.sh +++ b/hack/build/embed-ui.sh @@ -1,6 +1,4 @@ #!/usr/bin/env bash -# Build the frontend dashboard and copy the compiled bundle into the CLI's -# embedded assets directory so it ships inside the `hatchet` binary. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -17,11 +15,8 @@ rm -rf "$DEST" mkdir -p "$DEST" cp -R "$FRONTEND/dist/." "$DEST/" -# Drop source maps to keep the embedded bundle (and the binary) small. find "$DEST" -name '*.map' -delete -# Keep the placeholder marker so `go:embed all:assets` still compiles from a -# clean checkout after the built assets are removed. touch "$DEST/.gitkeep" echo "Embedded UI bundle ready." From c11c53b25c09d235620e5d35a90c55dadbe6df06 Mon Sep 17 00:00:00 2001 From: Mohammed Nafees Date: Fri, 10 Jul 2026 13:58:00 +0200 Subject: [PATCH 5/5] suffix fix --- cmd/hatchet-cli/cli/ui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/hatchet-cli/cli/ui.go b/cmd/hatchet-cli/cli/ui.go index 5b36b6118d..270c36eae4 100644 --- a/cmd/hatchet-cli/cli/ui.go +++ b/cmd/hatchet-cli/cli/ui.go @@ -220,7 +220,7 @@ func newSPAHandler() (http.Handler, error) { return } - if base := path.Base(r.URL.Path); strings.Contains(base, "html") || strings.Contains(base, "js") || base == "." || base == "/" { + if base := path.Base(r.URL.Path); strings.HasSuffix(base, ".html") || strings.HasSuffix(base, ".js") || base == "." || base == "/" { w.Header().Set("Cache-Control", "no-cache") }