Search docs

Jump to any table example

Discord—

Logs

A log explorer like the ones in observability tools: a date range picker, multi-select level, service, and status filters with live counts, and search that highlights its matches. Pick a layout for reading an event.

Click a row, or its chevron, to open the full event underneath it. Several events can be open at once, which makes it easy to compare them.

$ npx shadcn add https://www.shad-table.dev/r/logs-table.json
DetailsTime (UTC)LevelServiceRequestMessage
Sep 23 13:59:58infobilling200GET/billing/invoicesUser signed in
Sep 23 12:27:43warnauth429POST/auth/refreshRate limit reached for token
Sep 23 12:18:06infobilling304GET/billing/invoicesCache hit
Sep 23 11:17:31errorauth504POST/auth/refreshConnection pool exhausted (max=20)
Sep 23 09:47:17infoedge200GET/pricingRequest completed
Sep 23 09:30:24warnbilling401GET/billing/invoicesValidation failed: name is required
Sep 23 08:24:41infoedge200GET/Cache hit
Sep 23 06:42:48infoworker201POST/jobs/thumbnailRequest completed
Sep 23 05:26:44errorbilling502GET/billing/invoicesUpstream timed out after 10000ms
Sep 23 04:22:05debugbilling201POST/billing/webhookFeature flag new-pricing evaluated to false
Sep 23 02:22:26infoapi304DELETE/v1/projects/:idRequest completed
Sep 22 23:47:15debugauth201POST/auth/refreshFeature flag new-pricing evaluated to false
Sep 22 22:49:14warnbilling422GET/billing/invoicesValidation failed: name is required
Sep 22 21:50:56erroredge504GET/assets/app.jsStripe webhook signature mismatch
Sep 22 20:19:01infoworker201POST/jobs/thumbnailJob finished
Sep 22 18:18:29warnedge401GET/assets/app.jsValidation failed: name is required
Sep 22 16:44:15warnapi422POST/v1/projectsSlow query: 1843ms on projects_by_owner
Sep 22 15:06:20infoapi200GET/v1/projectsJob finished
Sep 22 13:06:07infoedge200GET/pricingInvoice generated
Sep 22 11:49:57warnedge401GET/assets/app.jsInvalid refresh token

Showing 20 of 80

How it works

1.Rows expand into the full event

getRowCanExpand opts every row into expansion even though logs have no sub-rows. Clicking a row, or its chevron, renders a detail row with every field, including ones that are not columns, like region and duration.

src/components/logs/data-table.tsx
useLogsTable({
data,
columns: [expandColumn, ...columns],
getRowCanExpand: () => true,
getExpandedRowModel: getExpandedRowModel(),
})
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={row.getVisibleCells().length}>
<LogFields log={row.original} />
</TableCell>
</TableRow>
)}

2.Level, service, and status share one multi-select

Each dropdown writes a string[] filter value on its column, checked by a single inList filterFn. Counts come from getFacetedUniqueValues(), which applies every other filter but not the column's own, so with billing selected the Level menu shows how many billing errors you would get by ticking Error.

src/components/logs/toolbar.tsx
<FacetedFilter
column={table.getColumn('level')}
title="Level"
options={levels}
renderIcon={(level) => <LevelDot level={level as LogLevel} />}
/>

3.Pick a start, then an end

Left to its defaults, react-day-picker turns the first click into a one-day range and later clicks only move one end, so there is no way to start a new range. The picker keeps a draft instead: the first click sets the start, the second sets the end in either direction, and only then does the column filter change. Days are read as UTC days to match the Time column.

src/components/logs/date-range-filter.tsx
function pickDay(day: Date) {
if (!draft?.from || draft.to) {
setDraft({ from: day, to: undefined })
return
}
apply(
day < draft.from
? { from: day, to: draft.from }
: { from: draft.from, to: day },
)
}

4.Status classes are a derived column

Nobody filters logs by "status 502"; they filter by 5xx. The Request column uses an accessorFn that turns the status code into its class, so faceting and filtering work on 2xx through 5xx while the cell still renders the exact code, method, and path.

src/components/logs/columns.tsx
{
id: 'statusClass',
accessorFn: (row) => toStatusClass(row.status),
filterFn: inList,
cell: ({ row }) => /* 502 GET /v1/projects */,
}

5.Search highlights what it matched

A custom globalFilterFn searches message, path, and request id, the things people paste from an alert. Cells read the same query from table.getState().globalFilter and wrap each match in a <mark>, so you can see why a row survived the filter.

src/components/logs/columns.tsx
cell: ({ row, table }) => (
<Highlight
text={row.original.message}
query={table.getState().globalFilter}
/>
)

6.Load older events instead of paging

Paging through logs loses your place. The table keeps pageIndex at 0 and grows pageSize by 20 each time you ask for older events, so the list only ever gets longer and newest events stay at the top.

src/components/logs/toolbar.tsx
onClick={() => table.setPageSize((size) => size + PAGE_SIZE)}