-
Notifications
You must be signed in to change notification settings - Fork 609
feat: Logs v2 design phase #2789
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
aff2e34
2b99fdf
3232d4e
f19c261
0e69036
0280478
06cd6d7
f3ec2b1
c62ef04
fc0429f
79c8a81
7709226
efeba35
574e262
d0ff121
4817c63
624c27d
2facb80
f1694e7
965186a
125c084
8c8bb8e
6291f6e
ed56a43
6393bad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| "use client"; | ||
| import { | ||
| type ChartConfig, | ||
| ChartContainer, | ||
| ChartTooltip, | ||
| ChartTooltipContent, | ||
| } from "@/components/ui/chart"; | ||
| import { Grid } from "@unkey/icons"; | ||
| import { addMinutes, format } from "date-fns"; | ||
| import { useEffect, useRef } from "react"; | ||
| import { Bar, BarChart, ResponsiveContainer, YAxis } from "recharts"; | ||
| import { generateMockLogsData } from "./util"; | ||
|
|
||
| const chartConfig = { | ||
| success: { | ||
| label: "Success", | ||
| subLabel: "2xx", | ||
| color: "hsl(var(--accent-4))", | ||
| }, | ||
| warning: { | ||
| label: "Warning", | ||
| subLabel: "4xx", | ||
| color: "hsl(var(--warning-9))", | ||
| }, | ||
| error: { | ||
| label: "Error", | ||
| subLabel: "5xx", | ||
| color: "hsl(var(--error-9))", | ||
| }, | ||
| } satisfies ChartConfig; | ||
|
|
||
| const formatTimestampTooltip = (value: string | number) => { | ||
| const date = new Date(value); | ||
| const offset = new Date().getTimezoneOffset() * -1; | ||
| const localDate = addMinutes(date, offset); | ||
| return format(localDate, "MMM dd HH:mm:ss.SS aa"); | ||
| }; | ||
|
|
||
| const formatTimestampLabel = (timestamp: string | number | Date) => { | ||
| const date = new Date(timestamp); | ||
| return format(date, "MMM dd, h:mma").toUpperCase(); | ||
| }; | ||
|
|
||
| type Timeseries = { | ||
| x: number; | ||
| displayX: string; | ||
| originalTimestamp: string; | ||
| success: number; | ||
| error: number; | ||
| warning: number; | ||
| total: number; | ||
| }; | ||
|
|
||
| const calculateTimePoints = (timeseries: Timeseries[]) => { | ||
| const startTime = timeseries[0].x; | ||
| const endTime = timeseries.at(-1)?.x; | ||
| const timeRange = endTime ?? 0 - startTime; | ||
| const timePoints = Array.from({ length: 5 }, (_, i) => { | ||
| return new Date(startTime + (timeRange * i) / 5); | ||
| }); | ||
| return timePoints; | ||
| }; | ||
|
|
||
| const timeseries = generateMockLogsData(24, 10); | ||
|
|
||
| export function LogsChart({ | ||
| onMount, | ||
| }: { | ||
| onMount: (distanceToTop: number) => void; | ||
| }) { | ||
| const chartRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| useEffect(() => { | ||
| const distanceToTop = chartRef.current?.getBoundingClientRect().top ?? 0; | ||
| onMount(distanceToTop); | ||
| }, [onMount]); | ||
|
|
||
| return ( | ||
| <div className="w-full relative" ref={chartRef}> | ||
| <div className="px-2 text-accent-11 font-mono absolute top-0 text-xxs w-full flex justify-between"> | ||
| {calculateTimePoints(timeseries).map((time, i) => ( | ||
| // biome-ignore lint/suspicious/noArrayIndexKey: use of index is acceptable here. | ||
| <div key={i}>{formatTimestampLabel(time)}</div> | ||
| ))} | ||
| </div> | ||
| <ResponsiveContainer width="100%" height={50} className="border-b border-gray-4"> | ||
| <ChartContainer config={chartConfig}> | ||
| <BarChart data={timeseries} barGap={2} margin={{ top: 0, right: 0, bottom: 0, left: 0 }}> | ||
| <YAxis domain={[0, (dataMax: number) => dataMax * 1.5]} hide /> | ||
| <ChartTooltip | ||
| position={{ y: 50 }} | ||
| isAnimationActive | ||
| wrapperStyle={{ zIndex: 1000 }} | ||
| cursor={{ | ||
| fill: "hsl(var(--accent-3))", | ||
| strokeWidth: 1, | ||
| strokeDasharray: "5 5", | ||
| strokeOpacity: 0.7, | ||
| }} | ||
| content={({ active, payload, label }) => { | ||
| if (!active || !payload?.length) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <ChartTooltipContent | ||
| payload={payload} | ||
| label={label} | ||
| active={active} | ||
| bottomExplainer={ | ||
| <div className="grid gap-1.5 pt-2 border-t border-gray-4"> | ||
| <div className="flex w-full [&>svg]:size-4 gap-4 px-4 items-center"> | ||
| <Grid className="text-gray-6" /> | ||
| <div className="flex gap-4 leading-none justify-between w-full py-1 items-center"> | ||
| <div className="flex gap-4 items-center min-w-[80px]"> | ||
| <span className="capitalize text-accent-9 text-xs w-[2ch] inline-block"> | ||
| All | ||
| </span> | ||
| <span className="capitalize text-accent-12 text-xs">Total</span> | ||
| </div> | ||
| <div className="ml-auto"> | ||
| <span className="font-mono tabular-nums text-accent-12"> | ||
| {payload[0]?.payload?.total} | ||
| </span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| } | ||
| className="rounded-lg shadow-lg border border-gray-4" | ||
| labelFormatter={(_, tooltipPayload) => { | ||
| const originalTimestamp = tooltipPayload[0]?.payload?.originalTimestamp; | ||
| return originalTimestamp ? ( | ||
| <div> | ||
| <span className="font-mono text-accent-9 text-xs px-4"> | ||
| {formatTimestampTooltip(originalTimestamp)} | ||
| </span> | ||
| </div> | ||
| ) : ( | ||
| "" | ||
| ); | ||
| }} | ||
| /> | ||
| ); | ||
| }} | ||
| /> | ||
| {["success", "error", "warning"].map((key) => ( | ||
| <Bar key={key} dataKey={key} stackId="a" fill={`var(--color-${key})`} /> | ||
| ))} | ||
| </BarChart> | ||
| </ChartContainer> | ||
| </ResponsiveContainer> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| /** | ||
| * Generates mock time series data for logs visualization | ||
| * @param hours Number of hours of data to generate | ||
| * @param intervalMinutes Interval between data points in minutes | ||
| * @returns Array of LogsTimeseriesDataPoint | ||
| */ | ||
| export function generateMockLogsData(hours = 24, intervalMinutes = 5) { | ||
| const now = new Date(); | ||
| const points = Math.floor((hours * 60) / intervalMinutes); | ||
| const data = []; | ||
|
|
||
| for (let i = points - 1; i >= 0; i--) { | ||
| const timestamp = new Date(now.getTime() - i * intervalMinutes * 60 * 1000); | ||
|
|
||
| const success = Math.floor(Math.random() * 50) + 20; | ||
| const error = Math.floor(Math.random() * 30); | ||
| const warning = Math.floor(Math.random() * 25); | ||
|
|
||
| data.push({ | ||
| x: Math.floor(timestamp.getTime()), | ||
| displayX: timestamp.toISOString(), | ||
| originalTimestamp: timestamp.toISOString(), | ||
| success, | ||
| error, | ||
| warning, | ||
| total: success + error + warning, | ||
| }); | ||
| } | ||
|
|
||
| return data; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { | ||
| BarsFilter, | ||
| Calendar, | ||
| CircleCarretRight, | ||
| Magnifier, | ||
| Refresh3, | ||
| Sliders, | ||
| } from "@unkey/icons"; | ||
|
|
||
| export function LogsFilters() { | ||
| return ( | ||
| <div className="flex flex-col border-b border-gray-4 "> | ||
| <div className="px-3 py-2 w-full justify-between flex items-center min-h-10"> | ||
| <div className="flex gap-2"> | ||
| <div className="flex gap-2 items-center px-2"> | ||
| <Magnifier className="text-accent-9 size-4" /> | ||
| <span className="text-accent-12 font-medium text-[13px]">Search logs...</span> | ||
| </div> | ||
| <div className="flex gap-2 items-center px-2"> | ||
| <BarsFilter className="text-accent-9 size-4" /> | ||
| <span className="text-accent-12 font-medium text-[13px]">Filter</span> | ||
| </div> | ||
| <div className="flex gap-2 items-center px-2"> | ||
| <Calendar className="text-accent-9 size-4" /> | ||
| <span className="text-accent-12 font-medium text-[13px]">Last 24 hours</span> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="flex gap-2"> | ||
| <div className="flex gap-2 items-center px-2"> | ||
| <CircleCarretRight className="text-accent-9 size-4" /> | ||
| <span className="text-accent-12 font-medium text-[13px]">Live</span> | ||
| </div> | ||
| <div className="flex gap-2 items-center px-2"> | ||
| <Refresh3 className="text-accent-9 size-4" /> | ||
| <span className="text-accent-12 font-medium text-[13px]">Refresh</span> | ||
| </div> | ||
| <div className="flex gap-2 items-center px-2"> | ||
| <Sliders className="text-accent-9 size-4" /> | ||
| <span className="text-accent-12 font-medium text-[13px]">Display</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
Comment on lines
+10
to
+46
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Add interactivity and accessibility to filter controls The filter controls appear to be static UI elements without any interaction handlers. Consider the following improvements:
Example implementation for the search filter: - <div className="flex gap-2 items-center px-2">
- <Magnifier className="text-accent-9 size-4" />
- <span className="text-accent-12 font-medium text-[13px]">Search logs...</span>
- </div>
+ <div className="flex gap-2 items-center px-2 relative">
+ <Magnifier className="text-accent-9 size-4 absolute left-4" />
+ <input
+ type="search"
+ placeholder="Search logs..."
+ aria-label="Search logs"
+ className="pl-10 pr-4 py-2 text-[13px] bg-transparent border border-gray-4 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-7"
+ />
+ </div>
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| "use client"; | ||
|
|
||
| import type { Log } from "@unkey/clickhouse/src/logs"; | ||
| import { useCallback, useState } from "react"; | ||
| import { LogsChart } from "./charts"; | ||
| import { LogsFilters } from "./filters"; | ||
| import { LogDetails } from "./table/log-details"; | ||
| import { LogsTable } from "./table/logs-table"; | ||
|
|
||
| export const LogsClient = () => { | ||
| const [selectedLog, setSelectedLog] = useState<Log | null>(null); | ||
| const [tableDistanceToTop, setTableDistanceToTop] = useState(0); | ||
|
|
||
| const handleDistanceToTop = useCallback((distanceToTop: number) => { | ||
| setTableDistanceToTop(distanceToTop); | ||
| }, []); | ||
|
|
||
| const handleLogSelection = useCallback((log: Log | null) => { | ||
| setSelectedLog(log); | ||
| }, []); | ||
|
|
||
| return ( | ||
| <> | ||
| <LogsFilters /> | ||
| <LogsChart onMount={handleDistanceToTop} /> | ||
| <LogsTable onLogSelect={handleLogSelection} selectedLog={selectedLog} /> | ||
| <LogDetails | ||
| log={selectedLog} | ||
| onClose={() => handleLogSelection(null)} | ||
| distanceToTop={tableDistanceToTop} | ||
| /> | ||
| </> | ||
| ); | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.