feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

This commit is contained in:
Waleed
2026-01-01 13:47:30 -08:00
committed by GitHub
parent 4da128d77c
commit 852562cfdd
20 changed files with 374 additions and 134 deletions

View File

@@ -30,6 +30,18 @@ import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/component
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard'
```
## No Re-exports
Do not re-export from non-barrel files. Import directly from the source.
```typescript
// ✓ Good - import from where it's declared
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
// ✗ Bad - re-exporting in utils.ts then importing from there
import { CORE_TRIGGER_TYPES } from '@/app/workspace/.../utils'
```
## Import Order
1. React/core libraries

View File

@@ -52,7 +52,7 @@ import { useWorkflowStore } from '@/stores/workflows/store'
import { useWorkflowStore } from '../../../stores/workflows/store'
```
Use barrel exports (`index.ts`) when a folder has 3+ exports.
Use barrel exports (`index.ts`) when a folder has 3+ exports. Do not re-export from non-barrel files; import directly from the source.
### Import Order
1. React/core libraries

View File

@@ -12,7 +12,6 @@ import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { processInputFileFields } from '@/lib/execution/files'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
@@ -27,13 +26,14 @@ import { normalizeName } from '@/executor/constants'
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import type { SubflowType } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowExecuteAPI')
const ExecuteWorkflowSchema = z.object({
selectedOutputs: z.array(z.string()).optional().default([]),
triggerType: z.enum(ALL_TRIGGER_TYPES).optional(),
triggerType: z.enum(CORE_TRIGGER_TYPES).optional(),
stream: z.boolean().optional(),
useDraftState: z.boolean().optional(),
input: z.any().optional(),

View File

@@ -6,14 +6,14 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants'
const logger = createLogger('WorkspaceNotificationAPI')
const levelFilterSchema = z.array(z.enum(['info', 'error']))
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
const triggerFilterSchema = z.array(z.enum(CORE_TRIGGER_TYPES))
const alertRuleSchema = z.enum([
'consecutive_failures',

View File

@@ -7,15 +7,15 @@ import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants'
const logger = createLogger('WorkspaceNotificationsAPI')
const notificationTypeSchema = z.enum(['webhook', 'email', 'slack'])
const levelFilterSchema = z.array(z.enum(['info', 'error']))
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
const triggerFilterSchema = z.array(z.enum(CORE_TRIGGER_TYPES))
const alertRuleSchema = z.enum([
'consecutive_failures',
@@ -81,7 +81,7 @@ const createNotificationSchema = z
workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).default([]),
allWorkflows: z.boolean().default(false),
levelFilter: levelFilterSchema.default(['info', 'error']),
triggerFilter: triggerFilterSchema.default([...ALL_TRIGGER_TYPES]),
triggerFilter: triggerFilterSchema.default([...CORE_TRIGGER_TYPES]),
includeFinalOutput: z.boolean().default(false),
includeTraceSpans: z.boolean().default(false),
includeRateLimits: z.boolean().default(false),

View File

@@ -3,5 +3,6 @@ export { LogDetails } from './log-details'
export { FileCards } from './log-details/components/file-download'
export { FrozenCanvas } from './log-details/components/frozen-canvas'
export { TraceSpans } from './log-details/components/trace-spans'
export { LogRowContextMenu } from './log-row-context-menu'
export { LogsList } from './logs-list'
export { AutocompleteSearch, LogsToolbar, NotificationSettings } from './logs-toolbar'

View File

@@ -0,0 +1 @@
export { LogRowContextMenu } from './log-row-context-menu'

View File

@@ -0,0 +1,102 @@
'use client'
import type { RefObject } from 'react'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import type { WorkflowLog } from '@/stores/logs/filters/types'
interface LogRowContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
log: WorkflowLog | null
onCopyExecutionId: () => void
onOpenWorkflow: () => void
onToggleWorkflowFilter: () => void
onClearAllFilters: () => void
isFilteredByThisWorkflow: boolean
hasActiveFilters: boolean
}
/**
* Context menu for log rows.
* Provides quick actions for copying data, navigation, and filtering.
*/
export function LogRowContextMenu({
isOpen,
position,
menuRef,
onClose,
log,
onCopyExecutionId,
onOpenWorkflow,
onToggleWorkflowFilter,
onClearAllFilters,
isFilteredByThisWorkflow,
hasActiveFilters,
}: LogRowContextMenuProps) {
const hasExecutionId = Boolean(log?.executionId)
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Copy Execution ID */}
<PopoverItem
disabled={!hasExecutionId}
onClick={() => {
onCopyExecutionId()
onClose()
}}
>
Copy Execution ID
</PopoverItem>
{/* Open Workflow */}
<PopoverItem
disabled={!hasWorkflow}
onClick={() => {
onOpenWorkflow()
onClose()
}}
>
Open Workflow
</PopoverItem>
{/* Filter by Workflow - only show when not already filtered by this workflow */}
{!isFilteredByThisWorkflow && (
<PopoverItem
disabled={!hasWorkflow}
onClick={() => {
onToggleWorkflowFilter()
onClose()
}}
>
Filter by Workflow
</PopoverItem>
)}
{/* Clear All Filters - show when any filters are active */}
{hasActiveFilters && (
<PopoverItem
onClick={() => {
onClearAllFilters()
onClose()
}}
>
Clear Filters
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -10,6 +10,7 @@ import {
formatDate,
formatDuration,
getDisplayStatus,
LOG_COLUMNS,
StatusBadge,
TriggerBadge,
} from '@/app/workspace/[workspaceId]/logs/utils'
@@ -21,6 +22,7 @@ interface LogRowProps {
log: WorkflowLog
isSelected: boolean
onClick: (log: WorkflowLog) => void
onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null
}
@@ -29,11 +31,21 @@ interface LogRowProps {
* Uses shallow comparison for the log object.
*/
const LogRow = memo(
function LogRow({ log, isSelected, onClick, selectedRowRef }: LogRowProps) {
function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
const handleClick = useCallback(() => onClick(log), [onClick, log])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (onContextMenu) {
e.preventDefault()
onContextMenu(e, log)
}
},
[onContextMenu, log]
)
return (
<div
ref={isSelected ? selectedRowRef : null}
@@ -42,25 +54,28 @@ const LogRow = memo(
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
)}
onClick={handleClick}
onContextMenu={handleContextMenu}
>
<div className='flex flex-1 items-center'>
{/* Date */}
<span className='w-[8%] min-w-[70px] font-medium text-[12px] text-[var(--text-primary)]'>
<span
className={`${LOG_COLUMNS.date.width} ${LOG_COLUMNS.date.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
>
{formattedDate.compactDate}
</span>
{/* Time */}
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
<span
className={`${LOG_COLUMNS.time.width} ${LOG_COLUMNS.time.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
>
{formattedDate.compactTime}
</span>
{/* Status */}
<div className='w-[12%] min-w-[100px]'>
<div className={`${LOG_COLUMNS.status.width} ${LOG_COLUMNS.status.minWidth}`}>
<StatusBadge status={getDisplayStatus(log.status)} />
</div>
{/* Workflow */}
<div className='flex w-[22%] min-w-[140px] items-center gap-[8px] pr-[8px]'>
<div
className={`flex ${LOG_COLUMNS.workflow.width} ${LOG_COLUMNS.workflow.minWidth} items-center gap-[8px] pr-[8px]`}
>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
@@ -70,13 +85,13 @@ const LogRow = memo(
</span>
</div>
{/* Cost */}
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
<span
className={`${LOG_COLUMNS.cost.width} ${LOG_COLUMNS.cost.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
>
{typeof log.cost?.total === 'number' ? `$${log.cost.total.toFixed(4)}` : '—'}
</span>
{/* Trigger */}
<div className='w-[14%] min-w-[110px]'>
<div className={`${LOG_COLUMNS.trigger.width} ${LOG_COLUMNS.trigger.minWidth}`}>
{log.trigger ? (
<TriggerBadge trigger={log.trigger} />
) : (
@@ -84,8 +99,7 @@ const LogRow = memo(
)}
</div>
{/* Duration */}
<div className='w-[20%] min-w-[100px]'>
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
{formatDuration(log.duration) || '—'}
</Badge>
@@ -125,6 +139,7 @@ interface RowProps {
logs: WorkflowLog[]
selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
isFetchingNextPage: boolean
loaderRef: React.RefObject<HTMLDivElement | null>
@@ -140,11 +155,11 @@ function Row({
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,
loaderRef,
}: RowComponentProps<RowProps>) {
// Show loader for the last item if loading more
if (index >= logs.length) {
return (
<div style={style} className='flex items-center justify-center'>
@@ -171,6 +186,7 @@ function Row({
log={log}
isSelected={isSelected}
onClick={onLogClick}
onContextMenu={onLogContextMenu}
selectedRowRef={isSelected ? selectedRowRef : null}
/>
</div>
@@ -181,6 +197,7 @@ export interface LogsListProps {
logs: WorkflowLog[]
selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
hasNextPage: boolean
isFetchingNextPage: boolean
@@ -198,6 +215,7 @@ export function LogsList({
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
hasNextPage,
isFetchingNextPage,
@@ -208,7 +226,6 @@ export function LogsList({
const containerRef = useRef<HTMLDivElement>(null)
const [listHeight, setListHeight] = useState(400)
// Measure container height for virtualization
useEffect(() => {
const container = containerRef.current
if (!container) return
@@ -226,7 +243,6 @@ export function LogsList({
return () => ro.disconnect()
}, [])
// Handle infinite scroll when nearing the end of the list
const handleRowsRendered = useCallback(
({ stopIndex }: { startIndex: number; stopIndex: number }) => {
const threshold = logs.length - 10
@@ -237,20 +253,27 @@ export function LogsList({
[logs.length, hasNextPage, isFetchingNextPage, onLoadMore]
)
// Calculate total item count including loader row
const itemCount = hasNextPage ? logs.length + 1 : logs.length
// Row props passed to each row component
const rowProps = useMemo<RowProps>(
() => ({
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,
loaderRef,
}),
[logs, selectedLogId, onLogClick, selectedRowRef, isFetchingNextPage, loaderRef]
[
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,
loaderRef,
]
)
return (

View File

@@ -22,7 +22,6 @@ import {
import { SlackIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { ALL_TRIGGER_TYPES, type TriggerType } from '@/lib/logs/types'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import {
type NotificationSubscription,
@@ -34,6 +33,7 @@ import {
} from '@/hooks/queries/notifications'
import { useConnectOAuthService } from '@/hooks/queries/oauth-connections'
import { useSlackAccounts } from '@/hooks/use-slack-accounts'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
import { SlackChannelSelector } from './components/slack-channel-selector'
import { WorkflowSelector } from './components/workflow-selector'
@@ -133,7 +133,7 @@ export function NotificationSettings({
workflowIds: [] as string[],
allWorkflows: true,
levelFilter: ['info', 'error'] as LogLevel[],
triggerFilter: [...ALL_TRIGGER_TYPES] as TriggerType[],
triggerFilter: [...CORE_TRIGGER_TYPES] as CoreTriggerType[],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,
@@ -203,7 +203,7 @@ export function NotificationSettings({
workflowIds: [],
allWorkflows: true,
levelFilter: ['info', 'error'],
triggerFilter: [...ALL_TRIGGER_TYPES],
triggerFilter: [...CORE_TRIGGER_TYPES],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,
@@ -516,7 +516,7 @@ export function NotificationSettings({
workflowIds: subscription.workflowIds || [],
allWorkflows: subscription.allWorkflows,
levelFilter: subscription.levelFilter as LogLevel[],
triggerFilter: subscription.triggerFilter as TriggerType[],
triggerFilter: subscription.triggerFilter as CoreTriggerType[],
includeFinalOutput: subscription.includeFinalOutput,
includeTraceSpans: subscription.includeTraceSpans,
includeRateLimits: subscription.includeRateLimits,
@@ -849,14 +849,14 @@ export function NotificationSettings({
<div className='flex flex-col gap-[8px]'>
<Label className='text-[var(--text-secondary)]'>Trigger Type Filters</Label>
<Combobox
options={ALL_TRIGGER_TYPES.map((trigger) => ({
options={CORE_TRIGGER_TYPES.map((trigger) => ({
label: trigger.charAt(0).toUpperCase() + trigger.slice(1),
value: trigger,
}))}
multiSelect
multiSelectValues={formData.triggerFilter}
onMultiSelectChange={(values) => {
setFormData({ ...formData, triggerFilter: values as TriggerType[] })
setFormData({ ...formData, triggerFilter: values as CoreTriggerType[] })
setFormErrors({ ...formErrors, triggerFilter: '' })
}}
placeholder='Select trigger types...'

View File

@@ -17,15 +17,15 @@ import {
} from '@/components/emcn'
import { DatePicker } from '@/components/emcn/components/date-picker/date-picker'
import { cn } from '@/lib/core/utils/cn'
import { hasActiveFilters } from '@/lib/logs/filters'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { AutocompleteSearch } from './components/search'
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
{ value: 'All time', label: 'All time' },
{ value: 'Past 30 minutes', label: 'Past 30 minutes' },
@@ -182,6 +182,7 @@ export function LogsToolbar({
endDate,
setDateRange,
clearDateRange,
resetFilters,
} = useFilterStore()
const [datePickerOpen, setDatePickerOpen] = useState(false)
@@ -346,23 +347,23 @@ export function LogsToolbar({
setDatePickerOpen(false)
}, [timeRange, startDate, previousTimeRange, setTimeRange])
const hasActiveFilters = useMemo(() => {
return (
level !== 'all' ||
workflowIds.length > 0 ||
folderIds.length > 0 ||
triggers.length > 0 ||
timeRange !== 'All time'
)
}, [level, workflowIds, folderIds, triggers, timeRange])
const filtersActive = useMemo(
() =>
hasActiveFilters({
timeRange,
level,
workflowIds,
folderIds,
triggers,
searchQuery,
}),
[timeRange, level, workflowIds, folderIds, triggers, searchQuery]
)
const handleClearFilters = useCallback(() => {
setLevel('all')
setWorkflowIds([])
setFolderIds([])
setTriggers([])
clearDateRange()
}, [setLevel, setWorkflowIds, setFolderIds, setTriggers, clearDateRange])
resetFilters()
onSearchQueryChange('')
}, [resetFilters, onSearchQueryChange])
return (
<div className='flex flex-col gap-[19px]'>
@@ -462,7 +463,7 @@ export function LogsToolbar({
</div>
<div className='ml-auto flex items-center gap-[8px]'>
{/* Clear Filters Button */}
{hasActiveFilters && (
{filtersActive && (
<Button
variant='active'
onClick={handleClearFilters}

View File

@@ -4,7 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import {
getEndDateFromTimeRange,
getStartDateFromTimeRange,
hasActiveFilters,
} from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import { useFolders } from '@/hooks/queries/folders'
import { useDashboardLogs, useLogDetail, useLogsList } from '@/hooks/queries/logs'
@@ -12,7 +16,15 @@ import { useDebounce } from '@/hooks/use-debounce'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import { Dashboard, LogDetails, LogsList, LogsToolbar, NotificationSettings } from './components'
import {
Dashboard,
LogDetails,
LogRowContextMenu,
LogsList,
LogsToolbar,
NotificationSettings,
} from './components'
import { LOG_COLUMN_ORDER, LOG_COLUMNS } from './utils'
const LOGS_PER_PAGE = 50 as const
const REFRESH_SPINNER_DURATION_MS = 1000 as const
@@ -35,10 +47,12 @@ export default function Logs() {
level,
workflowIds,
folderIds,
setWorkflowIds,
setSearchQuery: setStoreSearchQuery,
triggers,
viewMode,
setViewMode,
resetFilters,
} = useFilterStore()
useEffect(() => {
@@ -71,6 +85,11 @@ export default function Logs() {
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
const userPermissions = useUserPermissionsContext()
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [contextMenuLog, setContextMenuLog] = useState<WorkflowLog | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const logFilters = useMemo(
() => ({
timeRange,
@@ -216,6 +235,56 @@ export default function Logs() {
prevSelectedLogRef.current = null
}, [])
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
e.preventDefault()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setContextMenuLog(log)
setContextMenuOpen(true)
}, [])
const handleCopyExecutionId = useCallback(() => {
if (contextMenuLog?.executionId) {
navigator.clipboard.writeText(contextMenuLog.executionId)
}
}, [contextMenuLog])
const handleOpenWorkflow = useCallback(() => {
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
if (wfId) {
window.open(`/workspace/${workspaceId}/w/${wfId}`, '_blank')
}
}, [contextMenuLog, workspaceId])
const handleToggleWorkflowFilter = useCallback(() => {
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
if (!wfId) return
if (workflowIds.length === 1 && workflowIds[0] === wfId) {
setWorkflowIds([])
} else {
setWorkflowIds([wfId])
}
}, [contextMenuLog, workflowIds, setWorkflowIds])
const handleClearAllFilters = useCallback(() => {
resetFilters()
setSearchQuery('')
}, [resetFilters, setSearchQuery])
const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
const isFilteredByThisWorkflow = Boolean(
contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId
)
const filtersActive = hasActiveFilters({
timeRange,
level,
workflowIds,
folderIds,
triggers,
searchQuery: debouncedSearchQuery,
})
useEffect(() => {
if (selectedRowRef.current) {
selectedRowRef.current.scrollIntoView({
@@ -400,27 +469,17 @@ export default function Logs() {
{/* Table header */}
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px] dark:bg-[var(--surface-3)]'>
<div className='flex items-center'>
<span className='w-[8%] min-w-[70px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Date
</span>
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Time
</span>
<span className='w-[12%] min-w-[100px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Status
</span>
<span className='w-[22%] min-w-[140px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow
</span>
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Cost
</span>
<span className='w-[14%] min-w-[110px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Trigger
</span>
<span className='w-[20%] min-w-[100px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Duration
</span>
{LOG_COLUMN_ORDER.map((key) => {
const col = LOG_COLUMNS[key]
return (
<span
key={key}
className={`${col.width} ${col.minWidth} font-medium text-[12px] text-[var(--text-tertiary)]`}
>
{col.label}
</span>
)
})}
</div>
</div>
@@ -452,6 +511,7 @@ export default function Logs() {
logs={logs}
selectedLogId={selectedLog?.id ?? null}
onLogClick={handleLogClick}
onLogContextMenu={handleLogContextMenu}
selectedRowRef={selectedRowRef}
hasNextPage={logsQuery.hasNextPage ?? false}
isFetchingNextPage={logsQuery.isFetchingNextPage}
@@ -481,6 +541,20 @@ export default function Logs() {
open={isNotificationSettingsOpen}
onOpenChange={setIsNotificationSettingsOpen}
/>
<LogRowContextMenu
isOpen={contextMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={() => setContextMenuOpen(false)}
log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId}
onOpenWorkflow={handleOpenWorkflow}
onToggleWorkflowFilter={handleToggleWorkflowFilter}
onClearAllFilters={handleClearAllFilters}
isFilteredByThisWorkflow={isFilteredByThisWorkflow}
hasActiveFilters={filtersActive}
/>
</div>
)
}

View File

@@ -3,8 +3,32 @@ import { format } from 'date-fns'
import { Badge } from '@/components/emcn'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
/** Column configuration for logs table - shared between header and rows */
export const LOG_COLUMNS = {
date: { width: 'w-[8%]', minWidth: 'min-w-[70px]', label: 'Date' },
time: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Time' },
status: { width: 'w-[12%]', minWidth: 'min-w-[100px]', label: 'Status' },
workflow: { width: 'w-[22%]', minWidth: 'min-w-[140px]', label: 'Workflow' },
cost: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Cost' },
trigger: { width: 'w-[14%]', minWidth: 'min-w-[110px]', label: 'Trigger' },
duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' },
} as const
/** Type-safe column key derived from LOG_COLUMNS */
export type LogColumnKey = keyof typeof LOG_COLUMNS
/** Ordered list of column keys for rendering table headers */
export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'date',
'time',
'status',
'workflow',
'cost',
'trigger',
'duration',
] as const
/** Possible execution status values for workflow logs */
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'

View File

@@ -24,11 +24,9 @@ export function useFloatBoundarySync({
const positionRef = useRef(position)
const previousDimensionsRef = useRef({ sidebarWidth: 0, panelWidth: 0, terminalHeight: 0 })
// Keep position ref up to date
positionRef.current = position
const checkAndUpdatePosition = useCallback(() => {
// Get current layout dimensions
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
@@ -39,20 +37,17 @@ export function useFloatBoundarySync({
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
// Check if dimensions actually changed
const prev = previousDimensionsRef.current
if (
prev.sidebarWidth === sidebarWidth &&
prev.panelWidth === panelWidth &&
prev.terminalHeight === terminalHeight
) {
return // No change, skip update
return
}
// Update previous dimensions
previousDimensionsRef.current = { sidebarWidth, panelWidth, terminalHeight }
// Calculate bounds
const minX = sidebarWidth
const maxX = window.innerWidth - panelWidth - width
const minY = 0
@@ -60,9 +55,7 @@ export function useFloatBoundarySync({
const currentPos = positionRef.current
// Check if current position is out of bounds
if (currentPos.x < minX || currentPos.x > maxX || currentPos.y < minY || currentPos.y > maxY) {
// Constrain to new bounds
const newPosition = {
x: Math.max(minX, Math.min(maxX, currentPos.x)),
y: Math.max(minY, Math.min(maxY, currentPos.y)),
@@ -75,30 +68,24 @@ export function useFloatBoundarySync({
if (!isOpen) return
const handleResize = () => {
// Cancel any pending animation frame
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
// Schedule update on next animation frame for smooth 60fps updates
rafIdRef.current = requestAnimationFrame(() => {
checkAndUpdatePosition()
rafIdRef.current = null
})
}
// Listen for window resize
window.addEventListener('resize', handleResize)
// Create MutationObserver to watch for CSS variable changes
// This fires immediately when sidebar/panel/terminal resize
const observer = new MutationObserver(handleResize)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style'],
})
// Initial check
checkAndUpdatePosition()
return () => {

View File

@@ -22,7 +22,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
*/
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only left click
if (e.button !== 0) return
e.preventDefault()
@@ -32,7 +31,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
dragStartRef.current = { x: e.clientX, y: e.clientY }
initialPositionRef.current = { ...position }
// Add dragging cursor to body
document.body.style.cursor = 'grabbing'
document.body.style.userSelect = 'none'
},
@@ -54,7 +52,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
y: initialPositionRef.current.y + deltaY,
}
// Constrain to bounds
const constrainedPosition = constrainChatPosition(newPosition, width, height)
onPositionChange(constrainedPosition)
},
@@ -69,7 +66,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
isDraggingRef.current = false
// Remove dragging cursor
document.body.style.cursor = ''
document.body.style.userSelect = ''
}, [])

View File

@@ -84,13 +84,11 @@ export function useFloatResize({
const isNearLeft = x <= EDGE_THRESHOLD
const isNearRight = x >= rect.width - EDGE_THRESHOLD
// Check corners first (they take priority over edges)
if (isNearTop && isNearLeft) return 'top-left'
if (isNearTop && isNearRight) return 'top-right'
if (isNearBottom && isNearLeft) return 'bottom-left'
if (isNearBottom && isNearRight) return 'bottom-right'
// Check edges
if (isNearTop) return 'top'
if (isNearBottom) return 'bottom'
if (isNearLeft) return 'left'
@@ -155,7 +153,6 @@ export function useFloatResize({
*/
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only left click
if (e.button !== 0) return
const chatElement = e.currentTarget as HTMLElement
@@ -176,7 +173,6 @@ export function useFloatResize({
height,
}
// Set cursor on body
document.body.style.cursor = getCursorForDirection(direction)
document.body.style.userSelect = 'none'
},
@@ -195,7 +191,6 @@ export function useFloatResize({
const initial = initialStateRef.current
const direction = activeDirectionRef.current
// Get layout bounds
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
@@ -206,18 +201,13 @@ export function useFloatResize({
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
// Clamp vertical drag when resizing from the top so the chat does not grow downward
// after its top edge hits the top of the viewport.
if (direction === 'top' || direction === 'top-left' || direction === 'top-right') {
// newY = initial.y + deltaY should never be less than 0
const maxUpwardDelta = initial.y
if (deltaY < -maxUpwardDelta) {
deltaY = -maxUpwardDelta
}
}
// Clamp vertical drag when resizing from the bottom so the chat does not grow upward
// after its bottom edge hits the top of the terminal.
if (direction === 'bottom' || direction === 'bottom-left' || direction === 'bottom-right') {
const maxBottom = window.innerHeight - terminalHeight
const initialBottom = initial.y + initial.height
@@ -228,8 +218,6 @@ export function useFloatResize({
}
}
// Clamp horizontal drag when resizing from the left so the chat does not grow to the right
// after its left edge hits the sidebar.
if (direction === 'left' || direction === 'top-left' || direction === 'bottom-left') {
const minLeft = sidebarWidth
const minDeltaX = minLeft - initial.x
@@ -239,8 +227,6 @@ export function useFloatResize({
}
}
// Clamp horizontal drag when resizing from the right so the chat does not grow to the left
// after its right edge hits the panel.
if (direction === 'right' || direction === 'top-right' || direction === 'bottom-right') {
const maxRight = window.innerWidth - panelWidth
const initialRight = initial.x + initial.width
@@ -256,9 +242,7 @@ export function useFloatResize({
let newWidth = initial.width
let newHeight = initial.height
// Calculate new dimensions based on resize direction
switch (direction) {
// Corners
case 'top-left':
newWidth = initial.width - deltaX
newHeight = initial.height - deltaY
@@ -280,7 +264,6 @@ export function useFloatResize({
newHeight = initial.height + deltaY
break
// Edges
case 'top':
newHeight = initial.height - deltaY
newY = initial.y + deltaY
@@ -297,8 +280,6 @@ export function useFloatResize({
break
}
// Constrain dimensions to min/max. If explicit constraints are not provided,
// fall back to the chat defaults for backward compatibility.
const effectiveMinWidth = typeof minWidth === 'number' ? minWidth : MIN_CHAT_WIDTH
const effectiveMaxWidth = typeof maxWidth === 'number' ? maxWidth : MAX_CHAT_WIDTH
const effectiveMinHeight = typeof minHeight === 'number' ? minHeight : MIN_CHAT_HEIGHT
@@ -310,7 +291,6 @@ export function useFloatResize({
Math.min(effectiveMaxHeight, newHeight)
)
// Adjust position if dimensions were constrained on left/top edges
if (direction === 'top-left' || direction === 'bottom-left' || direction === 'left') {
if (constrainedWidth !== newWidth) {
newX = initial.x + initial.width - constrainedWidth
@@ -322,7 +302,6 @@ export function useFloatResize({
}
}
// Constrain position to bounds
const minX = sidebarWidth
const maxX = window.innerWidth - panelWidth - constrainedWidth
const minY = 0
@@ -331,7 +310,6 @@ export function useFloatResize({
const finalX = Math.max(minX, Math.min(maxX, newX))
const finalY = Math.max(minY, Math.min(maxY, newY))
// Update state
onDimensionsChange({
width: constrainedWidth,
height: constrainedHeight,
@@ -353,7 +331,6 @@ export function useFloatResize({
isResizingRef.current = false
activeDirectionRef.current = null
// Remove cursor from body
document.body.style.cursor = ''
document.body.style.userSelect = ''
setCursor('')

View File

@@ -3,6 +3,31 @@ import { and, eq, gt, gte, inArray, lt, lte, ne, type SQL, sql } from 'drizzle-o
import { z } from 'zod'
import type { TimeRange } from '@/stores/logs/filters/types'
interface FilterValues {
timeRange: string
level: string
workflowIds: string[]
folderIds: string[]
triggers: string[]
searchQuery: string
}
/**
* Determines if any filters are currently active.
* @param filters - Current filter values
* @returns True if any filter is active
*/
export function hasActiveFilters(filters: FilterValues): boolean {
return (
filters.timeRange !== 'All time' ||
filters.level !== 'all' ||
filters.workflowIds.length > 0 ||
filters.folderIds.length > 0 ||
filters.triggers.length > 0 ||
filters.searchQuery.trim() !== ''
)
}
/**
* Shared schema for log filter parameters.
* Used by both the logs list API and export API.

View File

@@ -51,11 +51,10 @@ export interface ExecutionEnvironment {
workspaceId: string
}
export const ALL_TRIGGER_TYPES = ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'] as const
export type TriggerType = (typeof ALL_TRIGGER_TYPES)[number]
import type { CoreTriggerType } from '@/stores/logs/filters/types'
export interface ExecutionTrigger {
type: TriggerType | string
type: CoreTriggerType | string
source: string
data?: Record<string, unknown>
timestamp: string

View File

@@ -1,5 +1,11 @@
import { create } from 'zustand'
import type { FilterState, LogLevel, TimeRange, TriggerType } from '@/stores/logs/filters/types'
import {
CORE_TRIGGER_TYPES,
type FilterState,
type LogLevel,
type TimeRange,
type TriggerType,
} from '@/stores/logs/filters/types'
const getSearchParams = () => {
if (typeof window === 'undefined') return new URLSearchParams()
@@ -59,9 +65,7 @@ const parseTriggerArrayFromURL = (value: string | null): TriggerType[] => {
if (!value) return []
return value
.split(',')
.filter((t): t is TriggerType =>
['chat', 'api', 'webhook', 'manual', 'schedule', 'mcp'].includes(t)
)
.filter((t): t is TriggerType => (CORE_TRIGGER_TYPES as readonly string[]).includes(t))
}
const parseStringArrayFromURL = (value: string | null): string[] => {
@@ -251,6 +255,22 @@ export const useFilterStore = create<FilterState>((set, get) => ({
})
},
resetFilters: () => {
set({
timeRange: DEFAULT_TIME_RANGE,
startDate: undefined,
endDate: undefined,
level: 'all',
workflowIds: [],
folderIds: [],
triggers: [],
searchQuery: '',
})
if (!get().isInitializing) {
get().syncWithURL()
}
},
syncWithURL: () => {
const { timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, searchQuery } =
get()

View File

@@ -173,15 +173,12 @@ export type TimeRange =
| 'Custom range'
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all' | (string & {})
export type TriggerType =
| 'chat'
| 'api'
| 'webhook'
| 'manual'
| 'schedule'
| 'mcp'
| 'all'
| (string & {})
/** Core trigger types for workflow execution */
export const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
export type CoreTriggerType = (typeof CORE_TRIGGER_TYPES)[number]
export type TriggerType = CoreTriggerType | 'all' | (string & {})
/** Filter state for logs and dashboard views */
export interface FilterState {
@@ -212,4 +209,5 @@ export interface FilterState {
toggleTrigger: (trigger: TriggerType) => void
initializeFromURL: () => void
syncWithURL: () => void
resetFilters: () => void
}