- 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>
362 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|