Skip to content

Commit

Permalink
improve routing of webui requests (#4645)
Browse files Browse the repository at this point in the history
Have the webui server act as a reverse proxy and route requests to the
orchestrator instead of the frontend directly calling the orchestrator.
This will simplify webui setup instead of having to define the
`WebUI.Backend` to the orchestrator's public ip with bacalhau serve

The configuration `WebUI.Backend` is still available and make accepting
endpoints more graceful

### Testing done:
Mostly manual testing, but verified the following:
1. Routing of basic http requests
2. Routing of websockets, such as log stream
3. Disconnecting and reconnecting the backend
4. Setting `WebUI.Backend` to the demo network. Doesn't make sense
today, but in case in the future we want to spin up the WebUI server in
a separate process from the orchestrator
  • Loading branch information
wdbaruni authored Oct 24, 2024
1 parent 72c7c48 commit e4623b9
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 84 deletions.
15 changes: 3 additions & 12 deletions webui/app/providers/ApiProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,21 @@
'use client'

import {
ReactNode,
createContext,
useContext,
useState,
useEffect,
} from 'react'
import { useApiInitialization, useApiUrl } from '@/lib/api'
import { ReactNode, createContext, useContext } from 'react'
import { useApiInitialization } from '@/lib/api'

interface ApiContextType {
isInitialized: boolean
apiUrl: string | null
}

const ApiContext = createContext<ApiContextType>({
isInitialized: false,
apiUrl: null,
})

export function ApiProvider({ children }: { children: ReactNode }) {
const isInitialized = useApiInitialization()
const apiUrl = useApiUrl()

return (
<ApiContext.Provider value={{ isInitialized, apiUrl }}>
<ApiContext.Provider value={{ isInitialized }}>
{children}
</ApiContext.Provider>
)
Expand Down
8 changes: 4 additions & 4 deletions webui/components/ConnectionStatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '@/components/ui/tooltip'

export function ConnectionStatusIndicator() {
const { isOnline, clientUrl } = useConnectionMonitor()
const { isOnline } = useConnectionMonitor()
const { toast } = useToast()
const [prevOnlineState, setPrevOnlineState] = useState<boolean | undefined>(
undefined
Expand All @@ -26,7 +26,7 @@ export function ConnectionStatusIndicator() {
toast({
variant: 'destructive',
title: 'Connection Lost',
description: `You are currently offline. Please check your connection to ${clientUrl}.`,
description: `You are currently offline. Please check your connection and that Bacalhau is still running.`,
duration: Infinity,
})
} else if (isOnline && prevOnlineState === false) {
Expand All @@ -41,7 +41,7 @@ export function ConnectionStatusIndicator() {
}

setPrevOnlineState(isOnline)
}, [isOnline, prevOnlineState, toast, clientUrl])
}, [isOnline, prevOnlineState, toast])

const getIconColor = () => {
if (isOnline === undefined) return 'text-gray-500'
Expand All @@ -51,7 +51,7 @@ export function ConnectionStatusIndicator() {
const tooltipContent =
isOnline === undefined
? 'Checking connection...'
: `${isOnline ? 'Connected to' : 'Failed to connect to'} ${clientUrl}`
: `${isOnline ? 'Connected successfully' : 'Failed to connect'}`

return (
<>
Expand Down
12 changes: 1 addition & 11 deletions webui/components/jobs/details/JobLogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
CheckCircle,
} from 'lucide-react'
import { useApi } from '@/app/providers/ApiProvider'
import { client } from '@/lib/api/generated'

interface LogEntry {
type: number
Expand Down Expand Up @@ -43,15 +42,7 @@ const JobLogs = ({ jobId }: { jobId: string | undefined }) => {
setError(null)
setIsStreamEnded(false)

const baseUrl = client.getConfig().baseUrl
if (!baseUrl) {
console.error('Base URL is not set')
setError(
'Failed to connect to log stream. Client not configured properly.'
)
return
}
const wsUrl = `${baseUrl.replace(/^http/, 'ws')}/api/v1/orchestrator/jobs/${jobId}/logs?follow=true`
const wsUrl = `/api/v1/orchestrator/jobs/${jobId}/logs?follow=true`
console.log('Attempting to connect to:', wsUrl)

const ws = new WebSocket(wsUrl)
Expand All @@ -62,7 +53,6 @@ const JobLogs = ({ jobId }: { jobId: string | undefined }) => {
}

ws.onmessage = (event) => {
console.log('Received message:', event.data)
try {
const message = JSON.parse(event.data)
if (message.value && message.value.Line) {
Expand Down
4 changes: 2 additions & 2 deletions webui/hooks/useConnectionMonitor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useApi } from '@/app/providers/ApiProvider'
import { Ops } from '@/lib/api/generated'

export const useConnectionMonitor = (checkInterval = 5000) => {
const { isInitialized, apiUrl } = useApi()
const { isInitialized } = useApi()
const [isOnline, setIsOnline] = useState<boolean | undefined>(undefined)
const [error, setError] = useState<string | null>(null)

Expand Down Expand Up @@ -35,5 +35,5 @@ export const useConnectionMonitor = (checkInterval = 5000) => {
return () => clearInterval(intervalId)
}, [checkConnection, checkInterval])

return { isOnline, checkConnection, clientUrl: apiUrl, error }
return { isOnline, checkConnection, error }
}
37 changes: 4 additions & 33 deletions webui/lib/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,18 @@
import { client } from './generated'
import { useState, useEffect } from 'react'

interface Config {
APIEndpoint: string
}

const DEFAULT_API_URL = 'http://localhost:1234'

async function fetchConfig(): Promise<Config | null> {
try {
const response = await fetch('/_config')
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.warn('Config fetch failed, assuming standalone mode:', error)
return null
}
}

let apiUrl: string | null = null

export async function initializeApi(): Promise<string> {
const config = await fetchConfig()
apiUrl = config?.APIEndpoint || DEFAULT_API_URL

client.setConfig({ baseUrl: apiUrl })

console.log('API initialized with URL:', apiUrl)
return apiUrl
export function initializeApi(): void {
client.setConfig({ baseUrl: "" })
}

export function useApiInitialization() {
const [isInitialized, setIsInitialized] = useState(false)

useEffect(() => {
initializeApi().then(() => setIsInitialized(true))
initializeApi()
setIsInitialized(true)
}, [])

return isInitialized
}

export function useApiUrl() {
return apiUrl
}
99 changes: 77 additions & 22 deletions webui/webui.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import (
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path"
"strings"
"sync"
"time"

"github.com/rs/zerolog/log"
Expand All @@ -27,33 +29,38 @@ type Config struct {
}

type Server struct {
config Config
configLock sync.RWMutex
mux *http.ServeMux
apiURL *url.URL
listenAddress string
mux *http.ServeMux
apiProxy *httputil.ReverseProxy
}

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")
// Parse and validate API endpoint
apiURL, err := normalizeAPIEndpoint(cfg.APIEndpoint)
if err != nil {
return nil, fmt.Errorf("invalid API endpoint: %w", err)
}

s := &Server{
config: cfg,
mux: http.NewServeMux(),
apiURL: apiURL,
listenAddress: cfg.Listen,
mux: http.NewServeMux(),
apiProxy: newReverseProxy(apiURL),
}

s.mux.HandleFunc("/_config", s.handleConfig)
s.mux.HandleFunc("/api/", s.handleAPIProxy)
s.mux.HandleFunc("/", s.handleFiles)

return s, nil
}

func (s *Server) ListenAndServe(ctx context.Context) error {
server := &http.Server{
Addr: s.config.Listen,
Addr: s.listenAddress,
Handler: s.mux,
ReadTimeout: time.Minute,
WriteTimeout: time.Minute,
Expand All @@ -62,8 +69,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
BaseContext: func(l net.Listener) context.Context { return ctx },
}

log.Info().Str("listen", s.config.Listen).Msg("Starting UI server")

// Graceful shutdown
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Expand All @@ -73,22 +79,21 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
}
}()

if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Info().Str("listen", server.Addr).Str("backend", sanitizeURL(s.apiURL)).Msg("Starting server")
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("server error: %w", err)
}

return nil
}

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) handleAPIProxy(w http.ResponseWriter, r *http.Request) {
log.Trace().
Str("path", r.URL.Path).
Str("method", r.Method).
Bool("is_websocket", r.Header.Get("Upgrade") == "websocket").
Msg("Proxying request")
s.apiProxy.ServeHTTP(w, r)
}

func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -232,3 +237,53 @@ func (s *Server) serve404(w http.ResponseWriter, r *http.Request) {
log.Error().Err(err).Msg("Failed to write 404 page content")
}
}

// normalizeAPIEndpoint validates and normalizes the API endpoint URL
func normalizeAPIEndpoint(endpoint string) (*url.URL, error) {
if !strings.HasPrefix(strings.ToLower(endpoint), "http") {
endpoint = "http://" + endpoint
}

u, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}

if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("unsupported scheme %q", u.Scheme)
}

if u.Host == "" {
return nil, fmt.Errorf("missing host")
}

u.Path = strings.TrimSuffix(u.Path, "/")
return u, nil
}

func newReverseProxy(target *url.URL) *httputil.ReverseProxy {
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
response := map[string]string{
"error": "Backend service unavailable",
"endpoint": sanitizeURL(target),
"method": r.Method,
"path": r.URL.Path,
}

log.Error().Err(err).Any("response", response).Msg("Proxy error")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
json.NewEncoder(w).Encode(response)
}
return proxy
}

// sanitizeURL removes sensitive information from URL for error messages
func sanitizeURL(u *url.URL) string {
sanitized := *u
sanitized.User = nil
sanitized.RawQuery = ""
sanitized.Fragment = ""
return sanitized.String()
}

0 comments on commit e4623b9

Please sign in to comment.