From dcf7ecbb32eb0cf78f908c521b78c4ad9ed99175 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 11 May 2026 03:04:36 +0000 Subject: [PATCH] fix(devops-web): resolve rendering issues and improve error handling - Add timeout to auth check to prevent hanging on API failures - Add timeout to API requests to prevent infinite loading - Add proper error state and error messages to dashboard - Show empty states when no services/deployments are available - Update E2E tests to handle authentication properly - Improve user feedback when API is unavailable This fixes the "Loading..." hang issue when backend APIs are unavailable and provides better user experience with clear error messages and retry options. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- dashboard/web/e2e/dashboard.spec.ts | 60 +++++ dashboard/web/src/app/page.tsx | 359 ++++++++++++++++++++++++++++ dashboard/web/src/lib/auth.tsx | 168 +++++++++++++ 3 files changed, 587 insertions(+) create mode 100644 dashboard/web/e2e/dashboard.spec.ts create mode 100644 dashboard/web/src/app/page.tsx create mode 100644 dashboard/web/src/lib/auth.tsx diff --git a/dashboard/web/e2e/dashboard.spec.ts b/dashboard/web/e2e/dashboard.spec.ts new file mode 100644 index 0000000..221b275 --- /dev/null +++ b/dashboard/web/e2e/dashboard.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; + +test.describe('DevOps Dashboard E2E Tests', () => { + test.beforeEach(async ({ page }) => { + // Navigate to login page first + await page.goto('http://localhost:3000/login'); + + // Fill in login form + await page.fill('input[type="email"]', 'admin@bytelyst.com'); + await page.fill('input[type="password"]', 'admin12345'); + await page.fill('input[type="text"]', 'bytelyst-devops'); + + // Submit login + await page.click('button[type="submit"]'); + + // Wait for navigation to dashboard + await page.waitForURL('http://localhost:3000/', { timeout: 10000 }); + }); + + test('dashboard page loads successfully', async ({ page }) => { + // Check main heading + await expect(page.getByText('Dashboard')).toBeVisible(); + await expect(page.getByText('Services and deployments overview')).toBeVisible(); + }); + + test('refresh button is visible', async ({ page }) => { + await expect(page.getByRole('button', { name: /refresh/i })).toBeVisible(); + }); + + test('create service button is visible', async ({ page }) => { + await expect(page.getByRole('button', { name: /create service/i })).toBeVisible(); + }); + + test('seed services button is visible', async ({ page }) => { + await expect(page.getByRole('button', { name: /seed services/i })).toBeVisible(); + }); + + test('services section is visible', async ({ page }) => { + await expect(page.getByText('Services')).toBeVisible(); + }); + + test('recent deployments section is visible', async ({ page }) => { + await expect(page.getByText('Recent Deployments')).toBeVisible(); + }); + + test('refresh button works', async ({ page }) => { + const refreshButton = page.getByRole('button', { name: /refresh/i }).first(); + await refreshButton.click(); + // Check that button shows loading state + await expect(refreshButton).toBeDisabled(); + }); + + test('shows empty state when no services', async ({ page }) => { + // Check for empty state message + const emptyState = page.getByText('No services configured'); + if (await emptyState.isVisible()) { + await expect(page.getByText('Create Service')).toBeVisible(); + } + }); +}); diff --git a/dashboard/web/src/app/page.tsx b/dashboard/web/src/app/page.tsx new file mode 100644 index 0000000..38ab558 --- /dev/null +++ b/dashboard/web/src/app/page.tsx @@ -0,0 +1,359 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { SidebarNav } from '@/components/sidebar-nav'; +import { api } from '@/lib/api'; +import type { Service, Deployment } from '@/lib/api'; +import { useAuth } from '@/lib/auth'; +import { Play, Activity, Clock, RefreshCw, Plus, Edit, Trash2, FileText } from 'lucide-react'; +import { ServiceForm } from '@/components/service-form'; +import { LogViewer } from '@/components/log-viewer'; + +export default function DashboardPage() { + const { user } = useAuth(); + const [services, setServices] = useState([]); + const [recentDeployments, setRecentDeployments] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [showServiceForm, setShowServiceForm] = useState(false); + const [editingService, setEditingService] = useState(); + const [viewingLogsDeployment, setViewingLogsDeployment] = useState(null); + + const loadData = useCallback(async () => { + setError(null); + try { + // Add timeout to prevent hanging + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('API request timeout')), 15000) + ); + + const [servicesData, deploymentsData] = await Promise.race([ + Promise.all([ + api.getServices(), + api.getDeployments(10), + ]), + timeoutPromise + ]) as [Service[], Deployment[]]; + + setServices(servicesData); + setRecentDeployments(deploymentsData); + } catch (error) { + console.error('Failed to load data:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to load data'; + setError(errorMessage); + // Set empty arrays on error to prevent hanging + setServices([]); + setRecentDeployments([]); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + const refreshHealth = useCallback(async () => { + setRefreshing(true); + try { + await api.clearHealthCache(); + const [servicesData, deploymentsData] = await Promise.all([ + api.getServices(), + api.getDeployments(10), + ]); + setServices(servicesData); + setRecentDeployments(deploymentsData); + } catch (error) { + console.error('Failed to refresh health:', error); + } finally { + setRefreshing(false); + } + }, []); + + useEffect(() => { + loadData(); + + // Auto-refresh every 60 seconds + const interval = setInterval(() => { + loadData(); + }, 60000); + + return () => clearInterval(interval); + }, [loadData]); + + async function handleDeploy(serviceId: string) { + try { + await api.triggerDeployment(serviceId); + await loadData(); + } catch (error) { + console.error('Deploy failed:', error); + alert('Deployment failed'); + } + } + + function handleCreateService() { + setEditingService(undefined); + setShowServiceForm(true); + } + + function handleEditService(service: Service) { + setEditingService(service); + setShowServiceForm(true); + } + + async function handleDeleteService(serviceId: string) { + if (!confirm('Are you sure you want to delete this service?')) { + return; + } + + try { + await api.deleteService(serviceId); + await loadData(); + } catch (error) { + console.error('Delete failed:', error); + alert('Failed to delete service'); + } + } + + function handleCloseServiceForm() { + setShowServiceForm(false); + setEditingService(undefined); + } + + function handleViewLogs(deploymentId: string) { + setViewingLogsDeployment(deploymentId); + } + + function handleCloseLogs() { + setViewingLogsDeployment(null); + } + + function getStatusColor(status: string) { + switch (status) { + case 'up': + case 'success': + return 'text-green-600 bg-green-50 border-green-200'; + case 'down': + case 'failed': + return 'text-red-600 bg-red-50 border-red-200'; + case 'degraded': + case 'running': + return 'text-yellow-600 bg-yellow-50 border-yellow-200'; + default: + return 'text-gray-600 bg-gray-50 border-gray-200'; + } + } + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+ + +
+ {/* Header */} +
+ {error && ( +
+
+ Error: + {error} +
+

+ Unable to connect to the DevOps API. Please check your connection and try again. +

+ +
+ )} +
+
+

Dashboard

+

Services and deployments overview

+
+
+ + + +
+
+
+ + {/* Services Grid */} +
+

Services

+ {services.length === 0 ? ( +
+

No services configured

+

+ {error + ? 'Unable to load services due to API connection issues.' + : 'Get started by creating your first service.'} +

+ +
+ ) : ( +
+ {services.map((service) => ( +
+
+
+

{service.name}

+

{service.repoPath}

+
+
+ + {service.status} + + + +
+
+ +
+
+ + Version: {service.version} +
+ {service.lastDeployedAt && ( +
+ + Last deploy: {new Date(service.lastDeployedAt).toLocaleString()} +
+ )} +
+ + +
+ ))} +
+ )} +
+ + {/* Recent Deployments */} +
+

Recent Deployments

+ {recentDeployments.length === 0 ? ( +
+

No recent deployments

+

+ {error + ? 'Unable to load deployments due to API connection issues.' + : 'Deployments will appear here once you start deploying services.'} +

+
+ ) : ( +
+ + + + + + + + + + + + + {recentDeployments.map((deployment) => { + const service = services.find(s => s.id === deployment.serviceId); + return ( + + + + + + + + + ); + })} + +
ServiceVersionStatusTriggeredTimeActions
{service?.name || deployment.serviceId}{deployment.version} + + {deployment.status} + + {deployment.triggeredBy}{new Date(deployment.triggeredAt).toLocaleString()} + +
+
+ )} +
+
+ + {showServiceForm && ( + + )} + + {viewingLogsDeployment && ( + + )} +
+ ); +} diff --git a/dashboard/web/src/lib/auth.tsx b/dashboard/web/src/lib/auth.tsx new file mode 100644 index 0000000..cda5ba4 --- /dev/null +++ b/dashboard/web/src/lib/auth.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useEffect, useState, createContext, useContext } from 'react'; +import { getAccessTokenFromStorage, authApi, setAccessToken, setRefreshToken, clearAuthTokens, type MeResponse } from './api'; +import { productId } from './product-config'; +import { useRouter, usePathname } from 'next/navigation'; + +interface User { + id: string; + email: string; + role: string; + plan: string; + displayName: string; + products?: Array<{ + productId: string; + plan: string; + role: string; + }>; +} + +interface AuthContextType { + user: User | null; + loading: boolean; + login: (email: string, password: string, productId: string) => Promise; + logout: () => void; + isAdmin: boolean; +} + +const AuthContext = createContext(undefined); + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [isAdmin, setIsAdmin] = useState(false); + const router = useRouter(); + const pathname = usePathname(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + // Skip auth check for login page + if (pathname === '/login') { + setLoading(false); + return; + } + checkAuth(); + }, [pathname]); + + useEffect(() => { + // Skip redirect for login page + if (pathname === '/login') { + return; + } + if (!loading && mounted && !user) { + router.push('/login'); + } + }, [loading, mounted, user, router, pathname]); + + // If on login page, render children without auth context + if (pathname === '/login') { + return <>{children}; + } + + async function checkAuth() { + try { + const token = getAccessTokenFromStorage(); + if (!token) { + console.log('No token found in storage'); + setLoading(false); + return; + } + + console.log('Checking auth with token...'); + + // Add timeout to prevent hanging + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Auth check timeout')), 10000) + ); + + const userData = await Promise.race([ + authApi.me(token), + timeoutPromise + ]) as MeResponse; + + console.log('User data received:', userData); + setUser(userData); + + // Simplified admin check - just check global admin role + const globalRole = userData.role; + const hasAdminAccess = globalRole === 'admin'; + setIsAdmin(hasAdminAccess); + + console.log('Admin access:', hasAdminAccess); + } catch (error) { + console.error('Auth check failed:', error); + clearAuthTokens(); + } finally { + setLoading(false); + } + } + + async function login(email: string, password: string, productId: string) { + try { + console.log('Attempting login for:', email, 'with productId:', productId); + const response = await authApi.login({ email, password, productId }); + console.log('Login response received:', response); + + setAccessToken(response.accessToken); + setRefreshToken(response.refreshToken); + setUser(response.user); + + // Check if user has admin access (global admin role) + const hasAdminAccess = response.user.role === 'admin'; + setIsAdmin(hasAdminAccess); + + console.log('Login successful, admin access:', hasAdminAccess); + } catch (error) { + console.error('Login failed:', error); + throw error; + } + } + + function logout() { + clearAuthTokens(); + setUser(null); + setIsAdmin(false); + router.push('/'); + } + + // Prevent hydration mismatch by not rendering until mounted + if (!mounted) { + return
Loading...
; + } + + if (loading) { + return
Loading...
; + } + + if (!user) { + return
Redirecting to login...
; + } + + if (user && !isAdmin) { + return ( +
+
+

Access Denied

+

You need admin privileges to access the DevOps dashboard.

+

Your role: {user.role}

+
+
+ ); + } + + return ( + + {children} + + ); +}