Skip to content
Merged
5 changes: 3 additions & 2 deletions apps/dashboard/app/(app)/logs/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Log } from "@unkey/clickhouse/src/logs";
import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits";

export type ResponseBody = {
keyId: string;
Expand All @@ -17,7 +18,7 @@ export type ResponseBody = {
};

export const extractResponseField = <K extends keyof ResponseBody>(
log: Log,
log: Log | RatelimitLog,
fieldName: K,
): ResponseBody[K] | null => {
if (!log?.response_body) {
Expand All @@ -34,7 +35,7 @@ export const extractResponseField = <K extends keyof ResponseBody>(
}
};

export const getRequestHeader = (log: Log, headerName: string): string | null => {
export const getRequestHeader = (log: Log | RatelimitLog, headerName: string): string | null => {
if (!headerName.trim()) {
console.error("Invalid header name provided");
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ResponsiveContainer } from "recharts";

export const LogsChartError = () => {
return (
<div className="w-full relative">
<div className="px-2 text-accent-11 font-mono absolute top-0 text-xxs w-full flex justify-between opacity-50">
{Array(5)
.fill(0)
.map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: it's okay
<div key={i} className="z-10">
--:--
</div>
))}
</div>
<ResponsiveContainer height={50} className="border-b border-gray-4" width="100%">
<div className="h-full w-full flex items-center justify-center">
<span className="text-xs text-error-11 font-mono">Could not retrieve logs</span>
</div>
</ResponsiveContainer>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Bar, BarChart, ResponsiveContainer, YAxis } from "recharts";
import { calculateTimePoints } from "../utils/calculate-timepoints";
import { formatTimestampLabel } from "../utils/format-timestamp";

export const LogsChartLoading = () => {
const mockData = Array.from({ length: 100 }).map(() => ({
success: Math.random() * 0.5 + 0.5, // Random values between 0.5 and 1
}));

return (
<div className="w-full relative animate-pulse">
<div className="px-2 text-accent-11 font-mono absolute top-0 text-xxs w-full flex justify-between">
{calculateTimePoints(Date.now(), Date.now()).map((time, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: it's safe to use index here
<div key={i} className="z-10">
{formatTimestampLabel(time)}
</div>
))}
</div>
<ResponsiveContainer height={50} className="border-b border-gray-4" width="100%">
<BarChart margin={{ top: 0, right: -20, bottom: 0, left: -20 }} barGap={0} data={mockData}>
<YAxis domain={[0, (dataMax: number) => dataMax * 2]} hide />
<Bar dataKey="success" fill="hsl(var(--accent-3))" />
</BarChart>
</ResponsiveContainer>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { trpc } from "@/lib/trpc/client";
import type { TimeseriesGranularity } from "@/lib/trpc/routers/ratelimit/query-timeseries/utils";
import { addMinutes, format } from "date-fns";
import { useMemo } from "react";
import { useFilters } from "../../../hooks/use-filters";
import type { RatelimitQueryTimeseriesPayload } from "../query-timeseries.schema";

// Duration in milliseconds for historical data fetch window (1 hours)
const TIMESERIES_DATA_WINDOW = 60 * 60 * 1000;

const formatTimestamp = (value: string | number, granularity: TimeseriesGranularity) => {
const date = new Date(value);
const offset = new Date().getTimezoneOffset() * -1;
const localDate = addMinutes(date, offset);

switch (granularity) {
case "perMinute":
return format(localDate, "HH:mm:ss");
case "perHour":
return format(localDate, "MMM d, HH:mm");
case "perDay":
return format(localDate, "MMM d");
case "perMonth":
return format(localDate, "MMM yyyy");
default:
return format(localDate, "Pp");
}
};

export const useFetchRatelimitTimeseries = (namespaceId: string) => {
const { filters } = useFilters();
const dateNow = useMemo(() => Date.now(), []);

const queryParams = useMemo(() => {
const params: RatelimitQueryTimeseriesPayload = {
namespaceId,
startTime: dateNow - TIMESERIES_DATA_WINDOW,
endTime: dateNow,
identifiers: { filters: [] },
since: "",
};

filters.forEach((filter) => {
switch (filter.field) {
case "identifiers": {
if (typeof filter.value !== "string") {
console.error("Identifier filter value type has to be 'string'");
return;
}
params.identifiers?.filters.push({
operator: filter.operator as "is" | "contains",
value: filter.value,
});
break;
}
case "startTime":
case "endTime": {
if (typeof filter.value !== "number") {
console.error(`${filter.field} filter value type has to be 'number'`);
return;
}
params[filter.field] = filter.value;
break;
}
case "since": {
if (typeof filter.value !== "string") {
console.error("Since filter value type has to be 'string'");
return;
}
params.since = filter.value;
break;
}
}
});

return params;
}, [filters, dateNow, namespaceId]);

const { data, isLoading, isError } = trpc.ratelimit.logs.queryRatelimitTimeseries.useQuery(
queryParams,
{
refetchInterval: queryParams.endTime ? false : 10_000,
},
);

const timeseries = data?.timeseries.map((ts) => ({
displayX: formatTimestamp(ts.x, data.granularity),
originalTimestamp: ts.x,
success: ts.y.passed,
error: ts.y.total - ts.y.passed,
total: ts.y.total,
}));

return { timeseries, isLoading, isError };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"use client";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { Grid } from "@unkey/icons";
import { useEffect, useRef } from "react";
import { Bar, BarChart, ResponsiveContainer, YAxis } from "recharts";
import { useRatelimitLogsContext } from "../../context/logs";
import { LogsChartError } from "./components/logs-chart-error";
import { LogsChartLoading } from "./components/logs-chart-loading";
import { useFetchRatelimitTimeseries } from "./hooks/use-fetch-timeseries";
import { calculateTimePoints } from "./utils/calculate-timepoints";
import { formatTimestampLabel, formatTimestampTooltip } from "./utils/format-timestamp";

const chartConfig = {
success: {
label: "Passed",
subLabel: "Passed",
color: "hsl(var(--accent-4))",
},
error: {
label: "Blocked",
subLabel: "Blocked",
color: "hsl(var(--warning-9))",
},
} satisfies ChartConfig;

export function RatelimitLogsChart({
onMount,
}: {
onMount: (distanceToTop: number) => void;
}) {
const chartRef = useRef<HTMLDivElement>(null);

const { namespaceId } = useRatelimitLogsContext();
const { timeseries, isLoading, isError } = useFetchRatelimitTimeseries(namespaceId);
// biome-ignore lint/correctness/useExhaustiveDependencies: We need this to re-trigger distanceToTop calculation
useEffect(() => {
const distanceToTop = chartRef.current?.getBoundingClientRect().top ?? 0;
onMount(distanceToTop);
}, [onMount, isLoading, isError]);

if (isError) {
return <LogsChartError />;
}

if (isLoading) {
return <LogsChartLoading />;
}

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">
{timeseries
? calculateTimePoints(
timeseries[0].originalTimestamp ?? Date.now(),
timeseries.at(-1)?.originalTimestamp ?? Date.now(),
).map((time, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: use of index is acceptable here.
<div key={i} className="z-10">
{formatTimestampLabel(time)}
</div>
))
: null}
</div>
<ResponsiveContainer width="100%" height={50} className="border-b border-gray-4">
<ChartContainer config={chartConfig}>
<BarChart
data={timeseries}
barGap={2}
barSize={8}
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 || payload?.[0]?.payload.total === 0) {
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>
) : (
""
);
}}
/>
);
}}
/>
<Bar dataKey="success" stackId="a" fill={chartConfig.success.color} />
<Bar dataKey="error" stackId="a" fill={chartConfig.error.color} />
</BarChart>
</ChartContainer>
</ResponsiveContainer>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { z } from "zod";
import { filterOperatorEnum } from "../../filters.schema";

export const ratelimitQueryTimeseriesPayload = z.object({
startTime: z.number().int(),
endTime: z.number().int(),
since: z.string(),
namespaceId: z.string(),
identifiers: z
.object({
filters: z.array(
z.object({
operator: filterOperatorEnum,
value: z.string(),
}),
),
})
.nullable(),
});

export type RatelimitQueryTimeseriesPayload = z.infer<typeof ratelimitQueryTimeseriesPayload>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const calculateTimePoints = (startTime: number, endTime: number) => {
const points = 6;
const timeRange = endTime - startTime;
const step = Math.floor(timeRange / (points - 1));

return Array.from({ length: points }, (_, i) => new Date(startTime + step * i)).filter(
(date) => date.getTime() <= endTime,
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { addMinutes, format } from "date-fns";

export 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 aa");
};

export const formatTimestampLabel = (timestamp: string | number | Date) => {
const date = new Date(timestamp);
return format(date, "MMM dd, h:mma").toUpperCase();
};
Loading