feat(logs): added logs sidebar

This commit is contained in:
Emir Karabeg
2025-03-10 02:00:44 -07:00
parent 03e1ab8443
commit 7db6f8b273
3 changed files with 346 additions and 45 deletions

View File

@@ -0,0 +1,250 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { WorkflowLog } from '@/app/w/logs/stores/types'
import { formatDate } from '@/app/w/logs/utils/format-date'
interface LogSidebarProps {
log: WorkflowLog | null
isOpen: boolean
onClose: () => void
}
/**
* Formats JSON content for display, handling multiple JSON objects separated by '--'
*/
const formatJsonContent = (content: string): JSX.Element => {
// Check if the content has multiple parts separated by '--'
const parts = content.split(/\s*--\s*/g).filter((part) => part.trim().length > 0)
if (parts.length > 1) {
// Handle multiple parts
return (
<div className="space-y-4">
{parts.map((part, index) => (
<div key={index} className="border-b pb-4 last:border-b-0 last:pb-0">
{formatSingleJsonContent(part)}
</div>
))}
</div>
)
}
// Handle single part
return formatSingleJsonContent(content)
}
/**
* Formats a single JSON content part
*/
const formatSingleJsonContent = (content: string): JSX.Element => {
try {
// Try to parse the content as JSON
const jsonStart = content.indexOf('{')
if (jsonStart === -1) return <div className="text-sm break-words">{content}</div>
const messagePart = content.substring(0, jsonStart).trim()
const jsonPart = content.substring(jsonStart)
try {
const jsonData = JSON.parse(jsonPart)
return (
<div>
{messagePart && <div className="mb-2 font-medium text-sm break-words">{messagePart}</div>}
<div className="bg-secondary/50 p-3 rounded-md">
<pre className="text-xs whitespace-pre-wrap break-all max-w-full overflow-hidden">
<code>{JSON.stringify(jsonData, null, 2)}</code>
</pre>
</div>
</div>
)
} catch (e) {
// If JSON parsing fails, try to find and format any valid JSON objects in the content
const jsonRegex = /{[^{}]*({[^{}]*})*[^{}]*}/g
const jsonMatches = content.match(jsonRegex)
if (jsonMatches && jsonMatches.length > 0) {
return (
<div>
{messagePart && (
<div className="mb-2 font-medium text-sm break-words">{messagePart}</div>
)}
{jsonMatches.map((jsonStr, idx) => {
try {
const parsedJson = JSON.parse(jsonStr)
return (
<div key={idx} className="bg-secondary/50 p-3 rounded-md mt-2">
<pre className="text-xs whitespace-pre-wrap break-all max-w-full overflow-hidden">
<code>{JSON.stringify(parsedJson, null, 2)}</code>
</pre>
</div>
)
} catch {
return (
<div key={idx} className="mt-2 text-sm break-words">
{jsonStr}
</div>
)
}
})}
</div>
)
}
}
} catch (e) {
// If all parsing fails, return the original content
}
return <div className="text-sm break-words">{content}</div>
}
export function Sidebar({ log, isOpen, onClose }: LogSidebarProps) {
const [width, setWidth] = useState(400) // Default width from the original styles
const [isDragging, setIsDragging] = useState(false)
const formattedContent = useMemo(() => {
if (!log) return null
return formatJsonContent(log.message)
}, [log])
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true)
e.preventDefault()
}
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newWidth = window.innerWidth - e.clientX
// Maintain minimum and maximum widths
setWidth(Math.max(400, Math.min(newWidth, window.innerWidth * 0.8)))
}
}
const handleMouseUp = () => {
setIsDragging(false)
}
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [isDragging])
// Handle escape key to close the sidebar
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose])
return (
<div
className={`fixed inset-y-0 right-0 bg-background border-l shadow-lg transform transition-transform duration-200 ease-in-out z-50 ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
style={{ top: '64px', width: `${width}px` }}
>
<div
className="absolute left-[-4px] top-0 bottom-0 w-4 cursor-ew-resize hover:bg-accent/50 z-50"
onMouseDown={handleMouseDown}
/>
{log && (
<>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<h2 className="text-base font-medium">Log Details</h2>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 p-0"
onClick={onClose}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<ScrollArea className="h-[calc(100vh-64px-49px)]">
{' '}
{/* Adjust for header height */}
<div className="p-4 space-y-4">
{/* Timestamp */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Timestamp</h3>
<p className="text-sm">{formatDate(log.createdAt).full}</p>
</div>
{/* Workflow */}
{log.workflow && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Workflow</h3>
<div
className="inline-flex items-center px-2 py-1 text-xs rounded-md"
style={{
backgroundColor: `${log.workflow.color}20`,
color: log.workflow.color,
}}
>
{log.workflow.name}
</div>
</div>
)}
{/* Execution ID */}
{log.executionId && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Execution ID</h3>
<p className="text-sm font-mono break-all">{log.executionId}</p>
</div>
)}
{/* Level */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Level</h3>
<p className="text-sm capitalize">{log.level}</p>
</div>
{/* Trigger */}
{log.trigger && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Trigger</h3>
<p className="text-sm capitalize">{log.trigger}</p>
</div>
)}
{/* Duration */}
{log.duration && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Duration</h3>
<p className="text-sm">{log.duration}</p>
</div>
)}
{/* Message Content */}
<div className="pb-2">
<h3 className="text-xs font-medium text-muted-foreground mb-1">Message</h3>
<div>{formattedContent}</div>
</div>
</div>
</ScrollArea>
</>
)}
</div>
)
}

View File

@@ -1,52 +1,14 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { format } from 'date-fns'
import { AlertCircle, Clock, Info, Loader2 } from 'lucide-react'
import { AlertCircle, Info, Loader2 } from 'lucide-react'
import { ControlBar } from './components/control-bar/control-bar'
import { Filters } from './components/filters/filters'
import { Sidebar } from './components/sidebar/sidebar'
import { useFilterStore } from './stores/store'
import { LogsResponse } from './stores/types'
// Helper function to format date
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return {
full: date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
time: date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
formatted: format(date, 'HH:mm:ss'),
relative: (() => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays === 1) return 'yesterday'
if (diffDays < 7) return `${diffDays}d ago`
return format(date, 'MMM d')
})(),
}
}
import { LogsResponse, WorkflowLog } from './stores/types'
import { formatDate } from './utils/format-date'
// Helper function to get level badge styling
const getLevelBadgeStyles = (level: string) => {
@@ -69,6 +31,43 @@ const getTriggerBadgeStyles = (trigger: string) => {
export default function Logs() {
const { filteredLogs, logs, loading, error, setLogs, setLoading, setError } = useFilterStore()
const [selectedLog, setSelectedLog] = useState<WorkflowLog | null>(null)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
// Group logs by executionId to identify the last log in each group
const executionGroups = useMemo(() => {
const groups: Record<string, WorkflowLog[]> = {}
// Group logs by executionId
filteredLogs.forEach((log) => {
if (log.executionId) {
if (!groups[log.executionId]) {
groups[log.executionId] = []
}
groups[log.executionId].push(log)
}
})
// Sort logs within each group by createdAt
Object.keys(groups).forEach((executionId) => {
groups[executionId].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
})
return groups
}, [filteredLogs])
// Handle log click
const handleLogClick = (log: WorkflowLog) => {
setSelectedLog(log)
setIsSidebarOpen(true)
}
// Close sidebar
const handleCloseSidebar = () => {
setIsSidebarOpen(false)
}
// Fetch logs on component mount
useEffect(() => {
@@ -160,10 +159,12 @@ export default function Logs() {
<div>
{filteredLogs.map((log) => {
const formattedDate = formatDate(log.createdAt)
return (
<div
key={log.id}
className="group border-b hover:bg-accent/30 transition-colors"
className={`group border-b hover:bg-accent/30 transition-colors cursor-pointer`}
onClick={() => handleLogClick(log)}
>
<div className="grid grid-cols-12 gap-4 px-4 py-3">
{/* Time column */}
@@ -172,7 +173,11 @@ export default function Logs() {
<span>{formattedDate.formatted}</span>
<span className="mx-1.5 text-muted-foreground hidden xl:inline"></span>
<span className="text-muted-foreground hidden xl:inline">
{format(new Date(log.createdAt), 'MMM d, yyyy')}
{new Date(log.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5 flex items-center justify-between">
@@ -245,6 +250,9 @@ export default function Logs() {
</div>
</div>
</div>
{/* Log Sidebar */}
<Sidebar log={selectedLog} isOpen={isSidebarOpen} onClose={handleCloseSidebar} />
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { format } from 'date-fns'
/**
* Helper function to format date in various formats
*/
export const formatDate = (dateString: string) => {
const date = new Date(dateString)
return {
full: date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
time: date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
formatted: format(date, 'HH:mm:ss'),
relative: (() => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays === 1) return 'yesterday'
if (diffDays < 7) return `${diffDays}d ago`
return format(date, 'MMM d')
})(),
}
}