Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/cli-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions cmd/hatchet-cli/.goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cmd/hatchet-cli/cli/internal/ui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/assets/*
!/assets/.gitkeep
Empty file.
33 changes: 33 additions & 0 deletions cmd/hatchet-cli/cli/internal/ui/ui.go
Original file line number Diff line number Diff line change
@@ -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
}
304 changes: 304 additions & 0 deletions cmd/hatchet-cli/cli/ui.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does the UI "know" which tenant / tenant memberships to render? It seems like this would only work in no-auth mode, otherwise I can't see how this would work without modifying frontend code.

Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
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")

if !ui.Bundled() {
configcli.Logger.Fatal("This CLI build does not include the dashboard UI.")
}
Comment thread
mnafees marked this conversation as resolved.

target, insecureSkipVerify, profileName := resolveUITarget(apiURLFlag, profileFlag)

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
}
}()
Comment thread
mnafees marked this conversation as resolved.
Outdated

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, ""
Comment thread
mnafees marked this conversation as resolved.
}

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
Comment thread
mnafees marked this conversation as resolved.
}

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worry about this, users running hatchet ui will be opening a port with full access to their target tenant. There are lots of network misconfigurations where this would be particularly bad, for example Docker Desktop for a while had an issue where ports would be discoverable if you were bound to a network. Same with VPNs like Tailscale, lots of (mis)configurations would allow for remote access to this port.

I wonder if we should require some form of auth to the /api/ proxy which we control from the CLI process. Again, this would require some frontend changes to pass some kind of token through (perhaps can hook into the exchange token mechanism).

mux.Handle("/", spa)

Comment thread
mnafees marked this conversation as resolved.
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) {
Comment thread
mnafees marked this conversation as resolved.
Outdated
assets, err := ui.Assets()
if err != nil {
return nil, err
}

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")
}
Comment thread
mnafees marked this conversation as resolved.
Outdated

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)
}
Comment thread
mnafees marked this conversation as resolved.
Outdated

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

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")
}
5 changes: 5 additions & 0 deletions frontend/docs/pages/reference/cli/_meta.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading