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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user