diff --git a/package-lock.json b/package-lock.json index 650395f..aa900d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "nuc-portal", "version": "1.0.0", "dependencies": { + "@tanstack/react-table": "^8.21.3", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" @@ -1513,6 +1514,39 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", diff --git a/package.json b/package.json index a3e216a..c834dbe 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint": "eslint" }, "dependencies": { + "@tanstack/react-table": "^8.21.3", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" diff --git a/src/app/api/deployments/[uuid]/route.ts b/src/app/api/deployments/[uuid]/route.ts new file mode 100644 index 0000000..e1a31e7 --- /dev/null +++ b/src/app/api/deployments/[uuid]/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from 'next/server'; + +const IS_PRODUCTION = process.env.NODE_ENV === 'production'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ uuid: string }> } +) { + const { uuid } = await params; + + try { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + + // PHP code to fetch single deployment with logs + const phpCode = ` +$d = \\App\\Models\\ApplicationDeploymentQueue::with('application') + ->where('deployment_uuid', '${uuid}') + ->first(); + +if (!$d) { + echo json_encode(['error' => 'Not found']); + exit; +} + +echo json_encode([ + 'deployment_uuid' => $d->deployment_uuid, + 'application_uuid' => $d->application?->uuid ?? 'unknown', + 'application_name' => $d->application?->name ?? 'Unknown', + 'status' => $d->status, + 'created_at' => $d->created_at->toIso8601String(), + 'updated_at' => $d->updated_at->toIso8601String(), + 'git_branch' => $d->application?->git_branch ?? 'main', + 'git_commit_sha' => $d->commit ?? null, + 'commit_message' => $d->commit_message ?? null, + 'is_webhook' => $d->is_webhook ?? false, + 'logs' => $d->logs ?? null, +]); +`; + + const base64Code = Buffer.from(phpCode).toString('base64'); + + let command: string; + if (IS_PRODUCTION) { + command = `echo '${base64Code}' | base64 -d | docker exec -i coolify php artisan tinker`; + } else { + command = `ssh nuc "echo '${base64Code}' | base64 -d | docker exec -i coolify php artisan tinker"`; + } + + const { stdout } = await execAsync(command, { + maxBuffer: 10 * 1024 * 1024, + timeout: 30000, + }); + + // Parse output - find JSON object in tinker output + const lines = stdout.split('\n'); + let jsonStr = ''; + + for (const line of lines) { + let cleaned = line; + if (cleaned.startsWith('. ')) { + cleaned = cleaned.substring(2); + } else if (cleaned.startsWith('> ')) { + continue; + } + + const trimmed = cleaned.trim(); + if (trimmed.startsWith('{')) { + jsonStr = trimmed; + break; + } + } + + if (!jsonStr) { + throw new Error('No JSON output found'); + } + + const deployment = JSON.parse(jsonStr); + + if (deployment.error) { + return NextResponse.json({ error: deployment.error }, { status: 404 }); + } + + return NextResponse.json(deployment, { + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + }); + } catch (error) { + console.error('Error fetching deployment:', error); + return NextResponse.json( + { error: 'Failed to fetch deployment', details: String(error) }, + { status: 500 } + ); + } +} diff --git a/src/app/api/deployments/route.ts b/src/app/api/deployments/route.ts new file mode 100644 index 0000000..ba3752d --- /dev/null +++ b/src/app/api/deployments/route.ts @@ -0,0 +1,141 @@ +import { NextResponse } from 'next/server'; +import type { Deployment, DeploymentStatus } from '@/lib/deployments'; + +const IS_PRODUCTION = process.env.NODE_ENV === 'production'; + +async function fetchDeploymentsFromCoolify(): Promise { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + + // Base64 encode the PHP code to avoid escaping issues + const phpCode = ` +$deployments = \\App\\Models\\ApplicationDeploymentQueue::with('application') + ->orderBy('created_at', 'desc') + ->limit(50) + ->get(); + +$result = $deployments->map(function($d) { + return [ + 'deployment_uuid' => $d->deployment_uuid, + 'application_uuid' => $d->application?->uuid ?? 'unknown', + 'application_name' => $d->application?->name ?? 'Unknown', + 'status' => $d->status, + 'created_at' => $d->created_at->toIso8601String(), + 'updated_at' => $d->updated_at->toIso8601String(), + 'git_branch' => $d->application?->git_branch ?? 'main', + 'git_commit_sha' => $d->commit ?? null, + 'commit_message' => $d->commit_message ?? null, + 'is_webhook' => $d->is_webhook ?? false, + ]; +}); + +echo json_encode($result->toArray()); +`; + + const base64Code = Buffer.from(phpCode).toString('base64'); + + let command: string; + if (IS_PRODUCTION) { + // Running on NUC - direct docker exec + command = `echo '${base64Code}' | base64 -d | docker exec -i coolify php artisan tinker`; + } else { + // Running locally - SSH to NUC + command = `ssh nuc "echo '${base64Code}' | base64 -d | docker exec -i coolify php artisan tinker"`; + } + + const { stdout } = await execAsync(command, { + maxBuffer: 10 * 1024 * 1024, // 10MB buffer + timeout: 30000, // 30 second timeout + }); + + // The output contains tinker prompts (lines starting with > or .) followed by JSON + // Tinker outputs ". " prefix on continuation lines and the result + const lines = stdout.split('\n'); + let jsonStr = ''; + + for (const line of lines) { + // Remove tinker prompt prefixes + let cleaned = line; + if (cleaned.startsWith('. ')) { + cleaned = cleaned.substring(2); + } else if (cleaned.startsWith('> ')) { + continue; // Skip command echo lines + } + + const trimmed = cleaned.trim(); + // Look for line starting with [{ which indicates JSON array of objects + if (trimmed.startsWith('[{') || trimmed.startsWith('[{"') || trimmed === '[]') { + jsonStr = trimmed; + break; + } + } + + if (!jsonStr) { + console.error('Raw output:', stdout.substring(0, 1000)); + throw new Error('No JSON output found in tinker response'); + } + + const rawDeployments = JSON.parse(jsonStr); + + // Track latest deployment per application for "Current" badge + const latestByApp = new Map(); + + // First pass: find latest finished deployment per app + for (const d of rawDeployments) { + if (d.status === 'finished' && !latestByApp.has(d.application_uuid)) { + latestByApp.set(d.application_uuid, d.deployment_uuid); + } + } + + // Transform to our Deployment type + const deployments: Deployment[] = rawDeployments.map((d: Record) => { + // Calculate duration + let duration: number | undefined; + if (d.created_at && d.updated_at) { + const start = new Date(d.created_at as string).getTime(); + const end = new Date(d.updated_at as string).getTime(); + duration = Math.floor((end - start) / 1000); + } + + // Map Coolify statuses to our enum + let status: DeploymentStatus = d.status as DeploymentStatus; + if ((status as string) === 'failed') status = 'error'; + if ((status as string) === 'cancelled-by-user') status = 'cancelled'; + + return { + deployment_uuid: d.deployment_uuid as string, + application_uuid: d.application_uuid as string, + application_name: (d.application_name as string) || 'Unknown App', + status, + created_at: d.created_at as string, + updated_at: d.updated_at as string, + git_branch: (d.git_branch as string) || 'main', + git_commit_sha: d.git_commit_sha as string | undefined, + commit_message: d.commit_message as string | undefined, + is_webhook: d.is_webhook as boolean | undefined, + duration, + is_current: latestByApp.get(d.application_uuid as string) === d.deployment_uuid, + }; + }); + + return deployments; +} + +export async function GET() { + try { + const deployments = await fetchDeploymentsFromCoolify(); + + return NextResponse.json(deployments, { + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + }); + } catch (error) { + console.error('Error fetching deployments:', error); + return NextResponse.json( + { error: 'Failed to fetch deployments', details: String(error) }, + { status: 500 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index c6d11cf..dfad7b3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,15 +1,15 @@ 'use client'; -import { useState } from 'react'; -import { Header, SearchBar, ServiceCard, BookmarkCard, CategorySection, Icon } from '@/components'; +import { Header, SearchBar, ServiceCard, BookmarkCard, CategorySection, Icon, DeploymentsTable } from '@/components'; import { usePortal } from '@/lib/PortalContext'; import { categoryLabels, categoryOrder, bookmarkCategoryLabels, bookmarkCategoryOrder, ServiceCategory, BookmarkCategory } from '@/lib/services'; -type TabId = 'services' | 'bookmarks' | 'ai' | 'whyrating' | 'settings'; +type TabId = 'services' | 'bookmarks' | 'ai' | 'whyrating' | 'deployments' | 'settings'; const tabs: { id: TabId; label: string; icon: string }[] = [ { id: 'whyrating', label: 'WhyRating', icon: 'whyrating' }, { id: 'services', label: 'Services', icon: 'server' }, + { id: 'deployments', label: 'Deployments', icon: 'rocket' }, { id: 'ai', label: 'AI', icon: 'bot' }, { id: 'bookmarks', label: 'Bookmarks', icon: 'external-link' }, { id: 'settings', label: 'Settings', icon: 'settings' }, @@ -32,8 +32,20 @@ const whyratingLinks = [ ]; export default function Home() { - const [activeTab, setActiveTab] = useState('whyrating'); - const { filteredServices, filteredBookmarks, healthStatus, searchQuery, darkMode, setDarkMode, services } = usePortal(); + const { + filteredServices, + filteredBookmarks, + healthStatus, + searchQuery, + darkMode, + setDarkMode, + services, + deployments, + deploymentsLoading, + refreshDeployments, + activeTab, + setActiveTab, + } = usePortal(); // Group services by category const servicesByCategory = categoryOrder.reduce((acc, category) => { @@ -221,6 +233,25 @@ export default function Home() { ); + case 'deployments': + return ( +
+
+

+ Deployments +

+

+ All deployments across Coolify applications +

+
+ +
+ ); + case 'settings': return (
diff --git a/src/components/DeploymentLogs.tsx b/src/components/DeploymentLogs.tsx new file mode 100644 index 0000000..2080d26 --- /dev/null +++ b/src/components/DeploymentLogs.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { parseDeploymentLogs, DeploymentLog, DeploymentStatus } from '@/lib/deployments'; +import { Icon } from './Icons'; + +interface DeploymentLogsProps { + deploymentUuid: string; + status: DeploymentStatus; + initialLogs?: string; +} + +export function DeploymentLogs({ deploymentUuid, status, initialLogs }: DeploymentLogsProps) { + const [logs, setLogs] = useState(() => parseDeploymentLogs(initialLogs)); + const [isPolling, setIsPolling] = useState(status === 'in_progress' || status === 'queued'); + const [copied, setCopied] = useState(false); + const logsEndRef = useRef(null); + const containerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + const fetchLogs = useCallback(async () => { + try { + const response = await fetch(`/api/deployments/${deploymentUuid}`); + if (response.ok) { + const data = await response.json(); + const parsedLogs = parseDeploymentLogs(data.logs); + setLogs(parsedLogs); + + // Stop polling if deployment finished + if (data.status !== 'in_progress' && data.status !== 'queued') { + setIsPolling(false); + } + } + } catch (error) { + console.error('Failed to fetch logs:', error); + } + }, [deploymentUuid]); + + // Poll for logs while deployment is in progress + useEffect(() => { + if (!isPolling) return; + + const interval = setInterval(fetchLogs, 2000); + return () => clearInterval(interval); + }, [isPolling, fetchLogs]); + + // Initial fetch if no logs provided + useEffect(() => { + if (!initialLogs) { + fetchLogs(); + } + }, [initialLogs, fetchLogs]); + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + if (autoScroll && logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [logs, autoScroll]); + + // Detect manual scroll to disable auto-scroll + const handleScroll = () => { + if (!containerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + // If user scrolled up more than 100px from bottom, disable auto-scroll + setAutoScroll(scrollHeight - scrollTop - clientHeight < 100); + }; + + const copyToClipboard = async () => { + const text = logs.map((log) => log.output).join('\n'); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error('Failed to copy:', error); + } + }; + + return ( +
+ {/* Header */} +
+ Build Logs +
+ {isPolling && ( + + + + + 2s + + )} + +
+
+ + {/* Logs */} +
+ {logs.length === 0 ? ( +
+ {isPolling ? 'Waiting for logs...' : 'No logs available'} +
+ ) : ( + logs.map((log, index) => ( +
+ {log.timestamp && ( + + {log.timestamp} + + )} + {log.output} +
+ )) + )} +
+
+ + {/* Auto-scroll indicator */} + {!autoScroll && logs.length > 0 && ( + + )} +
+ ); +} diff --git a/src/components/DeploymentsTable.tsx b/src/components/DeploymentsTable.tsx new file mode 100644 index 0000000..e5271e8 --- /dev/null +++ b/src/components/DeploymentsTable.tsx @@ -0,0 +1,417 @@ +'use client'; + +import { useState, useMemo, Fragment } from 'react'; +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + getPaginationRowModel, + getExpandedRowModel, + flexRender, + SortingState, + ColumnFiltersState, + ExpandedState, + createColumnHelper, + Row, +} from '@tanstack/react-table'; +import { + Deployment, + DeploymentStatus, + STATUS_COLORS, + STATUS_LABELS, + formatDuration, + formatRelativeTime, + truncateCommitMessage, +} from '@/lib/deployments'; +import { Icon } from './Icons'; +import { DeploymentLogs } from './DeploymentLogs'; + +interface DeploymentsTableProps { + deployments: Deployment[]; + isLoading: boolean; + onRefresh: () => void; +} + +const columnHelper = createColumnHelper(); + +const StatusDot = ({ status }: { status: DeploymentStatus }) => ( + +); + +const StatusBadge = ({ status }: { status: DeploymentStatus }) => ( + + + {STATUS_LABELS[status]} + +); + +export function DeploymentsTable({ deployments, isLoading, onRefresh }: DeploymentsTableProps) { + const [sorting, setSorting] = useState([ + { id: 'created_at', desc: true }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [expanded, setExpanded] = useState({}); + const [statusFilter, setStatusFilter] = useState('all'); + const [appFilter, setAppFilter] = useState('all'); + + // Get unique application names for filter dropdown + const applicationNames = useMemo(() => { + const names = new Set(deployments.map((d) => d.application_name)); + return Array.from(names).sort(); + }, [deployments]); + + // Filter deployments + const filteredDeployments = useMemo(() => { + return deployments.filter((d) => { + if (statusFilter !== 'all' && d.status !== statusFilter) return false; + if (appFilter !== 'all' && d.application_name !== appFilter) return false; + return true; + }); + }, [deployments, statusFilter, appFilter]); + + const columns = useMemo( + () => [ + columnHelper.accessor('deployment_uuid', { + header: 'Deployment', + cell: ({ row, getValue }) => ( + + ), + }), + columnHelper.accessor('is_current', { + header: 'Environment', + cell: ({ getValue }) => ( +
+ + Production + + {getValue() && ( + + + Current + + )} +
+ ), + }), + columnHelper.accessor('duration', { + header: 'Duration', + cell: ({ getValue }) => { + const duration = getValue(); + return ( + + {duration ? formatDuration(duration) : '-'} + + ); + }, + }), + columnHelper.accessor('status', { + header: 'Status', + cell: ({ getValue }) => , + }), + columnHelper.accessor('application_name', { + header: 'Application', + cell: ({ getValue, row }) => ( + e.stopPropagation()} + > + + + + {getValue()} + + ), + }), + columnHelper.accessor('git_branch', { + header: 'Source', + cell: ({ getValue, row }) => ( +
+ + + + {getValue() || 'main'} + {row.original.git_commit_sha && ( + <> + + {row.original.git_commit_sha.substring(0, 7)} + + + {truncateCommitMessage(row.original.commit_message || '')} + + + )} +
+ ), + }), + columnHelper.accessor('created_at', { + header: 'Created', + cell: ({ getValue, row }) => ( +
+ {formatRelativeTime(getValue())} + by + {row.original.is_webhook ? 'webhook' : 'API'} +
+ ), + }), + columnHelper.display({ + id: 'actions', + cell: ({ row }) => ( + + ), + }), + ], + [] + ); + + const table = useReactTable({ + data: filteredDeployments, + columns, + state: { + sorting, + columnFilters, + expanded, + }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onExpandedChange: setExpanded, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getRowCanExpand: () => true, + initialState: { + pagination: { + pageSize: 10, + }, + }, + }); + + const renderExpandedRow = (row: Row) => ( + + ); + + // Count active filters + const activeFilterCount = (statusFilter !== 'all' ? 1 : 0) + (appFilter !== 'all' ? 1 : 0); + + return ( +
+ {/* Filters */} +
+
+ {/* Application Filter */} + + + {/* Status Filter */} + + + {activeFilterCount > 0 && ( + + )} +
+ +
+ + {filteredDeployments.length} deployments + + +
+
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {isLoading && deployments.length === 0 ? ( + + + + ) : filteredDeployments.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => ( + + row.toggleExpanded()} + > + {row.getVisibleCells().map((cell) => ( + + ))} + + {row.getIsExpanded() && ( + + + + )} + + )) + )} + +
+ {header.isPlaceholder ? null : ( + + )} +
+
+ + Loading deployments... +
+
+ No deployments found +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ {renderExpandedRow(row)} +
+ + {/* Pagination */} + {filteredDeployments.length > 10 && ( +
+
+ Rows per page: + +
+ +
+ + Page {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount()} + +
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 333743a..ab05dc5 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -109,6 +109,18 @@ export const icons: Record> = { 'settings': createIcon(''), 'loader': createIcon(''), + // Navigation + 'chevron-right': createIcon(''), + 'chevron-left': createIcon(''), + 'chevron-up': createIcon(''), + 'chevron-down': createIcon(''), + + // Actions + 'check': createIcon(''), + 'copy': createIcon(''), + 'box': createIcon(''), + 'rocket': createIcon(''), + // WhyRating brand logo icon 'whyrating': function WhyRatingIcon({ className = '', size = 24 }: IconProps) { return ( diff --git a/src/components/index.ts b/src/components/index.ts index 9866244..8b39b74 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,3 +5,5 @@ export { CategorySection } from './CategorySection'; export { SearchBar } from './SearchBar'; export { Header } from './Header'; export { Section } from './ui/Section'; +export { DeploymentsTable } from './DeploymentsTable'; +export { DeploymentLogs } from './DeploymentLogs'; diff --git a/src/lib/PortalContext.tsx b/src/lib/PortalContext.tsx index 9284e1b..f95a75e 100644 --- a/src/lib/PortalContext.tsx +++ b/src/lib/PortalContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; import { services, bookmarks, Service, Bookmark } from './services'; +import type { Deployment } from './deployments'; export type HealthStatus = 'running' | 'stopped' | 'unknown' | 'loading'; @@ -21,6 +22,12 @@ interface PortalContextType { filteredBookmarks: Bookmark[]; refreshHealth: () => Promise; isRefreshing: boolean; + // Deployments + deployments: Deployment[]; + deploymentsLoading: boolean; + refreshDeployments: () => Promise; + activeTab: string; + setActiveTab: (tab: string) => void; } const PortalContext = createContext(undefined); @@ -37,6 +44,9 @@ export function PortalProvider({ children }: { children: ReactNode }) { return initial; }); const [isRefreshing, setIsRefreshing] = useState(false); + const [deployments, setDeployments] = useState([]); + const [deploymentsLoading, setDeploymentsLoading] = useState(false); + const [activeTab, setActiveTab] = useState('whyrating'); // Apply dark mode to document useEffect(() => { @@ -82,6 +92,31 @@ export function PortalProvider({ children }: { children: ReactNode }) { return () => clearInterval(interval); }, [refreshHealth]); + // Fetch deployments + const refreshDeployments = useCallback(async () => { + setDeploymentsLoading(true); + try { + const response = await fetch('/api/deployments'); + if (response.ok) { + const data = await response.json(); + setDeployments(data); + } + } catch (error) { + console.error('Failed to fetch deployments:', error); + } finally { + setDeploymentsLoading(false); + } + }, []); + + // Fetch deployments when tab is active, poll every 10 seconds + useEffect(() => { + if (activeTab === 'deployments') { + refreshDeployments(); + const interval = setInterval(refreshDeployments, 10000); + return () => clearInterval(interval); + } + }, [activeTab, refreshDeployments]); + // Filter services and bookmarks based on search query const filteredServices = services.filter(service => { if (!searchQuery) return true; @@ -117,6 +152,11 @@ export function PortalProvider({ children }: { children: ReactNode }) { filteredBookmarks, refreshHealth, isRefreshing, + deployments, + deploymentsLoading, + refreshDeployments, + activeTab, + setActiveTab, }} > {children} diff --git a/src/lib/deployments.ts b/src/lib/deployments.ts new file mode 100644 index 0000000..f4d691f --- /dev/null +++ b/src/lib/deployments.ts @@ -0,0 +1,101 @@ +export type DeploymentStatus = 'queued' | 'in_progress' | 'finished' | 'error' | 'cancelled'; + +export interface Deployment { + deployment_uuid: string; + application_uuid: string; + application_name: string; + status: DeploymentStatus; + created_at: string; + updated_at: string; + server_name?: string; + git_branch?: string; + git_commit_sha?: string; + commit_message?: string; + is_webhook?: boolean; + logs?: string; + // Computed fields + duration?: number; // in seconds + is_current?: boolean; // latest deployment for this app +} + +export interface DeploymentLog { + timestamp: string; + output: string; + type: 'stdout' | 'stderr'; + hidden?: boolean; +} + +export const STATUS_COLORS: Record = { + finished: 'bg-cyan-500', + error: 'bg-red-500', + in_progress: 'bg-orange-500', + queued: 'bg-gray-400', + cancelled: 'bg-gray-400', +}; + +export const STATUS_LABELS: Record = { + finished: 'Ready', + error: 'Error', + in_progress: 'Building', + queued: 'Queued', + cancelled: 'Cancelled', +}; + +export function formatDuration(seconds: number): string { + if (seconds < 60) { + return `${seconds}s`; + } + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; +} + +export function formatRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +export function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'numeric', + day: 'numeric', + year: '2-digit' + }); +} + +export function truncateCommitMessage(message: string, maxLength = 40): string { + if (!message) return ''; + const firstLine = message.split('\n')[0]; + if (firstLine.length <= maxLength) return firstLine; + return firstLine.substring(0, maxLength - 3) + '...'; +} + +export function parseDeploymentLogs(logsJson: string | undefined): DeploymentLog[] { + if (!logsJson) return []; + try { + const parsed = JSON.parse(logsJson); + if (Array.isArray(parsed)) { + return parsed.filter((log: DeploymentLog) => !log.hidden); + } + return []; + } catch { + // If not valid JSON, treat as plain text + return logsJson.split('\n').filter(Boolean).map((line) => ({ + timestamp: '', + output: line, + type: 'stdout' as const, + })); + } +}