diff --git a/apps/frontend/src/app/app/deployments/[id]/page.tsx b/apps/frontend/src/app/app/deployments/[id]/page.tsx new file mode 100644 index 0000000..80b31b8 --- /dev/null +++ b/apps/frontend/src/app/app/deployments/[id]/page.tsx @@ -0,0 +1,447 @@ +'use client'; + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { AppShell } from '@/components/app'; +import { + DeploymentDetailActions, + DeploymentHistorySection, + DeploymentLogViewerShell, + DeploymentProgressIndicator, + type DeploymentHistoryItem, +} from '@/components/deployments'; +import type { + DeploymentDetail, + DeploymentLogEntry, + DeploymentStatusSnapshot, +} from '@/types/deployment'; +import type { NavItem, User } from '@/types/navigation'; +import { + deleteDeployment, + DeploymentApiError, + fetchDeploymentDetail, + fetchDeploymentLogs, + fetchDeploymentStatus, + fetchDeploymentUpdateContext, + redeployDeployment, +} from '@/services/deployment-detail-api'; +import { isDeploymentDetailStatusActive } from '@/components/deployments/deployment-detail-status'; + +const mockUser: User = { + id: '1', + name: 'John Doe', + email: 'john@example.com', + role: 'user', +}; + +const navItems: NavItem[] = [ + { + id: 'home', + label: 'Home', + path: '/app', + icon: ( + + + + ), + }, + { + id: 'deployments', + label: 'Deployments', + path: '/app/deployments', + icon: ( + + + + ), + }, + { + id: 'settings', + label: 'Settings', + path: '/app/settings/profile', + icon: ( + + + + + ), + }, +]; + +interface DeploymentDetailPageProps { + params: { id: string }; +} + +function formatDateTime(value: string | null): string { + if (!value) return 'Not available'; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString(); +} + +function buildHistoryItems( + detail: DeploymentDetail | null, + status: DeploymentStatusSnapshot | null, + updateContext: unknown, +): DeploymentHistoryItem[] { + const items: DeploymentHistoryItem[] = []; + + if (detail) { + items.push({ + id: `${detail.id}-created`, + title: 'Deployment created', + summary: `Initial deployment record for ${detail.name}.`, + timestamp: detail.timestamps.created, + status: 'success', + }); + } + + if (status?.timestamps.deployed) { + items.push({ + id: `${status.id}-deployed`, + title: 'Deployment completed', + summary: 'Deployment reached a terminal completed state.', + timestamp: status.timestamps.deployed, + status: 'success', + }); + } + + if (status?.status === 'failed') { + items.push({ + id: `${status.id}-failed-${status.timestamps.updated}`, + title: 'Deployment failed', + summary: status.error ?? 'Deployment failed without an error message.', + timestamp: status.timestamps.updated, + status: 'failed', + }); + } + + if (updateContext && typeof updateContext === 'object') { + const context = updateContext as { id?: unknown; updatedAt?: unknown }; + if (typeof context.updatedAt === 'string') { + items.push({ + id: typeof context.id === 'string' ? context.id : 'update-context', + title: 'Update context found', + summary: 'Update API integration point returned draft metadata.', + timestamp: context.updatedAt, + status: 'pending', + }); + } + } + + return items.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); +} + +export default function DeploymentDetailPage({ params }: DeploymentDetailPageProps) { + const router = useRouter(); + const deploymentId = params.id; + + const [detail, setDetail] = useState(null); + const [status, setStatus] = useState(null); + const [logs, setLogs] = useState([]); + const [logsPage, setLogsPage] = useState(1); + const [hasMoreLogs, setHasMoreLogs] = useState(false); + const [historyItems, setHistoryItems] = useState([]); + + const [isLoading, setIsLoading] = useState(true); + const [pageError, setPageError] = useState(null); + const [logsLoading, setLogsLoading] = useState(false); + const [logsError, setLogsError] = useState(null); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); + + const loadLogs = useCallback( + async (page = 1, append = false) => { + setLogsLoading(true); + setLogsError(null); + try { + const response = await fetchDeploymentLogs(deploymentId, { + page, + limit: 50, + order: 'desc', + }); + + setLogs((prev) => (append ? [...prev, ...response.data] : response.data)); + setHasMoreLogs(response.pagination.hasNextPage); + setLogsPage(page); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load deployment logs'; + setLogsError(message); + } finally { + setLogsLoading(false); + } + }, + [deploymentId], + ); + + const loadHistory = useCallback( + async (currentDetail: DeploymentDetail | null, currentStatus: DeploymentStatusSnapshot | null) => { + setHistoryLoading(true); + setHistoryError(null); + + try { + const updateContext = await fetchDeploymentUpdateContext(deploymentId); + setHistoryItems(buildHistoryItems(currentDetail, currentStatus, updateContext)); + } catch (error) { + if (error instanceof DeploymentApiError && error.status === 404) { + setHistoryItems(buildHistoryItems(currentDetail, currentStatus, null)); + } else { + const message = error instanceof Error ? error.message : 'Failed to load deployment history'; + setHistoryError(message); + setHistoryItems(buildHistoryItems(currentDetail, currentStatus, null)); + } + } finally { + setHistoryLoading(false); + } + }, + [deploymentId], + ); + + const loadDetailView = useCallback(async () => { + setIsLoading(true); + setPageError(null); + + try { + const [detailResponse, statusResponse] = await Promise.all([ + fetchDeploymentDetail(deploymentId), + fetchDeploymentStatus(deploymentId), + ]); + + setDetail(detailResponse); + setStatus(statusResponse); + await Promise.all([ + loadLogs(1, false), + loadHistory(detailResponse, statusResponse), + ]); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load deployment detail'; + setPageError(message); + } finally { + setIsLoading(false); + } + }, [deploymentId, loadHistory, loadLogs]); + + const refreshStatus = useCallback(async () => { + try { + const snapshot = await fetchDeploymentStatus(deploymentId); + setStatus(snapshot); + setDetail((prev) => { + if (!prev) return prev; + + return { + ...prev, + status: snapshot.status, + deploymentUrl: snapshot.deploymentUrl ?? prev.deploymentUrl, + timestamps: { + ...prev.timestamps, + updated: snapshot.timestamps.updated, + deployed: snapshot.timestamps.deployed, + }, + errorMessage: snapshot.error, + }; + }); + } catch { + // Polling should be resilient to transient errors. + } + }, [deploymentId]); + + useEffect(() => { + void loadDetailView(); + }, [loadDetailView]); + + const shouldPollStatus = useMemo( + () => (status ? isDeploymentDetailStatusActive(status.status) : false), + [status], + ); + + useEffect(() => { + if (!shouldPollStatus) return; + + const timer = window.setInterval(() => { + void refreshStatus(); + }, 5000); + + return () => { + window.clearInterval(timer); + }; + }, [refreshStatus, shouldPollStatus]); + + const handleRedeploy = useCallback(async (id: string) => { + try { + await redeployDeployment(id); + await Promise.all([refreshStatus(), loadLogs(1, false)]); + } catch (error) { + if (error instanceof DeploymentApiError && error.status === 404) { + throw new Error('Redeploy endpoint is not available yet in this environment.'); + } + + throw error; + } + }, [loadLogs, refreshStatus]); + + const handleDelete = useCallback(async (id: string) => { + await deleteDeployment(id); + router.push('/app/deployments'); + }, [router]); + + const effectiveStatus = status?.status ?? detail?.status ?? 'pending'; + const effectiveProgress = status?.progress.percentage; + const effectiveDescription = status?.progress.description; + + if (isLoading) { + return ( + +
+
+
+
+
+ + ); + } + + if (pageError || !detail) { + return ( + +
+
+

Failed to load deployment detail

+

{pageError ?? 'Deployment detail is unavailable.'}

+
+ + +
+
+
+
+ ); + } + + return ( + +
+
+
+

{detail.name}

+

Deployment ID: {detail.id}

+
+ + +
+ + + + {detail.errorMessage && ( +
+ {detail.errorMessage} +
+ )} + +
+
+

Template ID

+

{detail.templateId ?? 'Not available'}

+
+
+

Vercel Project

+

{detail.vercelProjectId ?? 'Not linked'}

+
+
+

Created

+

{formatDateTime(detail.timestamps.created)}

+
+
+

Last Updated

+

{formatDateTime(detail.timestamps.updated)}

+
+
+ +
+ { + const element = document.getElementById('deployment-logs-section'); + element?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }} + onRedeploy={handleRedeploy} + onDelete={handleDelete} + /> + + void loadHistory(detail, status)} + /> +
+ +
+ void loadLogs(1, false)} + onLoadMore={hasMoreLogs ? () => void loadLogs(logsPage + 1, true) : undefined} + /> +
+
+
+ ); +} diff --git a/apps/frontend/src/components/deployments/DeploymentDetailActions.test.tsx b/apps/frontend/src/components/deployments/DeploymentDetailActions.test.tsx new file mode 100644 index 0000000..134ba58 --- /dev/null +++ b/apps/frontend/src/components/deployments/DeploymentDetailActions.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { DeploymentDetailActions } from './DeploymentDetailActions'; + +describe('DeploymentDetailActions', () => { + it('calls onRedeploy when redeploy action is triggered', async () => { + const onRedeploy = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + fireEvent.click(screen.getByTestId('deployment-redeploy-btn')); + + await waitFor(() => { + expect(onRedeploy).toHaveBeenCalledWith('dep-1'); + }); + }); + + it('requires exact deployment name before delete confirmation can be submitted', () => { + render( + , + ); + + fireEvent.click(screen.getByTestId('deployment-delete-btn')); + + const confirmButton = screen.getByTestId('deployment-confirm-delete-btn') as HTMLButtonElement; + expect(confirmButton.disabled).toBe(true); + + fireEvent.change(screen.getByLabelText('Confirm deployment name'), { + target: { value: 'stellar-app' }, + }); + + expect(confirmButton.disabled).toBe(false); + }); + + it('calls onDelete only after confirmation text is valid', async () => { + const onDelete = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + fireEvent.click(screen.getByTestId('deployment-delete-btn')); + fireEvent.change(screen.getByLabelText('Confirm deployment name'), { + target: { value: 'stellar-app' }, + }); + fireEvent.click(screen.getByTestId('deployment-confirm-delete-btn')); + + await waitFor(() => { + expect(onDelete).toHaveBeenCalledWith('dep-1'); + }); + }); +}); diff --git a/apps/frontend/src/components/deployments/DeploymentDetailActions.tsx b/apps/frontend/src/components/deployments/DeploymentDetailActions.tsx new file mode 100644 index 0000000..6a7c5c4 --- /dev/null +++ b/apps/frontend/src/components/deployments/DeploymentDetailActions.tsx @@ -0,0 +1,205 @@ +'use client'; + +import React, { useState } from 'react'; + +interface DeploymentDetailActionsProps { + deploymentId: string; + deploymentName: string; + deploymentUrl: string | null; + repositoryUrl: string | null; + canRedeploy?: boolean; + canDelete?: boolean; + onViewLogs?: () => void; + onRedeploy: (deploymentId: string) => Promise | void; + onDelete: (deploymentId: string) => Promise | void; +} + +export function DeploymentDetailActions({ + deploymentId, + deploymentName, + deploymentUrl, + repositoryUrl, + canRedeploy = true, + canDelete = true, + onViewLogs, + onRedeploy, + onDelete, +}: DeploymentDetailActionsProps) { + const [isRedeploying, setIsRedeploying] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteConfirmationInput, setDeleteConfirmationInput] = useState(''); + const [actionError, setActionError] = useState(null); + + const canConfirmDelete = deleteConfirmationInput.trim() === deploymentName; + + async function handleRedeploy() { + if (!canRedeploy) return; + + setActionError(null); + setIsRedeploying(true); + try { + await onRedeploy(deploymentId); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to redeploy deployment'; + setActionError(message); + } finally { + setIsRedeploying(false); + } + } + + async function handleDelete() { + if (!canDelete || !canConfirmDelete) return; + + setActionError(null); + setIsDeleting(true); + try { + await onDelete(deploymentId); + setDeleteDialogOpen(false); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete deployment'; + setActionError(message); + } finally { + setIsDeleting(false); + } + } + + function openDeleteDialog() { + setDeleteConfirmationInput(''); + setDeleteDialogOpen(true); + setActionError(null); + } + + return ( +
+

Actions

+ +

+ Trigger a redeploy, open runtime links, or safely remove this deployment. +

+ +
+ + + +
+ +
+ {deploymentUrl && ( + + Open Live URL + + )} + + {repositoryUrl && ( + + Open Repository + + )} +
+ +
+

Danger zone

+

+ Deleting removes deployment metadata and linked provider resources. +

+ + +
+ + {actionError && ( +

+ {actionError} +

+ )} + + {deleteDialogOpen && ( +
+
+

+ Delete deployment? +

+

+ This action cannot be undone. Type {deploymentName} to confirm. +

+ + + setDeleteConfirmationInput(event.target.value)} + className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-200" + placeholder={deploymentName} + autoFocus + /> + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/deployments/DeploymentDetailStatusBadge.test.tsx b/apps/frontend/src/components/deployments/DeploymentDetailStatusBadge.test.tsx new file mode 100644 index 0000000..429cf53 --- /dev/null +++ b/apps/frontend/src/components/deployments/DeploymentDetailStatusBadge.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DeploymentDetailStatusBadge } from './DeploymentDetailStatusBadge'; + +describe('DeploymentDetailStatusBadge', () => { + it('renders human-readable labels for backend deployment statuses', () => { + render(); + + expect(screen.getByText('Creating Repository')).toBeDefined(); + }); + + it('animates active statuses by default', () => { + const { container } = render(); + expect(container.querySelector('.animate-pulse')).not.toBeNull(); + }); + + it('does not animate terminal statuses', () => { + const { container } = render(); + expect(container.querySelector('.animate-pulse')).toBeNull(); + }); +}); diff --git a/apps/frontend/src/components/deployments/DeploymentDetailStatusBadge.tsx b/apps/frontend/src/components/deployments/DeploymentDetailStatusBadge.tsx new file mode 100644 index 0000000..b859ad1 --- /dev/null +++ b/apps/frontend/src/components/deployments/DeploymentDetailStatusBadge.tsx @@ -0,0 +1,39 @@ +'use client'; + +import React from 'react'; +import type { DeploymentDetailStatus } from '@/types/deployment'; +import { + getDeploymentDetailStatusPresentation, + isDeploymentDetailStatusActive, +} from './deployment-detail-status'; + +interface DeploymentDetailStatusBadgeProps { + status: DeploymentDetailStatus; + size?: 'sm' | 'md'; + animated?: boolean; +} + +export function DeploymentDetailStatusBadge({ + status, + size = 'md', + animated = true, +}: DeploymentDetailStatusBadgeProps) { + const presentation = getDeploymentDetailStatusPresentation(status); + const shouldAnimate = animated && isDeploymentDetailStatusActive(status); + + const sizeClass = size === 'sm' ? 'px-2.5 py-1 text-[11px]' : 'px-3 py-1.5 text-xs'; + const dotSizeClass = size === 'sm' ? 'w-1.5 h-1.5' : 'w-2 h-2'; + + return ( + + + ); +} diff --git a/apps/frontend/src/components/deployments/DeploymentHistorySection.tsx b/apps/frontend/src/components/deployments/DeploymentHistorySection.tsx new file mode 100644 index 0000000..c5a6ae4 --- /dev/null +++ b/apps/frontend/src/components/deployments/DeploymentHistorySection.tsx @@ -0,0 +1,100 @@ +'use client'; + +import React from 'react'; + +export interface DeploymentHistoryItem { + id: string; + title: string; + summary: string; + timestamp: string; + actor?: string; + status: 'success' | 'failed' | 'pending'; +} + +interface DeploymentHistorySectionProps { + items: DeploymentHistoryItem[]; + isLoading?: boolean; + error?: string | null; + onRefresh?: () => void; +} + +function statusClass(status: DeploymentHistoryItem['status']): string { + if (status === 'success') return 'bg-green-100 text-green-700'; + if (status === 'failed') return 'bg-red-100 text-red-700'; + return 'bg-amber-100 text-amber-700'; +} + +function formatTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +export function DeploymentHistorySection({ + items, + isLoading = false, + error = null, + onRefresh, +}: DeploymentHistorySectionProps) { + return ( +
+
+

History

+ + {onRefresh && ( + + )} +
+ + {error && ( +

+ {error} +

+ )} + + {!error && isLoading && ( +
+
+
+
+ )} + + {!error && !isLoading && items.length === 0 && ( +
+

History integration ready

+

+ This section is prepared for deployment update and redeploy events once the history feed endpoint is available. +

+
+ )} + + {!error && items.length > 0 && ( +
    + {items.map((item) => ( +
  • +
    + + {item.status} + + + {item.actor && by {item.actor}} +
    +

    {item.title}

    +

    {item.summary}

    +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/deployments/DeploymentLogViewerShell.tsx b/apps/frontend/src/components/deployments/DeploymentLogViewerShell.tsx new file mode 100644 index 0000000..b841aff --- /dev/null +++ b/apps/frontend/src/components/deployments/DeploymentLogViewerShell.tsx @@ -0,0 +1,109 @@ +'use client'; + +import React from 'react'; +import type { DeploymentLogEntry } from '@/types/deployment'; + +interface DeploymentLogViewerShellProps { + logs: DeploymentLogEntry[]; + isLoading?: boolean; + error?: string | null; + onRefresh?: () => void; + onLoadMore?: () => void; + hasMore?: boolean; +} + +function levelClass(level: DeploymentLogEntry['level']): string { + if (level === 'error') return 'bg-red-100 text-red-700'; + if (level === 'warn') return 'bg-amber-100 text-amber-700'; + return 'bg-blue-100 text-blue-700'; +} + +function formatLogTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +export function DeploymentLogViewerShell({ + logs, + isLoading = false, + error = null, + onRefresh, + onLoadMore, + hasMore = false, +}: DeploymentLogViewerShellProps) { + return ( +
+
+

Logs

+ + {onRefresh && ( + + )} +
+ + {error && ( +

+ {error} +

+ )} + + {!error && isLoading && logs.length === 0 && ( +
+
+
+
+
+ )} + + {!error && !isLoading && logs.length === 0 && ( +
+

No logs yet

+

+ Logs will stream here when the deployment starts emitting events. +

+
+ )} + + {logs.length > 0 && ( +
    + {logs.map((log) => ( +
  • +
    + + {log.level} + + +
    +

    {log.message}

    +
  • + ))} +
+ )} + + {logs.length > 0 && hasMore && onLoadMore && ( + + )} +
+ ); +} diff --git a/apps/frontend/src/components/deployments/DeploymentProgressIndicator.test.tsx b/apps/frontend/src/components/deployments/DeploymentProgressIndicator.test.tsx new file mode 100644 index 0000000..509f168 --- /dev/null +++ b/apps/frontend/src/components/deployments/DeploymentProgressIndicator.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DeploymentProgressIndicator } from './DeploymentProgressIndicator'; + +describe('DeploymentProgressIndicator', () => { + it('renders provided progress value in progressbar aria attributes', () => { + render( + , + ); + + const progressbar = screen.getByRole('progressbar'); + expect(progressbar.getAttribute('aria-valuenow')).toBe('82'); + }); + + it('falls back to default progress percentage when percentage is omitted', () => { + render(); + + const progressbar = screen.getByRole('progressbar'); + expect(progressbar.getAttribute('aria-valuenow')).toBe('40'); + }); + + it('clamps out-of-range percentage values', () => { + render(); + + const progressbar = screen.getByRole('progressbar'); + expect(progressbar.getAttribute('aria-valuenow')).toBe('100'); + }); +}); diff --git a/apps/frontend/src/components/deployments/DeploymentProgressIndicator.tsx b/apps/frontend/src/components/deployments/DeploymentProgressIndicator.tsx new file mode 100644 index 0000000..da58c42 --- /dev/null +++ b/apps/frontend/src/components/deployments/DeploymentProgressIndicator.tsx @@ -0,0 +1,76 @@ +'use client'; + +import React from 'react'; +import type { DeploymentDetailStatus } from '@/types/deployment'; +import { + getDeploymentDefaultProgress, + getDeploymentDetailStatusPresentation, + isDeploymentDetailStatusActive, +} from './deployment-detail-status'; +import { DeploymentDetailStatusBadge } from './DeploymentDetailStatusBadge'; + +interface DeploymentProgressIndicatorProps { + status: DeploymentDetailStatus; + percentage?: number; + description?: string; + updatedAt?: string | null; +} + +function clampPercentage(value: number): number { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function formatUpdatedAt(value?: string | null): string | null { + if (!value) return null; + + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) return null; + + return timestamp.toLocaleString(); +} + +export function DeploymentProgressIndicator({ + status, + percentage, + description, + updatedAt, +}: DeploymentProgressIndicatorProps) { + const presentation = getDeploymentDetailStatusPresentation(status); + const progress = clampPercentage(percentage ?? getDeploymentDefaultProgress(status)); + const showActivityPulse = isDeploymentDetailStatusActive(status); + const lastUpdated = formatUpdatedAt(updatedAt); + + return ( +
+
+ + {progress}% +
+ +

{description ?? presentation.description}

+ +
+
+
+ + {lastUpdated && ( +

+ Last updated: {lastUpdated} +

+ )} +
+ ); +} diff --git a/apps/frontend/src/components/deployments/DeploymentRow.tsx b/apps/frontend/src/components/deployments/DeploymentRow.tsx index 4e6299a..ff02f1b 100644 --- a/apps/frontend/src/components/deployments/DeploymentRow.tsx +++ b/apps/frontend/src/components/deployments/DeploymentRow.tsx @@ -76,9 +76,12 @@ export function DeploymentRow({ deployment, onViewLogs, onRedeploy }: Deployment {/* Left: name + commit */}
- + {deployment.name} - + @@ -129,6 +132,22 @@ export function DeploymentRow({ deployment, onViewLogs, onRedeploy }: Deployment {/* Right: actions */}
+ + + + {deployment.url && ( = { + pending: { + label: 'Pending', + description: 'Deployment is queued and waiting to start.', + dotClass: 'bg-amber-500', + bgClass: 'bg-amber-50', + textClass: 'text-amber-700', + trackClass: 'bg-amber-100', + fillClass: 'bg-amber-500', + }, + generating: { + label: 'Generating', + description: 'Generating deployment configuration.', + dotClass: 'bg-blue-500', + bgClass: 'bg-blue-50', + textClass: 'text-blue-700', + trackClass: 'bg-blue-100', + fillClass: 'bg-blue-500', + }, + creating_repo: { + label: 'Creating Repository', + description: 'Creating a repository for generated code.', + dotClass: 'bg-indigo-500', + bgClass: 'bg-indigo-50', + textClass: 'text-indigo-700', + trackClass: 'bg-indigo-100', + fillClass: 'bg-indigo-500', + }, + pushing_code: { + label: 'Pushing Code', + description: 'Uploading files and commit history.', + dotClass: 'bg-cyan-500', + bgClass: 'bg-cyan-50', + textClass: 'text-cyan-700', + trackClass: 'bg-cyan-100', + fillClass: 'bg-cyan-500', + }, + deploying: { + label: 'Deploying', + description: 'Publishing the project to hosting infrastructure.', + dotClass: 'bg-violet-500', + bgClass: 'bg-violet-50', + textClass: 'text-violet-700', + trackClass: 'bg-violet-100', + fillClass: 'bg-violet-500', + }, + completed: { + label: 'Completed', + description: 'Deployment completed successfully.', + dotClass: 'bg-green-500', + bgClass: 'bg-green-50', + textClass: 'text-green-700', + trackClass: 'bg-green-100', + fillClass: 'bg-green-500', + }, + failed: { + label: 'Failed', + description: 'Deployment failed and needs attention.', + dotClass: 'bg-red-500', + bgClass: 'bg-red-50', + textClass: 'text-red-700', + trackClass: 'bg-red-100', + fillClass: 'bg-red-500', + }, +}; + +const DEFAULT_PROGRESS: Record = { + pending: 0, + generating: 20, + creating_repo: 40, + pushing_code: 60, + deploying: 80, + completed: 100, + failed: 0, +}; + +const ACTIVE_STATUSES = new Set([ + 'pending', + 'generating', + 'creating_repo', + 'pushing_code', + 'deploying', +]); + +export function getDeploymentDetailStatusPresentation( + status: DeploymentDetailStatus, +): DeploymentDetailStatusPresentation { + return STATUS_PRESENTATION[status]; +} + +export function getDeploymentDefaultProgress(status: DeploymentDetailStatus): number { + return DEFAULT_PROGRESS[status]; +} + +export function isDeploymentDetailStatusActive(status: DeploymentDetailStatus): boolean { + return ACTIVE_STATUSES.has(status); +} diff --git a/apps/frontend/src/components/deployments/index.ts b/apps/frontend/src/components/deployments/index.ts index f0eddab..a3b320e 100644 --- a/apps/frontend/src/components/deployments/index.ts +++ b/apps/frontend/src/components/deployments/index.ts @@ -7,6 +7,12 @@ export { AnalyticsGrid } from './AnalyticsGrid'; export { DeploymentFiltersBar } from './DeploymentFiltersBar'; export { DeploymentList, applyFilters } from './DeploymentList'; export { DeploymentListSkeleton } from './DeploymentListSkeleton'; +export { DeploymentDetailActions } from './DeploymentDetailActions'; +export { DeploymentDetailStatusBadge } from './DeploymentDetailStatusBadge'; +export { DeploymentHistorySection } from './DeploymentHistorySection'; +export type { DeploymentHistoryItem } from './DeploymentHistorySection'; +export { DeploymentLogViewerShell } from './DeploymentLogViewerShell'; +export { DeploymentProgressIndicator } from './DeploymentProgressIndicator'; export { DeploymentRow } from './DeploymentRow'; export { DeploymentStatusBadge } from './DeploymentStatusBadge'; export { HealthSummaryBar } from './HealthSummaryBar'; diff --git a/apps/frontend/src/services/deployment-detail-api.test.ts b/apps/frontend/src/services/deployment-detail-api.test.ts new file mode 100644 index 0000000..8e5482f --- /dev/null +++ b/apps/frontend/src/services/deployment-detail-api.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildDeploymentLogsQuery, + DeploymentApiError, + fetchDeploymentDetail, + fetchDeploymentLogs, + fetchDeploymentStatus, + redeployDeployment, +} from './deployment-detail-api'; + +describe('deployment-detail-api', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('builds logs query params and clamps values', () => { + const query = buildDeploymentLogsQuery({ + page: 0, + limit: 999, + order: 'desc', + since: '2026-01-01T00:00:00.000Z', + level: 'error', + }); + + expect(query).toContain('page=1'); + expect(query).toContain('limit=200'); + expect(query).toContain('order=desc'); + expect(query).toContain('since=2026-01-01T00%3A00%3A00.000Z'); + expect(query).toContain('level=error'); + }); + + it('fetches deployment detail', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ id: 'dep-1', name: 'demo' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await fetchDeploymentDetail('dep-1'); + + expect(fetchSpy).toHaveBeenCalledWith('/api/deployments/dep-1', expect.any(Object)); + expect(result).toMatchObject({ id: 'dep-1', name: 'demo' }); + }); + + it('throws DeploymentApiError with backend message on non-2xx responses', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ error: 'Deployment not found' }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect(fetchDeploymentStatus('missing-deployment')).rejects.toMatchObject({ + name: 'DeploymentApiError', + status: 404, + message: 'Deployment not found', + } as Partial); + }); + + it('calls redeploy endpoint with POST', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ success: true, deploymentId: 'dep-1' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await redeployDeployment('dep-1'); + + expect(fetchSpy).toHaveBeenCalledWith( + '/api/deployments/dep-1/redeploy', + expect.objectContaining({ method: 'POST' }), + ); + expect(result.success).toBe(true); + }); + + it('attaches serialized query params to logs endpoint', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ data: [], pagination: { page: 2, limit: 20, total: 0, hasNextPage: false } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await fetchDeploymentLogs('dep-1', { page: 2, limit: 20, order: 'asc' }); + + const [calledUrl] = fetchSpy.mock.calls[0]; + expect(calledUrl).toBe('/api/deployments/dep-1/logs?page=2&limit=20&order=asc'); + }); +}); diff --git a/apps/frontend/src/services/deployment-detail-api.ts b/apps/frontend/src/services/deployment-detail-api.ts new file mode 100644 index 0000000..702a8a4 --- /dev/null +++ b/apps/frontend/src/services/deployment-detail-api.ts @@ -0,0 +1,138 @@ +import type { + DeploymentDetail, + DeploymentLogsQuery, + DeploymentLogsResponse, + DeploymentStatusSnapshot, +} from '@/types/deployment'; + +export interface DeploymentActionResponse { + success: boolean; + deploymentId: string; + message?: string; +} + +export class DeploymentApiError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(message: string, status: number, body: unknown) { + super(message); + this.name = 'DeploymentApiError'; + this.status = status; + this.body = body; + } +} + +function normalizeErrorMessage(payload: unknown, fallback = 'Request failed'): string { + if (typeof payload === 'object' && payload && 'error' in payload) { + const candidate = (payload as { error?: unknown }).error; + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate; + } + } + + if (typeof payload === 'string' && payload.trim().length > 0) { + return payload; + } + + return fallback; +} + +async function readBody(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +async function requestJson(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + headers: { + ...(init?.headers ?? {}), + Accept: 'application/json', + }, + }); + + const body = await readBody(response); + if (!response.ok) { + throw new DeploymentApiError( + normalizeErrorMessage(body, `Request failed with status ${response.status}`), + response.status, + body, + ); + } + + return body as T; +} + +export function buildDeploymentLogsQuery(query: DeploymentLogsQuery = {}): string { + const params = new URLSearchParams(); + + if (query.page !== undefined) { + params.set('page', String(Math.max(1, Math.floor(query.page)))); + } + + if (query.limit !== undefined) { + params.set('limit', String(Math.min(200, Math.max(1, Math.floor(query.limit))))); + } + + if (query.order) { + params.set('order', query.order); + } + + if (query.since) { + params.set('since', query.since); + } + + if (query.level) { + params.set('level', query.level); + } + + const serialized = params.toString(); + return serialized ? `?${serialized}` : ''; +} + +export async function fetchDeploymentDetail(deploymentId: string): Promise { + return requestJson(`/api/deployments/${deploymentId}`); +} + +export async function fetchDeploymentStatus(deploymentId: string): Promise { + return requestJson(`/api/deployments/${deploymentId}/status`); +} + +export async function fetchDeploymentLogs( + deploymentId: string, + query: DeploymentLogsQuery = {}, +): Promise { + const suffix = buildDeploymentLogsQuery(query); + return requestJson(`/api/deployments/${deploymentId}/logs${suffix}`); +} + +/** + * Integration point for update/redeploy API. + * The backend endpoint may not be present yet in all environments. + */ +export async function redeployDeployment(deploymentId: string): Promise { + return requestJson(`/api/deployments/${deploymentId}/redeploy`, { + method: 'POST', + }); +} + +export async function deleteDeployment(deploymentId: string): Promise { + return requestJson(`/api/deployments/${deploymentId}`, { + method: 'DELETE', + }); +} + +/** + * Integration point for update-related metadata. + * Uses the draft endpoint until a dedicated updates feed is available. + */ +export async function fetchDeploymentUpdateContext(deploymentId: string): Promise { + return requestJson(`/api/drafts/deployment/${deploymentId}`); +} diff --git a/apps/frontend/src/types/deployment.ts b/apps/frontend/src/types/deployment.ts index c3acd5d..af61ec7 100644 --- a/apps/frontend/src/types/deployment.ts +++ b/apps/frontend/src/types/deployment.ts @@ -1,6 +1,6 @@ /** * Deployment domain types. - * All data contracts here must stay consistent with the backend API spec. + * All data contracts here must stay consistent with backend API contracts. */ export type DeploymentStatus = @@ -38,7 +38,7 @@ export interface Deployment { region: DeploymentRegion; /** ISO-8601 timestamp */ createdAt: string; - /** ISO-8601 timestamp – undefined while still running */ + /** ISO-8601 timestamp; undefined while still running */ completedAt?: string; /** Duration in seconds */ durationSeconds?: number; @@ -68,3 +68,82 @@ export interface DeploymentFilters { environment: DeploymentFilterEnvironment; search: string; } + +/** + * Backend deployment detail/status route contract. + * Mirrors apps/backend/src/app/api/deployments/[id] routes. + */ +export type DeploymentDetailStatus = + | 'pending' + | 'generating' + | 'creating_repo' + | 'pushing_code' + | 'deploying' + | 'completed' + | 'failed'; + +export interface DeploymentDetail { + id: string; + name: string; + status: DeploymentDetailStatus; + templateId: string | null; + vercelProjectId: string | null; + deploymentUrl: string | null; + repositoryUrl: string | null; + customizationConfig: Record | null; + errorMessage: string | null; + timestamps: { + created: string; + updated: string; + deployed: string | null; + }; +} + +export interface DeploymentProgressMetadata { + stage: string; + percentage: number; + description: string; +} + +export interface DeploymentStatusSnapshot { + id: string; + status: DeploymentDetailStatus; + error: string | null; + deploymentUrl: string | null; + timestamps: { + created: string; + updated: string; + deployed: string | null; + }; + progress: DeploymentProgressMetadata; +} + +export type DeploymentLogLevel = 'info' | 'warn' | 'error'; + +export interface DeploymentLogEntry { + id: string; + deploymentId: string; + timestamp: string; + level: DeploymentLogLevel; + message: string; +} + +export interface DeploymentLogsPagination { + page: number; + limit: number; + total: number; + hasNextPage: boolean; +} + +export interface DeploymentLogsResponse { + data: DeploymentLogEntry[]; + pagination: DeploymentLogsPagination; +} + +export interface DeploymentLogsQuery { + page?: number; + limit?: number; + order?: 'asc' | 'desc'; + since?: string; + level?: DeploymentLogLevel; +}