Improve deployment logs with colors, formatting, and expand view
- Add syntax highlighting: errors (red), warnings (yellow), success (green) - Add line numbers - Add expand button for fullscreen modal view - Fix horizontal overflow with overflow-x-hidden and break-words - Dark terminal-style background for better readability Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,10 +10,34 @@ interface DeploymentLogsProps {
|
||||
initialLogs?: string;
|
||||
}
|
||||
|
||||
// Color log lines based on content
|
||||
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';
|
||||
}
|
||||
// Commands often start with $ or >
|
||||
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 [logs, setLogs] = useState<DeploymentLog[]>(() => parseDeploymentLogs(initialLogs));
|
||||
const [isPolling, setIsPolling] = useState(status === 'in_progress' || status === 'queued');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
@@ -62,7 +86,6 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -77,14 +100,101 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
||||
}
|
||||
};
|
||||
|
||||
// Expanded modal view
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div className="relative border-t border-slate-200 dark:border-stone-800 bg-slate-50 dark:bg-stone-950">
|
||||
<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()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-200 dark:border-stone-800">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-stone-300">Build Logs</span>
|
||||
<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">
|
||||
{isPolling && (
|
||||
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-stone-500">
|
||||
<span className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<span className="animate-spin">
|
||||
<Icon name="refresh-cw" size={12} />
|
||||
</span>
|
||||
Polling
|
||||
</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>
|
||||
|
||||
{/* Logs */}
|
||||
<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">
|
||||
{isPolling ? '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>
|
||||
|
||||
{/* Footer */}
|
||||
<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">
|
||||
{/* Header */}
|
||||
<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">
|
||||
{isPolling && (
|
||||
<span className="flex items-center gap-1 text-xs text-slate-500">
|
||||
<span className="animate-spin">
|
||||
<Icon name="refresh-cw" size={12} />
|
||||
</span>
|
||||
@@ -96,11 +206,21 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
||||
e.stopPropagation();
|
||||
copyToClipboard();
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-500 dark:text-stone-400 hover:text-slate-700 dark:hover:text-stone-200 hover:bg-slate-200 dark:hover:bg-stone-800 rounded transition-colors"
|
||||
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>
|
||||
|
||||
@@ -109,26 +229,19 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-80 overflow-y-auto overflow-x-auto font-mono text-xs p-4 space-y-0.5"
|
||||
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 dark:text-stone-500 text-center py-8">
|
||||
<div className="text-slate-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-600 dark:text-red-400' : 'text-slate-700 dark:text-stone-300'
|
||||
}`}
|
||||
>
|
||||
{log.timestamp && (
|
||||
<span className="text-slate-400 dark:text-stone-600 mr-3 select-none shrink-0">
|
||||
{log.timestamp}
|
||||
<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-all">{log.output}</span>
|
||||
<span className="whitespace-pre-wrap break-words min-w-0">{log.output}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -145,7 +258,7 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}}
|
||||
className="absolute bottom-4 right-4 px-3 py-1.5 bg-slate-200 dark:bg-stone-800 text-slate-700 dark:text-stone-300 text-xs rounded-full shadow-lg hover:bg-slate-300 dark:hover:bg-stone-700 transition-colors"
|
||||
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>
|
||||
|
||||
@@ -106,6 +106,7 @@ export const icons: Record<string, React.ComponentType<IconProps>> = {
|
||||
'external-link': createIcon('<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>'),
|
||||
'refresh-cw': createIcon('<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>'),
|
||||
'x': createIcon('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'),
|
||||
'maximize-2': createIcon('<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/>'),
|
||||
'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"/>'),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user