Skip to content

Commit f72cdab

Browse files
Makisuoclaude
andcommitted
feat: compact logs page with severity left border
Replace multi-column table layout with dense log stream. Severity is now indicated by a colored left border instead of a text badge. Timestamps simplified to compact HH:mm:ss.SSS format. Row height reduced from 44px to 28px. Dropped TanStack Table in favor of div-based virtual list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 251cbb5 commit f72cdab

2 files changed

Lines changed: 81 additions & 181 deletions

File tree

apps/web/src/components/logs/logs-table.tsx

Lines changed: 62 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,36 @@
11
import * as React from "react"
22
import { Result } from "@/lib/effect-atom"
3-
import {
4-
type ColumnDef,
5-
flexRender,
6-
getCoreRowModel,
7-
useReactTable,
8-
} from "@tanstack/react-table"
93
import { useVirtualizer } from "@tanstack/react-virtual"
104

115
import { Skeleton } from "@maple/ui/components/ui/skeleton"
126
import { type Log } from "@/api/tinybird/logs"
13-
import { SeverityBadge } from "./severity-badge"
147
import { LogDetailSheet } from "./log-detail-sheet"
158
import type { LogsSearchParams } from "@/routes/logs"
169
import { useTimezonePreference } from "@/hooks/use-timezone-preference"
17-
import { formatTimestampInTimezone } from "@/lib/timezone-format"
18-
import { formatRelativeTime } from "@/lib/format"
10+
import { formatCompactTimeInTimezone } from "@/lib/timezone-format"
11+
import { getSeverityColor } from "@/lib/severity"
1912
import { useInfiniteLogs, FETCH_THRESHOLD } from "@/hooks/use-infinite-logs"
2013

21-
function truncateBody(body: string, maxLength = 100): string {
22-
if (body.length <= maxLength) return body
23-
return body.slice(0, maxLength) + "..."
24-
}
25-
26-
const ROW_HEIGHT = 44
14+
const ROW_HEIGHT = 28
2715

2816
interface LogsTableProps {
2917
filters?: LogsSearchParams
3018
}
3119

