improvement(ui/ux): logs (#762)

* improvement: filters ui/ux and logic complete

* improvement(ui/ux): logs control bar

* improvement(ui): log table

* improvement: logs sidebar

* ran lint
This commit is contained in:
Emir Karabeg
2025-07-24 18:48:10 -07:00
committed by GitHub
parent 17e493b3b5
commit af1c7dc39d
17 changed files with 846 additions and 584 deletions

View File

@@ -1,170 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Loader2, Play, RefreshCw, Search, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { useDebounce } from '@/hooks/use-debounce'
import { useFilterStore } from '../../stores/store'
import type { LogsResponse } from '../../stores/types'
const logger = createLogger('ControlBar')
/**
* Control bar for logs page - includes search functionality and refresh/live controls
*/
export function ControlBar() {
const [isLive, setIsLive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isRefreshing, setIsRefreshing] = useState(false)
const liveIntervalRef = useRef<NodeJS.Timeout | null>(null)
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const {
setSearchQuery: setStoreSearchQuery,
setLogs,
setError,
buildQueryParams,
} = useFilterStore()
// Update store when debounced search query changes
useEffect(() => {
setStoreSearchQuery(debouncedSearchQuery)
}, [debouncedSearchQuery, setStoreSearchQuery])
const fetchLogs = async () => {
try {
const queryParams = buildQueryParams(1, 50)
const response = await fetch(`/api/logs/enhanced?${queryParams}`)
if (!response.ok) {
throw new Error(`Error fetching logs: ${response.statusText}`)
}
const data: LogsResponse = await response.json()
return data
} catch (err) {
logger.error('Failed to fetch logs:', { err })
throw err
}
}
const handleRefresh = async () => {
if (isRefreshing) return
setIsRefreshing(true)
// Create a timer to ensure the spinner shows for at least 1 second
const minLoadingTime = new Promise((resolve) => setTimeout(resolve, 1000))
try {
// Fetch new logs
const logsResponse = await fetchLogs()
// Wait for minimum loading time
await minLoadingTime
// Replace logs with fresh filtered results from server
setLogs(logsResponse.data)
setError(null)
} catch (err) {
// Wait for minimum loading time
await minLoadingTime
setError(err instanceof Error ? err.message : 'An unknown error occurred')
} finally {
setIsRefreshing(false)
}
}
// Setup or clear the live refresh interval when isLive changes
useEffect(() => {
// Clear any existing interval
if (liveIntervalRef.current) {
clearInterval(liveIntervalRef.current)
liveIntervalRef.current = null
}
// If live mode is active, set up the interval
if (isLive) {
// Initial refresh when live mode is activated
handleRefresh()
// Set up interval for subsequent refreshes (every 5 seconds)
liveIntervalRef.current = setInterval(() => {
handleRefresh()
}, 5000)
}
// Cleanup function to clear interval when component unmounts or isLive changes
return () => {
if (liveIntervalRef.current) {
clearInterval(liveIntervalRef.current)
liveIntervalRef.current = null
}
}
}, [isLive])
const toggleLive = () => {
setIsLive(!isLive)
}
return (
<div className='flex h-16 w-full items-center justify-between border-b bg-background px-6 transition-all duration-300'>
{/* Left Section - Search */}
<div className='relative w-[400px]'>
<div className='pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3'>
<Search className='h-4 w-4 text-muted-foreground' />
</div>
<Input
type='search'
placeholder='Search logs...'
className='h-9 pl-10'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* Middle Section - Reserved for future use */}
<div className='flex-1' />
{/* Right Section - Actions */}
<div className='flex items-center gap-3'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={handleRefresh}
className='hover:text-foreground'
disabled={isRefreshing}
>
{isRefreshing ? (
<Loader2 className='h-5 w-5 animate-spin' />
) : (
<RefreshCw className='h-5 w-5' />
)}
<span className='sr-only'>Refresh</span>
</Button>
</TooltipTrigger>
<TooltipContent>{isRefreshing ? 'Refreshing...' : 'Refresh'}</TooltipContent>
</Tooltip>
<Button
className={`gap-2 border bg-background text-foreground hover:bg-accent ${
isLive ? 'border-[#802FFF]' : 'border-input'
}`}
onClick={toggleLive}
>
{isLive ? (
<Square className='!h-3.5 !w-3.5 text-[#802FFF]' />
) : (
<Play className='!h-3.5 !w-3.5' />
)}
<span className={`${isLive ? 'text-[#802FFF]' : 'text-foreground'}`}>Live</span>
</Button>
</div>
</div>
)
}

View File

