diff --git a/cmd/cli/serve/serve.go b/cmd/cli/serve/serve.go index 34a9eedc28..d8c913bdea 100644 --- a/cmd/cli/serve/serve.go +++ b/cmd/cli/serve/serve.go @@ -6,7 +6,6 @@ import ( "net" "net/url" "os" - "strconv" "strings" "github.com/pkg/errors" @@ -193,24 +192,18 @@ func serve(cmd *cobra.Command, cfg types.Bacalhau, fsRepo *repo.FsRepo) error { // Start up Dashboard - default: 8483 if cfg.WebUI.Enabled { - apiURL := standardNode.APIServer.GetURI().JoinPath("api", "v1") - host, portStr, err := net.SplitHostPort(cfg.WebUI.Listen) - if err != nil { - return err + webuiConfig := webui.Config{ + APIEndpoint: standardNode.APIServer.GetURI().String(), + Listen: cfg.WebUI.Listen, } - webuiPort, err := strconv.ParseInt(portStr, 10, 64) + webuiServer, err := webui.NewServer(webuiConfig) if err != nil { - return err + // not failing the node if the webui server fails to start + log.Error().Err(err).Msg("Failed to start ui server") } go func() { - // Specifically leave the host blank. The app will just use whatever - // host it is served on and replace the port and path. - apiPort := apiURL.Port() - apiPath := apiURL.Path - - err := webui.ListenAndServe(ctx, host, apiPort, apiPath, int(webuiPort)) - if err != nil { - cmd.PrintErrln(err) + if err := webuiServer.ListenAndServe(ctx); err != nil { + log.Error().Err(err).Msg("ui server error") } }() } diff --git a/webui/.earthlyignore b/webui/.earthlyignore index 65d8546db0..52ff402102 100644 --- a/webui/.earthlyignore +++ b/webui/.earthlyignore @@ -23,5 +23,6 @@ coverage build .yarn .storybook +.next .git diff --git a/webui/.yarnrc.yml b/webui/.yarnrc.yml index 8b757b29a1..3186f3f079 100644 --- a/webui/.yarnrc.yml +++ b/webui/.yarnrc.yml @@ -1 +1 @@ -nodeLinker: node-modules \ No newline at end of file +nodeLinker: node-modules diff --git a/webui/components/ui/toast.tsx b/webui/components/ui/toast.tsx index cc4e0ab2f0..3a52d0e6d3 100644 --- a/webui/components/ui/toast.tsx +++ b/webui/components/ui/toast.tsx @@ -1,11 +1,11 @@ -"use client" +'use client' -import * as React from "react" -import { Cross2Icon } from "@radix-ui/react-icons" -import * as ToastPrimitives from "@radix-ui/react-toast" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from 'react' +import { Cross2Icon } from '@radix-ui/react-icons' +import * as ToastPrimitives from '@radix-ui/react-toast' +import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils' const ToastProvider = ToastPrimitives.Provider @@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef< (({ className, ...props }, ref) => ( )) @@ -106,7 +106,7 @@ const ToastDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )) diff --git a/webui/components/ui/toaster.tsx b/webui/components/ui/toaster.tsx index 171beb46d9..3b91885a3a 100644 --- a/webui/components/ui/toaster.tsx +++ b/webui/components/ui/toaster.tsx @@ -1,6 +1,6 @@ -"use client" +'use client' -import { useToast } from "@/hooks/use-toast" +import { useToast } from '@/hooks/use-toast' import { Toast, ToastClose, @@ -8,7 +8,7 @@ import { ToastProvider, ToastTitle, ToastViewport, -} from "@/components/ui/toast" +} from '@/components/ui/toast' export function Toaster() { const { toasts } = useToast() diff --git a/webui/lib/api/index.ts b/webui/lib/api/index.ts index 849def1f02..4a1a6dbe25 100644 --- a/webui/lib/api/index.ts +++ b/webui/lib/api/index.ts @@ -1,14 +1,23 @@ import { OpenAPI } from './generated' import { useState, useEffect } from 'react' -// This function will be used to initialize the API -export function initializeApi(apiUrl?: string) { - OpenAPI.BASE = - apiUrl || - process.env.NEXT_PUBLIC_BACALHAU_API_ADDRESS || - 'http://localhost:1234' - // log all env - console.log('API initialized with URL:', OpenAPI.BASE) +interface Config { + APIEndpoint: string +} + +export async function initializeApi() { + try { + const response = await fetch('/_config') + if (!response.ok) { + throw new Error('Failed to fetch config') + } + const config: Config = await response.json() + OpenAPI.BASE = config.APIEndpoint || 'http://localhost:1234' + console.log('API initialized with URL:', OpenAPI.BASE) + } catch (error) { + console.error('Error initializing API:', error) + OpenAPI.BASE = 'http://localhost:1234' // Fallback to default + } } export { OpenAPI } @@ -17,8 +26,7 @@ export function useApiInitialization() { const [isInitialized, setIsInitialized] = useState(false) useEffect(() => { - initializeApi() - setIsInitialized(true) + initializeApi().then(() => setIsInitialized(true)) }, []) return isInitialized diff --git a/webui/webui.go b/webui/webui.go index bd7af78195..ab226e60ae 100644 --- a/webui/webui.go +++ b/webui/webui.go @@ -1,9 +1,9 @@ package webui import ( - "bytes" "context" "embed" + "encoding/json" "fmt" "io" "io/fs" @@ -12,6 +12,7 @@ import ( "os" "path" "strings" + "sync" "time" "github.com/rs/zerolog/log" @@ -20,10 +21,40 @@ import ( //go:embed build/** var buildFiles embed.FS -func ListenAndServe(ctx context.Context, host, apiPort, apiPath string, listenPort int) error { +type Config struct { + APIEndpoint string `json:"APIEndpoint"` + Listen string `json:"Listen"` +} + +type Server struct { + config Config + configLock sync.RWMutex + mux *http.ServeMux +} + +func NewServer(cfg Config) (*Server, error) { + if cfg.Listen == "" { + return nil, fmt.Errorf("listen address cannot be empty") + } + if cfg.APIEndpoint == "" { + return nil, fmt.Errorf("API endpoint cannot be empty") + } + + s := &Server{ + config: cfg, + mux: http.NewServeMux(), + } + + s.mux.HandleFunc("/_config", s.handleConfig) + s.mux.HandleFunc("/", s.handleFiles) + + return s, nil +} + +func (s *Server) ListenAndServe(ctx context.Context) error { server := &http.Server{ - Addr: fmt.Sprintf(":%d", listenPort), - Handler: http.HandlerFunc(serveFiles), + Addr: s.config.Listen, + Handler: s.mux, ReadTimeout: time.Minute, WriteTimeout: time.Minute, ReadHeaderTimeout: time.Minute, @@ -31,11 +62,36 @@ func ListenAndServe(ctx context.Context, host, apiPort, apiPath string, listenPo BaseContext: func(l net.Listener) context.Context { return ctx }, } - log.Info().Int("port", listenPort).Msg("Starting ui server") - return server.ListenAndServe() + log.Info().Str("listen", s.config.Listen).Msg("Starting UI server") + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Error().Err(err).Msg("Server shutdown error") + } + }() + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("server error: %w", err) + } + + return nil } -func serveFiles(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { + s.configLock.RLock() + defer s.configLock.RUnlock() + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(s.config); err != nil { + log.Error().Err(err).Msg("Failed to encode config") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { startTime := time.Now() statusCode := http.StatusOK var message string @@ -80,7 +136,7 @@ func serveFiles(w http.ResponseWriter, r *http.Request) { // If still not found, serve our custom 404 page statusCode = http.StatusNotFound message = "File not found" - serve404(w, r) + s.serve404(w, r) return } fsPath = htmlPath @@ -112,26 +168,26 @@ func serveFiles(w http.ResponseWriter, r *http.Request) { // If we found an index.html in this directory, serve it defer indexFile.Close() message = "Served index.html from directory" - serveFileContent(w, r, indexFile, "index.html") + s.serveFileContent(w, r, indexFile, "index.html") return } // If there's no index.html in this specific directory, // or if we encountered any errors, serve our custom 404 page statusCode = http.StatusNotFound message = "Directory without index.html" - serve404(w, r) + s.serve404(w, r) return } // If we've reached here, we're dealing with a normal file (not a directory) // Serve the file with its correct name message = fmt.Sprintf("Served file: %s", stat.Name()) - serveFileContent(w, r, file, stat.Name()) + s.serveFileContent(w, r, file, stat.Name()) } // serveFileContent reads the entire file into memory and serves it // This approach is used because fs.File doesn't guarantee implementation of io.ReadSeeker -func serveFileContent(w http.ResponseWriter, r *http.Request, file fs.File, name string) { +func (s *Server) serveFileContent(w http.ResponseWriter, r *http.Request, file fs.File, name string) { // Read the entire file content content, err := io.ReadAll(file) if err != nil { @@ -143,12 +199,12 @@ func serveFileContent(w http.ResponseWriter, r *http.Request, file fs.File, name // Create a ReadSeeker from the content and serve it // We use time.Time{} as the modtime, which will make the file always downloadable // You might want to get the actual modtime if caching is important - http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(content)) + http.ServeContent(w, r, name, time.Time{}, strings.NewReader(string(content))) } // serve404 serves the custom 404.html file // This is used as a fallback for non-existent paths or directories without an index.html -func serve404(w http.ResponseWriter, r *http.Request) { +func (s *Server) serve404(w http.ResponseWriter, r *http.Request) { notFoundPath := path.Join("build", "404.html") notFoundFile, err := buildFiles.Open(notFoundPath) if err != nil { @@ -172,8 +228,7 @@ func serve404(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") // Write the content - _, err = w.Write(content) - if err != nil { + if _, err := w.Write(content); err != nil { log.Error().Err(err).Msg("Failed to write 404 page content") } }