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;
|
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) {
|
export function DeploymentLogs({ deploymentUuid, status, initialLogs }: DeploymentLogsProps) {
|
||||||
const [logs, setLogs] = useState<DeploymentLog[]>(() => parseDeploymentLogs(initialLogs));
|
const [logs, setLogs] = useState<DeploymentLog[]>(() => parseDeploymentLogs(initialLogs));
|
||||||
const [isPolling, setIsPolling] = useState(status === 'in_progress' || status === 'queued');
|
const [isPolling, setIsPolling] = useState(status === 'in_progress' || status === 'queued');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
@@ -62,7 +86,6 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||||
// If user scrolled up more than 100px from bottom, disable auto-scroll
|
|
||||||
setAutoScroll(scrollHeight - scrollTop - clientHeight < 100);
|
setAutoScroll(scrollHeight - scrollTop - clientHeight < 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,14 +100,101 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Expanded modal view
|
||||||
|
if (isExpanded) {
|
||||||
return (
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-200 dark:border-stone-800">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-stone-300">Build Logs</span>
|
<span className="text-sm font-medium text-slate-200">Build Logs - {deploymentUuid.substring(0, 12)}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isPolling && (
|
{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">
|
<span className="animate-spin">
|
||||||
<Icon name="refresh-cw" size={12} />
|
<Icon name="refresh-cw" size={12} />
|
||||||
</span>
|
</span>
|
||||||
@@ -96,11 +206,21 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
copyToClipboard();
|
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} />
|
<Icon name={copied ? 'check' : 'copy'} size={14} />
|
||||||
{copied ? 'Copied' : 'Copy'}
|
{copied ? 'Copied' : 'Copy'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -109,26 +229,19 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
onClick={(e) => e.stopPropagation()}
|
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 ? (
|
{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'}
|
{isPolling ? 'Waiting for logs...' : 'No logs available'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log, index) => (
|
logs.map((log, index) => (
|
||||||
<div
|
<div key={index} className={`flex ${getLogLineStyle(log)}`}>
|
||||||
key={index}
|
<span className="text-slate-600 mr-2 select-none shrink-0 w-8 text-right">
|
||||||
className={`flex ${
|
{index + 1}
|
||||||
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}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className="whitespace-pre-wrap break-words min-w-0">{log.output}</span>
|
||||||
<span className="whitespace-pre-wrap break-all">{log.output}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -145,7 +258,7 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
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
|
Scroll to bottom
|
||||||
</button>
|
</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"/>'),
|
'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"/>'),
|
'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"/>'),
|
'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"/>'),
|
'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"/>'),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user