Add global Deployments dashboard with expandable logs

- New Deployments tab showing all Coolify deployments
- TanStack Table with sorting, filtering, pagination
- Status badges (Ready/Building/Error/Queued/Cancelled)
- Application and status filter dropdowns
- Expandable rows showing build logs in real-time
- Auto-refresh every 10 seconds when tab is active
- Log polling every 2 seconds for in-progress deployments
- API routes that query Coolify database directly via docker exec

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-02 01:40:43 +00:00
parent 299e7beb57
commit 58308c9c62
11 changed files with 1029 additions and 5 deletions

34
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "nuc-portal", "name": "nuc-portal",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-table": "^8.21.3",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
@@ -1513,6 +1514,39 @@
"tailwindcss": "4.1.18" "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": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",

View File

@@ -10,6 +10,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-table": "^8.21.3",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"

View File

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

View File

@@ -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<Deployment[]> {
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<string, string>();
// 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<string, unknown>) => {
// 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 }
);
}
}

View File

@@ -1,15 +1,15 @@
'use client'; 'use client';
import { useState } from 'react'; import { Header, SearchBar, ServiceCard, BookmarkCard, CategorySection, Icon, DeploymentsTable } from '@/components';
import { Header, SearchBar, ServiceCard, BookmarkCard, CategorySection, Icon } from '@/components';
import { usePortal } from '@/lib/PortalContext'; import { usePortal } from '@/lib/PortalContext';
import { categoryLabels, categoryOrder, bookmarkCategoryLabels, bookmarkCategoryOrder, ServiceCategory, BookmarkCategory } from '@/lib/services'; 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 }[] = [ const tabs: { id: TabId; label: string; icon: string }[] = [
{ id: 'whyrating', label: 'WhyRating', icon: 'whyrating' }, { id: 'whyrating', label: 'WhyRating', icon: 'whyrating' },
{ id: 'services', label: 'Services', icon: 'server' }, { id: 'services', label: 'Services', icon: 'server' },
{ id: 'deployments', label: 'Deployments', icon: 'rocket' },
{ id: 'ai', label: 'AI', icon: 'bot' }, { id: 'ai', label: 'AI', icon: 'bot' },
{ id: 'bookmarks', label: 'Bookmarks', icon: 'external-link' }, { id: 'bookmarks', label: 'Bookmarks', icon: 'external-link' },
{ id: 'settings', label: 'Settings', icon: 'settings' }, { id: 'settings', label: 'Settings', icon: 'settings' },
@@ -32,8 +32,20 @@ const whyratingLinks = [
]; ];
export default function Home() { export default function Home() {
const [activeTab, setActiveTab] = useState<TabId>('whyrating'); const {
const { filteredServices, filteredBookmarks, healthStatus, searchQuery, darkMode, setDarkMode, services } = usePortal(); filteredServices,
filteredBookmarks,
healthStatus,
searchQuery,
darkMode,
setDarkMode,
services,
deployments,
deploymentsLoading,
refreshDeployments,
activeTab,
setActiveTab,
} = usePortal();
// Group services by category // Group services by category
const servicesByCategory = categoryOrder.reduce((acc, category) => { const servicesByCategory = categoryOrder.reduce((acc, category) => {
@@ -221,6 +233,25 @@ export default function Home() {
</div> </div>
); );
case 'deployments':
return (
<div>
<div className="mb-6">
<h2 className="text-xl font-semibold text-slate-900 dark:text-stone-100 mb-2">
Deployments
</h2>
<p className="text-sm text-slate-500 dark:text-stone-500">
All deployments across Coolify applications
</p>
</div>
<DeploymentsTable
deployments={deployments}
isLoading={deploymentsLoading}
onRefresh={refreshDeployments}
/>
</div>
);
case 'settings': case 'settings':
return ( return (
<div className="max-w-2xl"> <div className="max-w-2xl">

View File

@@ -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<DeploymentLog[]>(() => parseDeploymentLogs(initialLogs));
const [isPolling, setIsPolling] = useState(status === 'in_progress' || status === 'queued');
const [copied, setCopied] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<div className="border-t border-stone-800 bg-stone-950">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-stone-800">
<span className="text-sm font-medium text-stone-300">Build Logs</span>
<div className="flex items-center gap-2">
{isPolling && (
<span className="flex items-center gap-1 text-xs text-stone-500">
<span className="animate-spin">
<Icon name="refresh-cw" size={12} />
</span>
2s
</span>
)}
<button
onClick={copyToClipboard}
className="flex items-center gap-1 px-2 py-1 text-xs text-stone-400 hover:text-stone-200 hover:bg-stone-800 rounded transition-colors"
>
<Icon name={copied ? 'check' : 'copy'} size={14} />
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
{/* Logs */}
<div
ref={containerRef}
onScroll={handleScroll}
className="max-h-80 overflow-y-auto font-mono text-xs p-4 space-y-0.5"
>
{logs.length === 0 ? (
<div className="text-stone-500 text-center py-8">
{isPolling ? 'Waiting for logs...' : 'No logs available'}
</div>
) : (
logs.map((log, index) => (
<div
key={index}
className={`flex ${
log.type === 'stderr' ? 'text-red-400' : 'text-stone-300'
}`}
>
{log.timestamp && (
<span className="text-stone-600 mr-3 select-none shrink-0">
{log.timestamp}
</span>
)}
<span className="whitespace-pre-wrap break-all">{log.output}</span>
</div>
))
)}
<div ref={logsEndRef} />
</div>
{/* Auto-scroll indicator */}
{!autoScroll && logs.length > 0 && (
<button
onClick={() => {
setAutoScroll(true);
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}}
className="absolute bottom-4 right-4 px-3 py-1.5 bg-stone-800 text-stone-300 text-xs rounded-full shadow-lg hover:bg-stone-700 transition-colors"
>
Scroll to bottom
</button>
)}
</div>
);
}

View File

@@ -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<Deployment>();
const StatusDot = ({ status }: { status: DeploymentStatus }) => (
<span
className={`inline-block w-2 h-2 rounded-full ${STATUS_COLORS[status]} ${
status === 'in_progress' ? 'animate-pulse' : ''
}`}
/>
);
const StatusBadge = ({ status }: { status: DeploymentStatus }) => (
<span className="flex items-center gap-1.5">
<StatusDot status={status} />
<span className="text-stone-300">{STATUS_LABELS[status]}</span>
</span>
);
export function DeploymentsTable({ deployments, isLoading, onRefresh }: DeploymentsTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{ id: 'created_at', desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [expanded, setExpanded] = useState<ExpandedState>({});
const [statusFilter, setStatusFilter] = useState<DeploymentStatus | 'all'>('all');
const [appFilter, setAppFilter] = useState<string>('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 }) => (
<button
onClick={() => row.toggleExpanded()}
className="flex items-center gap-2 text-stone-300 hover:text-white font-mono text-sm"
>
<Icon
name="chevron-right"
size={14}
className={`transition-transform ${row.getIsExpanded() ? 'rotate-90' : ''}`}
/>
<span>{getValue().substring(0, 9)}</span>
</button>
),
}),
columnHelper.accessor('is_current', {
header: 'Environment',
cell: ({ getValue }) => (
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 text-xs rounded bg-stone-800 text-stone-300">
Production
</span>
{getValue() && (
<span className="flex items-center gap-1 text-xs text-cyan-400">
<Icon name="check" size={12} />
Current
</span>
)}
</div>
),
}),
columnHelper.accessor('duration', {
header: 'Duration',
cell: ({ getValue }) => {
const duration = getValue();
return (
<span className="text-stone-400 text-sm">
{duration ? formatDuration(duration) : '-'}
</span>
);
},
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ getValue }) => <StatusBadge status={getValue()} />,
}),
columnHelper.accessor('application_name', {
header: 'Application',
cell: ({ getValue, row }) => (
<a
href={`http://192.168.1.3:8000/project/a8484ggc88c40w4g4k004ow0/production/application/${row.original.application_uuid}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-stone-300 hover:text-white"
onClick={(e) => e.stopPropagation()}
>
<span className="w-5 h-5 flex items-center justify-center rounded bg-stone-800">
<Icon name="box" size={12} className="text-stone-400" />
</span>
{getValue()}
</a>
),
}),
columnHelper.accessor('git_branch', {
header: 'Source',
cell: ({ getValue, row }) => (
<div className="flex items-center gap-2 text-sm">
<span className="text-stone-500">
<Icon name="git-branch" size={14} />
</span>
<span className="text-stone-300">{getValue() || 'main'}</span>
{row.original.git_commit_sha && (
<>
<span className="text-stone-600 font-mono">
{row.original.git_commit_sha.substring(0, 7)}
</span>
<span className="text-stone-500 truncate max-w-[200px]">
{truncateCommitMessage(row.original.commit_message || '')}
</span>
</>
)}
</div>
),
}),
columnHelper.accessor('created_at', {
header: 'Created',
cell: ({ getValue, row }) => (
<div className="flex items-center gap-2 text-sm text-stone-400">
<span>{formatRelativeTime(getValue())}</span>
<span className="text-stone-600">by</span>
<span>{row.original.is_webhook ? 'webhook' : 'API'}</span>
</div>
),
}),
columnHelper.display({
id: 'actions',
cell: ({ row }) => (
<div className="flex items-center gap-1">
<a
href={`http://192.168.1.3:8000/project/a8484ggc88c40w4g4k004ow0/production/application/${row.original.application_uuid}/deployment/${row.original.deployment_uuid}`}
target="_blank"
rel="noopener noreferrer"
className="p-1 text-stone-500 hover:text-stone-300 hover:bg-stone-800 rounded transition-colors"
onClick={(e) => e.stopPropagation()}
>
<Icon name="external-link" size={16} />
</a>
</div>
),
}),
],
[]
);
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<Deployment>) => (
<DeploymentLogs
deploymentUuid={row.original.deployment_uuid}
status={row.original.status}
initialLogs={row.original.logs}
/>
);
// Count active filters
const activeFilterCount = (statusFilter !== 'all' ? 1 : 0) + (appFilter !== 'all' ? 1 : 0);
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
{/* Application Filter */}
<select
value={appFilter}
onChange={(e) => setAppFilter(e.target.value)}
className="px-3 py-1.5 text-sm bg-stone-900 border border-stone-700 rounded-lg text-stone-300 focus:outline-none focus:border-stone-500"
>
<option value="all">All Applications</option>
{applicationNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
{/* Status Filter */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as DeploymentStatus | 'all')}
className="px-3 py-1.5 text-sm bg-stone-900 border border-stone-700 rounded-lg text-stone-300 focus:outline-none focus:border-stone-500"
>
<option value="all">All Statuses</option>
<option value="finished">Ready</option>
<option value="in_progress">Building</option>
<option value="error">Error</option>
<option value="queued">Queued</option>
<option value="cancelled">Cancelled</option>
</select>
{activeFilterCount > 0 && (
<button
onClick={() => {
setStatusFilter('all');
setAppFilter('all');
}}
className="text-xs text-stone-500 hover:text-stone-300"
>
Clear filters
</button>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-stone-500">
{filteredDeployments.length} deployments
</span>
<button
onClick={onRefresh}
disabled={isLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-stone-300 hover:text-white bg-stone-800 hover:bg-stone-700 rounded-lg transition-colors disabled:opacity-50"
>
<Icon
name="refresh-cw"
size={14}
className={isLoading ? 'animate-spin' : ''}
/>
Refresh
</button>
</div>
</div>
{/* Table */}
<div className="bg-stone-900 rounded-xl border border-stone-700/50 overflow-hidden">
<table className="w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b border-stone-800">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left text-xs font-medium text-stone-500 uppercase tracking-wider"
>
{header.isPlaceholder ? null : (
<button
className={`flex items-center gap-1 ${
header.column.getCanSort()
? 'cursor-pointer hover:text-stone-300'
: ''
}`}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' && (
<Icon name="chevron-up" size={14} />
)}
{header.column.getIsSorted() === 'desc' && (
<Icon name="chevron-down" size={14} />
)}
</button>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{isLoading && deployments.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center">
<div className="flex items-center justify-center gap-2 text-stone-500">
<Icon name="refresh-cw" size={16} className="animate-spin" />
Loading deployments...
</div>
</td>
</tr>
) : filteredDeployments.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center text-stone-500">
No deployments found
</td>
</tr>
) : (
table.getRowModel().rows.map((row) => (
<Fragment key={row.id}>
<tr
className={`border-b border-stone-800/50 hover:bg-stone-800/30 cursor-pointer transition-colors ${
row.getIsExpanded() ? 'bg-stone-800/50' : ''
}`}
onClick={() => row.toggleExpanded()}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
{row.getIsExpanded() && (
<tr>
<td colSpan={columns.length} className="p-0">
{renderExpandedRow(row)}
</td>
</tr>
)}
</Fragment>
))
)}
</tbody>
</table>
{/* Pagination */}
{filteredDeployments.length > 10 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-stone-800">
<div className="flex items-center gap-2">
<span className="text-sm text-stone-500">Rows per page:</span>
<select
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
className="px-2 py-1 text-sm bg-stone-800 border border-stone-700 rounded text-stone-300 focus:outline-none"
>
{[10, 25, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-stone-500">
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="p-1 text-stone-400 hover:text-stone-200 disabled:opacity-30 disabled:cursor-not-allowed"
>
<Icon name="chevron-left" size={20} />
</button>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="p-1 text-stone-400 hover:text-stone-200 disabled:opacity-30 disabled:cursor-not-allowed"
>
<Icon name="chevron-right" size={20} />
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -109,6 +109,18 @@ export const icons: Record<string, React.ComponentType<IconProps>> = {
'settings': createIcon('<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>'), 'settings': createIcon('<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>'),
'loader': createIcon('<path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/>'), 'loader': createIcon('<path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/>'),
// Navigation
'chevron-right': createIcon('<path d="m9 18 6-6-6-6"/>'),
'chevron-left': createIcon('<path d="m15 18-6-6 6-6"/>'),
'chevron-up': createIcon('<path d="m18 15-6-6-6 6"/>'),
'chevron-down': createIcon('<path d="m6 9 6 6 6-6"/>'),
// Actions
'check': createIcon('<path d="M20 6 9 17l-5-5"/>'),
'copy': createIcon('<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>'),
'box': createIcon('<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'),
'rocket': createIcon('<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2Z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>'),
// WhyRating brand logo icon // WhyRating brand logo icon
'whyrating': function WhyRatingIcon({ className = '', size = 24 }: IconProps) { 'whyrating': function WhyRatingIcon({ className = '', size = 24 }: IconProps) {
return ( return (

View File

@@ -5,3 +5,5 @@ export { CategorySection } from './CategorySection';
export { SearchBar } from './SearchBar'; export { SearchBar } from './SearchBar';
export { Header } from './Header'; export { Header } from './Header';
export { Section } from './ui/Section'; export { Section } from './ui/Section';
export { DeploymentsTable } from './DeploymentsTable';
export { DeploymentLogs } from './DeploymentLogs';

View File

@@ -2,6 +2,7 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { services, bookmarks, Service, Bookmark } from './services'; import { services, bookmarks, Service, Bookmark } from './services';
import type { Deployment } from './deployments';
export type HealthStatus = 'running' | 'stopped' | 'unknown' | 'loading'; export type HealthStatus = 'running' | 'stopped' | 'unknown' | 'loading';
@@ -21,6 +22,12 @@ interface PortalContextType {
filteredBookmarks: Bookmark[]; filteredBookmarks: Bookmark[];
refreshHealth: () => Promise<void>; refreshHealth: () => Promise<void>;
isRefreshing: boolean; isRefreshing: boolean;
// Deployments
deployments: Deployment[];
deploymentsLoading: boolean;
refreshDeployments: () => Promise<void>;
activeTab: string;
setActiveTab: (tab: string) => void;
} }
const PortalContext = createContext<PortalContextType | undefined>(undefined); const PortalContext = createContext<PortalContextType | undefined>(undefined);
@@ -37,6 +44,9 @@ export function PortalProvider({ children }: { children: ReactNode }) {
return initial; return initial;
}); });
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [deployments, setDeployments] = useState<Deployment[]>([]);
const [deploymentsLoading, setDeploymentsLoading] = useState(false);
const [activeTab, setActiveTab] = useState('whyrating');
// Apply dark mode to document // Apply dark mode to document
useEffect(() => { useEffect(() => {
@@ -82,6 +92,31 @@ export function PortalProvider({ children }: { children: ReactNode }) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [refreshHealth]); }, [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 // Filter services and bookmarks based on search query
const filteredServices = services.filter(service => { const filteredServices = services.filter(service => {
if (!searchQuery) return true; if (!searchQuery) return true;
@@ -117,6 +152,11 @@ export function PortalProvider({ children }: { children: ReactNode }) {
filteredBookmarks, filteredBookmarks,
refreshHealth, refreshHealth,
isRefreshing, isRefreshing,
deployments,
deploymentsLoading,
refreshDeployments,
activeTab,
setActiveTab,
}} }}
> >
{children} {children}

101
src/lib/deployments.ts Normal file
View File

@@ -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<DeploymentStatus, string> = {
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<DeploymentStatus, string> = {
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,
}));
}
}