@@ -1,41 +1,20 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
export default function FilterSection({
title,
defaultOpen = false,
content,
}: {
title: string
defaultOpen?: boolean
content?: React.ReactNode
}) {
const [isOpen, setIsOpen] = useState(defaultOpen)
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className='mb-3'>
<CollapsibleTrigger asChild>
<Button
variant='ghost'
className='flex w-full justify-between rounded-md px-2 font-medium text-sm hover:bg-accent'
>
<span>{title}</span>
<ChevronDown
className={`mr-[5px] h-4 w-4 text-muted-foreground transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className='pt-2'>
<div className='space-y-1'>
<div className='font-medium text-muted-foreground text-xs'>{title}</div>
<div>
{content || (
<div className='text-muted-foreground text-sm'>
Filter options for {title} will go here
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Check, ChevronDown, Folder } from 'lucide-react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
@@ -9,8 +9,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useFilterStore } from '@/app/workspace/[workspaceId]/logs/stores/store'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
interface FolderOption {
id: string
@@ -91,49 +91,35 @@ export default function FolderFilter() {
setFolderIds([])
}
// Add special option for workflows without folders
const includeRootOption = true
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className='w-full justify-between font-normal text-sm'>
<Button
variant='outline'
size='sm'
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[#202020]'
>
{loading ? 'Loading folders...' : getSelectedFoldersText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' className='max-h-[300px] w-[200px] overflow-y-auto'>
<DropdownMenuContent
align='start'
className='max-h-[300px] w-[200px] overflow-y-auto rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
<DropdownMenuItem
key='all'
onSelect={(e) => {
e.preventDefault()
clearSelections()
}}
className='flex cursor-pointer items-center justify-between p-2 text-sm'
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>All folders</span>
{folderIds.length === 0 && <Check className='h-4 w-4 text-primary' />}
</DropdownMenuItem>
{/* Option for workflows without folders */}
{includeRootOption && (
<DropdownMenuItem
key='root'
onSelect={(e) => {
e.preventDefault()
toggleFolderId('root')
}}
className='flex cursor-pointer items-center justify-between p-2 text-sm'
>
<div className='flex items-center'>
<Folder className='mr-2 h-3 w-3 text-muted-foreground' />
No folder
</div>
{isFolderSelected('root') && <Check className='h-4 w-4 text-primary' />}
</DropdownMenuItem>
)}
{(!loading && folders.length > 0) || includeRootOption ? <DropdownMenuSeparator /> : null}
{!loading && folders.length > 0 && <DropdownMenuSeparator />}
{!loading &&
folders.map((folder) => (
@@ -143,13 +129,9 @@ export default function FolderFilter() {
e.preventDefault()
toggleFolderId(folder.id)
}}
className='flex cursor-pointer items-center justify-between p-2 text-sm'
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='flex items-center'>
<div
className='mr-2 h-2 w-2 rounded-full'
style={{ backgroundColor: folder.color }}
/>
<span className='truncate' title={folder.path}>
{folder.path}
</span>
@@ -159,7 +141,10 @@ export default function FolderFilter() {
))}
{loading && (
<DropdownMenuItem disabled className='p-2 text-muted-foreground text-sm'>
<DropdownMenuItem
disabled
className='rounded-md px-3 py-2 font-[380] text-muted-foreground text-sm'
>
Loading folders...
</DropdownMenuItem>
)}

View File

@@ -4,46 +4,66 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useFilterStore } from '@/app/workspace/[workspaceId]/logs/stores/store'
import type { LogLevel } from '@/app/workspace/[workspaceId]/logs/stores/types'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { LogLevel } from '@/stores/logs/filters/types'
export default function Level() {
const { level, setLevel } = useFilterStore()
const levels: { value: LogLevel; label: string; color?: string }[] = [
{ value: 'all', label: 'Any status' },
const specificLevels: { value: LogLevel; label: string; color: string }[] = [
{ value: 'error', label: 'Error', color: 'bg-destructive/100' },
{ value: 'info', label: 'Info', color: 'bg-muted-foreground/100' },
]
const getDisplayLabel = () => {
const selected = levels.find((l) => l.value === level)
if (level === 'all') return 'Any status'
const selected = specificLevels.find((l) => l.value === level)
return selected ? selected.label : 'Any status'
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className='w-full justify-between font-normal text-sm'>
<Button
variant='outline'
size='sm'
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[#202020]'
>
{getDisplayLabel()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' className='w-[180px]'>
{levels.map((levelItem) => (
<DropdownMenuContent
align='start'
className='w-[180px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
<DropdownMenuItem
key='all'
onSelect={(e) => {
e.preventDefault()
setLevel('all')
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>Any status</span>
{level === 'all' && <Check className='h-4 w-4 text-primary' />}
</DropdownMenuItem>
<DropdownMenuSeparator />
{specificLevels.map((levelItem) => (
<DropdownMenuItem
key={levelItem.value}
onSelect={(e) => {
e.preventDefault()
setLevel(levelItem.value)
}}
className='flex cursor-pointer items-center justify-between p-2 text-sm'
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='flex items-center'>
{levelItem.color && (
<div className={`mr-2 h-2 w-2 rounded-full ${levelItem.color}`} />
)}
<div className={`mr-2 h-2 w-2 rounded-full ${levelItem.color}`} />
{levelItem.label}
</div>
{level === levelItem.value && <Check className='h-4 w-4 text-primary' />}

View File

@@ -4,32 +4,54 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useFilterStore } from '@/app/workspace/[workspaceId]/logs/stores/store'
import type { TimeRange } from '@/app/workspace/[workspaceId]/logs/stores/types'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { TimeRange } from '@/stores/logs/filters/types'
export default function Timeline() {
const { timeRange, setTimeRange } = useFilterStore()
const timeRanges: TimeRange[] = ['All time', 'Past 30 minutes', 'Past hour', 'Past 24 hours']
const specificTimeRanges: TimeRange[] = ['Past 30 minutes', 'Past hour', 'Past 24 hours']
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className='w-full justify-between font-normal text-sm'>
<Button
variant='outline'
size='sm'
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[#202020]'
>
{timeRange}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' className='w-[180px]'>
{timeRanges.map((range) => (
<DropdownMenuContent
align='start'
className='w-[180px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
<DropdownMenuItem
key='all'
onSelect={(e) => {
e.preventDefault()
setTimeRange('All time')
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>All time</span>
{timeRange === 'All time' && <Check className='h-4 w-4 text-primary' />}
</DropdownMenuItem>
<DropdownMenuSeparator />
{specificTimeRanges.map((range) => (
<DropdownMenuItem
key={range}
onSelect={(e) => {
e.preventDefault()
setTimeRange(range)
}}
className='flex cursor-pointer items-center justify-between p-2 text-sm'
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>{range}</span>
{timeRange === range && <Check className='h-4 w-4 text-primary' />}

View File

@@ -7,13 +7,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useFilterStore } from '@/app/workspace/[workspaceId]/logs/stores/store'
import type { TriggerType } from '../../../stores/types'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { TriggerType } from '../../../../../../../stores/logs/filters/types'
export default function Trigger() {
const { triggers, toggleTrigger, setTriggers } = useFilterStore()
const triggerOptions: { value: TriggerType; label: string; color?: string }[] = [
{ value: 'manual', label: 'Manual', color: 'bg-secondary' },
{ value: 'manual', label: 'Manual', color: 'bg-gray-500' },
{ value: 'api', label: 'API', color: 'bg-blue-500' },
{ value: 'webhook', label: 'Webhook', color: 'bg-orange-500' },
{ value: 'schedule', label: 'Schedule', color: 'bg-green-500' },
@@ -43,19 +43,26 @@ export default function Trigger() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className='w-full justify-between font-normal text-sm'>
<Button
variant='outline'
size='sm'
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[#202020]'
>
{getSelectedTriggersText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' className='w-[180px]'>
<DropdownMenuContent
align='start'
className='w-[180px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
<DropdownMenuItem
key='all'
onSelect={(e) => {
e.preventDefault()
clearSelections()
}}
className='flex cursor-pointer items-center justify-between p-2 text-sm'
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>All triggers</span>
{triggers.length === 0 && <Check className='h-4 w-4 text-primary' />}
@@ -70,7 +77,7 @@ export default function Trigger() {
e.preventDefault()
toggleTrigger(triggerItem.value)
}}
className='flex cursor-pointer items-center justify-between p-2 text-sm'
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='flex items-center'>
{triggerItem.color && (

View File

@@ -8,7 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useFilterStore } from '@/app/workspace/[workspaceId]/logs/stores/store'
import { useFilterStore } from '@/stores/logs/filters/store'
interface WorkflowOption {
id: string
@@ -69,19 +69,30 @@ export default function Workflow() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className='w-full justify-between font-normal text-sm'>
<Button
variant='outline'
size='sm'
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[#202020]'
>
{loading ? 'Loading workflows...' : getSelectedWorkflowsText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' className='max-h-[300px] w-[180px] overflow-y-auto'>
<DropdownMenuContent
align='start'
className='max-h-[300px] w-[180px] overflow-y-auto rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
<DropdownMenuItem
key='all'
onSelect={(e) => {
e.preventDefault()
clearSelections()
}}
className='flex cursor-pointer items-center justify-between p-2 text-sm'
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>All workflows</span>
{workflowIds.length === 0 && <Check className='h-4 w-4 text-primary' />}
@@ -97,7 +108,7 @@ export default function Workflow() {
e.preventDefault()
toggleWorkflowId(workflow.id)
}}
className='flex cursor-pointer items-center justify-between p-2 text-sm'
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='flex items-center'>
<div
@@ -111,7 +122,10 @@ export default function Workflow() {
))}
{loading && (
<DropdownMenuItem disabled className='p-2 text-muted-foreground text-sm'>
<DropdownMenuItem
disabled
className='rounded-md px-3 py-2 font-[380] text-muted-foreground text-sm'
>
Loading workflows...
</DropdownMenuItem>
)}

View File

@@ -57,19 +57,19 @@ export function Filters() {
<h2 className='mb-4 pl-2 font-medium text-sm'>Filters</h2>
{/* Timeline Filter */}
<FilterSection title='Timeline' defaultOpen={true} content={<Timeline />} />
<FilterSection title='Timeline' content={<Timeline />} />
{/* Level Filter */}
<FilterSection title='Level' defaultOpen={true} content={<Level />} />
<FilterSection title='Level' content={<Level />} />
{/* Trigger Filter */}
<FilterSection title='Trigger' defaultOpen={true} content={<Trigger />} />
<FilterSection title='Trigger' content={<Trigger />} />
{/* Folder Filter */}
<FilterSection title='Folder' defaultOpen={true} content={<FolderFilter />} />
<FilterSection title='Folder' content={<FolderFilter />} />
{/* Workflow Filter */}
<FilterSection title='Workflow' defaultOpen={true} content={<Workflow />} />
<FilterSection title='Workflow' content={<Workflow />} />
</div>
)
}

View File

@@ -7,9 +7,9 @@ import { CopyButton } from '@/components/ui/copy-button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { redactApiKeys } from '@/lib/utils'
import type { WorkflowLog } from '@/app/workspace/[workspaceId]/logs/stores/types'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date'
import { formatCost } from '@/providers/utils'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { FrozenCanvasModal } from '../frozen-canvas/frozen-canvas-modal'
import { ToolCallsDisplay } from '../tool-calls/tool-calls-display'
import { TraceSpansDisplay } from '../trace-spans/trace-spans-display'
@@ -350,10 +350,10 @@ export function Sidebar({
return (
<div
className={`fixed inset-y-0 right-0 transform border-l bg-background ${
isOpen ? 'translate-x-0' : 'translate-x-full'
className={`fixed top-[148px] right-4 bottom-4 transform rounded-[14px] border bg-card shadow-xs ${
isOpen ? 'translate-x-0' : 'translate-x-[calc(100%+1rem)]'
} ${isDragging ? '' : 'transition-all duration-300 ease-in-out'} z-50 flex flex-col`}
style={{ top: '64px', width: `${width}px`, minWidth: `${MIN_WIDTH}px` }}
style={{ width: `${width}px`, minWidth: `${MIN_WIDTH}px` }}
>
<div
className='absolute top-0 bottom-0 left-[-4px] z-50 w-4 cursor-ew-resize hover:bg-accent/50'
@@ -362,9 +362,9 @@ export function Sidebar({
{log && (
<>
{/* Header */}
<div className='flex flex-shrink-0 items-center justify-between border-b px-4 py-3'>
<h2 className='font-medium text-base'>Log Details</h2>
<div className='flex items-center space-x-1'>
<div className='flex items-center justify-between px-3 pt-3 pb-1'>
<h2 className='font-[450] text-base text-card-foreground'>Log Details</h2>
<div className='flex items-center gap-2'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -414,277 +414,280 @@ export function Sidebar({
</div>
{/* Content */}
<ScrollArea
className='h-[calc(100vh-64px-49px)] w-full overflow-y-auto'
ref={scrollAreaRef}
>
<div className='w-full space-y-4 p-4 pr-6'>
{/* Timestamp */}
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Timestamp</h3>
<div className='group relative text-sm'>
<CopyButton text={formatDate(log.createdAt).full} />
{formatDate(log.createdAt).full}
</div>
</div>
{/* Workflow */}
{log.workflow && (
<div className='flex-1 overflow-hidden px-3'>
<ScrollArea className='h-full w-full overflow-y-auto' ref={scrollAreaRef}>
<div className='w-full space-y-4 pr-3 pb-4'>
{/* Timestamp */}
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Workflow</h3>
<div
className='group relative text-sm'
style={{
color: log.workflow.color,
}}
>
<CopyButton text={log.workflow.name} />
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Timestamp</h3>
<div className='group relative text-sm'>
<CopyButton text={formatDate(log.createdAt).full} />
{formatDate(log.createdAt).full}
</div>
</div>
{/* Workflow */}
{log.workflow && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Workflow</h3>
<div
className='inline-flex items-center rounded-md px-2 py-1 text-xs'
className='group relative text-sm'
style={{
backgroundColor: `${log.workflow.color}20`,
color: log.workflow.color,
}}
>
{log.workflow.name}
<CopyButton text={log.workflow.name} />
<div
className='inline-flex items-center rounded-md px-2 py-1 text-xs'
style={{
backgroundColor: `${log.workflow.color}20`,
color: log.workflow.color,
}}
>
{log.workflow.name}
</div>
</div>
</div>
</div>
)}
)}
{/* Execution ID */}
{log.executionId && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Execution ID</h3>
<div className='group relative break-all font-mono text-sm'>
<CopyButton text={log.executionId} />
{log.executionId}
{/* Execution ID */}
{log.executionId && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Execution ID</h3>
<div className='group relative break-all font-mono text-sm'>
<CopyButton text={log.executionId} />
{log.executionId}
</div>
</div>
</div>
)}
)}
{/* Level */}
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Level</h3>
<div className='group relative text-sm capitalize'>
<CopyButton text={log.level} />
{log.level}
</div>
</div>
{/* Trigger */}
{log.trigger && (
{/* Level */}
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Trigger</h3>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Level</h3>
<div className='group relative text-sm capitalize'>
<CopyButton text={log.trigger} />
{log.trigger}
<CopyButton text={log.level} />
{log.level}
</div>
</div>
)}
{/* Duration */}
{log.duration && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Duration</h3>
<div className='group relative text-sm'>
<CopyButton text={log.duration} />
{log.duration}
</div>
</div>
)}
{/* Enhanced Cost - only show for enhanced logs with actual cost data */}
{log.metadata?.enhanced && hasCostInfo && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Cost Breakdown</h3>
<div className='space-y-1 text-sm'>
{(log.metadata?.cost?.total ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Total Cost:</span>
<span className='font-medium'>
${log.metadata?.cost?.total?.toFixed(4)}
</span>
</div>
)}
{(log.metadata?.cost?.input ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Input Cost:</span>
<span className='text-muted-foreground'>
${log.metadata?.cost?.input?.toFixed(4)}
</span>
</div>
)}
{(log.metadata?.cost?.output ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Output Cost:</span>
<span className='text-muted-foreground'>
${log.metadata?.cost?.output?.toFixed(4)}
</span>
</div>
)}
{(log.metadata?.cost?.tokens?.total ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Total Tokens:</span>
<span className='text-muted-foreground'>
{log.metadata?.cost?.tokens?.total?.toLocaleString()}
</span>
</div>
)}
</div>
</div>
)}
{/* Frozen Canvas Button - only show for workflow execution logs with execution ID */}
{isWorkflowExecutionLog && log.executionId && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Workflow State</h3>
<Button
variant='outline'
size='sm'
onClick={() => setIsFrozenCanvasOpen(true)}
className='w-full justify-start gap-2'
>
<Eye className='h-4 w-4' />
View Snapshot
</Button>
<p className='mt-1 text-muted-foreground text-xs'>
See the exact workflow state and block inputs/outputs at execution time
</p>
</div>
)}
{/* Message Content */}
<div className='w-full pb-2'>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Message</h3>
<div className='w-full'>{formattedContent}</div>
</div>
{/* Trace Spans (if available and this is a workflow execution log) */}
{isWorkflowExecutionLog && log.metadata?.traceSpans && (
<div className='w-full'>
<div className='w-full overflow-x-hidden'>
<TraceSpansDisplay
traceSpans={log.metadata.traceSpans}
totalDuration={log.metadata.totalDuration}
onExpansionChange={handleTraceSpanToggle}
/>
</div>
</div>
)}
{/* Tool Calls (if available) */}
{log.metadata?.toolCalls && log.metadata.toolCalls.length > 0 && (
<div className='w-full'>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Tool Calls</h3>
<div className='w-full overflow-x-hidden rounded-md bg-secondary/30 p-3'>
<ToolCallsDisplay metadata={log.metadata} />
</div>
</div>
)}
{/* Cost Information (moved to bottom) */}
{hasCostInfo && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Models</h3>
<div className='overflow-hidden rounded-md border'>
<div className='space-y-2 p-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Input:</span>
<span className='text-sm'>
{formatCost(log.metadata?.cost?.input || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Output:</span>
<span className='text-sm'>
{formatCost(log.metadata?.cost?.output || 0)}
</span>
</div>
<div className='mt-1 flex items-center justify-between border-t pt-2'>
<span className='text-muted-foreground text-sm'>Total:</span>
<span className='text-foreground text-sm'>
{formatCost(log.metadata?.cost?.total || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs'>Tokens:</span>
<span className='text-muted-foreground text-xs'>
{log.metadata?.cost?.tokens?.prompt || 0} in /{' '}
{log.metadata?.cost?.tokens?.completion || 0} out
</span>
</div>
{/* Trigger */}
{log.trigger && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Trigger</h3>
<div className='group relative text-sm capitalize'>
<CopyButton text={log.trigger} />
{log.trigger}
</div>
</div>
)}
{/* Models Breakdown */}
{log.metadata?.cost?.models &&
Object.keys(log.metadata?.cost?.models).length > 0 && (
<div className='border-t'>
<button
onClick={() => setIsModelsExpanded(!isModelsExpanded)}
className='flex w-full items-center justify-between p-3 text-left transition-colors hover:bg-muted/50'
>
<span className='font-medium text-muted-foreground text-xs'>
Model Breakdown (
{Object.keys(log.metadata?.cost?.models || {}).length})
</span>
{isModelsExpanded ? (
<ChevronUp className='h-3 w-3 text-muted-foreground' />
) : (
<ChevronDown className='h-3 w-3 text-muted-foreground' />
)}
</button>
{/* Duration */}
{log.duration && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Duration</h3>
<div className='group relative text-sm'>
<CopyButton text={log.duration} />
{log.duration}
</div>
</div>
)}
{isModelsExpanded && (
<div className='space-y-3 border-t bg-muted/30 p-3'>
{Object.entries(log.metadata?.cost?.models || {}).map(
([model, cost]: [string, any]) => (
<div key={model} className='space-y-1'>
<div className='font-medium font-mono text-xs'>{model}</div>
<div className='space-y-1 text-xs'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Input:</span>
<span>{formatCost(cost.input || 0)}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Output:</span>
<span>{formatCost(cost.output || 0)}</span>
</div>
<div className='flex justify-between border-t pt-1'>
<span className='text-muted-foreground'>Total:</span>
<span className='font-medium'>
{formatCost(cost.total || 0)}
</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Tokens:</span>
<span>
{cost.tokens?.prompt || 0} in /{' '}
{cost.tokens?.completion || 0} out
</span>
</div>
</div>
</div>
)
)}
</div>
)}
{/* Enhanced Cost - only show for enhanced logs with actual cost data */}
{log.metadata?.enhanced && hasCostInfo && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>
Cost Breakdown
</h3>
<div className='space-y-1 text-sm'>
{(log.metadata?.cost?.total ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Total Cost:</span>
<span className='font-medium'>
${log.metadata?.cost?.total?.toFixed(4)}
</span>
</div>
)}
{isWorkflowWithCost && (
<div className='border-t bg-muted p-3 text-muted-foreground text-xs'>
<p>
This is the total cost for all LLM-based blocks in this workflow
execution.
</p>
</div>
)}
{(log.metadata?.cost?.input ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Input Cost:</span>
<span className='text-muted-foreground'>
${log.metadata?.cost?.input?.toFixed(4)}
</span>
</div>
)}
{(log.metadata?.cost?.output ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Output Cost:</span>
<span className='text-muted-foreground'>
${log.metadata?.cost?.output?.toFixed(4)}
</span>
</div>
)}
{(log.metadata?.cost?.tokens?.total ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Total Tokens:</span>
<span className='text-muted-foreground'>
{log.metadata?.cost?.tokens?.total?.toLocaleString()}
</span>
</div>
)}
</div>
</div>
)}
{/* Frozen Canvas Button - only show for workflow execution logs with execution ID */}
{isWorkflowExecutionLog && log.executionId && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>
Workflow State
</h3>
<Button
variant='outline'
size='sm'
onClick={() => setIsFrozenCanvasOpen(true)}
className='w-full justify-start gap-2'
>
<Eye className='h-4 w-4' />
View Snapshot
</Button>
<p className='mt-1 text-muted-foreground text-xs'>
See the exact workflow state and block inputs/outputs at execution time
</p>
</div>
)}
{/* Message Content */}
<div className='w-full pb-2'>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Message</h3>
<div className='w-full'>{formattedContent}</div>
</div>
)}
</div>
</ScrollArea>
{/* Trace Spans (if available and this is a workflow execution log) */}
{isWorkflowExecutionLog && log.metadata?.traceSpans && (
<div className='w-full'>
<div className='w-full overflow-x-hidden'>
<TraceSpansDisplay
traceSpans={log.metadata.traceSpans}
totalDuration={log.metadata.totalDuration}
onExpansionChange={handleTraceSpanToggle}
/>
</div>
</div>
)}
{/* Tool Calls (if available) */}
{log.metadata?.toolCalls && log.metadata.toolCalls.length > 0 && (
<div className='w-full'>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Tool Calls</h3>
<div className='w-full overflow-x-hidden rounded-md bg-secondary/30 p-3'>
<ToolCallsDisplay metadata={log.metadata} />
</div>
</div>
)}
{/* Cost Information (moved to bottom) */}
{hasCostInfo && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Models</h3>
<div className='overflow-hidden rounded-md border'>
<div className='space-y-2 p-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Input:</span>
<span className='text-sm'>
{formatCost(log.metadata?.cost?.input || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Output:</span>
<span className='text-sm'>
{formatCost(log.metadata?.cost?.output || 0)}
</span>
</div>
<div className='mt-1 flex items-center justify-between border-t pt-2'>
<span className='text-muted-foreground text-sm'>Total:</span>
<span className='text-foreground text-sm'>
{formatCost(log.metadata?.cost?.total || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs'>Tokens:</span>
<span className='text-muted-foreground text-xs'>
{log.metadata?.cost?.tokens?.prompt || 0} in /{' '}
{log.metadata?.cost?.tokens?.completion || 0} out
</span>
</div>
</div>
{/* Models Breakdown */}
{log.metadata?.cost?.models &&
Object.keys(log.metadata?.cost?.models).length > 0 && (
<div className='border-t'>
<button
onClick={() => setIsModelsExpanded(!isModelsExpanded)}
className='flex w-full items-center justify-between p-3 text-left transition-colors hover:bg-muted/50'
>
<span className='font-medium text-muted-foreground text-xs'>
Model Breakdown (
{Object.keys(log.metadata?.cost?.models || {}).length})
</span>
{isModelsExpanded ? (
<ChevronUp className='h-3 w-3 text-muted-foreground' />
) : (
<ChevronDown className='h-3 w-3 text-muted-foreground' />
)}
</button>
{isModelsExpanded && (
<div className='space-y-3 border-t bg-muted/30 p-3'>
{Object.entries(log.metadata?.cost?.models || {}).map(
([model, cost]: [string, any]) => (
<div key={model} className='space-y-1'>
<div className='font-medium font-mono text-xs'>{model}</div>
<div className='space-y-1 text-xs'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Input:</span>
<span>{formatCost(cost.input || 0)}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Output:</span>
<span>{formatCost(cost.output || 0)}</span>
</div>
<div className='flex justify-between border-t pt-1'>
<span className='text-muted-foreground'>Total:</span>
<span className='font-medium'>
{formatCost(cost.total || 0)}
</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Tokens:</span>
<span>
{cost.tokens?.prompt || 0} in /{' '}
{cost.tokens?.completion || 0} out
</span>
</div>
</div>
</div>
)
)}
</div>
)}
</div>
)}
{isWorkflowWithCost && (
<div className='border-t bg-muted p-3 text-muted-foreground text-xs'>
<p>
This is the total cost for all LLM-based blocks in this workflow
execution.
</p>
</div>
)}
</div>
</div>
)}
</div>
</ScrollArea>
</div>
</>
)}

View File

@@ -4,7 +4,7 @@ import { useState } from 'react'
import { AlertCircle, CheckCircle2, ChevronDown, ChevronRight, Clock } from 'lucide-react'
import { CopyButton } from '@/components/ui/copy-button'
import { cn } from '@/lib/utils'
import type { ToolCall, ToolCallMetadata } from '../../stores/types'
import type { ToolCall, ToolCallMetadata } from '../../../../../../stores/logs/filters/types'
interface ToolCallsDisplayProps {
metadata: ToolCallMetadata

View File

@@ -11,7 +11,7 @@ import {
ConnectIcon,
} from '@/components/icons'
import { cn, redactApiKeys } from '@/lib/utils'
import type { TraceSpan } from '../../stores/types'
import type { TraceSpan } from '../../../../../../stores/logs/filters/types'
interface TraceSpansDisplayProps {
traceSpans?: TraceSpan[]

View File

@@ -1,19 +1,42 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertCircle, Info, Loader2 } from 'lucide-react'
import { AlertCircle, Info, Loader2, Play, RefreshCw, Search, Square } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { ControlBar } from './components/control-bar/control-bar'
import { Filters } from './components/filters/filters'
import { cn } from '@/lib/utils'
import { useDebounce } from '@/hooks/use-debounce'
import { useFilterStore } from '../../../../stores/logs/filters/store'
import type { LogsResponse, WorkflowLog } from '../../../../stores/logs/filters/types'
import { Sidebar } from './components/sidebar/sidebar'
import { useFilterStore } from './stores/store'
import type { LogsResponse, WorkflowLog } from './stores/types'
import { formatDate } from './utils/format-date'
const logger = createLogger('Logs')
const LOGS_PER_PAGE = 50
// Get color for different trigger types using app's color scheme
const getTriggerColor = (trigger: string | null | undefined): string => {
if (!trigger) return '#9ca3af'
switch (trigger.toLowerCase()) {
case 'manual':
return '#9ca3af' // gray-400 (matches secondary styling better)
case 'schedule':
return '#10b981' // green (emerald-500)
case 'webhook':
return '#f97316' // orange (orange-500)
case 'chat':
return '#8b5cf6' // purple (violet-500)
case 'api':
return '#3b82f6' // blue (blue-500)
default:
return '#9ca3af' // gray-400
}
}
const selectedRowAnimation = `
@keyframes borderPulse {
0% { border-left-color: hsl(var(--primary) / 0.3) }
@@ -45,11 +68,13 @@ export default function Logs() {
isFetchingMore,
setIsFetchingMore,
buildQueryParams,
initializeFromURL,
timeRange,
level,
workflowIds,
folderIds,
searchQuery,
searchQuery: storeSearchQuery,
setSearchQuery: setStoreSearchQuery,
triggers,
} = useFilterStore()
@@ -64,6 +89,28 @@ export default function Logs() {
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
const loaderRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const isInitialized = useRef<boolean>(false)
// Local search state with debouncing for the header
const [searchQuery, setSearchQuery] = useState(storeSearchQuery)
const debouncedSearchQuery = useDebounce(searchQuery, 300)
// Live and refresh state
const [isLive, setIsLive] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const liveIntervalRef = useRef<NodeJS.Timeout | null>(null)
// Sync local search query with store search query
useEffect(() => {
setSearchQuery(storeSearchQuery)
}, [storeSearchQuery])
// Update store when debounced search query changes
useEffect(() => {
if (debouncedSearchQuery !== storeSearchQuery) {
setStoreSearchQuery(debouncedSearchQuery)
}
}, [debouncedSearchQuery, storeSearchQuery, setStoreSearchQuery])
const handleLogClick = (log: WorkflowLog) => {
setSelectedLog(log)
@@ -140,15 +187,80 @@ export default function Logs() {
[setLogs, setLoading, setError, setHasMore, setIsFetchingMore, buildQueryParams]
)
const handleRefresh = async () => {
if (isRefreshing) return
setIsRefreshing(true)
const minLoadingTime = new Promise((resolve) => setTimeout(resolve, 1000))
try {
const logsResponse = await fetchLogs(1)
await minLoadingTime
setError(null)
} catch (err) {
await minLoadingTime
setError(err instanceof Error ? err.message : 'An unknown error occurred')
} finally {
setIsRefreshing(false)
}
}
// Setup or clear the live refresh interval when isLive changes
useEffect(() => {
fetchLogs(1)
if (liveIntervalRef.current) {
clearInterval(liveIntervalRef.current)
liveIntervalRef.current = null
}
if (isLive) {
handleRefresh()
liveIntervalRef.current = setInterval(() => {
handleRefresh()
}, 5000)
}
return () => {
if (liveIntervalRef.current) {
clearInterval(liveIntervalRef.current)
liveIntervalRef.current = null
}
}
}, [isLive])
const toggleLive = () => {
setIsLive(!isLive)
}
// Initialize filters from URL on mount
useEffect(() => {
if (!isInitialized.current) {
isInitialized.current = true
initializeFromURL()
}
}, [initializeFromURL])
// Handle browser navigation events (back/forward)
useEffect(() => {
const handlePopState = () => {
initializeFromURL()
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [initializeFromURL])
useEffect(() => {
// Only fetch logs after initialization
if (isInitialized.current) {
fetchLogs(1)
}
}, [fetchLogs])
// Refetch when filters change (but not on initial load)
const isInitialMount = useRef(true)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false
// Only fetch when initialized and filters change
if (!isInitialized.current) {
return
}
@@ -297,34 +409,99 @@ export default function Logs() {
])
return (
<div className='flex h-[100vh] flex-col pl-64'>
<div className='flex h-[100vh] min-w-0 flex-col pl-64'>
{/* Add the animation styles */}
<style jsx global>
{selectedRowAnimation}
</style>
<ControlBar />
<div className='flex flex-1 overflow-hidden'>
<Filters />
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='flex min-w-0 flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto p-6'>
{/* Header */}
<div className='mb-5'>
<h1 className='font-sans font-semibold text-3xl text-foreground tracking-[0.01em]'>
Logs
</h1>
</div>
{/* Search and Controls */}
<div className='mb-8 flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center'>
<div className='flex h-9 w-full min-w-[200px] max-w-[460px] items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search logs...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='flex flex-shrink-0 items-center gap-3'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={handleRefresh}
className='h-9 rounded-[11px] hover:bg-secondary'
disabled={isRefreshing}
>
{isRefreshing ? (
<Loader2 className='h-5 w-5 animate-spin' />
) : (
<RefreshCw className='h-5 w-5' />
)}
<span className='sr-only'>Refresh</span>
</Button>
</TooltipTrigger>
<TooltipContent>{isRefreshing ? 'Refreshing...' : 'Refresh'}</TooltipContent>
</Tooltip>
<Button
className={`group h-9 gap-2 rounded-[11px] border bg-card text-card-foreground shadow-xs transition-all duration-200 hover:border-[#701FFC] hover:bg-[#701FFC] hover:text-white ${
isLive ? 'border-[#701FFC] bg-[#701FFC] text-white' : 'border-border'
}`}
onClick={toggleLive}
>
{isLive ? (
<Square className='!h-3.5 !w-3.5 fill-current' />
) : (
<Play className='!h-3.5 !w-3.5 group-hover:fill-current' />
)}
<span>Live</span>
</Button>
</div>
</div>
{/* Table container */}
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Table with fixed layout */}
<div className='w-full min-w-[800px]'>
{/* Table with responsive layout */}
<div className='w-full overflow-x-auto'>
{/* Header */}
<div className='px-4 py-4'>
<div className='rounded-lg border border-border/30 bg-muted/30'>
<div className='grid grid-cols-[160px_100px_1fr_120px_100px_100px] gap-4 px-4 py-3'>
<div className='font-medium text-muted-foreground text-xs'>Time</div>
<div className='font-medium text-muted-foreground text-xs'>Status</div>
<div className='font-medium text-muted-foreground text-xs'>Workflow</div>
<div className='hidden font-medium text-muted-foreground text-xs lg:block'>
<div>
<div className='border-border border-b'>
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_80px_1fr] gap-2 px-2 pb-3 md:grid-cols-[140px_90px_140px_90px_1fr] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_100px_1fr] lg:gap-4 xl:grid-cols-[160px_100px_160px_100px_100px_1fr_100px]'>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Time
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Status
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Workflow
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
ID
</div>
<div className='hidden font-[480] font-sans text-[13px] text-muted-foreground leading-normal xl:block'>
Trigger
</div>
<div className='hidden font-medium text-muted-foreground text-xs xl:block'>
Cost
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Message
</div>
<div className='hidden font-[480] font-sans text-[13px] text-muted-foreground leading-normal xl:block'>
Duration
</div>
<div className='font-medium text-muted-foreground text-xs'>Duration</div>
</div>
</div>
</div>
@@ -354,7 +531,7 @@ export default function Logs() {
</div>
</div>
) : (
<div className='space-y-1 px-4 pb-4'>
<div className='pb-4'>
{logs.map((log) => {
const formattedDate = formatDate(log.createdAt)
const isSelected = selectedLog?.id === log.id
@@ -363,67 +540,85 @@ export default function Logs() {
<div
key={log.id}
ref={isSelected ? selectedRowRef : null}
className={`cursor-pointer rounded-lg border transition-all duration-200 ${
isSelected
? 'border-primary bg-accent/40 shadow-sm'
: 'border-border hover:border-border/80 hover:bg-accent/20'
className={`cursor-pointer border-border border-b transition-all duration-200 ${
isSelected ? 'bg-accent/40' : 'hover:bg-accent/20'
}`}
onClick={() => handleLogClick(log)}
>
<div className='grid grid-cols-[160px_100px_1fr_120px_100px_100px] gap-4 px-4 py-4'>
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_80px_1fr] items-center gap-2 px-2 py-4 md:grid-cols-[140px_90px_140px_90px_1fr] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_100px_1fr] lg:gap-4 xl:grid-cols-[160px_100px_160px_100px_100px_1fr_100px]'>
{/* Time */}
<div>
<div className='font-medium text-sm'>{formattedDate.formatted}</div>
<div className='text-muted-foreground text-xs'>
{formattedDate.relative}
<div className='text-[13px]'>
<span className='font-sm text-muted-foreground'>
{formattedDate.compactDate}
</span>
<span
style={{ marginLeft: '8px' }}
className='hidden font-medium sm:inline'
>
{formattedDate.compactTime}
</span>
</div>
</div>
{/* Status */}
<div>
<div
className={`inline-flex items-center justify-center rounded-md px-2 py-1 text-xs ${
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-medium text-xs transition-all duration-200 lg:px-[8px]',
log.level === 'error'
? 'bg-red-100 text-red-800'
: 'bg-green-100 text-green-800'
}`}
? 'bg-red-500 text-white'
: 'bg-secondary text-card-foreground'
)}
>
<span className='font-medium'>
{log.level === 'error' ? 'Failed' : 'Success'}
</span>
{log.level}
</div>
</div>
{/* Workflow */}
<div className='min-w-0'>
<div className='truncate font-medium text-sm'>
<div className='truncate font-medium text-[13px]'>
{log.workflow?.name || 'Unknown Workflow'}
</div>
<div className='truncate text-muted-foreground text-xs'>
{log.message}
</div>
{/* ID */}
<div>
<div className='font-medium text-muted-foreground text-xs'>
#{log.id.slice(-4)}
</div>
</div>
{/* Trigger */}
<div className='hidden lg:block'>
<div className='text-muted-foreground text-xs'>
{log.trigger || '—'}
</div>
<div className='hidden xl:block'>
{log.trigger ? (
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-medium text-xs transition-all duration-200 lg:px-[8px]',
log.trigger.toLowerCase() === 'manual'
? 'bg-secondary text-card-foreground'
: 'text-white'
)}
style={
log.trigger.toLowerCase() === 'manual'
? undefined
: { backgroundColor: getTriggerColor(log.trigger) }
}
>
{log.trigger}
</div>
) : (
<div className='text-muted-foreground text-xs'></div>
)}
</div>
{/* Cost */}
<div className='hidden xl:block'>
<div className='text-muted-foreground text-xs'>
{log.metadata?.enhanced && log.metadata?.cost?.total ? (
<span>${log.metadata.cost.total.toFixed(4)}</span>
) : (
<span className='pl-0.5'></span>
)}
</div>
{/* Message */}
<div className='min-w-0'>
<div className='truncate font-[420] text-[13px]'>{log.message}</div>
</div>
{/* Duration */}
<div>
<div className='hidden xl:block'>
<div className='text-muted-foreground text-xs'>
{log.duration || '—'}
</div>

View File

@@ -22,6 +22,9 @@ export const formatDate = (dateString: string) => {
hour12: false,
}),
formatted: format(date, 'HH:mm:ss'),
compact: format(date, 'MMM d HH:mm:ss'),
compactDate: format(date, 'MMM d').toUpperCase(),
compactTime: format(date, 'HH:mm:ss'),
relative: (() => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()

View File

@@ -0,0 +1,31 @@
'use client'
import { ScrollArea } from '@/components/ui/scroll-area'
import FilterSection from '@/app/workspace/[workspaceId]/logs/components/filters/components/filter-section'
import FolderFilter from '@/app/workspace/[workspaceId]/logs/components/filters/components/folder'
import Level from '@/app/workspace/[workspaceId]/logs/components/filters/components/level'
import Timeline from '@/app/workspace/[workspaceId]/logs/components/filters/components/timeline'
import Trigger from '@/app/workspace/[workspaceId]/logs/components/filters/components/trigger'
import Workflow from '@/app/workspace/[workspaceId]/logs/components/filters/components/workflow'
export function LogsFilters() {
const sections = [
{ key: 'timeline', title: 'Timeline', component: <Timeline /> },
{ key: 'level', title: 'Level', component: <Level /> },
{ key: 'trigger', title: 'Trigger', component: <Trigger /> },
{ key: 'folder', title: 'Folder', component: <FolderFilter /> },
{ key: 'workflow', title: 'Workflow', component: <Workflow /> },
]
return (
<div className='h-full'>
<ScrollArea className='h-full' hideScrollbar={true}>
<div className='space-y-4 px-3 py-3'>
{sections.map((section) => (
<FilterSection key={section.key} title={section.title} content={section.component} />
))}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -21,6 +21,7 @@ import { SearchModal } from '../search-modal/search-modal'
import { CreateMenu } from './components/create-menu/create-menu'
import { FolderTree } from './components/folder-tree/folder-tree'
import { HelpModal } from './components/help-modal/help-modal'
import { LogsFilters } from './components/logs-filters/logs-filters'
import { SettingsModal } from './components/settings-modal/settings-modal'
import { Toolbar } from './components/toolbar/toolbar'
import { WorkspaceHeader } from './components/workspace-header/workspace-header'
@@ -132,6 +133,13 @@ export function Sidebar() {
return workflowPageRegex.test(pathname)
}, [pathname])
// Check if we're on the logs page
const isOnLogsPage = useMemo(() => {
// Pattern: /workspace/[workspaceId]/logs
const logsPageRegex = /^\/workspace\/[^/]+\/logs$/
return logsPageRegex.test(pathname)
}, [pathname])
/**
* Refresh workspace list without validation logic - used for non-current workspace operations
*/
@@ -861,6 +869,19 @@ export function Sidebar() {
/>
</div>
{/* Floating Logs Filters - Only on logs page */}
<div
className={`pointer-events-auto fixed left-4 z-50 w-56 rounded-[14px] border bg-card shadow-xs ${
!isOnLogsPage || isSidebarCollapsed ? 'hidden' : ''
}`}
style={{
top: `${toolbarTop}px`,
bottom: `${navigationBottom + 42 + 12}px`, // Navigation height + gap
}}
>
<LogsFilters />
</div>
{/* Floating Navigation - Always visible */}
<div
className='pointer-events-auto fixed left-4 z-50 w-56'

View File

@@ -1,5 +1,62 @@
import { create } from 'zustand'
import type { FilterState, TriggerType } from './types'
import type { FilterState, LogLevel, TimeRange, TriggerType } from './types'
// Helper functions for URL synchronization
const getSearchParams = () => {
if (typeof window === 'undefined') return new URLSearchParams()
return new URLSearchParams(window.location.search)
}
const updateURL = (params: URLSearchParams) => {
if (typeof window === 'undefined') return
const url = new URL(window.location.href)
url.search = params.toString()
window.history.replaceState({}, '', url)
}
const parseTimeRangeFromURL = (value: string | null): TimeRange => {
switch (value) {
case 'past-30-minutes':
return 'Past 30 minutes'
case 'past-hour':
return 'Past hour'
case 'past-24-hours':
return 'Past 24 hours'
default:
return 'All time'
}
}
const parseLogLevelFromURL = (value: string | null): LogLevel => {
if (value === 'error' || value === 'info') return value
return 'all'
}
const parseTriggerArrayFromURL = (value: string | null): TriggerType[] => {
if (!value) return []
return value
.split(',')
.filter((t): t is TriggerType => ['chat', 'api', 'webhook', 'manual', 'schedule'].includes(t))
}
const parseStringArrayFromURL = (value: string | null): string[] => {
if (!value) return []
return value.split(',').filter(Boolean)
}
const timeRangeToURL = (timeRange: TimeRange): string => {
switch (timeRange) {
case 'Past 30 minutes':
return 'past-30-minutes'
case 'Past hour':
return 'past-hour'
case 'Past 24 hours':
return 'past-24-hours'
default:
return 'all-time'
}
}
export const useFilterStore = create<FilterState>((set, get) => ({
logs: [],
@@ -15,6 +72,7 @@ export const useFilterStore = create<FilterState>((set, get) => ({
page: 1,
hasMore: true,
isFetchingMore: false,
_isInitializing: false, // Internal flag to prevent URL sync during initialization
setLogs: (logs, append = false) => {
if (append) {
@@ -31,16 +89,25 @@ export const useFilterStore = create<FilterState>((set, get) => ({
setTimeRange: (timeRange) => {
set({ timeRange })
get().resetPagination()
if (!get()._isInitializing) {
get().syncWithURL()
}
},
setLevel: (level) => {
set({ level })
get().resetPagination()
if (!get()._isInitializing) {
get().syncWithURL()
}
},
setWorkflowIds: (workflowIds) => {
set({ workflowIds })
get().resetPagination()
if (!get()._isInitializing) {
get().syncWithURL()
}
},
toggleWorkflowId: (workflowId) => {
@@ -55,11 +122,17 @@ export const useFilterStore = create<FilterState>((set, get) => ({
set({ workflowIds: currentWorkflowIds })
get().resetPagination()
if (!get()._isInitializing) {
get().syncWithURL()
}
},
setFolderIds: (folderIds) => {
set({ folderIds })
get().resetPagination()
if (!get()._isInitializing) {
get().syncWithURL()
}
},
toggleFolderId: (folderId) => {
@@ -74,16 +147,25 @@ export const useFilterStore = create<FilterState>((set, get) => ({
set({ folderIds: currentFolderIds })
get().resetPagination()
if (!get()._isInitializing) {
get().syncWithURL()
}
},
setSearchQuery: (searchQuery) => {
set({ searchQuery })
get().resetPagination()
if (!get()._isInitializing) {
get().syncWithURL()
}
},
setTriggers: (triggers: TriggerType[]) => {
set({ triggers })
get().resetPagination()
if (!get()._isInitializing) {
get().syncWithURL()
}
},
toggleTrigger: (trigger: TriggerType) => {
@@ -98,6 +180,9 @@ export const useFilterStore = create<FilterState>((set, get) => ({
set({ triggers: currentTriggers })
get().resetPagination()
if (!get()._isInitializing) {
get().syncWithURL()
}
},
setLoading: (loading) => set({ loading }),
@@ -112,6 +197,66 @@ export const useFilterStore = create<FilterState>((set, get) => ({
resetPagination: () => set({ page: 1, hasMore: true }),
// URL synchronization methods
initializeFromURL: () => {
// Set initialization flag to prevent URL sync during init
set({ _isInitializing: true })
const params = getSearchParams()
const timeRange = parseTimeRangeFromURL(params.get('timeRange'))
const level = parseLogLevelFromURL(params.get('level'))
const workflowIds = parseStringArrayFromURL(params.get('workflowIds'))
const folderIds = parseStringArrayFromURL(params.get('folderIds'))
const triggers = parseTriggerArrayFromURL(params.get('triggers'))
const searchQuery = params.get('search') || ''
set({
timeRange,
level,
workflowIds,
folderIds,
triggers,
searchQuery,
_isInitializing: false, // Clear the flag after initialization
})
// Ensure URL reflects the initialized state
get().syncWithURL()
},
syncWithURL: () => {
const { timeRange, level, workflowIds, folderIds, triggers, searchQuery } = get()
const params = new URLSearchParams()
// Only add non-default values to keep URL clean
if (timeRange !== 'All time') {
params.set('timeRange', timeRangeToURL(timeRange))
}
if (level !== 'all') {
params.set('level', level)
}
if (workflowIds.length > 0) {
params.set('workflowIds', workflowIds.join(','))
}
if (folderIds.length > 0) {
params.set('folderIds', folderIds.join(','))
}
if (triggers.length > 0) {
params.set('triggers', triggers.join(','))
}
if (searchQuery.trim()) {
params.set('search', searchQuery.trim())
}
updateURL(params)
},
// Build query parameters for server-side filtering
buildQueryParams: (page: number, limit: number) => {
const { workspaceId, timeRange, level, workflowIds, folderIds, searchQuery, triggers } = get()

View File

@@ -140,6 +140,9 @@ export interface FilterState {
hasMore: boolean
isFetchingMore: boolean
// Internal state
_isInitializing: boolean
// Actions
setLogs: (logs: WorkflowLog[], append?: boolean) => void
setWorkspaceId: (workspaceId: string) => void
@@ -159,6 +162,10 @@ export interface FilterState {
setIsFetchingMore: (isFetchingMore: boolean) => void
resetPagination: () => void
// URL synchronization methods
initializeFromURL: () => void
syncWithURL: () => void
// Build query parameters for server-side filtering
buildQueryParams: (page: number, limit: number) => string
}