improvement: logs

This commit is contained in:
Emir Karabeg
2026-03-09 09:13:01 -07:00
parent 76981c356f
commit 4afc3bbff8
13 changed files with 1295 additions and 292 deletions
+14 -9
View File
@@ -87,6 +87,13 @@ const SECTION_TO_ICON: Record<string, string> = {
templates: 'templates',
}
function setFaviconHrefs(url: string) {
document.querySelectorAll<HTMLLinkElement>("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<HTMLLinkElement>("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])
+25 -10
View File
@@ -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)
}
@@ -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'
@@ -96,7 +96,9 @@ export function ResourceHeader({
variant='subtle'
className='px-[8px] py-[4px] text-[12px]'
>
{ActionIcon && <ActionIcon className='mr-[6px] h-[14px] w-[14px]' />}
{ActionIcon && (
<ActionIcon className={cn('h-[14px] w-[14px]', action.label && 'mr-[6px]')} />
)}
{action.label}
</Button>
)
@@ -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'
@@ -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<HTMLInputElement | null>
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onFocus?: () => void
onBlur?: () => void
tags?: SearchTag[]
highlightedTagIndex?: number | null
onClearAll?: () => void
dropdown?: ReactNode
dropdownRef?: React.RefObject<HTMLDivElement | null>
}
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
>
<div className='flex items-center justify-between'>
{search && (
<div className='flex flex-1 items-center'>
<div className='relative flex flex-1 items-center'>
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
<input
type='text'
value={search.value}
onChange={(e) => 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)]'
/>
<div className='flex flex-1 items-center gap-[6px] overflow-x-auto pl-[10px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{search.tags?.map((tag, i) => (
<Button
key={`${tag.label}-${tag.value}-${i}`}
variant='subtle'
className={cn(
'shrink-0 px-[8px] py-[4px] text-[12px]',
search.highlightedTagIndex === i &&
'ring-1 ring-[var(--border-focus)] ring-offset-1'
)}
onClick={tag.onRemove}
>
{tag.label}: {tag.value}
<span className='ml-[4px] text-[10px] text-[var(--text-icon)]'>✕</span>
</Button>
))}
<input
ref={search.inputRef}
type='text'
value={search.value}
onChange={(e) => 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)]'
/>
</div>
{search.tags?.length || search.value ? (
<button
type='button'
className='mr-[2px] flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover:text-[var(--text-secondary)]'
onClick={search.onClearAll}
>
<span className='text-[12px]'>✕</span>
</button>
) : null}
{search.dropdown && (
<div
ref={search.dropdownRef}
className='absolute top-full left-0 z-50 mt-[6px] w-full rounded-[8px] border border-[var(--border)] bg-white shadow-sm dark:bg-[var(--bg)]'
>
{search.dropdown}
</div>
)}
</div>
)}
<div className='flex items-center gap-[6px]'>
{filterTags?.map((tag) => (
<Button
key={tag.label}
variant='subtle'
className='px-[8px] py-[4px] text-[12px]'
onClick={tag.onRemove}
>
{tag.label}
<span className='ml-[4px] text-[10px] text-[var(--text-icon)]'>✕</span>
</Button>
))}
{filter && (
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger asChild>
@@ -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<HTMLDivElement>(null)
const loadMoreRef = useRef<HTMLDivElement>(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<SortConfig>(
() => ({
const sortConfig = useMemo<SortConfig | undefined>(() => {
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 (
<div
@@ -110,18 +146,39 @@ export function Resource({
onContextMenu={onContextMenu}
>
<ResourceHeader icon={icon} title={title} create={create} actions={headerActions} />
<ResourceOptionsBar search={search} sort={sortConfig} />
<ResourceOptionsBar
search={search}
sort={sortConfig}
filter={filter}
filterTags={filterTags}
/>
{isLoading ? (
{contentOverride ? (
<div className='min-h-0 flex-1 overflow-auto'>{contentOverride}</div>
) : isLoading ? (
<DataTableSkeleton columns={columns} rowCount={loadingRows} />
) : rows.length === 0 && emptyMessage ? (
<div className='flex min-h-0 flex-1 items-center justify-center'>
<span className='text-[13px] text-[var(--text-secondary)]'>{emptyMessage}</span>
</div>
) : (
<>
<div className='relative min-h-0 flex-1 overflow-hidden'>
<div ref={headerRef} className='overflow-hidden'>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} />
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{columns.map((col) => {
if (disableHeaderSort || !sortEnabled) {
return (
<th
key={col.id}
className='h-10 px-[24px] py-[6px] text-left align-middle font-base text-[var(--text-muted)]'
>
{col.header}
</th>
)
}
const isActive = sort.column === col.id
const SortIcon = sort.direction === 'asc' ? ArrowUp : ArrowDown
return (
@@ -152,15 +209,18 @@ export function Resource({
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} />
<tbody>
{sortedRows.map((row) => (
{displayRows.map((row) => (
<tr
key={row.id}
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer'
onRowClick && 'cursor-pointer',
selectedRowId === row.id && 'bg-[var(--surface-3)]'
)}
onClick={() => 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({
)}
</tbody>
</table>
{hasMore && (
<div ref={loadMoreRef} className='flex items-center justify-center py-[12px]'>
{isLoadingMore && (
<Loader className='h-[16px] w-[16px] text-[var(--text-secondary)]' animate />
)}
</div>
)}
</div>
</>
{overlay}
</div>
)}
</div>
)
}
function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean }) {
if (cell.content) return <>{cell.content}</>
return (
<span
className={cn(
File diff suppressed because it is too large Load Diff
@@ -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<TableRowType | null>(null)
const [deletingRows, setDeletingRows] = useState<string[]>([])
@@ -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<ColumnOption[]>(
() =>
@@ -722,6 +716,11 @@ export function Table() {
]
: []),
]}
create={{
label: 'New column',
onClick: handleAddColumn,
disabled: addColumnMutation.isPending,
}}
/>
<ResourceOptionsBar
@@ -793,7 +792,7 @@ export function Table() {
>
<Plus className='h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Add column
New column
</span>
</button>
</th>
@@ -845,14 +844,6 @@ export function Table() {
</table>
</div>
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
onPreviousPage={() => setCurrentPage((p) => Math.max(0, p - 1))}
onNextPage={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
/>
{showAddModal && tableData && (
<RowModal
mode='add'
@@ -1235,7 +1226,7 @@ function InlineEditor({
onKeyDown={handleKeyDown}
onBlur={handleSave}
className={cn(
'h-full w-full min-w-0 select-text border-none bg-transparent p-0 outline-none',
'w-full min-w-0 select-text border-none bg-transparent p-0 outline-none',
column.type === 'number'
? 'font-mono text-[12px] text-[var(--text-secondary)]'
: column.type === 'date'
@@ -1246,43 +1237,6 @@ function InlineEditor({
)
}
function TablePagination({
currentPage,
totalPages,
totalCount,
onPreviousPage,
onNextPage,
}: {
currentPage: number
totalPages: number
totalCount: number
onPreviousPage: () => void
onNextPage: () => void
}) {
if (totalPages <= 1) return null
return (
<div className='flex h-[40px] shrink-0 items-center justify-between border-[var(--border)] border-t px-[16px]'>
<span className='text-[11px] text-[var(--text-tertiary)]'>
Page {currentPage + 1} of {totalPages} ({totalCount} rows)
</span>
<div className='flex items-center gap-[4px]'>
<Button variant='ghost' size='sm' onClick={onPreviousPage} disabled={currentPage === 0}>
Previous
</Button>
<Button
variant='ghost'
size='sm'
onClick={onNextPage}
disabled={currentPage === totalPages - 1}
>
Next
</Button>
</div>
</div>
)
}
function TableBodySkeleton({ colCount }: { colCount: number }) {
return (
<>
@@ -1388,12 +1342,14 @@ function PlaceholderRows({
)}
{isAnchor && <div className={cn(SELECTION_OVERLAY, belowHeader && 'top-0')} />}
{isEditing ? (
<InlineEditor
value={hasPendingValue ? pendingValue : null}
column={col}
onSave={(value) => onSave(i, col.name, value)}
onCancel={onCancel}
/>
<div className={CELL_CONTENT}>
<InlineEditor
value={hasPendingValue ? pendingValue : null}
column={col}
onSave={(value) => onSave(i, col.name, value)}
onCancel={onCancel}
/>
</div>
) : hasPendingValue ? (
<div className={CELL_CONTENT}>
<CellContent
@@ -1,21 +1,17 @@
import type { TableDefinition, TableRow } from '@/lib/table'
import { useTable, useTableRows } from '@/hooks/queries/tables'
import { ROWS_PER_PAGE } from '../constants'
import type { QueryOptions } from '../types'
interface UseTableDataParams {
workspaceId: string
tableId: string
queryOptions: QueryOptions
currentPage: number
}
interface UseTableDataReturn {
tableData: TableDefinition | undefined
isLoadingTable: boolean
rows: TableRow[]
totalCount: number
totalPages: number
isLoadingRows: boolean
refetchRows: () => 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,
}
+25
View File
@@ -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<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M15.25 6.75C15.25 5.35761 14.6969 4.02226 13.7123 3.03769C12.7277 2.05312 11.3924 1.5 10 1.5C8.60761 1.5 7.27226 2.05312 6.28769 3.03769C5.30312 4.02226 4.75 5.35761 4.75 6.75C4.75 12.75 2.25 14.5 2.25 14.5H17.75C17.75 14.5 15.25 12.75 15.25 6.75Z' />
<path d='M11.4425 17.75C11.2655 18.0547 11.0133 18.3088 10.7101 18.4882C10.4068 18.6676 10.0627 18.7662 9.71 18.7749C9.35735 18.7836 9.00888 18.7022 8.69728 18.5381C8.38568 18.374 8.12138 18.1327 7.92999 17.8375' />
</svg>
)
}
+1
View File
@@ -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'
+2 -1
View File
@@ -26,7 +26,8 @@
"!**/public/fallback-*.js",
"!**/apps/docs/.source/**",
"!**/venv/**",
"!**/.venv/**"
"!**/.venv/**",
"!**/uploads/**"
]
},
"formatter": {