mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement: custom tools modal, logs-details (#2283)
This commit is contained in:
@@ -8,6 +8,7 @@ import clsx from 'clsx'
|
||||
import { Button, ChevronDown } from '@/components/emcn'
|
||||
import type { TraceSpan } from '@/stores/logs/filters/types'
|
||||
import '@/components/emcn/components/code/code.css'
|
||||
import { WorkflowIcon } from '@/components/icons'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getBlock, getBlockByToolName } from '@/blocks'
|
||||
@@ -120,6 +121,14 @@ function getBlockColor(type: string): string {
|
||||
return '#2FA1FF'
|
||||
case 'api':
|
||||
return '#2F55FF'
|
||||
case 'loop':
|
||||
case 'loop-iteration':
|
||||
return '#2FB3FF'
|
||||
case 'parallel':
|
||||
case 'parallel-iteration':
|
||||
return '#FEE12B'
|
||||
case 'workflow':
|
||||
return '#705335'
|
||||
default:
|
||||
return '#6b7280'
|
||||
}
|
||||
@@ -134,12 +143,15 @@ function getBlockIconAndColor(type: string): {
|
||||
} {
|
||||
const lowerType = type.toLowerCase()
|
||||
|
||||
if (lowerType === 'loop') {
|
||||
if (lowerType === 'loop' || lowerType === 'loop-iteration') {
|
||||
return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
|
||||
}
|
||||
if (lowerType === 'parallel') {
|
||||
if (lowerType === 'parallel' || lowerType === 'parallel-iteration') {
|
||||
return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
|
||||
}
|
||||
if (lowerType === 'workflow') {
|
||||
return { icon: WorkflowIcon, bgColor: '#705335' }
|
||||
}
|
||||
|
||||
const blockType = lowerType === 'model' ? 'agent' : lowerType
|
||||
const blockConfig = getBlock(blockType)
|
||||
@@ -289,15 +301,11 @@ function InputOutputSection({
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{isError && typeof data === 'object' && data !== null && 'error' in data ? (
|
||||
<div
|
||||
className='rounded-[6px] px-[10px] py-[8px]'
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-status-error-bg)',
|
||||
color: 'var(--text-error)',
|
||||
}}
|
||||
>
|
||||
<div className='font-medium text-[12px]'>Error</div>
|
||||
<div className='mt-[4px] text-[12px]'>{(data as { error: string }).error}</div>
|
||||
<div className='rounded-[4px] border border-[rgba(234,67,53,0.24)] bg-[rgba(234,67,53,0.08)] px-[10px] py-[8px]'>
|
||||
<div className='font-medium text-[#EA4335] text-[12px]'>Error</div>
|
||||
<div className='mt-[4px] text-[#FF8076] text-[12px]'>
|
||||
{(data as { error: string }).error}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='code-editor-theme overflow-hidden rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
|
||||
@@ -313,6 +321,116 @@ function InputOutputSection({
|
||||
)
|
||||
}
|
||||
|
||||
interface NestedBlockItemProps {
|
||||
span: TraceSpan
|
||||
parentId: string
|
||||
index: number
|
||||
expandedSections: Set<string>
|
||||
onToggle: (section: string) => void
|
||||
workflowStartTime: number
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive component for rendering nested blocks at any depth
|
||||
*/
|
||||
function NestedBlockItem({
|
||||
span,
|
||||
parentId,
|
||||
index,
|
||||
expandedSections,
|
||||
onToggle,
|
||||
workflowStartTime,
|
||||
totalDuration,
|
||||
}: NestedBlockItemProps): React.ReactNode {
|
||||
const spanId = span.id || `${parentId}-nested-${index}`
|
||||
const isError = span.status === 'error'
|
||||
const toolBlock =
|
||||
span.type?.toLowerCase() === 'tool' && span.name ? getBlockByToolName(span.name) : null
|
||||
const { icon: SpanIcon, bgColor } = toolBlock
|
||||
? { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
|
||||
: getBlockIconAndColor(span.type)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{SpanIcon && <SpanIcon className={clsx('text-white', '!h-[9px] !w-[9px]')} />}
|
||||
</div>
|
||||
<span
|
||||
className='font-medium text-[12px]'
|
||||
style={{
|
||||
color: isError ? 'var(--text-error)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{span.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{formatDuration(span.duration || 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
span={span}
|
||||
childSpans={span.children}
|
||||
workflowStartTime={workflowStartTime}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{span.input && (
|
||||
<InputOutputSection
|
||||
label='Input'
|
||||
data={span.input}
|
||||
isError={false}
|
||||
spanId={`${spanId}-input`}
|
||||
sectionType='input'
|
||||
expandedSections={expandedSections}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{span.input && span.output && (
|
||||
<div className='border-[var(--border)] border-t border-dashed' />
|
||||
)}
|
||||
|
||||
{span.output && (
|
||||
<InputOutputSection
|
||||
label={isError ? 'Error' : 'Output'}
|
||||
data={span.output}
|
||||
isError={isError}
|
||||
spanId={`${spanId}-output`}
|
||||
sectionType='output'
|
||||
expandedSections={expandedSections}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recursively render children */}
|
||||
{span.children && span.children.length > 0 && (
|
||||
<div className='mt-[8px] flex flex-col gap-[16px] border-[var(--border)] border-l-2 pl-[10px]'>
|
||||
{span.children.map((child, childIndex) => (
|
||||
<NestedBlockItem
|
||||
key={child.id || `${spanId}-child-${childIndex}`}
|
||||
span={child}
|
||||
parentId={spanId}
|
||||
index={childIndex}
|
||||
expandedSections={expandedSections}
|
||||
onToggle={onToggle}
|
||||
workflowStartTime={workflowStartTime}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TraceSpanItemProps {
|
||||
span: TraceSpan
|
||||
totalDuration: number
|
||||
@@ -346,11 +464,22 @@ function TraceSpanItem({
|
||||
const hasOutput = Boolean(span.output)
|
||||
const isError = span.status === 'error'
|
||||
|
||||
const inlineChildTypes = new Set(['tool', 'model'])
|
||||
const inlineChildren =
|
||||
span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
|
||||
const otherChildren =
|
||||
span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
|
||||
const inlineChildTypes = new Set([
|
||||
'tool',
|
||||
'model',
|
||||
'loop-iteration',
|
||||
'parallel-iteration',
|
||||
'workflow',
|
||||
])
|
||||
|
||||
// For workflow-in-workflow blocks, all children should be rendered inline/nested
|
||||
const isWorkflowBlock = span.type?.toLowerCase() === 'workflow'
|
||||
const inlineChildren = isWorkflowBlock
|
||||
? span.children || []
|
||||
: span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
|
||||
const otherChildren = isWorkflowBlock
|
||||
? []
|
||||
: span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
|
||||
|
||||
const toolCallSpans = useMemo(() => {
|
||||
if (!hasToolCalls) return []
|
||||
@@ -502,7 +631,14 @@ function TraceSpanItem({
|
||||
|
||||
<ProgressBar
|
||||
span={childSpan}
|
||||
childSpans={undefined}
|
||||
childSpans={
|
||||
childSpan.type?.toLowerCase() === 'loop-iteration' ||
|
||||
childSpan.type?.toLowerCase() === 'parallel-iteration' ||
|
||||
childSpan.type?.toLowerCase() === 'workflow' ||
|
||||
(isWorkflowBlock && childSpan.children && childSpan.children.length > 0)
|
||||
? childSpan.children
|
||||
: undefined
|
||||
}
|
||||
workflowStartTime={workflowStartTime}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
@@ -534,6 +670,29 @@ function TraceSpanItem({
|
||||
onToggle={handleSectionToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Render nested blocks for loop/parallel iterations, nested workflows, and workflow block children */}
|
||||
{(childSpan.type?.toLowerCase() === 'loop-iteration' ||
|
||||
childSpan.type?.toLowerCase() === 'parallel-iteration' ||
|
||||
childSpan.type?.toLowerCase() === 'workflow' ||
|
||||
isWorkflowBlock) &&
|
||||
childSpan.children &&
|
||||
childSpan.children.length > 0 && (
|
||||
<div className='mt-[8px] flex flex-col gap-[16px] border-[var(--border)] border-l-2 pl-[10px]'>
|
||||
{childSpan.children.map((nestedChild, nestedIndex) => (
|
||||
<NestedBlockItem
|
||||
key={nestedChild.id || `${childId}-nested-${nestedIndex}`}
|
||||
span={nestedChild}
|
||||
parentId={childId}
|
||||
index={nestedIndex}
|
||||
expandedSections={expandedSections}
|
||||
onToggle={handleSectionToggle}
|
||||
workflowStartTime={workflowStartTime}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,10 +6,12 @@ import { Button, Eye } from '@/components/emcn'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { FileCards, FrozenCanvas, TraceSpans } from '@/app/workspace/[workspaceId]/logs/components'
|
||||
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
|
||||
import type { LogStatus } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { formatDate, StatusBadge, TriggerBadge } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { formatCost } from '@/providers/utils'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import { useLogDetailsUIStore } from '@/stores/logs/store'
|
||||
|
||||
interface LogDetailsProps {
|
||||
/** The log to display details for */
|
||||
@@ -45,6 +47,8 @@ export function LogDetails({
|
||||
}: LogDetailsProps) {
|
||||
const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false)
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
|
||||
const { handleMouseDown } = useLogDetailsResize()
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
@@ -103,234 +107,255 @@ export function LogDetails({
|
||||
}, [log])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-[0px] right-0 bottom-0 z-50 w-[384px] transform overflow-hidden border-l bg-[var(--surface-1)] shadow-lg transition-transform duration-200 ease-out ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
aria-label='Log details sidebar'
|
||||
>
|
||||
{log && (
|
||||
<div className='flex h-full flex-col px-[14px] pt-[12px]'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium text-[14px] text-[var(--text-primary)]'>Log Details</h2>
|
||||
<div className='flex items-center gap-[1px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-[4px]'
|
||||
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
|
||||
disabled={!hasPrev}
|
||||
aria-label='Previous log'
|
||||
>
|
||||
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-[4px]'
|
||||
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
|
||||
disabled={!hasNext}
|
||||
aria-label='Next log'
|
||||
>
|
||||
<ChevronUp className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
<Button variant='ghost' className='!p-[4px]' onClick={onClose} aria-label='Close'>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{/* Resize Handle - positioned outside the panel */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className='absolute top-0 bottom-0 z-[60] w-[8px] cursor-ew-resize'
|
||||
style={{ right: `${panelWidth - 4}px` }}
|
||||
onMouseDown={handleMouseDown}
|
||||
role='separator'
|
||||
aria-label='Resize log details panel'
|
||||
aria-orientation='vertical'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<ScrollArea className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
|
||||
<div className='flex flex-col gap-[10px] pb-[16px]'>
|
||||
{/* Timestamp & Workflow Row */}
|
||||
<div className='flex items-center gap-[16px] px-[1px]'>
|
||||
{/* Timestamp Card */}
|
||||
<div className='flex w-[140px] flex-col gap-[8px]'>
|
||||
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Timestamp
|
||||
<div
|
||||
className={`absolute top-[0px] right-0 bottom-0 z-50 transform overflow-hidden border-l bg-[var(--surface-1)] shadow-md transition-transform duration-200 ease-out ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
style={{ width: `${panelWidth}px` }}
|
||||
aria-label='Log details sidebar'
|
||||
>
|
||||
{log && (
|
||||
<div className='flex h-full flex-col px-[14px] pt-[12px]'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium text-[14px] text-[var(--text-primary)]'>Log Details</h2>
|
||||
<div className='flex items-center gap-[1px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-[4px]'
|
||||
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
|
||||
disabled={!hasPrev}
|
||||
aria-label='Previous log'
|
||||
>
|
||||
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-[4px]'
|
||||
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
|
||||
disabled={!hasNext}
|
||||
aria-label='Next log'
|
||||
>
|
||||
<ChevronUp className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
<Button variant='ghost' className='!p-[4px]' onClick={onClose} aria-label='Close'>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<ScrollArea className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
|
||||
<div className='flex flex-col gap-[10px] pb-[16px]'>
|
||||
{/* Timestamp & Workflow Row */}
|
||||
<div className='flex items-center gap-[16px] px-[1px]'>
|
||||
{/* Timestamp Card */}
|
||||
<div className='flex w-[140px] flex-col gap-[8px]'>
|
||||
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Timestamp
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{formattedTimestamp?.compactDate || 'N/A'}
|
||||
</span>
|
||||
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{formattedTimestamp?.compactTime || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{formattedTimestamp?.compactDate || 'N/A'}
|
||||
|
||||
{/* Workflow Card */}
|
||||
{log.workflow && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Workflow
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: log.workflow?.color }}
|
||||
/>
|
||||
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{log.workflow.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution ID */}
|
||||
{log.executionId && (
|
||||
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Execution ID
|
||||
</span>
|
||||
<span className='truncate font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{log.executionId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Section */}
|
||||
<div className='flex flex-col'>
|
||||
{/* Level */}
|
||||
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Level
|
||||
</span>
|
||||
<StatusBadge status={logStatus} />
|
||||
</div>
|
||||
|
||||
{/* Trigger */}
|
||||
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Trigger
|
||||
</span>
|
||||
{log.trigger ? (
|
||||
<TriggerBadge trigger={log.trigger} />
|
||||
) : (
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className='flex h-[48px] items-center justify-between p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Duration
|
||||
</span>
|
||||
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{formattedTimestamp?.compactTime || 'N/A'}
|
||||
{log.duration || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Card */}
|
||||
{log.workflow && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Workflow
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: log.workflow?.color }}
|
||||
/>
|
||||
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{log.workflow.name}
|
||||
{/* Workflow State */}
|
||||
{isWorkflowExecutionLog && log.executionId && (
|
||||
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Workflow State
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsFrozenCanvasOpen(true)}
|
||||
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--c-2A2A2A)]'
|
||||
>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
View Snapshot
|
||||
</span>
|
||||
<Eye className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workflow Execution - Trace Spans */}
|
||||
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
|
||||
<TraceSpans
|
||||
traceSpans={log.executionData.traceSpans}
|
||||
totalDuration={log.executionData.totalDuration}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{log.files && log.files.length > 0 && (
|
||||
<FileCards files={log.files} isExecutionFile />
|
||||
)}
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
{hasCostInfo && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='px-[1px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Cost Breakdown
|
||||
</span>
|
||||
|
||||
<div className='flex flex-col gap-[4px] rounded-[6px] border border-[var(--border)]'>
|
||||
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Base Execution:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(BASE_EXECUTION_CHARGE)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Model Input:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(log.cost?.input || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Model Output:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(log.cost?.output || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-[var(--border)] border-t' />
|
||||
|
||||
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Total:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(log.cost?.total || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Tokens:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '}
|
||||
out
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-center rounded-[6px] bg-[var(--surface-2)] p-[8px] text-center'>
|
||||
<p className='font-medium text-[11px] text-[var(--text-subtle)]'>
|
||||
Total cost includes a base execution charge of{' '}
|
||||
{formatCost(BASE_EXECUTION_CHARGE)} plus any model usage costs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution ID */}
|
||||
{log.executionId && (
|
||||
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Execution ID
|
||||
</span>
|
||||
<span className='truncate font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{log.executionId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Section */}
|
||||
<div className='flex flex-col'>
|
||||
{/* Level */}
|
||||
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Level</span>
|
||||
<StatusBadge status={logStatus} />
|
||||
</div>
|
||||
|
||||
{/* Trigger */}
|
||||
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Trigger
|
||||
</span>
|
||||
{log.trigger ? (
|
||||
<TriggerBadge trigger={log.trigger} />
|
||||
) : (
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className='flex h-[48px] items-center justify-between p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Duration
|
||||
</span>
|
||||
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{log.duration || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow State */}
|
||||
{isWorkflowExecutionLog && log.executionId && (
|
||||
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Workflow State
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsFrozenCanvasOpen(true)}
|
||||
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--c-2A2A2A)]'
|
||||
>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
View Snapshot
|
||||
</span>
|
||||
<Eye className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workflow Execution - Trace Spans */}
|
||||
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
|
||||
<TraceSpans
|
||||
traceSpans={log.executionData.traceSpans}
|
||||
totalDuration={log.executionData.totalDuration}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{log.files && log.files.length > 0 && <FileCards files={log.files} isExecutionFile />}
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
{hasCostInfo && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='px-[1px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Cost Breakdown
|
||||
</span>
|
||||
|
||||
<div className='flex flex-col gap-[4px] rounded-[6px] border border-[var(--border)]'>
|
||||
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Base Execution:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(BASE_EXECUTION_CHARGE)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Model Input:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(log.cost?.input || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Model Output:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(log.cost?.output || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-[var(--border)] border-t' />
|
||||
|
||||
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Total:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(log.cost?.total || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Tokens:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '}
|
||||
out
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-center rounded-[6px] bg-[var(--surface-2)] p-[8px] text-center'>
|
||||
<p className='font-medium text-[11px] text-[var(--text-subtle)]'>
|
||||
Total cost includes a base execution charge of{' '}
|
||||
{formatCost(BASE_EXECUTION_CHARGE)} plus any model usage costs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Frozen Canvas Modal */}
|
||||
{log?.executionId && (
|
||||
<FrozenCanvas
|
||||
executionId={log.executionId}
|
||||
traceSpans={log.executionData?.traceSpans}
|
||||
isModal
|
||||
isOpen={isFrozenCanvasOpen}
|
||||
onClose={() => setIsFrozenCanvasOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Frozen Canvas Modal */}
|
||||
{log?.executionId && (
|
||||
<FrozenCanvas
|
||||
executionId={log.executionId}
|
||||
traceSpans={log.executionData?.traceSpans}
|
||||
isModal
|
||||
isOpen={isFrozenCanvasOpen}
|
||||
onClose={() => setIsFrozenCanvasOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
2
apps/sim/app/workspace/[workspaceId]/logs/hooks/index.ts
Normal file
2
apps/sim/app/workspace/[workspaceId]/logs/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useLogDetailsResize } from './use-log-details-resize'
|
||||
export { useSearchState } from './use-search-state'
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
MAX_LOG_DETAILS_WIDTH,
|
||||
MIN_LOG_DETAILS_WIDTH,
|
||||
useLogDetailsUIStore,
|
||||
} from '@/stores/logs/store'
|
||||
|
||||
/**
|
||||
* Hook for handling log details panel resize via mouse drag.
|
||||
* @returns Resize state and mouse event handler.
|
||||
*/
|
||||
export function useLogDetailsResize() {
|
||||
const setPanelWidth = useLogDetailsUIStore((state) => state.setPanelWidth)
|
||||
const setIsResizing = useLogDetailsUIStore((state) => state.setIsResizing)
|
||||
const [isResizing, setLocalIsResizing] = useState(false)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setLocalIsResizing(true)
|
||||
setIsResizing(true)
|
||||
},
|
||||
[setIsResizing]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// Calculate new width from right edge of window
|
||||
const newWidth = window.innerWidth - e.clientX
|
||||
const clampedWidth = Math.max(
|
||||
MIN_LOG_DETAILS_WIDTH,
|
||||
Math.min(newWidth, MAX_LOG_DETAILS_WIDTH)
|
||||
)
|
||||
|
||||
setPanelWidth(clampedWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setLocalIsResizing(false)
|
||||
setIsResizing(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = 'ew-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
}, [isResizing, setPanelWidth, setIsResizing])
|
||||
|
||||
return {
|
||||
isResizing,
|
||||
handleMouseDown,
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,33 @@ export default function Logs() {
|
||||
}
|
||||
}, [debouncedSearchQuery, setStoreSearchQuery])
|
||||
|
||||
// Sync selected log with refreshed data from logs list
|
||||
useEffect(() => {
|
||||
if (!selectedLog?.id || logs.length === 0) return
|
||||
|
||||
const updatedLog = logs.find((l) => l.id === selectedLog.id)
|
||||
if (updatedLog) {
|
||||
// Update selectedLog with fresh data from the list
|
||||
setSelectedLog(updatedLog)
|
||||
// Update index in case position changed
|
||||
const newIndex = logs.findIndex((l) => l.id === selectedLog.id)
|
||||
if (newIndex !== selectedLogIndex) {
|
||||
setSelectedLogIndex(newIndex)
|
||||
}
|
||||
}
|
||||
}, [logs, selectedLog?.id, selectedLogIndex])
|
||||
|
||||
// Refetch log details during live mode
|
||||
useEffect(() => {
|
||||
if (!isLive || !selectedLog?.id) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
logDetailQuery.refetch()
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isLive, selectedLog?.id, logDetailQuery])
|
||||
|
||||
const handleLogClick = (log: WorkflowLog) => {
|
||||
// If clicking on the same log that's already selected and sidebar is open, close it
|
||||
if (selectedLog?.id === log.id && isSidebarOpen) {
|
||||
|
||||
@@ -836,8 +836,7 @@ try {
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent
|
||||
size='full'
|
||||
className='h-[80vh]'
|
||||
size='xl'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && (showEnvVars || showTags || showSchemaParams)) {
|
||||
e.preventDefault()
|
||||
|
||||
46
apps/sim/stores/logs/store.ts
Normal file
46
apps/sim/stores/logs/store.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
/**
|
||||
* Width constraints for the log details panel.
|
||||
*/
|
||||
export const MIN_LOG_DETAILS_WIDTH = 340
|
||||
export const MAX_LOG_DETAILS_WIDTH = 700
|
||||
export const DEFAULT_LOG_DETAILS_WIDTH = 340
|
||||
|
||||
/**
|
||||
* Log details UI state persisted across sessions.
|
||||
*/
|
||||
interface LogDetailsUIState {
|
||||
panelWidth: number
|
||||
setPanelWidth: (width: number) => void
|
||||
isResizing: boolean
|
||||
setIsResizing: (isResizing: boolean) => void
|
||||
}
|
||||
|
||||
export const useLogDetailsUIStore = create<LogDetailsUIState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
panelWidth: DEFAULT_LOG_DETAILS_WIDTH,
|
||||
/**
|
||||
* Updates the log details panel width, enforcing min/max constraints.
|
||||
* @param width - Desired width in pixels for the panel.
|
||||
*/
|
||||
setPanelWidth: (width) => {
|
||||
const clampedWidth = Math.max(MIN_LOG_DETAILS_WIDTH, Math.min(width, MAX_LOG_DETAILS_WIDTH))
|
||||
set({ panelWidth: clampedWidth })
|
||||
},
|
||||
isResizing: false,
|
||||
/**
|
||||
* Updates the resize state flag.
|
||||
* @param isResizing - True while the panel is being resized via mouse drag.
|
||||
*/
|
||||
setIsResizing: (isResizing) => {
|
||||
set({ isResizing })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'log-details-ui-state',
|
||||
}
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user