Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions cmd/hatchet-cli/.goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ version: 2

project_name: hatchet

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.
26 changes: 26 additions & 0 deletions cmd/hatchet-cli/cli/internal/ui/ui.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package ui

import (
"embed"
"io/fs"
)

//go:embed all:assets
var embedded embed.FS

func Assets() (fs.FS, error) {
return fs.Sub(embedded, "assets")
}

func Bundled() bool {
sub, err := Assets()
if err != nil {
return false
}

if _, err := fs.Stat(sub, "index.html"); err != nil {
return false
}

return true
}
298 changes: 298 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,298 @@
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)
}

listener, err := listenUI(host, port)
if err != nil {
configcli.Logger.Fatalf("%v", err)
}

localURL := fmt.Sprintf("http://%s:%d", browserHost(host), listener.Addr().(*net.TCPAddr).Port)

server := &http.Server{
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}

errCh := make(chan error, 1)
go func() {
if err := server.Serve(listener); 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)
}
}

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

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)

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

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
}

func rewriteSetCookie(cookie string) string {
parts := strings.Split(cookie, ";")
out := parts[:0]

for i, p := range parts {
trimmed := strings.TrimSpace(p)

if i > 0 {
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "domain=") || lower == "secure" {
continue
}
}

out = append(out, trimmed)
}

return strings.Join(out, "; ")
}

func newSPAHandler() (http.Handler, error) {
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 {
serveIndex(w, assets)
return
}

if base := path.Base(r.URL.Path); strings.HasSuffix(base, ".html") || strings.HasSuffix(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)
}

func listenUI(host string, port int) (net.Listener, error) {
if port != 0 {
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

for p := base; p < base+100; p++ {
ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, p))
if err == nil {
return ln, nil
}
}

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 {
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
2 changes: 2 additions & 0 deletions frontend/docs/pages/reference/cli/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading