diff --git a/.gitignore b/.gitignore index daa16c2d..b4624993 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,10 @@ charts/*/charts/ *.log logs/ +# Port forward artifacts +.port-forward-logs/ +.port-forward-pids/ + # Temporary files tmp/ temp/ diff --git a/api/cmd/main.go b/api/cmd/main.go index d4375be6..3f5e94d0 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -12,6 +12,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" "github.com/streamspace/streamspace/api/internal/activity" "github.com/streamspace/streamspace/api/internal/api" "github.com/streamspace/streamspace/api/internal/auth" @@ -23,7 +24,7 @@ import ( "github.com/streamspace/streamspace/api/internal/quota" "github.com/streamspace/streamspace/api/internal/sync" "github.com/streamspace/streamspace/api/internal/tracker" - "github.com/streamspace/streamspace/api/internal/websocket" + internalWebsocket "github.com/streamspace/streamspace/api/internal/websocket" ) func main() { @@ -118,7 +119,7 @@ func main() { // Initialize WebSocket manager log.Println("Initializing WebSocket manager...") - wsManager := websocket.NewManager(database, k8sClient) + wsManager := internalWebsocket.NewManager(database, k8sClient) wsManager.Start() // Initialize activity tracker @@ -253,7 +254,7 @@ func main() { batchHandler := handlers.NewBatchHandler(database) monitoringHandler := handlers.NewMonitoringHandler(database) quotasHandler := handlers.NewQuotasHandler(database) - websocketHandler := handlers.NewWebSocketHandler(database) + // NOTE: WebSocket routes now use wsManager directly (see ws.GET routes below) consoleHandler := handlers.NewConsoleHandler(database) collaborationHandler := handlers.NewCollaborationHandler(database) integrationsHandler := handlers.NewIntegrationsHandler(database) @@ -272,7 +273,7 @@ func main() { } // Setup routes - setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, websocketHandler, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, setupHandler, jwtManager, userDB, redisCache, webhookSecret) + setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, wsManager, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, setupHandler, jwtManager, userDB, redisCache, webhookSecret) // Create HTTP server with security timeouts srv := &http.Server{ @@ -353,7 +354,7 @@ func main() { log.Println("Graceful shutdown completed") } -func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, websocketHandler *handlers.WebSocketHandler, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, setupHandler *handlers.SetupHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, redisCache *cache.Cache, webhookSecret string) { +func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, wsManager *internalWebsocket.Manager, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, setupHandler *handlers.SetupHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, redisCache *cache.Cache, webhookSecret string) { // SECURITY: Create authentication middleware authMiddleware := auth.Middleware(jwtManager, userDB) adminMiddleware := auth.RequireRole("admin") @@ -365,6 +366,16 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH webhookAuth = middleware.NewWebhookAuth(webhookSecret) } + // WebSocket upgrader for real-time connections + var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + // Allow all origins for development (should be restricted in production) + return true + }, + } + // Health check (public - no auth required) router.GET("/health", h.Health) router.GET("/version", h.Version) @@ -784,16 +795,49 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH ws := router.Group("/api/v1/ws") ws.Use(authMiddleware) { - // NOTE: /ws/sessions is now handled by websocketHandler.RegisterRoutes() below - ws.GET("/cluster", operatorMiddleware, h.ClusterWebSocket) + // Session updates WebSocket - connects to wsManager for real-time session broadcasts + ws.GET("/sessions", func(c *gin.Context) { + // Get user ID from auth middleware + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + userIDStr, ok := userID.(string) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user ID"}) + return + } + + // Upgrade HTTP connection to WebSocket + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("Failed to upgrade WebSocket connection: %v", err) + return + } + + // Delegate to wsManager which broadcasts sessions every 3 seconds + wsManager.HandleSessionsWebSocket(conn, userIDStr, "") + }) + + // Metrics WebSocket - connects to wsManager for real-time metrics broadcasts + ws.GET("/cluster", operatorMiddleware, func(c *gin.Context) { + // Upgrade HTTP connection to WebSocket + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("Failed to upgrade WebSocket connection: %v", err) + return + } + + // Delegate to wsManager which broadcasts metrics every 5 seconds + wsManager.HandleMetricsWebSocket(conn) + }) + ws.GET("/logs/:namespace/:pod", operatorMiddleware, h.LogsWebSocket) ws.GET("/enterprise", handlers.HandleEnterpriseWebSocket) // Real-time enterprise features } - // Real-time updates via WebSocket - using dedicated handler (all authenticated users) - // Registers: /ws/sessions, /ws/notifications, /ws/metrics, /ws/alerts - websocketHandler.RegisterRoutes(router.Group("/api/v1", authMiddleware)) - // Webhook endpoints (HMAC signature validation required) webhooks := router.Group("/webhooks") { diff --git a/scripts/local-deploy-kubectl.sh b/scripts/local-deploy-kubectl.sh index 1e55817b..5a0b9c3d 100755 --- a/scripts/local-deploy-kubectl.sh +++ b/scripts/local-deploy-kubectl.sh @@ -542,6 +542,24 @@ show_status() { echo "" } +# Start port forwards +start_port_forwards() { + if [ "${AUTO_PORT_FORWARD:-true}" = "true" ]; then + echo "" + log "Starting port forwards automatically..." + + if [ -f "${PROJECT_ROOT}/scripts/local-port-forward.sh" ]; then + "${PROJECT_ROOT}/scripts/local-port-forward.sh" + return 0 + else + log_warning "Port forward script not found, skipping" + show_access_info + fi + else + show_access_info + fi +} + # Show access instructions show_access_info() { echo "" @@ -550,14 +568,13 @@ show_access_info() { echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" echo "" - log_info "Port-forward UI (in a separate terminal):" - echo " kubectl port-forward -n ${NAMESPACE} svc/streamspace-ui 3000:80" - echo " Then access: http://localhost:3000" + log_info "Start automatic port forwards:" + echo " ./scripts/local-port-forward.sh" echo "" - log_info "Port-forward API (in a separate terminal):" + log_info "Or manually port-forward (in separate terminals):" + echo " kubectl port-forward -n ${NAMESPACE} svc/streamspace-ui 3000:80" echo " kubectl port-forward -n ${NAMESPACE} svc/streamspace-api 8000:8000" - echo " Then access: http://localhost:8000" echo "" log_info "View logs:" @@ -568,7 +585,8 @@ show_access_info() { echo "" log_info "When finished testing:" - echo " kubectl delete namespace ${NAMESPACE}" + echo " ./scripts/local-stop-port-forward.sh # Stop port forwards" + echo " kubectl delete namespace ${NAMESPACE} # Delete everything" echo "" } @@ -594,7 +612,7 @@ main() { deploy_ui wait_for_pods show_status - show_access_info + start_port_forwards echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" log_success "Deployment complete!" diff --git a/scripts/local-deploy.sh b/scripts/local-deploy.sh index 417a0722..0419bf1b 100755 --- a/scripts/local-deploy.sh +++ b/scripts/local-deploy.sh @@ -269,6 +269,24 @@ show_status() { helm status "${RELEASE_NAME}" -n "${NAMESPACE}" } +# Start port forwards +start_port_forwards() { + if [ "${AUTO_PORT_FORWARD:-true}" = "true" ]; then + echo "" + log "Starting port forwards automatically..." + + if [ -f "${PROJECT_ROOT}/scripts/local-port-forward.sh" ]; then + "${PROJECT_ROOT}/scripts/local-port-forward.sh" + return 0 + else + log_warning "Port forward script not found, skipping" + show_access_info + fi + else + show_access_info + fi +} + # Show access instructions show_access_info() { echo "" @@ -277,14 +295,13 @@ show_access_info() { echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" echo "" - log_info "Port-forward UI (in a separate terminal):" - echo " kubectl port-forward -n ${NAMESPACE} svc/${RELEASE_NAME}-ui 3000:80" - echo " Then access: http://localhost:3000" + log_info "Start automatic port forwards:" + echo " ./scripts/local-port-forward.sh" echo "" - log_info "Port-forward API (in a separate terminal):" + log_info "Or manually port-forward (in separate terminals):" + echo " kubectl port-forward -n ${NAMESPACE} svc/${RELEASE_NAME}-ui 3000:80" echo " kubectl port-forward -n ${NAMESPACE} svc/${RELEASE_NAME}-api 8000:8000" - echo " Then access: http://localhost:8000" echo "" log_info "View logs:" @@ -294,7 +311,8 @@ show_access_info() { echo "" log_info "When finished testing:" - echo " ./scripts/local-teardown.sh" + echo " ./scripts/local-stop-port-forward.sh # Stop port forwards" + echo " ./scripts/local-teardown.sh # Full teardown" echo "" } @@ -316,7 +334,7 @@ main() { deploy_helm wait_for_pods show_status - show_access_info + start_port_forwards echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" log_success "Deployment complete!" diff --git a/scripts/local-port-forward.sh b/scripts/local-port-forward.sh new file mode 100755 index 00000000..10642cfc --- /dev/null +++ b/scripts/local-port-forward.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +# +# local-port-forward.sh - Start port forwards for StreamSpace services +# +# This script automatically creates port forwards for all StreamSpace services +# in the background, making them accessible on localhost. +# +# Services: +# - UI: http://localhost:3000 -> streamspace-ui:80 +# - API: http://localhost:8000 -> streamspace-api:8000 +# +# Port forwards run in the background with output redirected to log files. +# Use local-stop-port-forward.sh to stop all port forwards. +# + +set -euo pipefail + +# Colors for output +COLOR_RESET='\033[0m' +COLOR_BOLD='\033[1m' +COLOR_GREEN='\033[32m' +COLOR_YELLOW='\033[33m' +COLOR_BLUE='\033[34m' +COLOR_RED='\033[31m' + +# Project configuration +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +NAMESPACE="${NAMESPACE:-streamspace}" +LOG_DIR="${PROJECT_ROOT}/.port-forward-logs" +PID_DIR="${PROJECT_ROOT}/.port-forward-pids" + +# Port mappings +UI_LOCAL_PORT=3000 +UI_REMOTE_PORT=80 +API_LOCAL_PORT=8000 +API_REMOTE_PORT=8000 + +# Helper functions +log() { + echo -e "${COLOR_BOLD}==>${COLOR_RESET} $*" +} + +log_success() { + echo -e "${COLOR_GREEN}✓${COLOR_RESET} $*" +} + +log_error() { + echo -e "${COLOR_RED}✗${COLOR_RESET} $*" >&2 +} + +log_info() { + echo -e "${COLOR_BLUE}→${COLOR_RESET} $*" +} + +log_warning() { + echo -e "${COLOR_YELLOW}⚠${COLOR_RESET} $*" +} + +# Check prerequisites +check_prerequisites() { + if ! command -v kubectl &> /dev/null; then + log_error "kubectl is not installed or not in PATH" + exit 1 + fi + + if ! kubectl cluster-info &> /dev/null; then + log_error "Cannot connect to Kubernetes cluster" + exit 1 + fi + + if ! kubectl get namespace "${NAMESPACE}" &> /dev/null; then + log_error "Namespace ${NAMESPACE} does not exist" + exit 1 + fi +} + +# Create directories +create_directories() { + mkdir -p "${LOG_DIR}" + mkdir -p "${PID_DIR}" +} + +# Check if port is already in use +check_port() { + local port=$1 + if lsof -Pi ":${port}" -sTCP:LISTEN -t &> /dev/null; then + return 0 # Port is in use + else + return 1 # Port is free + fi +} + +# Start port forward +start_port_forward() { + local service=$1 + local local_port=$2 + local remote_port=$3 + local name=$4 + + log_info "Starting port forward: ${name}" + + # Check if service exists + if ! kubectl get svc "${service}" -n "${NAMESPACE}" &> /dev/null; then + log_error "Service ${service} not found in namespace ${NAMESPACE}" + return 1 + fi + + # Check if port is already in use + if check_port "${local_port}"; then + log_warning "Port ${local_port} already in use, skipping ${name}" + log_info "To free the port: ./scripts/local-stop-port-forward.sh" + return 1 + fi + + # Wait for service to have endpoints + log_info "Waiting for ${service} to be ready..." + local timeout=60 + local elapsed=0 + while [ $elapsed -lt $timeout ]; do + if kubectl get endpoints "${service}" -n "${NAMESPACE}" -o jsonpath='{.subsets[*].addresses[*].ip}' 2>/dev/null | grep -q .; then + break + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + + if [ $elapsed -ge $timeout ]; then + log_error "${service} has no ready endpoints after ${timeout}s" + return 1 + fi + + # Start port forward in background + local log_file="${LOG_DIR}/${name}.log" + local pid_file="${PID_DIR}/${name}.pid" + + kubectl port-forward -n "${NAMESPACE}" "svc/${service}" "${local_port}:${remote_port}" \ + > "${log_file}" 2>&1 & + + local pid=$! + echo "${pid}" > "${pid_file}" + + # Wait a moment and check if it's still running + sleep 2 + if kill -0 "${pid}" 2>/dev/null; then + log_success "${name} forwarded: localhost:${local_port} -> ${service}:${remote_port} (PID: ${pid})" + return 0 + else + log_error "Port forward failed to start for ${name}" + rm -f "${pid_file}" + return 1 + fi +} + +# Check running port forwards +check_running() { + log "Checking running port forwards..." + echo "" + + local running=0 + + for pid_file in "${PID_DIR}"/*.pid; do + if [ -f "${pid_file}" ]; then + local pid=$(cat "${pid_file}") + local name=$(basename "${pid_file}" .pid) + + if kill -0 "${pid}" 2>/dev/null; then + log_success "${name} (PID: ${pid})" + running=$((running + 1)) + else + log_warning "${name} (PID: ${pid}) - NOT RUNNING" + rm -f "${pid_file}" + fi + fi + done + + if [ $running -eq 0 ]; then + log_warning "No port forwards currently running" + fi + echo "" +} + +# Show access URLs +show_access_urls() { + echo "" + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo -e "${COLOR_BOLD} Access StreamSpace${COLOR_RESET}" + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo "" + + log_info "Web UI:" + echo " ${COLOR_GREEN}http://localhost:${UI_LOCAL_PORT}${COLOR_RESET}" + echo "" + + log_info "API Backend:" + echo " ${COLOR_GREEN}http://localhost:${API_LOCAL_PORT}${COLOR_RESET}" + echo " Health: ${COLOR_BLUE}http://localhost:${API_LOCAL_PORT}/health${COLOR_RESET}" + echo "" + + log_info "Logs:" + echo " UI: tail -f ${LOG_DIR}/ui.log" + echo " API: tail -f ${LOG_DIR}/api.log" + echo "" + + log_info "To stop port forwards:" + echo " ./scripts/local-stop-port-forward.sh" + echo "" +} + +# Cleanup on exit +cleanup() { + echo "" + log_warning "Received interrupt signal" + log_info "Port forwards will continue running in background" + log_info "Use ./scripts/local-stop-port-forward.sh to stop them" + exit 0 +} + +# Main execution +main() { + # Handle Ctrl+C gracefully + trap cleanup SIGINT SIGTERM + + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo -e "${COLOR_BOLD} Start Port Forwards${COLOR_RESET}" + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo "" + echo -e "${COLOR_BLUE}Namespace:${COLOR_RESET} ${NAMESPACE}" + echo "" + + check_prerequisites + create_directories + + # Check for existing port forwards + if [ -d "${PID_DIR}" ] && [ -n "$(ls -A "${PID_DIR}" 2>/dev/null)" ]; then + check_running + fi + + # Start port forwards + log "Starting port forwards..." + echo "" + + local success=0 + + if start_port_forward "streamspace-ui" "${UI_LOCAL_PORT}" "${UI_REMOTE_PORT}" "ui"; then + success=$((success + 1)) + fi + + if start_port_forward "streamspace-api" "${API_LOCAL_PORT}" "${API_REMOTE_PORT}" "api"; then + success=$((success + 1)) + fi + + echo "" + if [ $success -gt 0 ]; then + show_access_urls + + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + log_success "Started ${success} port forward(s)" + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo "" + + log_info "Port forwards are running in background" + log_info "They will persist until you stop them or restart your terminal" + else + log_error "Failed to start any port forwards" + exit 1 + fi +} + +# Run main function +main "$@" diff --git a/scripts/local-stop-apps.sh b/scripts/local-stop-apps.sh new file mode 100755 index 00000000..bfe0567b --- /dev/null +++ b/scripts/local-stop-apps.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# +# local-stop-apps.sh - Stop StreamSpace application containers (preserves database) +# +# This script safely stops the StreamSpace application components by scaling +# their deployments to 0 replicas. The database and all data are preserved. +# +# Use this when you want to: +# 1. Pull latest code changes +# 2. Rebuild Docker images +# 3. Redeploy with updated images +# +# Workflow: +# 1. ./scripts/local-stop-apps.sh # Stop apps (this script) +# 2. git pull # Get latest code +# 3. ./scripts/local-build.sh # Rebuild images +# 4. ./scripts/local-deploy-kubectl.sh # Deploy updated apps +# +# The database remains running and all data is preserved throughout. +# + +set -euo pipefail + +# Colors for output +COLOR_RESET='\033[0m' +COLOR_BOLD='\033[1m' +COLOR_GREEN='\033[32m' +COLOR_YELLOW='\033[33m' +COLOR_BLUE='\033[34m' +COLOR_RED='\033[31m' + +# Project configuration +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +NAMESPACE="${NAMESPACE:-streamspace}" + +# Helper functions +log() { + echo -e "${COLOR_BOLD}==>${COLOR_RESET} $*" +} + +log_success() { + echo -e "${COLOR_GREEN}✓${COLOR_RESET} $*" +} + +log_error() { + echo -e "${COLOR_RED}✗${COLOR_RESET} $*" >&2 +} + +log_info() { + echo -e "${COLOR_BLUE}→${COLOR_RESET} $*" +} + +log_warning() { + echo -e "${COLOR_YELLOW}⚠${COLOR_RESET} $*" +} + +# Check prerequisites +check_prerequisites() { + log "Checking prerequisites..." + + if ! command -v kubectl &> /dev/null; then + log_error "kubectl is not installed or not in PATH" + exit 1 + fi + + if ! kubectl cluster-info &> /dev/null; then + log_error "Cannot connect to Kubernetes cluster" + log_info "Make sure your kubeconfig is properly configured" + exit 1 + fi + + if ! kubectl get namespace "${NAMESPACE}" &> /dev/null; then + log_error "Namespace ${NAMESPACE} does not exist" + log_info "Nothing to stop - namespace not found" + exit 1 + fi + + local context=$(kubectl config current-context 2>/dev/null || echo "unknown") + log_success "Connected to cluster: ${context}" +} + +# Show current status +show_status_before() { + log "Current deployment status:" + echo "" + + log_info "Application Deployments:" + kubectl get deployments -n "${NAMESPACE}" \ + -l 'app.kubernetes.io/name=streamspace,app.kubernetes.io/component in (controller,api,ui)' \ + -o wide 2>/dev/null || log_warning "No application deployments found" + echo "" + + log_info "Database StatefulSet (will NOT be stopped):" + kubectl get statefulsets -n "${NAMESPACE}" \ + -l 'app.kubernetes.io/component=database' \ + -o wide 2>/dev/null || log_warning "No database found" + echo "" + + log_info "Running Pods:" + kubectl get pods -n "${NAMESPACE}" -o wide 2>/dev/null || log_warning "No pods found" + echo "" +} + +# Stop application deployments +stop_applications() { + log "Stopping application containers..." + echo "" + + local deployments=( + "streamspace-controller" + "streamspace-api" + "streamspace-ui" + ) + + local stopped=0 + for deployment in "${deployments[@]}"; do + if kubectl get deployment "${deployment}" -n "${NAMESPACE}" &> /dev/null; then + log_info "Scaling ${deployment} to 0 replicas..." + kubectl scale deployment "${deployment}" -n "${NAMESPACE}" --replicas=0 + stopped=$((stopped + 1)) + log_success "${deployment} stopped" + else + log_warning "${deployment} not found" + fi + done + + echo "" + if [ $stopped -gt 0 ]; then + log_success "Stopped ${stopped} application deployment(s)" + else + log_warning "No application deployments found to stop" + fi +} + +# Wait for pods to terminate +wait_for_termination() { + log "Waiting for application pods to terminate..." + + local timeout=60 # 1 minute + local elapsed=0 + local interval=2 + + while [ $elapsed -lt $timeout ]; do + local app_pods=$(kubectl get pods -n "${NAMESPACE}" \ + -l 'app.kubernetes.io/component in (controller,api,ui)' \ + --field-selector=status.phase!=Succeeded,status.phase!=Failed \ + --no-headers 2>/dev/null | wc -l || echo "0") + + if [ "$app_pods" -eq 0 ]; then + log_success "All application pods terminated" + return 0 + fi + + log_info "Waiting... (${app_pods} pod(s) still terminating)" + sleep $interval + elapsed=$((elapsed + interval)) + done + + log_warning "Some pods are still terminating (timeout reached)" + log_info "They will continue terminating in the background" +} + +# Show final status +show_status_after() { + echo "" + log "Final status:" + echo "" + + log_info "Application Deployments (should show 0/0 ready):" + kubectl get deployments -n "${NAMESPACE}" \ + -l 'app.kubernetes.io/name=streamspace,app.kubernetes.io/component in (controller,api,ui)' \ + 2>/dev/null || log_warning "No application deployments found" + echo "" + + log_info "Database StatefulSet (should still be running):" + kubectl get statefulsets -n "${NAMESPACE}" \ + -l 'app.kubernetes.io/component=database' \ + 2>/dev/null || log_warning "No database found" + echo "" + + log_info "Remaining Pods (should only be database):" + kubectl get pods -n "${NAMESPACE}" 2>/dev/null || log_warning "No pods found" + echo "" + + log_info "Persistent Volume Claims (all preserved):" + kubectl get pvc -n "${NAMESPACE}" 2>/dev/null || log_warning "No PVCs found" + echo "" +} + +# Show next steps +show_next_steps() { + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo -e "${COLOR_BOLD} Next Steps${COLOR_RESET}" + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo "" + + log_info "To update and restart StreamSpace:" + echo "" + echo " 1. Pull latest code:" + echo " ${COLOR_BLUE}git pull${COLOR_RESET}" + echo "" + echo " 2. Rebuild Docker images:" + echo " ${COLOR_BLUE}./scripts/local-build.sh${COLOR_RESET}" + echo "" + echo " 3. Deploy updated applications:" + echo " ${COLOR_BLUE}./scripts/local-deploy-kubectl.sh${COLOR_RESET}" + echo "" + echo " ${COLOR_YELLOW}NOTE:${COLOR_RESET} The deploy script will detect existing resources" + echo " and update them with the new images." + echo "" + + log_info "To manually restart without rebuilding:" + echo " ${COLOR_BLUE}kubectl scale deployment streamspace-controller -n ${NAMESPACE} --replicas=1${COLOR_RESET}" + echo " ${COLOR_BLUE}kubectl scale deployment streamspace-api -n ${NAMESPACE} --replicas=1${COLOR_RESET}" + echo " ${COLOR_BLUE}kubectl scale deployment streamspace-ui -n ${NAMESPACE} --replicas=1${COLOR_RESET}" + echo "" + + log_info "Database status:" + echo " The PostgreSQL database is still running and all data is preserved." + echo "" +} + +# Main execution +main() { + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo -e "${COLOR_BOLD} Stop StreamSpace Applications${COLOR_RESET}" + echo -e "${COLOR_BOLD} (Database will NOT be stopped)${COLOR_RESET}" + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo "" + echo -e "${COLOR_BLUE}Namespace:${COLOR_RESET} ${NAMESPACE}" + echo "" + + check_prerequisites + show_status_before + stop_applications + wait_for_termination + show_status_after + show_next_steps + + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + log_success "Applications stopped successfully!" + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" +} + +# Run main function +main "$@" diff --git a/scripts/local-stop-port-forward.sh b/scripts/local-stop-port-forward.sh new file mode 100755 index 00000000..cbc517c5 --- /dev/null +++ b/scripts/local-stop-port-forward.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# +# local-stop-port-forward.sh - Stop all StreamSpace port forwards +# +# This script stops all running port forwards created by local-port-forward.sh +# by killing the background kubectl port-forward processes. +# + +set -euo pipefail + +# Colors for output +COLOR_RESET='\033[0m' +COLOR_BOLD='\033[1m' +COLOR_GREEN='\033[32m' +COLOR_YELLOW='\033[33m' +COLOR_BLUE='\033[34m' +COLOR_RED='\033[31m' + +# Project configuration +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PID_DIR="${PROJECT_ROOT}/.port-forward-pids" +LOG_DIR="${PROJECT_ROOT}/.port-forward-logs" + +# Helper functions +log() { + echo -e "${COLOR_BOLD}==>${COLOR_RESET} $*" +} + +log_success() { + echo -e "${COLOR_GREEN}✓${COLOR_RESET} $*" +} + +log_error() { + echo -e "${COLOR_RED}✗${COLOR_RESET} $*" >&2 +} + +log_info() { + echo -e "${COLOR_BLUE}→${COLOR_RESET} $*" +} + +log_warning() { + echo -e "${COLOR_YELLOW}⚠${COLOR_RESET} $*" +} + +# Stop all port forwards +stop_port_forwards() { + log "Stopping port forwards..." + echo "" + + if [ ! -d "${PID_DIR}" ]; then + log_warning "No PID directory found (${PID_DIR})" + log_info "No port forwards to stop" + return 0 + fi + + local stopped=0 + local not_running=0 + + for pid_file in "${PID_DIR}"/*.pid; do + if [ -f "${pid_file}" ]; then + local pid=$(cat "${pid_file}") + local name=$(basename "${pid_file}" .pid) + + if kill -0 "${pid}" 2>/dev/null; then + log_info "Stopping ${name} (PID: ${pid})..." + kill "${pid}" 2>/dev/null || true + + # Wait for process to die + local timeout=5 + local elapsed=0 + while kill -0 "${pid}" 2>/dev/null && [ $elapsed -lt $timeout ]; do + sleep 1 + elapsed=$((elapsed + 1)) + done + + if kill -0 "${pid}" 2>/dev/null; then + log_warning "${name} did not stop gracefully, forcing..." + kill -9 "${pid}" 2>/dev/null || true + fi + + log_success "${name} stopped" + stopped=$((stopped + 1)) + else + log_info "${name} was not running" + not_running=$((not_running + 1)) + fi + + rm -f "${pid_file}" + fi + done + + echo "" + if [ $stopped -gt 0 ]; then + log_success "Stopped ${stopped} port forward(s)" + fi + + if [ $not_running -gt 0 ]; then + log_info "Cleaned up ${not_running} stale PID file(s)" + fi + + if [ $stopped -eq 0 ] && [ $not_running -eq 0 ]; then + log_warning "No port forwards were running" + fi +} + +# Clean up log directory +cleanup_logs() { + if [ -d "${LOG_DIR}" ]; then + log "Cleaning up log files..." + rm -rf "${LOG_DIR}" + log_success "Log files cleaned" + fi +} + +# Main execution +main() { + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo -e "${COLOR_BOLD} Stop Port Forwards${COLOR_RESET}" + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo "" + + stop_port_forwards + cleanup_logs + + echo "" + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + log_success "All port forwards stopped" + echo -e "${COLOR_BOLD}═══════════════════════════════════════════════════${COLOR_RESET}" + echo "" + + log_info "To restart port forwards:" + echo " ./scripts/local-port-forward.sh" + echo "" +} + +# Run main function +main "$@" diff --git a/ui/src/components/EnhancedWebSocketStatus.tsx b/ui/src/components/EnhancedWebSocketStatus.tsx index ea557e65..84d1243a 100644 --- a/ui/src/components/EnhancedWebSocketStatus.tsx +++ b/ui/src/components/EnhancedWebSocketStatus.tsx @@ -9,7 +9,7 @@ * * @component */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, memo } from 'react'; import { Box, Chip, @@ -39,7 +39,7 @@ interface EnhancedWebSocketStatusProps { showDetails?: boolean; } -export default function EnhancedWebSocketStatus({ +function EnhancedWebSocketStatus({ isConnected, reconnectAttempts, maxReconnectAttempts = 10, @@ -250,3 +250,6 @@ export default function EnhancedWebSocketStatus({ ); } + +// Export memoized version to prevent re-renders when props haven't changed +export default memo(EnhancedWebSocketStatus); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index d2a05b13..855d7c98 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { useState, ReactNode } from 'react'; +import { useState, ReactNode, memo } from 'react'; import { Box, Drawer, @@ -74,7 +74,7 @@ interface LayoutProps { * @see useUserStore for user authentication state * @see useNavigate for route navigation */ -export default function Layout({ children }: LayoutProps) { +function Layout({ children }: LayoutProps) { const [mobileOpen, setMobileOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); const navigate = useNavigate(); @@ -273,3 +273,7 @@ export default function Layout({ children }: LayoutProps) { ); } + +// Export memoized version to prevent unnecessary re-renders +// Only re-render when location changes, not when parent re-renders +export default memo(Layout); diff --git a/ui/src/components/NotificationQueue.tsx b/ui/src/components/NotificationQueue.tsx index 3eb2727c..1f10b58d 100644 --- a/ui/src/components/NotificationQueue.tsx +++ b/ui/src/components/NotificationQueue.tsx @@ -11,7 +11,7 @@ * * @component */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Snackbar, Alert, @@ -359,11 +359,13 @@ export default function NotificationQueue({ // Export hook for easy use export function useNotificationQueue() { - const addNotification = (notification: Omit) => { + // Use useCallback to return a stable function reference + // This prevents unnecessary re-renders in components that use this hook + const addNotification = useCallback((notification: Omit) => { if ((window as any).addNotification) { (window as any).addNotification(notification); } - }; + }, []); return { addNotification }; } diff --git a/ui/src/hooks/useWebSocketEnhancements.ts b/ui/src/hooks/useWebSocketEnhancements.ts index f693177f..fbc1ae34 100644 --- a/ui/src/hooks/useWebSocketEnhancements.ts +++ b/ui/src/hooks/useWebSocketEnhancements.ts @@ -9,7 +9,7 @@ * * @module useWebSocketEnhancements */ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; /** * Throttle function - limits function execution to once per interval @@ -250,12 +250,14 @@ export function useEnhancedWebSocket( const { latency, quality } = useConnectionQuality(isConnected); const { manualReconnect } = useManualReconnect(reconnectCallback); - return { + // Memoize the return value to prevent unnecessary re-renders + // Only update when actual values change, not on every render + return useMemo(() => ({ isConnected, reconnectAttempts, maxReconnectAttempts, latency, quality, onManualReconnect: manualReconnect, - }; + }), [isConnected, reconnectAttempts, maxReconnectAttempts, latency, quality, manualReconnect]); }