bytelyst-devops-tools/dashboard/web/src/app/page.tsx
root b35de88b08 feat(devops-web): fix responsive layout and add comprehensive dashboard pages
- Fix sidebar layout: use flexbox instead of margin-left approach
- Update sidebar to use responsive display (hidden on mobile, static on desktop)
- Fix mobile overlay z-index and positioning issues
- Add proper flex container structure to all pages
- Add new dashboard pages: health, metrics, system, env, code-quality, settings/cosmos
- Add comprehensive API client and type definitions
- Add error boundary and log viewer components
- Add test infrastructure with Vitest and Playwright
- Add Docker configuration and deployment scripts

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-11 03:10:31 +00:00

362 lines
14 KiB
TypeScript

'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<Service[]>([]);
const [recentDeployments, setRecentDeployments] = useState<Deployment[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showServiceForm, setShowServiceForm] = useState(false);
const [editingService, setEditingService] = useState<Service | undefined>();
const [viewingLogsDeployment, setViewingLogsDeployment] = useState<string | null>(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 (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-600">Loading...</div>
</div>
);
}
return (
<div className="flex min-h-screen bg-gray-50">
<SidebarNav />
<main className="flex-1 min-w-0 overflow-y-auto">
<div className="p-8 max-md:p-4">
{/* Header */}
<div className="mb-8">
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex items-center gap-2">
<span className="text-red-600 font-medium">Error:</span>
<span className="text-red-700">{error}</span>
</div>
<p className="text-sm text-red-600 mt-2">
Unable to connect to the DevOps API. Please check your connection and try again.
</p>
<button
onClick={loadData}
className="mt-2 px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
>
Retry
</button>
</div>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-600">Services and deployments overview</p>
</div>
<div className="flex gap-2">
<button
onClick={handleCreateService}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Plus className="w-4 h-4" />
Create Service
</button>
<button
onClick={() => api.seedServices().then(loadData)}
className="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Seed Services
</button>
<button
onClick={refreshHealth}
disabled={refreshing}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
</div>
{/* Services Grid */}
<section className="mb-12">
<h2 className="text-xl font-semibold text-gray-900 mb-6">Services</h2>
{services.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-lg p-8 text-center">
<p className="text-gray-600 mb-4">No services configured</p>
<p className="text-sm text-gray-500 mb-4">
{error
? 'Unable to load services due to API connection issues.'
: 'Get started by creating your first service.'}
</p>
<button
onClick={handleCreateService}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Create Service
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{services.map((service) => (
<div
key={service.id}
className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm"
>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">{service.name}</h3>
<p className="text-sm text-gray-500 mt-1">{service.repoPath}</p>
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 text-xs font-medium rounded-full border ${getStatusColor(service.status)}`}>
{service.status}
</span>
<button
onClick={() => handleEditService(service)}
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteService(service.id)}
className="text-gray-400 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 rounded p-1"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="space-y-2 text-sm text-gray-600 mb-4">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4" />
<span>Version: {service.version}</span>
</div>
{service.lastDeployedAt && (
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>Last deploy: {new Date(service.lastDeployedAt).toLocaleString()}</span>
</div>
)}
</div>
<button
onClick={() => handleDeploy(service.id)}
disabled={service.status !== 'up'}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Play className="w-4 h-4" />
Deploy
</button>
</div>
))}
</div>
)}
</section>
{/* Recent Deployments */}
<section>
<h2 className="text-xl font-semibold text-gray-900 mb-6">Recent Deployments</h2>
{recentDeployments.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-lg p-8 text-center">
<p className="text-gray-600">No recent deployments</p>
<p className="text-sm text-gray-500 mt-2">
{error
? 'Unable to load deployments due to API connection issues.'
: 'Deployments will appear here once you start deploying services.'}
</p>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Service</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Version</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Triggered</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Time</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{recentDeployments.map((deployment) => {
const service = services.find(s => s.id === deployment.serviceId);
return (
<tr key={deployment.id}>
<td className="px-6 py-4 text-sm text-gray-900">{service?.name || deployment.serviceId}</td>
<td className="px-6 py-4 text-sm text-gray-600">{deployment.version}</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs font-medium rounded-full border ${getStatusColor(deployment.status)}`}>
{deployment.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600">{deployment.triggeredBy}</td>
<td className="px-6 py-4 text-sm text-gray-600">{new Date(deployment.triggeredAt).toLocaleString()}</td>
<td className="px-6 py-4">
<button
onClick={() => handleViewLogs(deployment.id)}
className="flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
>
<FileText className="w-4 h-4" />
View Logs
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div>
</main>
{showServiceForm && (
<ServiceForm
service={editingService}
onClose={handleCloseServiceForm}
onSuccess={loadData}
/>
)}
{viewingLogsDeployment && (
<LogViewer
deploymentId={viewingLogsDeployment}
onClose={handleCloseLogs}
/>
)}
</div>
);
}