Files
nuc/nuc-portal/src/components/DeploymentLogs.tsx
Alejandro Gutiérrez 9a0881e852 Add NUC Portal - infrastructure dashboard
Next.js 16 dashboard for managing NUC services via Coolify API.
Features service cards with health indicators, deployment dashboard
with live log streaming, S3-backed preview images, SSE real-time
updates, and dark mode support. 18 services across 7 categories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:17:32 +01:00

227 lines
8.7 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { parseDeploymentLogs, DeploymentLog, DeploymentStatus } from '@/lib/deployments';
import { usePortal } from '@/lib/PortalContext';
import { Icon } from './Icons';
interface DeploymentLogsProps {
deploymentUuid: string;
status: DeploymentStatus;
initialLogs?: string;
}
function getLogLineStyle(log: DeploymentLog): string {
const output = log.output.toLowerCase();
if (log.type === 'stderr' || output.includes('error') || output.includes('failed')) {
return 'text-red-500 dark:text-red-400';
}
if (output.includes('warning') || output.includes('warn')) {
return 'text-yellow-600 dark:text-yellow-400';
}
if (output.includes('success') || output.includes('finished') || output.includes('done') || output.includes('✓')) {
return 'text-green-600 dark:text-green-400';
}
if (output.startsWith('---') || output.startsWith('===') || output.startsWith('###')) {
return 'text-cyan-600 dark:text-cyan-400 font-semibold';
}
if (output.startsWith('$') || output.startsWith('>') || output.startsWith('#')) {
return 'text-purple-600 dark:text-purple-400';
}
return 'text-slate-600 dark:text-stone-400';
}
export function DeploymentLogs({ deploymentUuid, status, initialLogs }: DeploymentLogsProps) {
const { activeDeployLogs } = usePortal();
const [logs, setLogs] = useState<DeploymentLog[]>(() => parseDeploymentLogs(initialLogs));
const isActive = status === 'in_progress' || status === 'queued';
const [copied, setCopied] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const logsEndRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
// Update logs from SSE active deploy logs
useEffect(() => {
if (!isActive) return;
const match = activeDeployLogs.find(l => l.uuid === deploymentUuid);
if (match?.logs) {
setLogs(parseDeploymentLogs(match.logs));
}
}, [activeDeployLogs, deploymentUuid, isActive]);
// Fetch logs on first render if none provided
const fetchLogs = useCallback(async () => {
try {
const response = await fetch(`/api/deployments/${deploymentUuid}`);
if (response.ok) {
const data = await response.json();
setLogs(parseDeploymentLogs(data.logs));
}
} catch (error) {
console.error('Failed to fetch logs:', error);
}
}, [deploymentUuid]);
useEffect(() => {
if (!initialLogs && !isActive) {
fetchLogs();
}
}, [initialLogs, isActive, fetchLogs]);
// Auto-scroll
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [logs, autoScroll]);
const handleScroll = () => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
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);
}
};
// Expanded modal view
if (isExpanded) {
return (
<div
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
>
<div
className="w-full max-w-6xl h-[80vh] bg-slate-900 rounded-xl shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<span className="text-sm font-medium text-slate-200">Build Logs - {deploymentUuid.substring(0, 12)}</span>
<div className="flex items-center gap-2">
{isActive && (
<span className="flex items-center gap-1 text-xs text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Live
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); copyToClipboard(); }}
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
>
<Icon name={copied ? 'check' : 'copy'} size={14} />
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
>
<Icon name="x" size={14} />
Close
</button>
</div>
</div>
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-auto font-mono text-xs p-4 space-y-0.5 bg-slate-950"
>
{logs.length === 0 ? (
<div className="text-slate-500 text-center py-8">
{isActive ? 'Waiting for logs...' : 'No logs available'}
</div>
) : (
logs.map((log, index) => (
<div key={index} className={`flex ${getLogLineStyle(log)}`}>
<span className="text-slate-600 mr-3 select-none shrink-0 w-16 text-right">{index + 1}</span>
{log.timestamp && (
<span className="text-slate-500 mr-3 select-none shrink-0">{log.timestamp}</span>
)}
<span className="whitespace-pre-wrap break-all">{log.output}</span>
</div>
))
)}
<div ref={logsEndRef} />
</div>
<div className="px-4 py-2 border-t border-slate-700 text-xs text-slate-500">
{logs.length} lines
</div>
</div>
</div>
);
}
// Inline view
return (
<div className="border-t border-slate-200 dark:border-stone-800 bg-slate-900 dark:bg-stone-950">
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-700 dark:border-stone-800">
<span className="text-sm font-medium text-slate-300">Build Logs</span>
<div className="flex items-center gap-2">
{isActive && (
<span className="flex items-center gap-1 text-xs text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Live
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); copyToClipboard(); }}
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
>
<Icon name={copied ? 'check' : 'copy'} size={14} />
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={(e) => { e.stopPropagation(); setIsExpanded(true); }}
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
>
<Icon name="maximize-2" size={14} />
Expand
</button>
</div>
</div>
<div
ref={containerRef}
onScroll={handleScroll}
onClick={(e) => e.stopPropagation()}
className="h-64 overflow-y-auto overflow-x-hidden font-mono text-xs p-4 space-y-0.5"
>
{logs.length === 0 ? (
<div className="text-slate-500 text-center py-8">
{isActive ? 'Waiting for logs...' : 'No logs available'}
</div>
) : (
logs.map((log, index) => (
<div key={index} className={`flex ${getLogLineStyle(log)}`}>
<span className="text-slate-600 mr-2 select-none shrink-0 w-8 text-right">{index + 1}</span>
<span className="whitespace-pre-wrap break-words min-w-0">{log.output}</span>
</div>
))
)}
<div ref={logsEndRef} />
</div>
{!autoScroll && logs.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setAutoScroll(true);
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}}
className="absolute bottom-4 right-4 px-3 py-1.5 bg-slate-700 text-slate-200 text-xs rounded-full shadow-lg hover:bg-slate-600 transition-colors"
>
Scroll to bottom
</button>
)}
</div>
);
}