diff --git a/apps/sim/app/_shell/dynamic-favicon.tsx b/apps/sim/app/_shell/dynamic-favicon.tsx index 804ee502c5..46e4f3ed37 100644 --- a/apps/sim/app/_shell/dynamic-favicon.tsx +++ b/apps/sim/app/_shell/dynamic-favicon.tsx @@ -87,6 +87,13 @@ const SECTION_TO_ICON: Record = { templates: 'templates', } +function setFaviconHrefs(url: string) { + document.querySelectorAll("link[rel*='icon']").forEach((link) => { + if (link.rel === 'apple-touch-icon') return + link.href = url + }) +} + export function DynamicFavicon() { const pathname = usePathname() @@ -95,17 +102,15 @@ export function DynamicFavicon() { const iconKey = section ? SECTION_TO_ICON[section] : null const url = iconKey ? FAVICONS[iconKey] : DEFAULT_FAVICON - const links = document.querySelectorAll("link[rel*='icon']") - links.forEach((link) => { - if (link.rel === 'apple-touch-icon') return - link.href = url - }) + setFaviconHrefs(url) + + // Re-apply whenever Next.js head reconciliation replaces link elements + const observer = new MutationObserver(() => setFaviconHrefs(url)) + observer.observe(document.head, { childList: true }) return () => { - links.forEach((link) => { - if (link.rel === 'apple-touch-icon') return - link.href = DEFAULT_FAVICON - }) + observer.disconnect() + setFaviconHrefs(DEFAULT_FAVICON) } }, [pathname]) diff --git a/apps/sim/app/_shell/hydration-error-handler.tsx b/apps/sim/app/_shell/hydration-error-handler.tsx index 56050875f0..61bc828f4a 100644 --- a/apps/sim/app/_shell/hydration-error-handler.tsx +++ b/apps/sim/app/_shell/hydration-error-handler.tsx @@ -15,26 +15,41 @@ const BROWSER_EXTENSION_ATTRIBUTES = [ ] /** - * Client component that intercepts console.error to filter and log hydration errors - * while ignoring errors caused by browser extensions. + * Checks whether a hydration error is caused by Radix UI's auto-generated IDs + * (`aria-controls`, `id`) differing between server and client. This is a known + * harmless artifact of React 19's streaming SSR producing different `useId()` + * tree paths. The IDs self-correct on first interaction and have no visual or + * functional impact since they only link closed (invisible) popover/menu content. + */ +function isRadixIdMismatch(args: unknown[]): boolean { + return args.some( + (arg) => typeof arg === 'string' && arg.includes('radix-') && /aria-controls|"\bid\b"/.test(arg) + ) +} + +/** + * Client component that intercepts console.error to filter hydration errors + * caused by browser extensions or Radix UI's `useId()` mismatch. */ export function HydrationErrorHandler() { useEffect(() => { const originalError = console.error console.error = (...args) => { - if (args[0].includes('Hydration')) { + if (typeof args[0] === 'string' && args[0].includes('Hydration')) { const isExtensionError = BROWSER_EXTENSION_ATTRIBUTES.some((attr) => args.some((arg) => typeof arg === 'string' && arg.includes(attr)) ) - if (!isExtensionError) { - logger.error('Hydration Error', { - details: args, - componentStack: args.find( - (arg) => typeof arg === 'string' && arg.includes('component stack') - ), - }) + if (isExtensionError || isRadixIdMismatch(args)) { + return } + + logger.error('Hydration Error', { + details: args, + componentStack: args.find( + (arg) => typeof arg === 'string' && arg.includes('component stack') + ), + }) } originalError.apply(console, args) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index e45db17eaa..b706c53c70 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -7,6 +7,7 @@ export type { HeaderAction, } from './resource/components/resource-header' export { ResourceHeader } from './resource/components/resource-header' +export type { FilterTag, SearchConfig } from './resource/components/resource-options-bar' export { ResourceOptionsBar } from './resource/components/resource-options-bar' export { timeCell } from './resource/components/time-cell/time-cell' export type { ResourceCell, ResourceColumn, ResourceRow } from './resource/resource' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 739d1095db..a50c03ac76 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -96,7 +96,9 @@ export function ResourceHeader({ variant='subtle' className='px-[8px] py-[4px] text-[12px]' > - {ActionIcon && } + {ActionIcon && ( + + )} {action.label} ) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/index.ts index 6711234562..ba2be6c912 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/index.ts @@ -1,2 +1,8 @@ -export type { ColumnOption, SortConfig } from './resource-options-bar' +export type { + ColumnOption, + FilterTag, + SearchConfig, + SearchTag, + SortConfig, +} from './resource-options-bar' export { ResourceOptionsBar } from './resource-options-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx index 4400d355be..6bc9198886 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx @@ -31,18 +31,41 @@ export interface SortConfig { onClear?: () => void } -interface ResourceOptionsBarProps { - search?: { - value: string - onChange: (value: string) => void - placeholder?: string - } - sort?: SortConfig - filter?: ReactNode +export interface FilterTag { + label: string + onRemove: () => void } -export function ResourceOptionsBar({ search, sort, filter }: ResourceOptionsBarProps) { - const hasContent = search || sort || filter +export interface SearchTag { + label: string + value: string + onRemove: () => void +} + +export interface SearchConfig { + value: string + onChange: (value: string) => void + placeholder?: string + inputRef?: React.RefObject + onKeyDown?: (e: React.KeyboardEvent) => void + onFocus?: () => void + onBlur?: () => void + tags?: SearchTag[] + highlightedTagIndex?: number | null + onClearAll?: () => void + dropdown?: ReactNode + dropdownRef?: React.RefObject +} + +interface ResourceOptionsBarProps { + search?: SearchConfig + sort?: SortConfig + filter?: ReactNode + filterTags?: FilterTag[] +} + +export function ResourceOptionsBar({ search, sort, filter, filterTags }: ResourceOptionsBarProps) { + const hasContent = search || sort || filter || (filterTags && filterTags.length > 0) if (!hasContent) return null return ( @@ -54,18 +77,67 @@ export function ResourceOptionsBar({ search, sort, filter }: ResourceOptionsBarP >
{search && ( -
+
- search.onChange(e.target.value)} - placeholder={search.placeholder ?? 'Search...'} - className='w-full bg-transparent py-[4px] pl-[10px] text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]' - /> +
+ {search.tags?.map((tag, i) => ( + + ))} + search.onChange(e.target.value)} + onKeyDown={search.onKeyDown} + onFocus={search.onFocus} + onBlur={search.onBlur} + placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')} + className='min-w-[80px] flex-1 bg-transparent py-[4px] text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]' + /> +
+ {search.tags?.length || search.value ? ( + + ) : null} + {search.dropdown && ( +
+ {search.dropdown} +
+ )}
)}
+ {filterTags?.map((tag) => ( + + ))} {filter && ( diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index b6da67916e..106ac98039 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -1,12 +1,12 @@ 'use client' import type { ReactNode } from 'react' -import { useCallback, useMemo, useRef, useState } from 'react' -import { ArrowDown, ArrowUp, Button, Plus, Skeleton } from '@/components/emcn' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ArrowDown, ArrowUp, Button, Loader, Plus, Skeleton } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { CreateAction, HeaderAction } from './components/resource-header' import { ResourceHeader } from './components/resource-header' -import type { SortConfig } from './components/resource-options-bar' +import type { FilterTag, SearchConfig, SortConfig } from './components/resource-options-bar' import { ResourceOptionsBar } from './components/resource-options-bar' export interface ResourceColumn { @@ -17,6 +17,7 @@ export interface ResourceColumn { export interface ResourceCell { icon?: ReactNode label?: string | null + content?: ReactNode } export interface ResourceRow { @@ -29,26 +30,33 @@ interface ResourceProps { icon: React.ElementType title: string create?: CreateAction - search?: { - value: string - onChange: (value: string) => void - placeholder?: string - } - defaultSort: string + search?: SearchConfig + defaultSort?: string + disableHeaderSort?: boolean headerActions?: HeaderAction[] columns: ResourceColumn[] rows: ResourceRow[] + selectedRowId?: string | null onRowClick?: (rowId: string) => void + onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void isLoading?: boolean loadingRows?: number onContextMenu?: (e: React.MouseEvent) => void + filter?: ReactNode + filterTags?: FilterTag[] + onLoadMore?: () => void + hasMore?: boolean + isLoadingMore?: boolean + emptyMessage?: string + contentOverride?: ReactNode + overlay?: ReactNode } const EMPTY_CELL_PLACEHOLDER = '- - -' /** - * Shared page shell for resource list pages (tables, files, knowledge, schedules). + * Shared page shell for resource list pages (tables, files, knowledge, schedules, logs). * Renders the header, toolbar with search, and a data table from column/row definitions. */ export function Resource({ @@ -57,18 +65,31 @@ export function Resource({ create, search, defaultSort, + disableHeaderSort, headerActions, columns, rows, + selectedRowId, onRowClick, + onRowHover, onRowContextMenu, isLoading, loadingRows = 5, onContextMenu, + filter, + filterTags, + onLoadMore, + hasMore, + isLoadingMore, + emptyMessage, + contentOverride, + overlay, }: ResourceProps) { const headerRef = useRef(null) + const loadMoreRef = useRef(null) + const sortEnabled = defaultSort != null && !disableHeaderSort const [sort, setSort] = useState<{ column: string; direction: 'asc' | 'desc' }>({ - column: defaultSort, + column: defaultSort ?? '', direction: 'desc', }) @@ -82,16 +103,17 @@ export function Resource({ setSort({ column, direction }) }, []) - const sortConfig = useMemo( - () => ({ + const sortConfig = useMemo(() => { + if (!sortEnabled) return undefined + return { options: columns.map((col) => ({ id: col.id, label: col.header })), active: sort, onSort: handleSort, - }), - [columns, sort, handleSort] - ) + } + }, [sortEnabled, columns, sort, handleSort]) - const sortedRows = useMemo(() => { + const displayRows = useMemo(() => { + if (!sortEnabled) return rows return [...rows].sort((a, b) => { const col = sort.column const aVal = a.sortValues?.[col] ?? a.cells[col]?.label ?? '' @@ -102,7 +124,21 @@ export function Resource({ : String(aVal).localeCompare(String(bVal)) return sort.direction === 'asc' ? -cmp : cmp }) - }, [rows, sort]) + }, [rows, sort, sortEnabled]) + + useEffect(() => { + if (!onLoadMore || !hasMore) return + const el = loadMoreRef.current + if (!el) return + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) onLoadMore() + }, + { rootMargin: '200px' } + ) + observer.observe(el) + return () => observer.disconnect() + }, [onLoadMore, hasMore]) return (
- + - {isLoading ? ( + {contentOverride ? ( +
{contentOverride}
+ ) : isLoading ? ( + ) : rows.length === 0 && emptyMessage ? ( +
+ {emptyMessage} +
) : ( - <> +
{columns.map((col) => { + if (disableHeaderSort || !sortEnabled) { + return ( + + ) + } const isActive = sort.column === col.id const SortIcon = sort.direction === 'asc' ? ArrowUp : ArrowDown return ( @@ -152,15 +209,18 @@ export function Resource({
+ {col.header} +
- {sortedRows.map((row) => ( + {displayRows.map((row) => ( onRowClick?.(row.id)} + onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined} onContextMenu={(e) => onRowContextMenu?.(e, row.id)} > {columns.map((col, colIdx) => { @@ -196,14 +256,23 @@ export function Resource({ )}
+ {hasMore && ( +
+ {isLoadingMore && ( + + )} +
+ )}
- + {overlay} +
)}
) } function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean }) { + if (cell.content) return <>{cell.content} return ( >() + +function getColorIcon( + color: string, + withRing = false +): React.ComponentType<{ className?: string }> { + const cacheKey = withRing ? `${color}-ring` : color + const cached = colorIconCache.get(cacheKey) + if (cached) return cached + + const ColorIcon = ({ className }: { className?: string }) => ( +
+ ) + ColorIcon.displayName = `ColorIcon(${color}${withRing ? '-ring' : ''})` + colorIconCache.set(cacheKey, ColorIcon) + return ColorIcon +} + +function getTriggerIcon( + triggerType: string +): React.ComponentType<{ className?: string }> | undefined { + if ((CORE_TRIGGER_TYPES as readonly string[]).includes(triggerType)) return undefined + const block = getBlock(triggerType) + if (!block?.icon) return undefined + const BlockIcon = block.icon + const TriggerIcon = ({ className }: { className?: string }) => ( + + ) + TriggerIcon.displayName = `TriggerIcon(${triggerType})` + return TriggerIcon +} + +function formatDateShort(dateStr: string): string { + const date = new Date(dateStr) + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ] + return `${months[date.getMonth()]} ${date.getDate()}` +} + /** * Logs page component displaying workflow execution history. * Supports filtering, search, live updates, and detailed log inspection. @@ -92,6 +221,12 @@ export default function Logs() { viewMode, setViewMode, resetFilters, + setLevel, + setFolderIds, + setTriggers, + setTimeRange, + setDateRange, + clearDateRange, } = useFilterStore() useEffect(() => { @@ -102,9 +237,6 @@ export default function Logs() { selectedLogId: null, isSidebarOpen: false, }) - const selectedRowRef = useRef(null) - const loaderRef = useRef(null) - const isInitialized = useRef(false) const [searchQuery, setSearchQuery] = useState('') @@ -118,10 +250,9 @@ export default function Logs() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const [isLive, setIsLive] = useState(true) + const isLive = true const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const [isExporting, setIsExporting] = useState(false) - const isSearchOpenRef = useRef(false) const refreshTimersRef = useRef(new Set()) const logsRef = useRef([]) const selectedLogIndexRef = useRef(-1) @@ -214,8 +345,8 @@ export default function Logs() { }, [selectedLogFromList, activeLogQuery.data, activeLogQuery.isPlaceholderData, isPreviewOpen]) const handleLogHover = useCallback( - (log: WorkflowLog) => { - prefetchLogDetail(queryClient, log.id) + (rowId: string) => { + prefetchLogDetail(queryClient, rowId) }, [queryClient] ) @@ -259,8 +390,8 @@ export default function Logs() { } }, [debouncedSearchQuery, setStoreSearchQuery]) - const handleLogClick = useCallback((log: WorkflowLog) => { - dispatch({ type: 'TOGGLE_LOG', logId: log.id }) + const handleLogClick = useCallback((rowId: string) => { + dispatch({ type: 'TOGGLE_LOG', logId: rowId }) }, []) const handleNavigateNext = useCallback(() => { @@ -282,12 +413,16 @@ export default function Logs() { dispatch({ type: 'CLOSE_SIDEBAR' }) }, []) - const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => { - e.preventDefault() - setContextMenuPosition({ x: e.clientX, y: e.clientY }) - setContextMenuLog(log) - setContextMenuOpen(true) - }, []) + const handleLogContextMenu = useCallback( + (e: React.MouseEvent, rowId: string) => { + e.preventDefault() + const log = logs.find((l) => l.id === rowId) ?? null + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setContextMenuLog(log) + setContextMenuOpen(true) + }, + [logs] + ) const handleCopyExecutionId = useCallback(() => { if (contextMenuLog?.executionId) { @@ -340,13 +475,12 @@ export default function Logs() { }) useEffect(() => { - if (selectedRowRef.current) { - selectedRowRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }) + if (!selectedLogId) return + const row = document.querySelector(`[data-row-id="${selectedLogId}"]`) as HTMLElement | null + if (row) { + row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) } - }, [selectedLogIndex]) + }, [selectedLogId, selectedLogIndex]) const handleRefresh = useCallback(() => { setIsVisuallyRefreshing(true) @@ -361,24 +495,6 @@ export default function Logs() { } }, []) - const handleToggleLive = useCallback(() => { - setIsLive((prev) => { - if (!prev) { - setIsVisuallyRefreshing(true) - const timerId = window.setTimeout(() => { - setIsVisuallyRefreshing(false) - refreshTimersRef.current.delete(timerId) - }, REFRESH_SPINNER_DURATION_MS) - refreshTimersRef.current.add(timerId) - logsRefetchRef.current() - if (selectedLogIdRef.current) { - activeLogRefetchRef.current() - } - } - return !prev - }) - }, []) - const prevIsFetchingRef = useRef(logsQuery.isFetching) useEffect(() => { const wasFetching = prevIsFetchingRef.current @@ -468,7 +584,8 @@ export default function Logs() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (isSearchOpenRef.current) return + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return const currentLogs = logsRef.current const currentIndex = selectedLogIndexRef.current if (currentLogs.length === 0) return @@ -506,9 +623,6 @@ export default function Logs() { const handleCloseContextMenu = useCallback(() => setContextMenuOpen(false), []) const handleOpenNotificationSettings = useCallback(() => setIsNotificationSettingsOpen(true), []) - const handleSearchOpenChange = useCallback((open: boolean) => { - isSearchOpenRef.current = open - }, []) const handleClosePreview = useCallback(() => { setIsPreviewOpen(false) setPreviewLogId(null) @@ -516,119 +630,485 @@ export default function Logs() { const isDashboardView = viewMode === 'dashboard' - return ( -
-
-
-
- 0} - onOpenNotificationSettings={handleOpenNotificationSettings} - searchQuery={searchQuery} - onSearchQueryChange={setSearchQuery} - onSearchOpenChange={handleSearchOpenChange} - /> -
+ const rows: ResourceRow[] = useMemo( + () => + logs.map((log) => { + const formattedDate = formatDate(log.createdAt) + const displayStatus = getDisplayStatus(log.status) + const isMothershipJob = log.trigger === 'mothership' + const isDeletedWorkflow = !isMothershipJob && !log.workflow?.id && !log.workflowId + const workflowName = isMothershipJob + ? log.jobTitle || 'Untitled Job' + : isDeletedWorkflow + ? DELETED_WORKFLOW_LABEL + : log.workflow?.name || 'Unknown' + const workflowColor = isMothershipJob + ? '#ec4899' + : isDeletedWorkflow + ? DELETED_WORKFLOW_COLOR + : log.workflow?.color - {/* Dashboard view - uses all logs (non-paginated) for accurate metrics */} -
- -
+ const durationMs = parseDuration({ duration: log.duration ?? undefined }) + const durationText = + durationMs != null ? (formatDuration(durationMs, { precision: 2 }) ?? '—') : '—' - {/* Main content area with table - only show in logs view */} -
- {/* Table container */} -
- {/* Table header */} -
-
- {LOG_COLUMN_ORDER.map((key) => { - const col = LOG_COLUMNS[key] - return ( - - {col.label} - - ) - })} -
-
+ const costCredits = + typeof log.cost?.total === 'number' ? dollarsToCredits(log.cost.total) : null + const costText = + costCredits !== null + ? `${costCredits.toLocaleString()} ${costCredits === 1 ? 'credit' : 'credits'}` + : '—' - {/* Table body - virtualized */} -
- {logsQuery.isLoading && !logsQuery.data ? ( -
-
- - Loading logs... -
-
- ) : logsQuery.isError ? ( -
-
- - Error: {logsQuery.error?.message || 'Failed to load logs'} - -
-
- ) : logs.length === 0 ? ( -
-
- No logs found -
-
- ) : ( - - )} -
-
+ return { + id: log.id, + cells: { + date: { + content: ( + + {formattedDate.compactDate} + + ), + }, + time: { + content: ( + + {formattedDate.compactTime} + + ), + }, + status: { + content: , + }, + workflow: { + content: ( + + {workflowColor && ( +
+ )} + + {workflowName} + + + ), + }, + cost: { + content: ( + + {costText} + + ), + }, + trigger: { + content: , + }, + duration: { + content: ( + + {durationText} + + ), + }, + }, + } + }), + [logs] + ) - {/* Log Details - rendered inside table container */} - 0} - /> -
-
+ const dashboardContent = useMemo(() => { + if (!isDashboardView) return undefined + return ( +
+
+ ) + }, [ + isDashboardView, + dashboardStatsQuery.data, + dashboardStatsQuery.isLoading, + dashboardStatsQuery.error, + ]) + + const sidebarOverlay = useMemo( + () => ( + 0} + /> + ), + [ + selectedLog, + isSidebarOpen, + handleCloseSidebar, + handleNavigateNext, + handleNavigatePrev, + selectedLogIndex, + logs.length, + ] + ) + + const allWorkflows = useWorkflowRegistry((state) => state.workflows) + const folders = useFolderStore((state) => state.folders) + + const filterTags = useMemo(() => { + const tags: FilterTag[] = [] + + if (level && level !== 'all') { + const statuses = level.split(',').filter(Boolean) + const labels = statuses.map((s) => STATUS_CONFIG[s as LogStatus]?.label ?? s) + tags.push({ + label: `Status: ${labels.join(', ')}`, + onRemove: () => setLevel('all'), + }) + } + + if (workflowIds.length > 0) { + const names = workflowIds.map((id) => allWorkflows[id]?.name ?? id.slice(0, 8)) + tags.push({ + label: `Workflow: ${names.join(', ')}`, + onRemove: () => setWorkflowIds([]), + }) + } + + if (folderIds.length > 0) { + const names = folderIds.map((id) => folders[id]?.name ?? id.slice(0, 8)) + tags.push({ + label: `Folder: ${names.join(', ')}`, + onRemove: () => setFolderIds([]), + }) + } + + if (triggers.length > 0) { + tags.push({ + label: `Trigger: ${triggers.join(', ')}`, + onRemove: () => setTriggers([]), + }) + } + + if (timeRange !== 'All time') { + tags.push({ + label: + timeRange === 'Custom range' && startDate && endDate + ? `${startDate} – ${endDate}` + : timeRange, + onRemove: () => { + clearDateRange() + setTimeRange('All time') + }, + }) + } + + return tags + }, [ + level, + setLevel, + workflowIds, + setWorkflowIds, + allWorkflows, + folderIds, + setFolderIds, + folders, + triggers, + setTriggers, + timeRange, + startDate, + endDate, + clearDateRange, + setTimeRange, + ]) + + const workflowsData = useMemo( + () => + Object.values(allWorkflows).map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + })), + [allWorkflows] + ) + const foldersData = useMemo( + () => Object.values(folders).map((f) => ({ id: f.id, name: f.name })), + [folders] + ) + const triggersData = useMemo( + () => getTriggerOptions().map((t) => ({ value: t.value, label: t.label, color: t.color })), + [] + ) + const suggestionEngine = useMemo( + () => new SearchSuggestions(workflowsData, foldersData, triggersData), + [workflowsData, foldersData, triggersData] + ) + + const handleFiltersChange = useCallback((filters: ParsedFilter[], textSearch: string) => { + const filterStrings = filters.map( + (f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}` + ) + const fullQuery = [...filterStrings, textSearch].filter(Boolean).join(' ') + setSearchQuery(fullQuery) + }, []) + + const { + appliedFilters, + currentInput, + textSearch, + isOpen: isSuggestionsOpen, + suggestions, + sections, + highlightedIndex, + highlightedBadgeIndex, + inputRef: searchInputRef, + dropdownRef: searchDropdownRef, + handleInputChange: handleSearchInputChange, + handleSuggestionSelect, + handleKeyDown: handleSearchKeyDown, + handleFocus: handleSearchFocus, + handleBlur: handleSearchBlur, + removeBadge, + clearAll: clearSearch, + setHighlightedIndex, + initializeFromQuery, + } = useSearchState({ + onFiltersChange: handleFiltersChange, + getSuggestions: (input) => suggestionEngine.getSuggestions(input), + }) + + const lastExternalSearchValue = useRef(searchQuery) + useEffect(() => { + if (searchQuery !== lastExternalSearchValue.current) { + lastExternalSearchValue.current = searchQuery + const parsed = parseQuery(searchQuery) + initializeFromQuery(parsed.textSearch, parsed.filters) + } + }, [searchQuery, initializeFromQuery]) + + useEffect(() => { + if (searchQuery) { + const parsed = parseQuery(searchQuery) + initializeFromQuery(parsed.textSearch, parsed.filters) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (!isSuggestionsOpen || highlightedIndex < 0) return + const container = searchDropdownRef.current + const el = container?.querySelector(`[data-index="${highlightedIndex}"]`) + if (container && el) { + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + } + }, [isSuggestionsOpen, highlightedIndex]) + + const suggestionsDropdown = useMemo(() => { + if (!isSuggestionsOpen || suggestions.length === 0) return undefined + const suggestionType = + sections.length > 0 ? 'multi-section' : (suggestions[0]?.category ?? null) + + return ( +
+ {sections.length > 0 ? ( +
+ {suggestions[0]?.category === 'show-all' && ( + + )} + {sections.map((section) => ( +
+
+ {section.title} +
+ {section.suggestions.map((suggestion) => { + if (suggestion.category === 'show-all') return null + const index = suggestions.indexOf(suggestion) + return ( + + ) + })} +
+ ))} +
+ ) : ( +
+ {suggestionType === 'filters' && ( +
+ SUGGESTED FILTERS +
+ )} + {suggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+ ) + }, [ + isSuggestionsOpen, + suggestions, + sections, + highlightedIndex, + setHighlightedIndex, + handleSuggestionSelect, + ]) + + const searchTags = useMemo( + () => [ + ...appliedFilters.map((f, i) => ({ + label: f.field, + value: `${f.operator !== '=' ? f.operator : ''}${f.originalValue}`, + onRemove: () => removeBadge(i), + })), + ...(textSearch + ? [ + { + label: 'search', + value: textSearch, + onRemove: () => handleFiltersChange(appliedFilters, ''), + }, + ] + : []), + ], + [appliedFilters, textSearch, removeBadge, handleFiltersChange] + ) + + const searchConfig = useMemo( + () => ({ + value: currentInput, + onChange: handleSearchInputChange, + placeholder: 'Search logs...', + inputRef: searchInputRef, + onKeyDown: handleSearchKeyDown, + onFocus: handleSearchFocus, + onBlur: handleSearchBlur, + tags: searchTags.length > 0 ? searchTags : undefined, + highlightedTagIndex: highlightedBadgeIndex, + onClearAll: clearSearch, + dropdown: suggestionsDropdown, + dropdownRef: searchDropdownRef, + }), + [ + currentInput, + handleSearchInputChange, + searchInputRef, + handleSearchKeyDown, + handleSearchFocus, + handleSearchBlur, + searchTags, + highlightedBadgeIndex, + clearSearch, + suggestionsDropdown, + searchDropdownRef, + ] + ) + + const refreshIcon = useMemo(() => { + if (!isVisuallyRefreshing) return Loader + const Spinning = (props: React.SVGProps) => + Spinning.displayName = 'SpinningLoader' + return Spinning + }, [isVisuallyRefreshing]) + + const headerActions = useMemo( + () => [ + { + label: 'Export', + icon: Download, + onClick: handleExport, + disabled: !userPermissions.canEdit || isExporting || logs.length === 0, + }, + { + label: 'Notifications', + icon: Bell, + onClick: handleOpenNotificationSettings, + }, + { + label: '', + icon: refreshIcon, + onClick: handleRefresh, + disabled: isVisuallyRefreshing, + }, + { + label: 'Logs', + onClick: () => setViewMode('logs'), + disabled: !isDashboardView, + }, + { + label: 'Dashboard', + onClick: () => setViewMode('dashboard'), + disabled: isDashboardView, + }, + ], + [ + isDashboardView, + setViewMode, + refreshIcon, + isVisuallyRefreshing, + handleRefresh, + handleExport, + userPermissions.canEdit, + isExporting, + logs.length, + handleOpenNotificationSettings, + ] + ) + + return ( + <> + } + filterTags={filterTags} + columns={LOG_COLUMNS} + rows={rows} + selectedRowId={selectedLogId} + onRowClick={handleLogClick} + onRowHover={handleLogHover} + onRowContextMenu={handleLogContextMenu} + isLoading={!logsQuery.data} + onLoadMore={loadMoreLogs} + hasMore={logsQuery.hasNextPage ?? false} + isLoadingMore={logsQuery.isFetchingNextPage} + emptyMessage='No logs found' + contentOverride={dashboardContent} + overlay={sidebarOverlay} + /> )} + + ) +} + +interface LogsFilterPanelProps { + searchQuery: string + onSearchQueryChange: (query: string) => void +} + +function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + + const { + level, + setLevel, + workflowIds, + setWorkflowIds, + folderIds, + setFolderIds, + triggers, + setTriggers, + timeRange, + setTimeRange, + startDate, + endDate, + setDateRange, + clearDateRange, + resetFilters, + } = useFilterStore() + + const [datePickerOpen, setDatePickerOpen] = useState(false) + const [previousTimeRange, setPreviousTimeRange] = useState(timeRange) + const folders = useFolderStore((state) => state.folders) + const allWorkflows = useWorkflowRegistry((state) => state.workflows) + + const workflows = useMemo( + () => Object.values(allWorkflows).map((w) => ({ id: w.id, name: w.name, color: w.color })), + [allWorkflows] + ) + + const folderList = useMemo( + () => Object.values(folders).filter((f) => f.workspaceId === workspaceId), + [folders, workspaceId] + ) + + const selectedStatuses = useMemo((): string[] => { + if (level === 'all' || !level) return [] + return level.split(',').filter(Boolean) + }, [level]) + + const statusOptions: ComboboxOption[] = useMemo( + () => + (Object.keys(STATUS_CONFIG) as LogStatus[]).map((status) => ({ + value: status, + label: STATUS_CONFIG[status].label, + icon: getColorIcon(STATUS_CONFIG[status].color), + })), + [] + ) + + const handleStatusChange = useCallback( + (values: string[]) => { + setLevel(values.length === 0 ? 'all' : values.join(',')) + }, + [setLevel] + ) + + const statusDisplayLabel = useMemo(() => { + if (selectedStatuses.length === 0) return 'Status' + if (selectedStatuses.length === 1) { + const status = statusOptions.find((s) => s.value === selectedStatuses[0]) + return status?.label || '1 selected' + } + return `${selectedStatuses.length} selected` + }, [selectedStatuses, statusOptions]) + + const selectedStatusColor = useMemo(() => { + if (selectedStatuses.length !== 1) return null + const status = selectedStatuses[0] as LogStatus + return STATUS_CONFIG[status]?.color ?? null + }, [selectedStatuses]) + + const workflowOptions: ComboboxOption[] = useMemo( + () => workflows.map((w) => ({ value: w.id, label: w.name, icon: getColorIcon(w.color, true) })), + [workflows] + ) + + const workflowDisplayLabel = useMemo(() => { + if (workflowIds.length === 0) return 'Workflow' + if (workflowIds.length === 1) { + const workflow = workflows.find((w) => w.id === workflowIds[0]) + return workflow?.name || '1 selected' + } + return `${workflowIds.length} workflows` + }, [workflowIds, workflows]) + + const selectedWorkflow = + workflowIds.length === 1 ? workflows.find((w) => w.id === workflowIds[0]) : null + + const folderOptions: ComboboxOption[] = useMemo( + () => folderList.map((f) => ({ value: f.id, label: f.name })), + [folderList] + ) + + const folderDisplayLabel = useMemo(() => { + if (folderIds.length === 0) return 'Folder' + if (folderIds.length === 1) { + const folder = folderList.find((f) => f.id === folderIds[0]) + return folder?.name || '1 selected' + } + return `${folderIds.length} folders` + }, [folderIds, folderList]) + + const triggerOptions: ComboboxOption[] = useMemo( + () => + getTriggerOptions().map((t) => ({ + value: t.value, + label: t.label, + icon: getTriggerIcon(t.value), + })), + [] + ) + + const triggerDisplayLabel = useMemo(() => { + if (triggers.length === 0) return 'Trigger' + if (triggers.length === 1) { + const trigger = triggerOptions.find((t) => t.value === triggers[0]) + return trigger?.label || '1 selected' + } + return `${triggers.length} triggers` + }, [triggers, triggerOptions]) + + const timeDisplayLabel = useMemo(() => { + if (timeRange === 'All time') return 'Time' + if (timeRange === 'Custom range' && startDate && endDate) { + return `${formatDateShort(startDate)} - ${formatDateShort(endDate)}` + } + if (timeRange === 'Custom range') return 'Custom range' + return timeRange + }, [timeRange, startDate, endDate]) + + const handleTimeRangeChange = useCallback( + (val: string) => { + if (val === 'Custom range') { + setPreviousTimeRange(timeRange) + setDatePickerOpen(true) + } else { + clearDateRange() + setTimeRange(val as typeof timeRange) + } + }, + [timeRange, setTimeRange, clearDateRange] + ) + + const handleDateRangeApply = useCallback( + (start: string, end: string) => { + setDateRange(start, end) + setDatePickerOpen(false) + }, + [setDateRange] + ) + + const handleDatePickerCancel = useCallback(() => { + if (timeRange === 'Custom range' && !startDate) { + setTimeRange(previousTimeRange) + } + setDatePickerOpen(false) + }, [timeRange, startDate, previousTimeRange, setTimeRange]) + + const filtersActive = useMemo( + () => + hasActiveFilters({ + timeRange, + level, + workflowIds, + folderIds, + triggers, + searchQuery, + }), + [timeRange, level, workflowIds, folderIds, triggers, searchQuery] + ) + + const handleClearFilters = useCallback(() => { + resetFilters() + onSearchQueryChange('') + }, [resetFilters, onSearchQueryChange]) + + return ( +
+
+ Status + + {selectedStatusColor && ( +
+ )} + {statusDisplayLabel} + + } + showAllOption + allOptionLabel='All statuses' + size='sm' + className='h-[32px] w-full rounded-[6px]' + /> +
+ +
+ Workflow + + {selectedWorkflow && ( +
+ )} + {workflowDisplayLabel} + + } + searchable + searchPlaceholder='Search workflows...' + showAllOption + allOptionLabel='All workflows' + size='sm' + className='h-[32px] w-full rounded-[6px]' + /> +
+ +
+ Folder + {folderDisplayLabel} + } + searchable + searchPlaceholder='Search folders...' + showAllOption + allOptionLabel='All folders' + size='sm' + className='h-[32px] w-full rounded-[6px]' + /> +
+ +
+ Trigger + {triggerDisplayLabel} + } + searchable + searchPlaceholder='Search triggers...' + showAllOption + allOptionLabel='All triggers' + size='sm' + className='h-[32px] w-full rounded-[6px]' + /> +
+ +
+ Time Range + + +
+ {timeDisplayLabel} + } + size='sm' + className='h-[32px] w-full rounded-[6px]' + /> +
+
+ + + +
+
+ + {filtersActive && ( + + )}
) } + +function SuggestionButton({ + suggestion, + index, + highlighted, + onHover, + onSelect, + showCategory, +}: { + suggestion: Suggestion + index: number + highlighted: boolean + onHover: (i: number) => void + onSelect: (s: Suggestion) => void + showCategory?: boolean +}) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 99e17c8ee9..39f0af6867 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -80,7 +80,7 @@ interface PendingPlaceholder { } const EMPTY_COLUMNS: never[] = [] -const PLACEHOLDER_ROW_COUNT = 50 +const PLACEHOLDER_ROW_COUNT = 1000 const COL_WIDTH = 160 const CHECKBOX_COL_WIDTH = 40 const ADD_COL_WIDTH = 120 @@ -134,8 +134,6 @@ export function Table() { filter: null, sort: null, }) - const [currentPage, setCurrentPage] = useState(0) - const [showAddModal, setShowAddModal] = useState(false) const [editingRow, setEditingRow] = useState(null) const [deletingRows, setDeletingRows] = useState([]) @@ -158,11 +156,10 @@ export function Table() { const isDraggingRef = useRef(false) - const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows } = useTableData({ + const { tableData, isLoadingTable, rows, isLoadingRows } = useTableData({ workspaceId, tableId, queryOptions, - currentPage, }) const { selectedRows, handleSelectAll, handleSelectRow, clearSelection } = useRowSelection(rows) @@ -651,17 +648,14 @@ export function Table() { const handleSortChange = useCallback((column: string, direction: SortDirection) => { setQueryOptions((prev) => ({ ...prev, sort: { [column]: direction } })) - setCurrentPage(0) }, []) const handleSortClear = useCallback(() => { setQueryOptions((prev) => ({ ...prev, sort: null })) - setCurrentPage(0) }, []) const handleFilterApply = useCallback((filter: Filter | null) => { setQueryOptions((prev) => ({ ...prev, filter })) - setCurrentPage(0) }, []) const columnOptions = useMemo( () => @@ -722,6 +716,11 @@ export function Table() { ] : []), ]} + create={{ + label: 'New column', + onClick: handleAddColumn, + disabled: addColumnMutation.isPending, + }} /> - Add column + New column @@ -845,14 +844,6 @@ export function Table() {
- setCurrentPage((p) => Math.max(0, p - 1))} - onNextPage={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))} - /> - {showAddModal && tableData && ( void - onNextPage: () => void -}) { - if (totalPages <= 1) return null - - return ( -
- - Page {currentPage + 1} of {totalPages} ({totalCount} rows) - -
- - -
-
- ) -} - function TableBodySkeleton({ colCount }: { colCount: number }) { return ( <> @@ -1388,12 +1342,14 @@ function PlaceholderRows({ )} {isAnchor &&
} {isEditing ? ( - onSave(i, col.name, value)} - onCancel={onCancel} - /> +
+ onSave(i, col.name, value)} + onCancel={onCancel} + /> +
) : hasPendingValue ? (
void } @@ -24,7 +20,6 @@ export function useTableData({ workspaceId, tableId, queryOptions, - currentPage, }: UseTableDataParams): UseTableDataReturn { const { data: tableData, isLoading: isLoadingTable } = useTable(workspaceId, tableId) @@ -35,23 +30,19 @@ export function useTableData({ } = useTableRows({ workspaceId, tableId, - limit: ROWS_PER_PAGE, - offset: currentPage * ROWS_PER_PAGE, + limit: 1000, + offset: 0, filter: queryOptions.filter, sort: queryOptions.sort, enabled: Boolean(workspaceId && tableId), }) const rows = (rowsData?.rows || []) as TableRow[] - const totalCount = rowsData?.totalCount || 0 - const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE) return { tableData, isLoadingTable, rows, - totalCount, - totalPages, isLoadingRows, refetchRows, } diff --git a/apps/sim/components/emcn/icons/bell.tsx b/apps/sim/components/emcn/icons/bell.tsx new file mode 100644 index 0000000000..d9d4b0f2b9 --- /dev/null +++ b/apps/sim/components/emcn/icons/bell.tsx @@ -0,0 +1,25 @@ +import type { SVGProps } from 'react' + +/** + * Bell icon component - notification bell + * @param props - SVG properties including className, fill, etc. + */ +export function Bell(props: SVGProps) { + return ( + + + + + ) +} diff --git a/apps/sim/components/emcn/icons/index.ts b/apps/sim/components/emcn/icons/index.ts index a9cfba0cc3..443c533752 100644 --- a/apps/sim/components/emcn/icons/index.ts +++ b/apps/sim/components/emcn/icons/index.ts @@ -4,6 +4,7 @@ export { ArrowRight } from './arrow-right' export { ArrowUp } from './arrow-up' export { ArrowUpDown } from './arrow-up-down' export { Asterisk } from './asterisk' +export { Bell } from './bell' export { Blimp } from './blimp' export { BubbleChatClose } from './bubble-chat-close' export { BubbleChatPreview } from './bubble-chat-preview' diff --git a/biome.json b/biome.json index 154625b78c..61224c957c 100644 --- a/biome.json +++ b/biome.json @@ -26,7 +26,8 @@ "!**/public/fallback-*.js", "!**/apps/docs/.source/**", "!**/venv/**", - "!**/.venv/**" + "!**/.venv/**", + "!**/uploads/**" ] }, "formatter": {