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} + + ); +}