3220
function LoadingState() {
3321
return (
34-
<div className="flex-1 min-h-0 flex flex-col gap-4">
35-
<div className="rounded-md border">
36-
<table className="w-full caption-bottom text-sm">
37-
<thead className="[&_tr]:border-b">
38-
<tr className="border-b transition-colors hover:bg-muted/50">
39-
<th className="h-10 px-2 text-left align-middle font-medium text-muted-foreground w-[160px]">Timestamp</th>
40-
<th className="h-10 px-2 text-left align-middle font-medium text-muted-foreground w-[120px]">Service</th>
41-
<th className="h-10 px-2 text-left align-middle font-medium text-muted-foreground w-[80px]">Severity</th>
42-
<th className="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Message</th>
43-
</tr>
44-
</thead>
45-
<tbody className="[&_tr:last-child]:border-0">
46-
{Array.from({ length: 10 }).map((_, i) => (
47-
<tr key={i} className="border-b transition-colors">
48-
<td className="p-2 align-middle"><Skeleton className="h-4 w-32" /></td>
49-
<td className="p-2 align-middle"><Skeleton className="h-4 w-20" /></td>
50-
<td className="p-2 align-middle"><Skeleton className="h-4 w-12" /></td>
51-
<td className="p-2 align-middle"><Skeleton className="h-4 w-full" /></td>
52-
</tr>
53-
))}
54-
</tbody>
55-
</table>
22+
<div className="flex-1 min-h-0 flex flex-col">
23+
<div className="rounded-md border overflow-hidden flex-1 min-h-0">
24+
{Array.from({ length: 40 }).map((_, i) => (
25+
<div
26+
key={i}
27+
className="border-l-2 border-l-transparent flex items-center gap-2 px-3 py-1 border-b border-border"
28+
>
29+
<Skeleton className="h-3 w-[72px] shrink-0" />
30+
<Skeleton className="h-3 w-16 shrink-0" />
31+
<Skeleton className="h-3 flex-1" />
32+
</div>
33+
))}
5634
</div>
5735
</div>
5836
)
@@ -81,60 +59,8 @@ function LogsTableContent({
8159
setSheetOpen(true)
8260
}, [])
8361

84-
const columns = React.useMemo<ColumnDef<Log>[]>(
85-
() => [
86-
{
87-
accessorKey: "timestamp",
88-
header: "Timestamp",
89-
size: 160,
90-
cell: ({ row }) => (
91-
<span className="font-mono text-muted-foreground">
92-
{formatTimestampInTimezone(row.original.timestamp, {
93-
timeZone: effectiveTimezone,
94-
})}{" "}
95-
<span className="text-muted-foreground/60">
96-
({formatRelativeTime(row.original.timestamp)})
97-
</span>
98-
</span>
99-
),
100-
},
101-
{
102-
accessorKey: "serviceName",
103-
header: "Service",
104-
size: 120,
105-
cell: ({ row }) => (
106-
<span className="font-mono text-xs">{row.original.serviceName}</span>
107-
),
108-
},
109-
{
110-
accessorKey: "severityText",
111-
header: "Severity",
112-
size: 80,
113-
cell: ({ row }) => (
114-
<SeverityBadge severity={row.original.severityText} />
115-
),
116-
},
117-
{
118-
accessorKey: "body",
119-
header: "Message",
120-
cell: ({ row }) => (
121-
<span className="font-mono text-xs">{truncateBody(row.original.body)}</span>
122-
),
123-
},
124-
],
125-
[effectiveTimezone],
126-
)
127-
128-
const table = useReactTable({
129-
data: allData,
130-
columns,
131-
getCoreRowModel: getCoreRowModel(),
132-
})
133-
134-
const { rows } = table.getRowModel()
135-
13662
const virtualizer = useVirtualizer({
137-
count: rows.length,
63+
count: allData.length,
13864
getScrollElement: () => scrollContainerRef.current,
13965
estimateSize: () => ROW_HEIGHT,
14066
overscan: 10,
@@ -146,31 +72,16 @@ function LogsTableContent({
14672
const lastItem = virtualItems[virtualItems.length - 1]
14773
if (!lastItem) return
14874

149-
if (lastItem.index >= rows.length - FETCH_THRESHOLD && hasNextPage && !isFetchingNextPage) {
75+
if (lastItem.index >= allData.length - FETCH_THRESHOLD && hasNextPage && !isFetchingNextPage) {
15076
fetchNextPage()
15177
}
152-
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage])
78+
}, [virtualItems, allData.length, hasNextPage, isFetchingNextPage, fetchNextPage])
15379

15480
if (allData.length === 0) {
15581
return (
15682
<div className="flex-1 min-h-0 flex flex-col gap-4">
157-
<div className="rounded-md border">
158-
<table className="w-full caption-bottom text-sm">
159-
<thead className="[&_tr]:border-b">
160-
<tr className="border-b transition-colors hover:bg-muted/50">
161-
<th className="h-10 px-2 text-left align-middle font-medium text-muted-foreground" colSpan={4}>
162-
<span className="sr-only">Log columns</span>
163-
</th>
164-
</tr>
165-
</thead>
166-
<tbody>
167-
<tr>
168-
<td colSpan={4} className="h-24 text-center">
169-
No logs found
170-
</td>
171-
</tr>
172-
</tbody>
173-
</table>
83+
<div className="rounded-md border flex items-center justify-center h-48">
84+
<span className="text-sm text-muted-foreground">No logs found</span>
17485
</div>
17586
</div>
17687
)
@@ -183,81 +94,51 @@ function LogsTableContent({
18394
ref={scrollContainerRef}
18495
className="flex-1 min-h-0 overflow-auto rounded-md border"
18596
>
186-
<table className="w-full caption-bottom text-sm" aria-label="Logs">
187-
<thead className="[&_tr]:border-b sticky top-0 z-10 bg-background">
188-
{table.getHeaderGroups().map((headerGroup) => (
189-
<tr key={headerGroup.id} className="border-b transition-colors hover:bg-muted/50">
190-
{headerGroup.headers.map((header) => (
191-
<th
192-
key={header.id}
193-
className={`h-10 px-2 text-left align-middle font-medium text-muted-foreground ${
194-
header.id === "serviceName" ? "hidden md:table-cell" : ""
195-
}`}
196-
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
197-
>
198-
{header.isPlaceholder
199-
? null
200-
: flexRender(header.column.columnDef.header, header.getContext())}
201-
</th>
202-
))}
203-
</tr>
204-
))}
205-
</thead>
206-
<tbody className="[&_tr:last-child]:border-0">
207-
{virtualItems.length > 0 && (
208-
<tr style={{ height: virtualItems[0].start }} aria-hidden="true">
209-
<td />
210-
</tr>
211-
)}
212-
{virtualItems.map((virtualRow) => {
213-
const row = rows[virtualRow.index]
214-
return (
215-
<tr
216-
key={row.id}
217-
ref={virtualizer.measureElement}
218-
data-index={virtualRow.index}
219-
className="border-b transition-colors hover:bg-muted/50 cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset"
220-
tabIndex={0}
221-
onClick={() => handleRowClick(row.original)}
222-
onKeyDown={(e) => {
223-
if (e.key === "Enter" || e.key === " ") {
224-
e.preventDefault()
225-
handleRowClick(row.original)
226-
}
227-
}}
228-
>
229-
{row.getVisibleCells().map((cell) => (
230-
<td
231-
key={cell.id}
232-
className={`p-2 align-middle [&:has([role=checkbox])]:pr-0 ${
233-
cell.column.id === "serviceName" ? "hidden md:table-cell" : ""
234-
}${cell.column.id === "body" ? " max-w-md" : ""}`}
235-
>
236-
{flexRender(cell.column.columnDef.cell, cell.getContext())}
237-
</td>
238-
))}
239-
</tr>
240-
)
241-
})}
242-
{virtualItems.length > 0 && (
243-
<tr
97+
<div
98+
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
99+
role="log"
100+
>
101+
{virtualItems.map((virtualRow) => {
102+
const log = allData[virtualRow.index]
103+
return (
104+
<div
105+
key={virtualRow.index}
106+
ref={virtualizer.measureElement}
107+
data-index={virtualRow.index}
244108
style={{
245-
height: virtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1].end),
109+
position: "absolute",
110+
top: 0,
111+
left: 0,
112+
width: "100%",
113+
transform: `translateY(${virtualRow.start}px)`,
114+
borderLeftColor: getSeverityColor(log.severityText),
115+
}}
116+
className="border-l-2 flex items-center gap-2 px-3 py-1 text-xs font-mono cursor-pointer border-b border-border hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset"
117+
tabIndex={0}
118+
role="listitem"
119+
onClick={() => handleRowClick(log)}
120+
onKeyDown={(e) => {
121+
if (e.key === "Enter" || e.key === " ") {
122+
e.preventDefault()
123+
handleRowClick(log)
124+
}
246125
}}
247-
aria-hidden="true"
248126
>
249-
<td />
250-
</tr>
251-
)}
252-
{isFetchingNextPage && (
253-
<tr className="border-b transition-colors">
254-
<td colSpan={4} className="p-2 text-center text-sm text-muted-foreground">
255-
Loading more logs...
256-
</td>
257-
</tr>
258-
)}
259-
</tbody>
260-
</table>
127+
<span className="shrink-0 text-muted-foreground tabular-nums">
128+
{formatCompactTimeInTimezone(log.timestamp, {
129+
timeZone: effectiveTimezone,
130+
})}
131+
</span>
132+
<span className="shrink-0 text-muted-foreground/60 truncate max-w-[120px] hidden md:inline">
133+
{log.serviceName}
134+
</span>
135+
<span className="min-w-0 flex-1 truncate text-foreground">
136+
{log.body}
137+
</span>
138+
</div>
139+
)
140+
})}
141+
</div>
261142
</div>
262143

263144
<div className="text-sm text-muted-foreground shrink-0">

apps/web/src/lib/timezone-format.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,22 @@ export function formatTimeInTimezone(
7070

7171
return formatter.format(date)
7272
}
73+
74+
export function formatCompactTimeInTimezone(
75+
input: TimezoneFormatInput,
76+
options: { timeZone: string },
77+
): string {
78+
const date = toValidDate(input)
79+
if (!date) return "-"
80+
81+
const formatter = new Intl.DateTimeFormat("en-GB", {
82+
timeZone: resolveTimeZone(options.timeZone),
83+
hour: "2-digit",
84+
minute: "2-digit",
85+
second: "2-digit",
86+
fractionalSecondDigits: 3,
87+
hour12: false,
88+
})
89+
90+
return formatter.format(date)
91+
}

0 commit comments

Comments
 (0)