mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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' />}
|
||||
|
||||
@@ -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' />}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user