mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 07:27:57 -05:00
improvement(logs): added infinite scroll, markdown rendering, and individual block input to logs (#364)
* add performant infinite scroll * added markdown rendering in the logs * added individual block input to logs * fixed markdown render * consolidate redactApiKeys to utils * acknowledged PR comments
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
export default function LogMarkdownRenderer({ content }: { content: string }) {
|
||||
// Process text to clean up unnecessary whitespace and formatting issues
|
||||
const processedContent = content
|
||||
.replace(/\n{2,}/g, '\n\n') // Replace multiple newlines with exactly double newlines
|
||||
.replace(/^(#{1,6})\s+(.+?)\n{2,}/gm, '$1 $2\n') // Reduce space after headings to single newline
|
||||
.replace(/^(#{1,6}.+)\n\n(-|\*)/gm, '$1\n$2') // Remove double newline between heading and list
|
||||
.trim()
|
||||
|
||||
const customComponents = {
|
||||
// Default component to ensure monospace font with minimal spacing
|
||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className="whitespace-pre-wrap font-mono text-sm leading-tight my-0.5">{children}</p>
|
||||
),
|
||||
|
||||
// Inline code - no background to maintain clean appearance
|
||||
code: ({
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLElement> & { className?: string; inline?: boolean }) => {
|
||||
return (
|
||||
<code className="font-mono text-sm" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
|
||||
// Links - maintain monospace while adding subtle link styling
|
||||
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<a
|
||||
href={href}
|
||||
className="font-mono text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
|
||||
// Tighter lists with minimal spacing
|
||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul className="list-disc pl-5 font-mono text-sm -mt-1.5 mb-1 leading-none">{children}</ul>
|
||||
),
|
||||
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
|
||||
<ol className="list-decimal pl-5 font-mono text-sm -mt-1.5 mb-1 leading-none">{children}</ol>
|
||||
),
|
||||
li: ({ children }: React.HTMLAttributes<HTMLLIElement>) => (
|
||||
<li className="font-mono text-sm mb-0 leading-tight">{children}</li>
|
||||
),
|
||||
|
||||
// Keep blockquotes minimal
|
||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||
<blockquote className="font-mono text-sm my-0">{children}</blockquote>
|
||||
),
|
||||
|
||||
// Make headings compact with minimal spacing after
|
||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className="font-mono text-sm font-medium mt-2 mb-0">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h2 className="font-mono text-sm font-medium mt-2 mb-0">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3 className="font-mono text-sm font-medium mt-1.5 mb-0">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h4 className="font-mono text-sm font-medium mt-1.5 mb-0">{children}</h4>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-sm whitespace-pre-wrap w-full overflow-visible font-mono leading-tight [&>ul]:mt-0 [&>h2+ul]:-mt-2.5 [&>h3+ul]:-mt-2.5">
|
||||
<ReactMarkdown components={customComponents}>{processedContent}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Code, X } from 'lucide-react'
|
||||
import { ChevronDown, ChevronUp, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { WorkflowLog } from '@/app/w/logs/stores/types'
|
||||
import { formatDate } from '@/app/w/logs/utils/format-date'
|
||||
import { formatCost } from '@/providers/utils'
|
||||
import { ToolCallsDisplay } from '../tool-calls/tool-calls-display'
|
||||
import { TraceSpansDisplay } from '../trace-spans/trace-spans-display'
|
||||
import LogMarkdownRenderer from './components/markdown-renderer'
|
||||
|
||||
interface LogSidebarProps {
|
||||
log: WorkflowLog | null
|
||||
@@ -49,7 +51,7 @@ const tryPrettifyJson = (content: string): { isJson: boolean; formatted: string
|
||||
/**
|
||||
* Formats JSON content for display, handling multiple JSON objects separated by '--'
|
||||
*/
|
||||
const formatJsonContent = (content: string): React.ReactNode => {
|
||||
const formatJsonContent = (content: string, blockInput?: Record<string, any>): React.ReactNode => {
|
||||
// Look for a pattern like "Block Agent 1 (agent):" to separate system comment from content
|
||||
const blockPattern = /^(Block .+?\(.+?\):)\s*/
|
||||
const match = content.match(blockPattern)
|
||||
@@ -57,30 +59,119 @@ const formatJsonContent = (content: string): React.ReactNode => {
|
||||
if (match) {
|
||||
const systemComment = match[1]
|
||||
const actualContent = content.substring(match[0].length).trim()
|
||||
const { formatted } = tryPrettifyJson(actualContent)
|
||||
const { isJson, formatted } = tryPrettifyJson(actualContent)
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="text-sm font-medium mb-2 text-muted-foreground">{systemComment}</div>
|
||||
<div className="bg-secondary/30 p-3 rounded-md relative group">
|
||||
<CopyButton text={formatted} className="h-7 w-7 z-10" />
|
||||
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-y-auto max-h-[500px] overflow-x-hidden">
|
||||
{formatted}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<BlockContentDisplay
|
||||
systemComment={systemComment}
|
||||
formatted={formatted}
|
||||
isJson={isJson}
|
||||
blockInput={blockInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// If no system comment pattern found, show the whole content
|
||||
const { formatted } = tryPrettifyJson(content)
|
||||
const { isJson, formatted } = tryPrettifyJson(content)
|
||||
|
||||
return (
|
||||
<div className="bg-secondary/30 p-3 rounded-md relative group w-full">
|
||||
<CopyButton text={formatted} className="h-7 w-7 z-10" />
|
||||
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-y-auto max-h-[500px] overflow-x-hidden">
|
||||
{formatted}
|
||||
</pre>
|
||||
{isJson ? (
|
||||
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-y-auto max-h-[500px] overflow-x-hidden">
|
||||
{formatted}
|
||||
</pre>
|
||||
) : (
|
||||
<LogMarkdownRenderer content={formatted} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BlockContentDisplay = ({
|
||||
systemComment,
|
||||
formatted,
|
||||
isJson,
|
||||
blockInput,
|
||||
}: {
|
||||
systemComment: string
|
||||
formatted: string
|
||||
isJson: boolean
|
||||
blockInput?: Record<string, any>
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'output' | 'input'>(blockInput ? 'output' : 'output')
|
||||
|
||||
const redactedBlockInput = useMemo(() => {
|
||||
return blockInput ? redactApiKeys(blockInput) : undefined
|
||||
}, [blockInput])
|
||||
|
||||
const redactedOutput = useMemo(() => {
|
||||
if (!isJson) return formatted
|
||||
|
||||
try {
|
||||
const parsedOutput = JSON.parse(formatted)
|
||||
const redactedJson = redactApiKeys(parsedOutput)
|
||||
return JSON.stringify(redactedJson, null, 2)
|
||||
} catch (e) {
|
||||
return formatted
|
||||
}
|
||||
}, [formatted, isJson])
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">{systemComment}</div>
|
||||
|
||||
{/* Tabs for switching between output and input */}
|
||||
{redactedBlockInput && (
|
||||
<div className="flex space-x-1 mb-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('output')}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
activeTab === 'output'
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'hover:bg-secondary/50 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Output
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('input')}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
activeTab === 'input'
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'hover:bg-secondary/50 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Input
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<div className="bg-secondary/30 p-3 rounded-md relative group">
|
||||
{activeTab === 'output' ? (
|
||||
<>
|
||||
<CopyButton text={redactedOutput} className="h-7 w-7 z-10" />
|
||||
{isJson ? (
|
||||
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-visible">
|
||||
{redactedOutput}
|
||||
</pre>
|
||||
) : (
|
||||
<LogMarkdownRenderer content={redactedOutput} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyButton
|
||||
text={JSON.stringify(redactedBlockInput, null, 2)}
|
||||
className="h-7 w-7 z-10"
|
||||
/>
|
||||
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-visible">
|
||||
{JSON.stringify(redactedBlockInput, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -115,10 +206,29 @@ export function Sidebar({
|
||||
|
||||
const formattedContent = useMemo(() => {
|
||||
if (!log) return null
|
||||
return formatJsonContent(log.message)
|
||||
|
||||
let blockInput: Record<string, any> | undefined = undefined
|
||||
|
||||
if (log.metadata?.blockInput) {
|
||||
blockInput = log.metadata.blockInput
|
||||
} else if (log.metadata?.traceSpans) {
|
||||
const blockIdMatch = log.message.match(/Block .+?(\d+)/i)
|
||||
const blockId = blockIdMatch ? blockIdMatch[1] : null
|
||||
|
||||
if (blockId) {
|
||||
const matchingSpan = log.metadata.traceSpans.find(
|
||||
(span) => span.blockId === blockId || span.name.includes(`Block ${blockId}`)
|
||||
)
|
||||
|
||||
if (matchingSpan && matchingSpan.input) {
|
||||
blockInput = matchingSpan.input
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formatJsonContent(log.message, blockInput)
|
||||
}, [log])
|
||||
|
||||
// Reset scroll position when log changes
|
||||
useEffect(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
scrollAreaRef.current.scrollTop = 0
|
||||
@@ -297,8 +407,11 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="h-[calc(100vh-64px-49px)] w-full" ref={scrollAreaRef}>
|
||||
<div className="p-4 space-y-4 w-full overflow-hidden pr-6">
|
||||
<ScrollArea
|
||||
className="h-[calc(100vh-64px-49px)] w-full overflow-y-auto"
|
||||
ref={scrollAreaRef}
|
||||
>
|
||||
<div className="p-4 space-y-4 w-full pr-6">
|
||||
{/* Timestamp */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-1">Timestamp</h3>
|
||||
@@ -374,7 +487,7 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Content - MOVED ABOVE the Trace Spans and Cost */}
|
||||
{/* Message Content */}
|
||||
<div className="pb-2 w-full">
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-1">Message</h3>
|
||||
<div className="w-full">{formattedContent}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertCircle, Info, Loader2 } from 'lucide-react'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
@@ -12,8 +12,8 @@ import { LogsResponse, WorkflowLog } from './stores/types'
|
||||
import { formatDate } from './utils/format-date'
|
||||
|
||||
const logger = createLogger('Logs')
|
||||
const LOGS_PER_PAGE = 50
|
||||
|
||||
// Helper function to get level badge styling
|
||||
const getLevelBadgeStyles = (level: string) => {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'error':
|
||||
@@ -25,7 +25,6 @@ const getLevelBadgeStyles = (level: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get trigger badge styling
|
||||
const getTriggerBadgeStyles = (trigger: string) => {
|
||||
switch (trigger.toLowerCase()) {
|
||||
case 'manual':
|
||||
@@ -43,30 +42,45 @@ const getTriggerBadgeStyles = (trigger: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Add a new CSS class for the selected row animation
|
||||
const selectedRowAnimation = `
|
||||
@keyframes borderPulse {
|
||||
0% { border-left-color: hsl(var(--primary) / 0.3); }
|
||||
50% { border-left-color: hsl(var(--primary) / 0.7); }
|
||||
100% { border-left-color: hsl(var(--primary) / 0.5); }
|
||||
0% { border-left-color: hsl(var(--primary) / 0.3) }
|
||||
50% { border-left-color: hsl(var(--primary) / 0.7) }
|
||||
100% { border-left-color: hsl(var(--primary) / 0.5) }
|
||||
}
|
||||
.selected-row {
|
||||
animation: borderPulse 1s ease-in-out;
|
||||
border-left-color: hsl(var(--primary) / 0.5);
|
||||
animation: borderPulse 1s ease-in-out
|
||||
border-left-color: hsl(var(--primary) / 0.5)
|
||||
}
|
||||
`
|
||||
|
||||
export default function Logs() {
|
||||
const { filteredLogs, logs, loading, error, setLogs, setLoading, setError } = useFilterStore()
|
||||
const {
|
||||
filteredLogs,
|
||||
logs,
|
||||
loading,
|
||||
error,
|
||||
setLogs,
|
||||
setLoading,
|
||||
setError,
|
||||
page,
|
||||
setPage,
|
||||
hasMore,
|
||||
setHasMore,
|
||||
isFetchingMore,
|
||||
setIsFetchingMore,
|
||||
} = useFilterStore()
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<WorkflowLog | null>(null)
|
||||
const [selectedLogIndex, setSelectedLogIndex] = useState<number>(-1)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { mode, isExpanded } = useSidebarStore()
|
||||
const isSidebarCollapsed =
|
||||
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
|
||||
|
||||
// Group logs by executionId to identify the last log in each group
|
||||
const executionGroups = useMemo(() => {
|
||||
const groups: Record<string, WorkflowLog[]> = {}
|
||||
|
||||
@@ -80,7 +94,6 @@ export default function Logs() {
|
||||
}
|
||||
})
|
||||
|
||||
// Sort logs within each group by createdAt
|
||||
Object.keys(groups).forEach((executionId) => {
|
||||
groups[executionId].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
@@ -90,16 +103,13 @@ export default function Logs() {
|
||||
return groups
|
||||
}, [filteredLogs])
|
||||
|
||||
// Handle log click
|
||||
const handleLogClick = (log: WorkflowLog) => {
|
||||
setSelectedLog(log)
|
||||
// Find the index of the clicked log in the filtered logs array
|
||||
const index = filteredLogs.findIndex((l) => l.id === log.id)
|
||||
setSelectedLogIndex(index)
|
||||
setIsSidebarOpen(true)
|
||||
}
|
||||
|
||||
// Navigate to the next log
|
||||
const handleNavigateNext = () => {
|
||||
if (selectedLogIndex < filteredLogs.length - 1) {
|
||||
const nextIndex = selectedLogIndex + 1
|
||||
@@ -108,7 +118,6 @@ export default function Logs() {
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to the previous log
|
||||
const handleNavigatePrev = () => {
|
||||
if (selectedLogIndex > 0) {
|
||||
const prevIndex = selectedLogIndex - 1
|
||||
@@ -117,12 +126,10 @@ export default function Logs() {
|
||||
}
|
||||
}
|
||||
|
||||
// Close sidebar
|
||||
const handleCloseSidebar = () => {
|
||||
setIsSidebarOpen(false)
|
||||
}
|
||||
|
||||
// Scroll selected log into view when it changes
|
||||
useEffect(() => {
|
||||
if (selectedRowRef.current) {
|
||||
selectedRowRef.current.scrollIntoView({
|
||||
@@ -132,13 +139,18 @@ export default function Logs() {
|
||||
}
|
||||
}, [selectedLogIndex])
|
||||
|
||||
// Fetch logs on component mount
|
||||
useEffect(() => {
|
||||
const fetchLogs = async () => {
|
||||
const fetchLogs = useCallback(
|
||||
async (pageNum: number, append: boolean = false) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
// Include workflow data in the response
|
||||
const response = await fetch('/api/logs?includeWorkflow=true')
|
||||
if (pageNum === 1) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
setIsFetchingMore(true)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/logs?includeWorkflow=true&limit=${LOGS_PER_PAGE}&offset=${(pageNum - 1) * LOGS_PER_PAGE}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching logs: ${response.statusText}`)
|
||||
@@ -146,26 +158,94 @@ export default function Logs() {
|
||||
|
||||
const data: LogsResponse = await response.json()
|
||||
|
||||
setLogs(data.data)
|
||||
setHasMore(data.data.length === LOGS_PER_PAGE && data.page < data.totalPages)
|
||||
|
||||
setLogs(data.data, append)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch logs:', { err })
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (pageNum === 1) {
|
||||
setLoading(false)
|
||||
} else {
|
||||
setIsFetchingMore(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[setLogs, setLoading, setError, setHasMore, setIsFetchingMore]
|
||||
)
|
||||
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
if (!isFetchingMore && hasMore) {
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
setIsFetchingMore(true)
|
||||
setTimeout(() => {
|
||||
fetchLogs(nextPage, true)
|
||||
}, 50)
|
||||
}
|
||||
}, [fetchLogs, isFetchingMore, hasMore, page, setPage, setIsFetchingMore])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !hasMore) return
|
||||
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) return
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollContainer) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||
|
||||
const scrollPercentage = (scrollTop / (scrollHeight - clientHeight)) * 100
|
||||
|
||||
if (scrollPercentage > 60 && !isFetchingMore && hasMore) {
|
||||
loadMoreLogs()
|
||||
}
|
||||
}
|
||||
|
||||
fetchLogs()
|
||||
}, [setLogs, setLoading, setError])
|
||||
scrollContainer.addEventListener('scroll', handleScroll)
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [loading, hasMore, isFetchingMore, loadMoreLogs])
|
||||
|
||||
useEffect(() => {
|
||||
const currentLoaderRef = loaderRef.current
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
|
||||
if (!currentLoaderRef || !scrollContainer || loading || !hasMore) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !isFetchingMore) {
|
||||
loadMoreLogs()
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollContainer,
|
||||
threshold: 0.1,
|
||||
rootMargin: '200px 0px 0px 0px',
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(currentLoaderRef)
|
||||
|
||||
return () => {
|
||||
observer.unobserve(currentLoaderRef)
|
||||
}
|
||||
}, [loading, hasMore, isFetchingMore, loadMoreLogs])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(1)
|
||||
}, [fetchLogs])
|
||||
|
||||
// Add keyboard navigation for the logs table
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle keyboard navigation if we have logs and a log is selected
|
||||
if (filteredLogs.length === 0) return
|
||||
|
||||
// If no log is selected yet, select the first one on arrow key press
|
||||
if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||
e.preventDefault()
|
||||
setSelectedLogIndex(0)
|
||||
@@ -173,13 +253,11 @@ export default function Logs() {
|
||||
return
|
||||
}
|
||||
|
||||
// Up arrow key for previous log
|
||||
if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && selectedLogIndex > 0) {
|
||||
e.preventDefault()
|
||||
handleNavigatePrev()
|
||||
}
|
||||
|
||||
// Down arrow key for next log
|
||||
if (
|
||||
e.key === 'ArrowDown' &&
|
||||
!e.metaKey &&
|
||||
@@ -190,7 +268,6 @@ export default function Logs() {
|
||||
handleNavigateNext()
|
||||
}
|
||||
|
||||
// Enter key to open/close sidebar
|
||||
if (e.key === 'Enter' && selectedLog) {
|
||||
e.preventDefault()
|
||||
setIsSidebarOpen(!isSidebarOpen)
|
||||
@@ -267,8 +344,8 @@ export default function Logs() {
|
||||
</div>
|
||||
|
||||
{/* Table body - scrollable */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex-1 overflow-auto" ref={scrollContainerRef}>
|
||||
{loading && page === 1 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
@@ -323,9 +400,7 @@ export default function Logs() {
|
||||
{/* Time column */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col justify-center">
|
||||
<div
|
||||
className={`text-xs font-medium flex items-center ${isSelected ? 'text-foreground' : ''}`}
|
||||
>
|
||||
<div className="text-xs font-medium flex items-center">
|
||||
<span>{formattedDate.formatted}</span>
|
||||
<span className="mx-1.5 text-muted-foreground hidden xl:inline">
|
||||
•
|
||||
@@ -389,25 +464,64 @@ export default function Logs() {
|
||||
|
||||
{/* Message column */}
|
||||
<td className="px-4 py-3">
|
||||
<div
|
||||
className={`text-sm truncate ${isSelected ? 'text-foreground' : ''}`}
|
||||
title={log.message}
|
||||
>
|
||||
<div className="text-sm truncate" title={log.message}>
|
||||
{log.message}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Duration column */}
|
||||
<td className="px-4 py-3">
|
||||
<div
|
||||
className={`text-xs ${isSelected ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{log.duration || '—'}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Infinite scroll loader */}
|
||||
{hasMore && (
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div
|
||||
ref={loaderRef}
|
||||
className="py-2 flex items-center justify-center"
|
||||
style={{ height: '50px' }}
|
||||
>
|
||||
{isFetchingMore && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground opacity-70">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-xs">Loading more logs...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Footer status indicator - useful for development */}
|
||||
<tr className="border-t">
|
||||
<td colSpan={7}>
|
||||
<div className="py-2 px-4 text-xs text-muted-foreground flex justify-between items-center">
|
||||
<span>Showing {filteredLogs.length} logs</span>
|
||||
<div className="flex items-center gap-4">
|
||||
{isFetchingMore ? (
|
||||
<div className="flex items-center gap-2"></div>
|
||||
) : hasMore ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMoreLogs}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Load more logs
|
||||
</button>
|
||||
) : (
|
||||
<span>End of logs</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
@@ -10,23 +10,36 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
||||
searchQuery: '',
|
||||
loading: true,
|
||||
error: null,
|
||||
page: 1,
|
||||
hasMore: true,
|
||||
isFetchingMore: false,
|
||||
|
||||
setLogs: (logs) => {
|
||||
set({ logs, filteredLogs: logs, loading: false })
|
||||
setLogs: (logs, append = false) => {
|
||||
if (append) {
|
||||
const currentLogs = [...get().logs]
|
||||
const newLogs = [...currentLogs, ...logs]
|
||||
set({ logs: newLogs })
|
||||
get().applyFilters()
|
||||
} else {
|
||||
set({ logs, filteredLogs: logs, loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
setTimeRange: (timeRange) => {
|
||||
set({ timeRange })
|
||||
get().resetPagination()
|
||||
get().applyFilters()
|
||||
},
|
||||
|
||||
setLevel: (level) => {
|
||||
set({ level })
|
||||
get().resetPagination()
|
||||
get().applyFilters()
|
||||
},
|
||||
|
||||
setWorkflowIds: (workflowIds) => {
|
||||
set({ workflowIds })
|
||||
get().resetPagination()
|
||||
get().applyFilters()
|
||||
},
|
||||
|
||||
@@ -41,11 +54,13 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
||||
}
|
||||
|
||||
set({ workflowIds: currentWorkflowIds })
|
||||
get().resetPagination()
|
||||
get().applyFilters()
|
||||
},
|
||||
|
||||
setSearchQuery: (searchQuery) => {
|
||||
set({ searchQuery })
|
||||
get().resetPagination()
|
||||
get().applyFilters()
|
||||
},
|
||||
|
||||
@@ -53,6 +68,14 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
setPage: (page) => set({ page }),
|
||||
|
||||
setHasMore: (hasMore) => set({ hasMore }),
|
||||
|
||||
setIsFetchingMore: (isFetchingMore) => set({ isFetchingMore }),
|
||||
|
||||
resetPagination: () => set({ page: 1, hasMore: true }),
|
||||
|
||||
applyFilters: () => {
|
||||
const { logs, timeRange, level, workflowIds, searchQuery } = get()
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface TraceSpan {
|
||||
tokens?: number
|
||||
relativeStartMs?: number // Time in ms from the start of the parent span
|
||||
blockId?: string // Added to track the original block ID for relationship mapping
|
||||
input?: Record<string, any> // Added to store input data for this span
|
||||
}
|
||||
|
||||
export interface WorkflowLog {
|
||||
@@ -68,6 +69,7 @@ export interface WorkflowLog {
|
||||
traceSpans?: TraceSpan[]
|
||||
totalDuration?: number
|
||||
cost?: CostMetadata
|
||||
blockInput?: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,18 +87,27 @@ export type LogLevel = 'error' | 'info' | 'all'
|
||||
export interface FilterState {
|
||||
// Original logs from API
|
||||
logs: WorkflowLog[]
|
||||
|
||||
// Filtered logs to display
|
||||
filteredLogs: WorkflowLog[]
|
||||
|
||||
// Filter states
|
||||
timeRange: TimeRange
|
||||
level: LogLevel
|
||||
workflowIds: string[]
|
||||
searchQuery: string
|
||||
|
||||
// Loading state
|
||||
loading: boolean
|
||||
error: string | null
|
||||
|
||||
// Pagination state
|
||||
page: number
|
||||
hasMore: boolean
|
||||
isFetchingMore: boolean
|
||||
|
||||
// Actions
|
||||
setLogs: (logs: WorkflowLog[]) => void
|
||||
setLogs: (logs: WorkflowLog[], append?: boolean) => void
|
||||
setTimeRange: (timeRange: TimeRange) => void
|
||||
setLevel: (level: LogLevel) => void
|
||||
setWorkflowIds: (workflowIds: string[]) => void
|
||||
@@ -104,6 +115,11 @@ export interface FilterState {
|
||||
setSearchQuery: (query: string) => void
|
||||
setLoading: (loading: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
setPage: (page: number) => void
|
||||
setHasMore: (hasMore: boolean) => void
|
||||
setIsFetchingMore: (isFetchingMore: boolean) => void
|
||||
resetPagination: () => void
|
||||
|
||||
// Apply filters
|
||||
applyFilters: () => void
|
||||
}
|
||||
|
||||
@@ -1007,6 +1007,9 @@ export class Executor {
|
||||
// Resolve inputs (which will look up references to other blocks including starter)
|
||||
const inputs = this.resolver.resolveInputs(block, context)
|
||||
|
||||
// Store input data in the block log
|
||||
blockLog.input = inputs
|
||||
|
||||
// Track block execution start
|
||||
trackWorkflowTelemetry('block_execution_start', {
|
||||
workflowId: context.workflowId,
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface BlockLog {
|
||||
durationMs: number // Duration of execution in milliseconds
|
||||
success: boolean // Whether execution completed successfully
|
||||
output?: any // Output data from successful execution
|
||||
input?: any // Input data for the block execution
|
||||
error?: string // Error message if execution failed
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { eq, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getCostMultiplier } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { redactApiKeys } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { userStats, workflow, workflowLogs } from '@/db/schema'
|
||||
import { ExecutionResult as ExecutorResult } from '@/executor/types'
|
||||
@@ -581,7 +582,10 @@ export async function persistExecutionLogs(
|
||||
duration: log.success ? `${log.durationMs}ms` : 'NA',
|
||||
trigger: triggerType,
|
||||
createdAt: new Date(log.endedAt || log.startedAt),
|
||||
metadata,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...(log.input ? { blockInput: log.input } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (metadata) {
|
||||
@@ -977,33 +981,3 @@ function isValidDate(dateString: string): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Add this utility function for redacting API keys in tool call inputs
|
||||
function redactApiKeys(obj: any): any {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(redactApiKeys)
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Check if the key is 'apiKey' (case insensitive) or related keys
|
||||
if (
|
||||
key.toLowerCase() === 'apikey' ||
|
||||
key.toLowerCase() === 'api_key' ||
|
||||
key.toLowerCase() === 'access_token'
|
||||
) {
|
||||
result[key] = '***REDACTED***'
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
result[key] = redactApiKeys(value)
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -308,3 +308,38 @@ export function getRotatingApiKey(provider: string): string {
|
||||
|
||||
return keys[keyIndex]
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively redacts API keys in an object
|
||||
* @param obj The object to redact API keys from
|
||||
* @returns A new object with API keys redacted
|
||||
*/
|
||||
export const redactApiKeys = (obj: any): any => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(redactApiKeys)
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (
|
||||
key.toLowerCase() === 'apikey' ||
|
||||
key.toLowerCase() === 'api_key' ||
|
||||
key.toLowerCase() === 'access_token' ||
|
||||
/\bsecret\b/i.test(key.toLowerCase()) ||
|
||||
/\bpassword\b/i.test(key.toLowerCase())
|
||||
) {
|
||||
result[key] = '***REDACTED***'
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
result[key] = redactApiKeys(value)
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,45 +1,12 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { redactApiKeys } from '@/lib/utils'
|
||||
import { useChatStore } from '../chat/store'
|
||||
import { ConsoleEntry, ConsoleStore } from './types'
|
||||
|
||||
// MAX across all workflows
|
||||
const MAX_ENTRIES = 50
|
||||
|
||||
/**
|
||||
* Recursively redacts API keys in an object
|
||||
* @param obj The object to redact API keys from
|
||||
* @returns A new object with API keys redacted
|
||||
*/
|
||||
const redactApiKeys = (obj: any): any => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(redactApiKeys)
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Check if the key is 'apiKey' (case insensitive)
|
||||
if (
|
||||
key.toLowerCase() === 'apikey' ||
|
||||
key.toLowerCase() === 'api_key' ||
|
||||
key.toLowerCase() === 'access_token'
|
||||
) {
|
||||
result[key] = '***REDACTED***'
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
result[key] = redactApiKeys(value)
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a nested property value from an object using a path string
|
||||
* @param obj The object to get the value from
|
||||
|
||||
Reference in New Issue
Block a user