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:
34
package-lock.json
generated
34
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
97
src/app/api/deployments/[uuid]/route.ts
Normal file
97
src/app/api/deployments/[uuid]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/app/api/deployments/route.ts
Normal file
141
src/app/api/deployments/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
148
src/components/DeploymentLogs.tsx
Normal file
148
src/components/DeploymentLogs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
417
src/components/DeploymentsTable.tsx
Normal file
417
src/components/DeploymentsTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
101
src/lib/deployments.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user