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| Details | Time (UTC) | Level | Service | Request | Message |
|---|---|---|---|---|---|
| Sep 23 13:59:58 | info | billing | 200GET/billing/invoices | User signed in | |
| Sep 23 12:27:43 | warn | auth | 429POST/auth/refresh | Rate limit reached for token | |
| Sep 23 12:18:06 | info | billing | 304GET/billing/invoices | Cache hit | |
| Sep 23 11:17:31 | error | auth | 504POST/auth/refresh | Connection pool exhausted (max=20) | |
| Sep 23 09:47:17 | info | edge | 200GET/pricing | Request completed | |
| Sep 23 09:30:24 | warn | billing | 401GET/billing/invoices | Validation failed: name is required | |
| Sep 23 08:24:41 | info | edge | 200GET/ | Cache hit | |
| Sep 23 06:42:48 | info | worker | 201POST/jobs/thumbnail | Request completed | |
| Sep 23 05:26:44 | error | billing | 502GET/billing/invoices | Upstream timed out after 10000ms | |
| Sep 23 04:22:05 | debug | billing | 201POST/billing/webhook | Feature flag new-pricing evaluated to false | |
| Sep 23 02:22:26 | info | api | 304DELETE/v1/projects/:id | Request completed | |
| Sep 22 23:47:15 | debug | auth | 201POST/auth/refresh | Feature flag new-pricing evaluated to false | |
| Sep 22 22:49:14 | warn | billing | 422GET/billing/invoices | Validation failed: name is required | |
| Sep 22 21:50:56 | error | edge | 504GET/assets/app.js | Stripe webhook signature mismatch | |
| Sep 22 20:19:01 | info | worker | 201POST/jobs/thumbnail | Job finished | |
| Sep 22 18:18:29 | warn | edge | 401GET/assets/app.js | Validation failed: name is required | |
| Sep 22 16:44:15 | warn | api | 422POST/v1/projects | Slow query: 1843ms on projects_by_owner | |
| Sep 22 15:06:20 | info | api | 200GET/v1/projects | Job finished | |
| Sep 22 13:06:07 | info | edge | 200GET/pricing | Invoice generated | |
| Sep 22 11:49:57 | warn | edge | 401GET/assets/app.js | Invalid 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.
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.
<FacetedFiltercolumn={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.
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.
{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.
cell: ({ row, table }) => (<Highlighttext={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.
onClick={() => table.setPageSize((size) => size + PAGE_SIZE)}