From 2cb3b62424d5283583469888cfd7195cd51a0f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Olguncu?= <21091016+ogzhanolguncu@users.noreply.github.com> Date: Wed, 8 Jan 2025 18:17:06 +0300 Subject: [PATCH] feat: Logs v2 design phase (#2789) * feat: add new layout for logs page * feat: add charts * fix: import paths * fix: colors * refactor: colors of timestamp info * feat: finish table rows * feat: fix log details to chart * refactor: clean up log details * feat: add new log details header section * refactor: improve feel of virtual table * feat: finalize log details * refactor: redundant null check * refactor: adjust texts * refactor: remove old virtual table and make it more readable * feat: add new chart tooltip * chore: fix build step * chore: run formatter * chore: run formatter * feat: update icons * fix: style issue due to virtual table changes * [autofix.ci] apply automated fixes * feat: add feature flog for logs-v2 --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../table/audit-log-table-client.tsx | 4 +- .../(app)/audit/components/table/columns.tsx | 33 +- .../(app)/logs-v2/components/charts/index.tsx | 155 ++++++++ .../(app)/logs-v2/components/charts/util.ts | 31 ++ .../logs-v2/components/filters/index.tsx | 46 +++ .../(app)/logs-v2/components/logs-client.tsx | 34 ++ .../(app)/logs-v2/components/table/hooks.ts | 91 +++++ .../log-details/components/log-footer.tsx | 99 +++++ .../log-details/components/log-header.tsx | 43 +++ .../table/log-details/components/log-meta.tsx | 37 ++ .../log-details/components/log-section.tsx | 71 ++++ .../components/request-response-details.tsx | 105 ++++++ .../components/table/log-details/index.tsx | 61 ++++ .../table/log-details/resizable-panel.tsx | 81 +++++ .../logs-v2/components/table/logs-table.tsx | 211 +++++++++++ .../(app)/logs-v2/components/table/utils.ts | 75 ++++ apps/dashboard/app/(app)/logs-v2/constants.ts | 9 + apps/dashboard/app/(app)/logs-v2/page.tsx | 36 ++ .../app/(app)/logs-v2/query-state.ts | 40 +++ apps/dashboard/app/(app)/logs-v2/utils.ts | 136 +++++++ .../logs/components/table/logs-table.tsx | 3 +- apps/dashboard/components/timestamp-info.tsx | 35 +- apps/dashboard/components/ui/chart.tsx | 29 +- apps/dashboard/components/virtual-table.tsx | 338 ------------------ .../virtual-table/components/empty-state.tsx | 15 + .../components/loading-indicator.tsx | 8 + .../virtual-table/components/loading-row.tsx | 18 + .../virtual-table/components/table-header.tsx | 23 ++ .../virtual-table/components/table-row.tsx | 83 +++++ .../components/virtual-table/constants.ts | 10 + .../virtual-table/hooks/useTableHeight.ts | 36 ++ .../virtual-table/hooks/useVirtualData.ts | 65 ++++ .../components/virtual-table/index.tsx | 149 ++++++++ .../components/virtual-table/types.ts | 33 ++ apps/engineering/content/design/icons.mdx | 160 ++++++--- internal/icons/src/icons/bars-filter.tsx | 55 +++ internal/icons/src/icons/calendar.tsx | 60 ++++ .../icons/src/icons/circle-carret-right.tsx | 37 ++ internal/icons/src/icons/grid.tsx | 26 ++ internal/icons/src/icons/magnifier.tsx | 43 +++ internal/icons/src/icons/refresh-3.tsx | 38 ++ internal/icons/src/icons/sliders.tsx | 86 +++++ .../icons/src/icons/triangle-warning-2.tsx | 43 +++ internal/icons/src/icons/xmark.tsx | 42 +++ internal/icons/src/index.ts | 9 + internal/ui/src/components/button.tsx | 2 +- 46 files changed, 2419 insertions(+), 425 deletions(-) create mode 100644 apps/dashboard/app/(app)/logs-v2/components/charts/index.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/charts/util.ts create mode 100644 apps/dashboard/app/(app)/logs-v2/components/filters/index.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/hooks.ts create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-footer.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-header.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-meta.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-section.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/request-response-details.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/log-details/index.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/log-details/resizable-panel.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/utils.ts create mode 100644 apps/dashboard/app/(app)/logs-v2/constants.ts create mode 100644 apps/dashboard/app/(app)/logs-v2/page.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/query-state.ts create mode 100644 apps/dashboard/app/(app)/logs-v2/utils.ts delete mode 100644 apps/dashboard/components/virtual-table.tsx create mode 100644 apps/dashboard/components/virtual-table/components/empty-state.tsx create mode 100644 apps/dashboard/components/virtual-table/components/loading-indicator.tsx create mode 100644 apps/dashboard/components/virtual-table/components/loading-row.tsx create mode 100644 apps/dashboard/components/virtual-table/components/table-header.tsx create mode 100644 apps/dashboard/components/virtual-table/components/table-row.tsx create mode 100644 apps/dashboard/components/virtual-table/constants.ts create mode 100644 apps/dashboard/components/virtual-table/hooks/useTableHeight.ts create mode 100644 apps/dashboard/components/virtual-table/hooks/useVirtualData.ts create mode 100644 apps/dashboard/components/virtual-table/index.tsx create mode 100644 apps/dashboard/components/virtual-table/types.ts create mode 100644 internal/icons/src/icons/bars-filter.tsx create mode 100644 internal/icons/src/icons/calendar.tsx create mode 100644 internal/icons/src/icons/circle-carret-right.tsx create mode 100644 internal/icons/src/icons/grid.tsx create mode 100644 internal/icons/src/icons/magnifier.tsx create mode 100644 internal/icons/src/icons/refresh-3.tsx create mode 100644 internal/icons/src/icons/sliders.tsx create mode 100644 internal/icons/src/icons/triangle-warning-2.tsx create mode 100644 internal/icons/src/icons/xmark.tsx diff --git a/apps/dashboard/app/(app)/audit/components/table/audit-log-table-client.tsx b/apps/dashboard/app/(app)/audit/components/table/audit-log-table-client.tsx index e0d9255fa1..421dee210c 100644 --- a/apps/dashboard/app/(app)/audit/components/table/audit-log-table-client.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/audit-log-table-client.tsx @@ -107,7 +107,9 @@ export const AuditLogTableClient = () => { renderDetails={(log, onClose, distanceToTop) => ( )} - loadingRows={DEFAULT_FETCH_COUNT} + config={{ + loadingRows: DEFAULT_FETCH_COUNT, + }} /> ); }; diff --git a/apps/dashboard/app/(app)/audit/components/table/columns.tsx b/apps/dashboard/app/(app)/audit/components/table/columns.tsx index 5186f46115..f8f9df6c16 100644 --- a/apps/dashboard/app/(app)/audit/components/table/columns.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/columns.tsx @@ -1,6 +1,6 @@ import { TimestampInfo } from "@/components/timestamp-info"; import { Badge } from "@/components/ui/badge"; -import type { Column } from "@/components/virtual-table"; +import type { Column } from "@/components/virtual-table/types"; import { cn } from "@unkey/ui/src/lib/utils"; import { FunctionSquare, KeySquare } from "lucide-react"; import type { Data } from "./types"; @@ -10,20 +10,24 @@ export const columns: Column[] = [ { key: "time", header: "Time", - width: "130px", + headerClassName: "pl-3", + width: "150px", render: (log) => ( - +
+ +
), }, { key: "actor", header: "Actor", - width: "10%", + headerClassName: "pl-3", + width: "7%", render: (log) => ( -
+
{log.auditLog.actor.type === "user" && log.user ? (
{`${log.user.firstName ?? ""} ${ @@ -47,7 +51,8 @@ export const columns: Column[] = [ { key: "action", header: "Action", - width: "72px", + headerClassName: "pl-3", + width: "7%", render: (log) => { const eventType = getEventType(log.auditLog.event); const badgeClassName = cn("font-mono capitalize", { @@ -56,15 +61,20 @@ export const columns: Column[] = [ "bg-success-3 text-success-11 hover:bg-success-4": eventType === "create", "bg-accent-3 text-accent-11 hover:bg-accent-4": eventType === "other", }); - return {eventType}; + return ( +
+ {eventType} +
+ ); }, }, { key: "event", header: "Event", + headerClassName: "pl-2", width: "20%", render: (log) => ( -
+
{log.auditLog.event}
), @@ -72,6 +82,7 @@ export const columns: Column[] = [ { key: "event-description", header: "Description", + headerClassName: "pl-1", width: "auto", render: (log) => (
{log.auditLog.description}
diff --git a/apps/dashboard/app/(app)/logs-v2/components/charts/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/charts/index.tsx new file mode 100644 index 0000000000..f1da1a1328 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/charts/index.tsx @@ -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(null); + + useEffect(() => { + const distanceToTop = chartRef.current?.getBoundingClientRect().top ?? 0; + onMount(distanceToTop); + }, [onMount]); + + return ( +
+
+ {calculateTimePoints(timeseries).map((time, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: use of index is acceptable here. +
{formatTimestampLabel(time)}
+ ))} +
+ + + + dataMax * 1.5]} hide /> + { + if (!active || !payload?.length) { + return null; + } + + return ( + +
+ +
+
+ + All + + Total +
+
+ + {payload[0]?.payload?.total} + +
+
+
+
+ } + className="rounded-lg shadow-lg border border-gray-4" + labelFormatter={(_, tooltipPayload) => { + const originalTimestamp = tooltipPayload[0]?.payload?.originalTimestamp; + return originalTimestamp ? ( +
+ + {formatTimestampTooltip(originalTimestamp)} + +
+ ) : ( + "" + ); + }} + /> + ); + }} + /> + {["success", "error", "warning"].map((key) => ( + + ))} + + + +
+ ); +} diff --git a/apps/dashboard/app/(app)/logs-v2/components/charts/util.ts b/apps/dashboard/app/(app)/logs-v2/components/charts/util.ts new file mode 100644 index 0000000000..e8d0c2545f --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/charts/util.ts @@ -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; +} diff --git a/apps/dashboard/app/(app)/logs-v2/components/filters/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/filters/index.tsx new file mode 100644 index 0000000000..fee8a049a7 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/filters/index.tsx @@ -0,0 +1,46 @@ +import { + BarsFilter, + Calendar, + CircleCarretRight, + Magnifier, + Refresh3, + Sliders, +} from "@unkey/icons"; + +export function LogsFilters() { + return ( +
+
+
+
+ + Search logs... +
+
+ + Filter +
+
+ + Last 24 hours +
+
+ +
+
+ + Live +
+
+ + Refresh +
+
+ + Display +
+
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx b/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx new file mode 100644 index 0000000000..d33dac0a17 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx @@ -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(null); + const [tableDistanceToTop, setTableDistanceToTop] = useState(0); + + const handleDistanceToTop = useCallback((distanceToTop: number) => { + setTableDistanceToTop(distanceToTop); + }, []); + + const handleLogSelection = useCallback((log: Log | null) => { + setSelectedLog(log); + }, []); + + return ( + <> + + + + handleLogSelection(null)} + distanceToTop={tableDistanceToTop} + /> + + ); +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/hooks.ts b/apps/dashboard/app/(app)/logs-v2/components/table/hooks.ts new file mode 100644 index 0000000000..ed2313c47c --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/hooks.ts @@ -0,0 +1,91 @@ +import { trpc } from "@/lib/trpc/client"; +import type { Log } from "@unkey/clickhouse/src/logs"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useDebounceCallback, useInterval } from "usehooks-ts"; +import { useLogSearchParams } from "../../query-state"; +import { getTimeseriesGranularity } from "../../utils"; + +const roundToSecond = (timestamp: number) => Math.floor(timestamp / 1000) * 1000; + +export const useFetchLogs = (initialLogs: Log[]) => { + const { searchParams, setSearchParams } = useLogSearchParams(); + const [logs, setLogs] = useState(initialLogs); + const [endTime, setEndTime] = useState(searchParams.endTime); + + useInterval(() => setEndTime(Date.now()), searchParams.endTime ? null : 5000); + + const filters = { + host: searchParams.host, + requestId: searchParams.requestId, + path: searchParams.path, + method: searchParams.method, + responseStatus: searchParams.responseStatus, + }; + + const hasFilters = useMemo( + () => + Boolean( + filters.host || + filters.requestId || + filters.path || + filters.method || + filters.responseStatus.length, + ), + [filters], + ); + + const { startTime: rawStartTime, endTime: rawEndTime } = getTimeseriesGranularity( + searchParams.startTime, + endTime, + ); + + const { data: newData, isLoading } = trpc.logs.queryLogs.useQuery( + { + limit: 100, + startTime: roundToSecond(rawStartTime), + endTime: roundToSecond(rawEndTime), + ...filters, + }, + { + refetchInterval: searchParams.endTime ? false : 5000, + keepPreviousData: true, + }, + ); + + const updateLogs = useCallback(() => { + if (hasFilters) { + setLogs(newData ?? []); + return; + } + + if (!newData?.length) { + return; + } + + setLogs((prevLogs) => { + const existingIds = new Set(prevLogs.map((log) => log.request_id)); + const uniqueNewLogs = newData.filter((newLog) => !existingIds.has(newLog.request_id)); + return [...prevLogs, ...uniqueNewLogs]; + }); + }, [newData, hasFilters]); + + const handleQueryParamReset = () => { + setSearchParams({ + host: null, + path: null, + method: null, + endTime: null, + requestId: null, + startTime: null, + responseStatus: null, + }); + }; + + const switchToLive = useDebounceCallback(handleQueryParamReset, 350); + + useEffect(() => { + updateLogs(); + }, [updateLogs]); + + return { logs, isLoading, switchToLive }; +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-footer.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-footer.tsx new file mode 100644 index 0000000000..1d79d48194 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-footer.tsx @@ -0,0 +1,99 @@ +"use client"; +import { RED_STATES, YELLOW_STATES } from "@/app/(app)/logs-v2/constants"; +import { extractResponseField, getRequestHeader } from "@/app/(app)/logs-v2/utils"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { Log } from "@unkey/clickhouse/src/logs"; +import { format } from "date-fns"; +import { RequestResponseDetails } from "./request-response-details"; + +type Props = { + log: Log; +}; + +const DEFAULT_OUTCOME = "VALID"; +export const LogFooter = ({ log }: Props) => { + return ( + {content}, + content: format(log.time, "MMM dd HH:mm:ss.SS"), + tooltipContent: "Copy Time", + tooltipSuccessMessage: "Time copied to clipboard", + }, + { + label: "Host", + description: (content) => {content}, + content: log.host, + tooltipContent: "Copy Host", + tooltipSuccessMessage: "Host copied to clipboard", + }, + { + label: "Request Path", + description: (content) => {content}, + content: log.path, + tooltipContent: "Copy Request Path", + tooltipSuccessMessage: "Request path copied to clipboard", + }, + { + label: "Request ID", + description: (content) => {content}, + content: log.request_id, + tooltipContent: "Copy Request ID", + tooltipSuccessMessage: "Request ID copied to clipboard", + }, + { + label: "Request User Agent", + description: (content) => {content}, + content: getRequestHeader(log, "user-agent") ?? "", + tooltipContent: "Copy Request User Agent", + tooltipSuccessMessage: "Request user agent copied to clipboard", + }, + { + label: "Outcome", + description: (content) => { + let contentCopy = content; + if (contentCopy == null) { + contentCopy = DEFAULT_OUTCOME; + } + return ( + + {content} + + ); + }, + content: extractResponseField(log, "code"), + tooltipContent: "Copy Outcome", + tooltipSuccessMessage: "Outcome copied to clipboard", + }, + { + label: "Permissions", + description: (content) => ( + + {content.map((permission) => ( + + {permission} + + ))} + + ), + content: extractResponseField(log, "permissions"), + tooltipContent: "Copy Permissions", + tooltipSuccessMessage: "Permissions copied to clipboard", + }, + ]} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-header.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-header.tsx new file mode 100644 index 0000000000..006bbaff72 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-header.tsx @@ -0,0 +1,43 @@ +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { Log } from "@unkey/clickhouse/src/logs"; +import { XMark } from "@unkey/icons"; +import { Button } from "@unkey/ui"; + +type Props = { + log: Log; + onClose: () => void; +}; + +export const LogHeader = ({ onClose, log }: Props) => { + return ( +
+
+ + {log.method} + +

{log.path}

+
+ +
+
+ = 200 && log.response_status < 300, + "bg-warning-3 text-warning-11 hover:bg-warning-4": + log.response_status >= 400 && log.response_status < 500, + "bg-error-3 text-error-11 hover:bg-error-4": log.response_status >= 500, + })} + > + {log.response_status} + + | + +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-meta.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-meta.tsx new file mode 100644 index 0000000000..7c2a533a76 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-meta.tsx @@ -0,0 +1,37 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "@/components/ui/toaster"; +import { Button } from "@unkey/ui"; +import { Copy } from "lucide-react"; + +export const LogMetaSection = ({ content }: { content: string }) => { + const handleClick = () => { + navigator.clipboard + .writeText(content) + .then(() => { + toast.success("Meta copied to clipboard"); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + }; + + return ( +
+
Meta
+ + +
{content}
+ +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-section.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-section.tsx new file mode 100644 index 0000000000..d51eee5539 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-section.tsx @@ -0,0 +1,71 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "@/components/ui/toaster"; +import { Button } from "@unkey/ui"; +import { Copy } from "lucide-react"; + +export const LogSection = ({ + details, + title, +}: { + details: string | string[]; + title: string; +}) => { + const handleClick = () => { + navigator.clipboard + .writeText(getFormattedContent(details)) + .then(() => { + toast.success(`${title} copied to clipboard`); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + }; + + return ( +
+
+ {title} +
+ + +
+            {Array.isArray(details)
+              ? details.map((header) => {
+                  const [key, ...valueParts] = header.split(":");
+                  const value = valueParts.join(":").trim();
+                  return (
+                    
+ {key}: + {value} +
+ ); + }) + : details} +
+ +
+
+
+ ); +}; + +const getFormattedContent = (details: string | string[]) => { + if (Array.isArray(details)) { + return details + .map((header) => { + const [key, ...valueParts] = header.split(":"); + const value = valueParts.join(":").trim(); + return `${key}: ${value}`; + }) + .join("\n"); + } + return details; +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/request-response-details.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/request-response-details.tsx new file mode 100644 index 0000000000..15869e7d80 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/request-response-details.tsx @@ -0,0 +1,105 @@ +import { toast } from "@/components/ui/toaster"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +type Field = { + label: string; + description: (content: NonNullable) => ReactNode; + content: T | null; + tooltipContent?: ReactNode; + tooltipSuccessMessage?: string; + className?: string; + skipTooltip?: boolean; +}; + +type Props = { + fields: { [K in keyof T]: Field }; + className?: string; +}; + +const isNonEmpty = (content: unknown): boolean => { + if (content === undefined || content === null) { + return false; + } + + if (Array.isArray(content)) { + return content.some((item) => item !== null && item !== undefined); + } + + if (typeof content === "object" && content !== null) { + return Object.values(content).some((value) => value !== null && value !== undefined); + } + + if (typeof content === "string") { + return content.trim().length > 0; + } + + return Boolean(content); +}; + +export const RequestResponseDetails = ({ fields, className }: Props) => { + const handleClick = (field: Field) => { + try { + const text = + typeof field.content === "object" ? JSON.stringify(field.content) : String(field.content); + navigator.clipboard + .writeText(text) + .then(() => { + if (field.tooltipSuccessMessage) { + toast.success(field.tooltipSuccessMessage); + } + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + } catch (error) { + console.error("Error preparing content for clipboard:", error); + toast.error("Failed to prepare content for clipboard"); + } + }; + + const renderField = (field: Field, index: number) => { + const baseContent = ( + // biome-ignore lint/a11y/useKeyWithClickEvents: no need +
handleClick(field) : undefined} + > + {field.label} + + {field.description(field.content as NonNullable)} + +
+ ); + + if (field.skipTooltip) { + return baseContent; + } + + return ( + + + {baseContent} + {field.tooltipContent} + + + ); + }; + + return ( +
+ {fields.map( + (field, index) => + isNonEmpty(field.content) && ( +
{renderField(field, index)}
+ ), + )} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/index.tsx new file mode 100644 index 0000000000..f5017a0d58 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/index.tsx @@ -0,0 +1,61 @@ +"use client"; + +import type { Log } from "@unkey/clickhouse/src/logs"; +import { useMemo } from "react"; +import { DEFAULT_DRAGGABLE_WIDTH } from "../../../constants"; +import { extractResponseField, safeParseJson } from "../../../utils"; +import { LogFooter } from "./components/log-footer"; +import { LogHeader } from "./components/log-header"; +import { LogMetaSection } from "./components/log-meta"; +import { LogSection } from "./components/log-section"; +import ResizablePanel from "./resizable-panel"; + +const PANEL_MAX_WIDTH = 600; +const PANEL_MIN_WIDTH = 400; + +const createPanelStyle = (distanceToTop: number) => ({ + top: `${distanceToTop}px`, + width: `${DEFAULT_DRAGGABLE_WIDTH}px`, + height: `calc(100vh - ${distanceToTop}px)`, + paddingBottom: "1rem", +}); + +type Props = { + log: Log | null; + onClose: () => void; + distanceToTop: number; +}; + +export const LogDetails = ({ log, onClose, distanceToTop }: Props) => { + const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); + + if (!log) { + return null; + } + + return ( + + + + + + + +
+ + + + ); +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/resizable-panel.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/resizable-panel.tsx new file mode 100644 index 0000000000..a79d1063a1 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/resizable-panel.tsx @@ -0,0 +1,81 @@ +import type React from "react"; +import { type PropsWithChildren, useCallback, useEffect, useRef, useState } from "react"; +import { useOnClickOutside } from "usehooks-ts"; +import { MAX_DRAGGABLE_WIDTH, MIN_DRAGGABLE_WIDTH } from "../../../constants"; + +const ResizablePanel = ({ + children, + onResize, + onClose, + className, + style, + minW = MIN_DRAGGABLE_WIDTH, + maxW = MAX_DRAGGABLE_WIDTH, +}: PropsWithChildren<{ + onResize?: (newWidth: number) => void; + onClose: () => void; + className: string; + style: Record; + minW?: number; + maxW?: number; +}>) => { + const [isDragging, setIsDragging] = useState(false); + const [width, setWidth] = useState(String(style?.width)); + const panelRef = useRef(null); + + useOnClickOutside(panelRef, onClose); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging || !panelRef.current) { + return; + } + + const containerRect = panelRef.current.getBoundingClientRect(); + const newWidth = Math.min(Math.max(containerRect.right - e.clientX, minW), maxW); + setWidth(`${newWidth}px`); + onResize?.(newWidth); + }, + [isDragging, minW, maxW, onResize], + ); + + useEffect(() => { + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + } else { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + return ( +
+
+ {children} +
+ ); +}; + +export default ResizablePanel; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx new file mode 100644 index 0000000000..bb52137f80 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { TimestampInfo } from "@/components/timestamp-info"; +import { Badge } from "@/components/ui/badge"; +import { VirtualTable } from "@/components/virtual-table/index"; +import type { Column } from "@/components/virtual-table/types"; +import { cn } from "@/lib/utils"; +import type { Log } from "@unkey/clickhouse/src/logs"; +import { TriangleWarning2 } from "@unkey/icons"; +import { generateMockLogs } from "./utils"; + +const logs = generateMockLogs(50); + +type Props = { + onLogSelect: (log: Log | null) => void; + selectedLog: Log | null; +}; + +type StatusStyle = { + base: string; + hover: string; + selected: string; + badge: { + default: string; + selected: string; + }; + focusRing: string; +}; + +const createStatusStyle = (colors: { + base: string; + hover: string; + selected: string; + badge: { bg: string; text: string; hoverBg: string; selectedBg: string }; + ring: string; +}): StatusStyle => ({ + base: `${colors.base} text-${colors.badge.text}`, + hover: colors.hover, + selected: colors.selected, + badge: { + default: `bg-${colors.badge.bg} text-${colors.badge.text} group-hover:bg-${colors.badge.hoverBg}`, + selected: `bg-${colors.badge.selectedBg} text-${colors.badge.text}`, + }, + focusRing: `ring-${colors.ring}`, +}); + +const STATUS_STYLES = { + success: createStatusStyle({ + base: "text-accent-9", + hover: "hover:text-accent-11 dark:hover:text-accent-12", + selected: "text-accent-11 bg-accent-3 dark:text-accent-12", + badge: { + bg: "accent-4", + text: "accent-11", + hoverBg: "accent-5", + selectedBg: "accent-5", + }, + ring: "accent-7", + }), + warning: createStatusStyle({ + base: "bg-warning-2", + hover: "hover:bg-warning-3", + selected: "bg-warning-3", + badge: { + bg: "warning-4", + text: "warning-11", + hoverBg: "warning-5", + selectedBg: "warning-5", + }, + ring: "warning-7", + }), + error: createStatusStyle({ + base: "bg-error-2", + hover: "hover:bg-error-3", + selected: "bg-error-3", + badge: { + bg: "error-4", + text: "error-11", + hoverBg: "error-5", + selectedBg: "error-5", + }, + ring: "error-7", + }), +}; + +const METHOD_BADGE = { + base: "uppercase px-[6px] rounded-md font-mono bg-accent-4 text-accent-11 group-hover:bg-accent-5 group-hover:text-accent-12", + selected: "bg-accent-5 text-accent-12", +}; + +const getStatusStyle = (status: number): StatusStyle => { + if (status >= 500) { + return STATUS_STYLES.error; + } + if (status >= 400) { + return STATUS_STYLES.warning; + } + return STATUS_STYLES.success; +}; + +const WARNING_ICON_STYLES = { + base: "size-3", + warning: "text-warning-11", + error: "text-error-11", +}; + +const getSelectedClassName = (log: Log, isSelected: boolean) => { + if (!isSelected) { + return ""; + } + return getStatusStyle(log.response_status).selected; +}; + +export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { + const getRowClassName = (log: Log) => { + const style = getStatusStyle(log.response_status); + const isSelected = selectedLog?.request_id === log.request_id; + + return cn( + style.base, + style.hover, + "group", + "focus:outline-none focus:ring-1 focus:ring-opacity-40", + style.focusRing, + // This creates a spotlight effect + selectedLog && { + "opacity-50 z-0": !isSelected, + "opacity-100 z-10": isSelected, + }, + ); + }; + const columns: Column[] = [ + { + key: "time", + header: "Time", + width: "165px", + headerClassName: "pl-9", + render: (log) => ( +
+ = 400 && + log.response_status < 500 && + WARNING_ICON_STYLES.warning, + log.response_status >= 500 && WARNING_ICON_STYLES.error, + )} + /> + +
+ ), + }, + { + key: "status", + header: "Status", + width: "78px", + render: (log) => { + const style = getStatusStyle(log.response_status); + const isSelected = selectedLog?.request_id === log.request_id; + return ( + + {log.response_status} + + ); + }, + }, + { + key: "method", + header: "Method", + width: "78px", + render: (log) => { + const isSelected = selectedLog?.request_id === log.request_id; + return ( + + {log.method} + + ); + }, + }, + { + key: "path", + header: "Path", + width: "15%", + render: (log) =>
{log.path}
, + }, + { + key: "response", + header: "Response Body", + width: "1fr", + render: (log) => {log.response_body}, + }, + ]; + + return ( + log.request_id} + rowClassName={getRowClassName} + selectedClassName={getSelectedClassName} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/utils.ts b/apps/dashboard/app/(app)/logs-v2/components/table/utils.ts new file mode 100644 index 0000000000..6f737f806a --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/utils.ts @@ -0,0 +1,75 @@ +function generateMockLog(overrides = {}) { + // Helper function to generate random string + const generateRandomString = (length = 10) => { + return Math.random() + .toString(36) + .substring(2, length + 2); + }; + + // Helper function to generate random HTTP method + const generateRandomMethod = () => { + const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"]; + return methods[Math.floor(Math.random() * methods.length)]; + }; + + // Helper function to generate random path + const generateRandomPath = () => { + const paths = [ + "/api/v1/users", + "/api/v1/workspaces", + "/api/v1/logs", + "/api/v1/settings", + "/health", + "/metrics", + ]; + return paths[Math.floor(Math.random() * paths.length)]; + }; + + // Helper function to generate random headers + const generateRandomHeaders = () => { + const possibleHeaders = [ + "Content-Type: application/json", + "Authorization: Bearer token123", + "X-Request-ID: req123", + "Accept: application/json", + "User-Agent: Mozilla/5.0", + ]; + const numHeaders = Math.floor(Math.random() * 3) + 1; // 1-3 headers + return possibleHeaders.slice(0, numHeaders); + }; + + // Generate base mock log + const mockLog = { + request_id: `req_${generateRandomString(8)}`, + time: Math.floor(Date.now() / 1000), + workspace_id: `ws_${generateRandomString(8)}`, + host: `${generateRandomString(5)}.example.com`, + method: generateRandomMethod(), + path: generateRandomPath(), + request_headers: generateRandomHeaders(), + request_body: JSON.stringify({ data: generateRandomString() }), + response_status: Math.random() < 0.6 ? 200 : Math.random() < 0.5 ? 500 : 400, // 80% success rate + response_headers: generateRandomHeaders(), + response_body: JSON.stringify({ + keyId: "key_2Krf19pCiGx5UE29qJeBu7JpTzHk", + valid: true, + meta: { + hello: "world", + }, + enabled: true, + }), + error: Math.random() < 0.8 ? "" : "Internal Server Error", + service_latency: Math.floor(Math.random() * 1000), // 0-1000ms + }; + + // Apply any overrides + return { + ...mockLog, + ...overrides, + }; +} + +// Generate multiple logs +export const generateMockLogs = (count: number, overrides = {}) => { + return Array.from({ length: count }, () => generateMockLog(overrides)); +}; diff --git a/apps/dashboard/app/(app)/logs-v2/constants.ts b/apps/dashboard/app/(app)/logs-v2/constants.ts new file mode 100644 index 0000000000..22bb62f1cb --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/constants.ts @@ -0,0 +1,9 @@ +export const DEFAULT_DRAGGABLE_WIDTH = 500; +export const MAX_DRAGGABLE_WIDTH = 800; +export const MIN_DRAGGABLE_WIDTH = 300; + +export const ONE_DAY_MS = 24 * 60 * 60 * 1000; +export const DEFAULT_LOGS_FETCH_COUNT = 100; + +export const YELLOW_STATES = ["RATE_LIMITED", "EXPIRED", "USAGE_EXCEEDED"]; +export const RED_STATES = ["DISABLED", "FORBIDDEN", "INSUFFICIENT_PERMISSIONS"]; diff --git a/apps/dashboard/app/(app)/logs-v2/page.tsx b/apps/dashboard/app/(app)/logs-v2/page.tsx new file mode 100644 index 0000000000..afb3bba66a --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/page.tsx @@ -0,0 +1,36 @@ +"use server"; + +import { Navbar } from "@/components/navbar"; +import { getTenantId } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { Layers3 } from "lucide-react"; +import { notFound } from "next/navigation"; +import { LogsClient } from "./components/logs-client"; + +export default async function Page() { + const tenantId = getTenantId(); + + const workspace = await db.query.workspaces.findFirst({ + where: (table, { and, eq, isNull }) => + and(eq(table.tenantId, tenantId), isNull(table.deletedAt)), + }); + + if (!workspace?.betaFeatures.logsPage) { + return notFound(); + } + + return ; +} + +const LogsContainerPage = () => { + return ( +
+ + }> + Logs + + + +
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs-v2/query-state.ts b/apps/dashboard/app/(app)/logs-v2/query-state.ts new file mode 100644 index 0000000000..38d6927662 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/query-state.ts @@ -0,0 +1,40 @@ +import { + parseAsArrayOf, + parseAsInteger, + parseAsNumberLiteral, + parseAsString, + useQueryStates, +} from "nuqs"; + +export type PickKeys = K; + +const TIMELINE_OPTIONS = ["1h", "3h", "6h", "12h", "24h"] as const; +export const STATUSES = [400, 500, 200] as const; +export type ResponseStatus = (typeof STATUSES)[number]; +export type Timeline = (typeof TIMELINE_OPTIONS)[number]; + +export type QuerySearchParams = { + host: string | null; + requestId: string | null; + method: string | null; + path: string | null; + responseStatus: ResponseStatus[]; + startTime?: number | null; + endTime?: number | null; +}; + +export const queryParamsPayload = { + requestId: parseAsString, + host: parseAsString, + method: parseAsString, + path: parseAsString, + responseStatus: parseAsArrayOf(parseAsNumberLiteral(STATUSES)).withDefault([]), + startTime: parseAsInteger, + endTime: parseAsInteger, +}; + +export const useLogSearchParams = () => { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload); + + return { searchParams, setSearchParams }; +}; diff --git a/apps/dashboard/app/(app)/logs-v2/utils.ts b/apps/dashboard/app/(app)/logs-v2/utils.ts new file mode 100644 index 0000000000..f7713598f3 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/utils.ts @@ -0,0 +1,136 @@ +import type { Log } from "@unkey/clickhouse/src/logs"; +import type { ResponseBody } from "../logs/types"; + +class ResponseBodyParseError extends Error { + constructor( + message: string, + public readonly context?: unknown, + ) { + super(message); + this.name = "ResponseBodyParseError"; + } +} + +export const extractResponseField = ( + log: Log, + fieldName: K, +): ResponseBody[K] | null => { + if (!log?.response_body) { + console.error("Invalid log or missing response_body"); + return null; + } + + try { + const parsedBody = JSON.parse(log.response_body) as ResponseBody; + + if (typeof parsedBody !== "object" || parsedBody === null) { + throw new ResponseBodyParseError("Parsed response body is not an object", parsedBody); + } + + if (!(fieldName in parsedBody)) { + throw new ResponseBodyParseError(`Field "${String(fieldName)}" not found in response body`, { + availableFields: Object.keys(parsedBody), + }); + } + + return parsedBody[fieldName]; + } catch (error) { + if (error instanceof ResponseBodyParseError) { + console.error(`Error parsing response body or accessing field: ${error.message}`, { + context: error.context, + fieldName, + logId: log.request_id, + }); + } else { + console.error("An unknown error occurred while parsing response body"); + } + return null; + } +}; + +export const getRequestHeader = (log: Log, headerName: string): string | null => { + if (!headerName.trim()) { + console.error("Invalid header name provided"); + return null; + } + + if (!Array.isArray(log.request_headers)) { + console.error("request_headers is not an array"); + return null; + } + + const lowerHeaderName = headerName.toLowerCase(); + const header = log.request_headers.find((h) => h.toLowerCase().startsWith(`${lowerHeaderName}:`)); + + if (!header) { + console.warn(`Header "${headerName}" not found in request headers`); + return null; + } + + const [, value] = header.split(":", 2); + return value ? value.trim() : null; +}; + +export const safeParseJson = (jsonString?: string | null) => { + if (!jsonString) { + return null; + } + + try { + return JSON.parse(jsonString); + } catch { + console.error("Cannot parse JSON:", jsonString); + return "Invalid JSON format"; + } +}; + +export const HOUR_IN_MS = 60 * 60 * 1000; +const DAY_IN_MS = 24 * HOUR_IN_MS; +const WEEK_IN_MS = 7 * DAY_IN_MS; + +export type TimeseriesGranularity = "perMinute" | "perHour" | "perDay"; +type TimeseriesConfig = { + granularity: TimeseriesGranularity; + startTime: number; + endTime: number; +}; + +export const getTimeseriesGranularity = ( + startTime?: number | null, + endTime?: number | null, +): TimeseriesConfig => { + const now = Date.now(); + + // If both of them are missing fallback to perMinute and fetch lastHour to show latest + if (!startTime && !endTime) { + return { + granularity: "perMinute", + startTime: now - HOUR_IN_MS, + endTime: now, + }; + } + + // Set default end time if missing + const effectiveEndTime = endTime ?? now; + // Set default start time if missing (last hour) + const effectiveStartTime = startTime ?? effectiveEndTime - HOUR_IN_MS; + const timeRange = effectiveEndTime - effectiveStartTime; + let granularity: TimeseriesGranularity; + + if (timeRange > WEEK_IN_MS) { + // > 7 days + granularity = "perDay"; + } else if (timeRange > HOUR_IN_MS) { + // > 1 hour + granularity = "perHour"; + } else { + // <= 1 hour + granularity = "perMinute"; + } + + return { + granularity, + startTime: effectiveStartTime, + endTime: effectiveEndTime, + }; +}; diff --git a/apps/dashboard/app/(app)/logs/components/table/logs-table.tsx b/apps/dashboard/app/(app)/logs/components/table/logs-table.tsx index 4537338796..3db18b1cc1 100644 --- a/apps/dashboard/app/(app)/logs/components/table/logs-table.tsx +++ b/apps/dashboard/app/(app)/logs/components/table/logs-table.tsx @@ -1,6 +1,7 @@ import { TimestampInfo } from "@/components/timestamp-info"; import { Badge } from "@/components/ui/badge"; -import { type Column, VirtualTable } from "@/components/virtual-table"; +import { VirtualTable } from "@/components/virtual-table"; +import type { Column } from "@/components/virtual-table/types"; import { cn } from "@/lib/utils"; import type { Log } from "@unkey/clickhouse/src/logs"; import { useState } from "react"; diff --git a/apps/dashboard/components/timestamp-info.tsx b/apps/dashboard/components/timestamp-info.tsx index 1f7b92fbec..a389dd74de 100644 --- a/apps/dashboard/components/timestamp-info.tsx +++ b/apps/dashboard/components/timestamp-info.tsx @@ -19,14 +19,14 @@ const isUnixMicro = (unix: string | number): boolean => { const timestampLocalFormatter = (value: string | number) => { const date = isUnixMicro(value) ? unixMicroToDate(value) : new Date(value); - return format(date, "dd MMM HH:mm:ss"); + return format(date, "MMM dd H:mm:ss.SS"); }; const timestampUtcFormatter = (value: string | number) => { const date = isUnixMicro(value) ? unixMicroToDate(value) : new Date(value); const isoDate = date.toISOString(); const utcDate = `${isoDate.substring(0, 10)} ${isoDate.substring(11, 19)}`; - return format(utcDate, "dd MMM HH:mm:ss"); + return format(utcDate, "MMM dd HH:mm:ss.SS"); }; const timestampRelativeFormatter = (value: string | number) => { @@ -76,16 +76,14 @@ export const TimestampInfo = ({ e.stopPropagation(); navigator.clipboard.writeText(value); setCopied(true); - setTimeout(() => { - setCopied(false); - }, 1000); + setTimeout(() => setCopied(false), 1000); }} - className={cn("flex items-center hover:bg-background-subtle text-left px-3 py-2", { - "bg-background-subtle": copied, - })} + className="flex items-center hover:bg-gray-3 text-left cursor-pointer w-full px-5 py-2" > - {label}: - + {label} + {copied ? "Copied!" : value} @@ -95,20 +93,19 @@ export const TimestampInfo = ({ return ( - {timestampLocalFormatter(value)} + {timestampLocalFormatter(value)} - -
- -
- -
- +
+ + + + +
); diff --git a/apps/dashboard/components/ui/chart.tsx b/apps/dashboard/components/ui/chart.tsx index cf2f8771f8..e457399880 100644 --- a/apps/dashboard/components/ui/chart.tsx +++ b/apps/dashboard/components/ui/chart.tsx @@ -11,6 +11,7 @@ const THEMES = { light: "", dark: ".dark" } as const; export type ChartConfig = { [k in string]: { label?: React.ReactNode; + subLabel?: React.ReactNode; icon?: React.ComponentType; } & ( | { color?: string; theme?: never } @@ -103,6 +104,7 @@ const ChartTooltipContent = React.forwardRef< indicator?: "line" | "dot" | "dashed"; nameKey?: string; labelKey?: string; + bottomExplainer?: React.ReactNode; } >( ( @@ -120,6 +122,7 @@ const ChartTooltipContent = React.forwardRef< color, nameKey, labelKey, + bottomExplainer, }, ref, ) => { @@ -161,7 +164,7 @@ const ChartTooltipContent = React.forwardRef<
@@ -176,7 +179,7 @@ const ChartTooltipContent = React.forwardRef<
svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + "flex w-full [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground gap-4 px-4", indicator === "dot" && "items-center", )} > @@ -210,21 +213,26 @@ const ChartTooltipContent = React.forwardRef< )}
-
+
{nestLabel ? tooltipLabel : null} - + + {itemConfig?.subLabel} + + {itemConfig?.label || item.name}
- {item.value && ( - - {item.value.toLocaleString()} - - )} +
+ {item.value && ( + + {item.value.toLocaleString()} + + )} +
)} @@ -232,6 +240,7 @@ const ChartTooltipContent = React.forwardRef< ); })}
+ {bottomExplainer}
); }, diff --git a/apps/dashboard/components/virtual-table.tsx b/apps/dashboard/components/virtual-table.tsx deleted file mode 100644 index ec0cb6363e..0000000000 --- a/apps/dashboard/components/virtual-table.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { Card, CardContent } from "@/components/ui/card"; -import { cn, throttle } from "@/lib/utils"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { ScrollText } from "lucide-react"; -import type React from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useScrollLock } from "usehooks-ts"; - -export type Column = { - key: string; - header: string; - width: string; - render: (item: T) => React.ReactNode; -}; - -export type VirtualTableProps = { - data: T[]; - columns: Column[]; - isLoading?: boolean; - rowHeight?: number; - onRowClick?: (item: T | null) => void; - onLoadMore?: () => void; - emptyState?: React.ReactNode; - loadingRows?: number; - overscanCount?: number; - keyExtractor: (item: T) => string | number; - rowClassName?: (item: T) => string; - selectedClassName?: (item: T, isSelected: boolean) => string; - selectedItem?: T | null; - isFetchingNextPage?: boolean; - renderDetails?: (item: T, onClose: () => void, distanceToTop: number) => React.ReactNode; -}; - -const DEFAULT_ROW_HEIGHT = 26; -const DEFAULT_LOADING_ROWS = 50; -const DEFAULT_OVERSCAN = 5; -const TABLE_BORDER_THICKNESS = 1; -const THROTTLE_DELAY = 350; -const HEADER_HEIGHT = 40; // Approximate height of the header - -export function VirtualTable({ - data, - columns, - isLoading = false, - rowHeight = DEFAULT_ROW_HEIGHT, - onRowClick, - onLoadMore, - emptyState, - loadingRows = DEFAULT_LOADING_ROWS, - overscanCount = DEFAULT_OVERSCAN, - keyExtractor, - rowClassName, - selectedClassName, - selectedItem, - renderDetails, - isFetchingNextPage, -}: VirtualTableProps) { - const parentRef = useRef(null); - const tableRef = useRef(null); - const containerRef = useRef(null); - const [tableDistanceToTop, setTableDistanceToTop] = useState(0); - const [fixedHeight, setFixedHeight] = useState(0); - - // We have to lock the wrapper div at layout, otherwise causes weird scrolling issues. - useScrollLock({ - autoLock: true, - lockTarget: - typeof window !== "undefined" - ? (document.querySelector("#layout-wrapper") as HTMLElement) - : undefined, - }); - - // Calculate and set fixed height on mount and resize - useEffect(() => { - const calculateHeight = () => { - if (!containerRef.current) { - return; - } - - const rect = containerRef.current.getBoundingClientRect(); - const headerHeight = HEADER_HEIGHT + TABLE_BORDER_THICKNESS; - const availableHeight = window.innerHeight - rect.top - headerHeight; - - setFixedHeight(Math.max(availableHeight, 0)); - }; - - calculateHeight(); - - const resizeObserver = new ResizeObserver(calculateHeight); - window.addEventListener("resize", calculateHeight); - - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - - return () => { - resizeObserver.disconnect(); - window.removeEventListener("resize", calculateHeight); - }; - }, []); - - // biome-ignore lint/correctness/useExhaustiveDependencies: No need to add more deps - const throttledLoadMore = useCallback( - throttle( - () => { - if (onLoadMore) { - onLoadMore(); - } - }, - THROTTLE_DELAY, - { leading: true, trailing: false }, - ), - [onLoadMore], - ); - - useEffect(() => { - return () => { - throttledLoadMore.cancel(); - }; - }, [throttledLoadMore]); - - const virtualizer = useVirtualizer({ - count: isLoading ? loadingRows : data.length, - getScrollElement: () => parentRef.current, - estimateSize: () => rowHeight, - overscan: overscanCount, - onChange: (instance) => { - const lastItem = instance.getVirtualItems().at(-1); - if (!lastItem || !onLoadMore) { - return; - } - - const scrollElement = instance.scrollElement; - if (!scrollElement) { - return; - } - - const scrollOffset = scrollElement.scrollTop + scrollElement.clientHeight; - const scrollThreshold = scrollElement.scrollHeight - rowHeight * 3; - - if ( - !isLoading && - !isFetchingNextPage && - lastItem.index >= data.length - 1 - instance.options.overscan && - scrollOffset >= scrollThreshold - ) { - throttledLoadMore(); - } - }, - }); - - const handleRowClick = (item: T) => { - if (onRowClick) { - onRowClick(item); - setTableDistanceToTop( - (tableRef.current?.getBoundingClientRect().top ?? 0) + - window.scrollY - - TABLE_BORDER_THICKNESS, - ); - } - }; - - const LoadingRow = () => ( -
col.width).join(" ") }} - > - {columns.map((column) => ( -
-
-
- ))} -
- ); - - const TableHeader = () => ( - <> -
col.width).join(" "), - }} - > - {columns.map((column) => ( -
-
{column.header}
-
- ))} -
-
- - ); - - if (!isLoading && data.length === 0) { - return ( -
- -
- {emptyState || ( - - - -
No data available
-
-
- )} -
-
- ); - } - - return ( -
- -
{ - if (el) { - //@ts-expect-error safe to bypass - parentRef.current = el; - //@ts-expect-error safe to bypass - tableRef.current = el; - } - }} - data-table-container="true" - className="overflow-auto pb-10" - style={{ height: `${fixedHeight}px` }} - > -
- {virtualizer.getVirtualItems().map((virtualRow) => { - if (isLoading) { - return ( -
- -
- ); - } - - const item = data[virtualRow.index]; - if (!item) { - return null; - } - - const isSelected = selectedItem - ? keyExtractor(selectedItem) === keyExtractor(item) - : false; - - return ( -
handleRowClick(item)} - tabIndex={virtualRow.index} - aria-selected={isSelected} - onKeyDown={(event) => { - if (event.key === "Escape" && onRowClick) { - onRowClick(null); - } - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleRowClick(item); - } - if (event.key === "ArrowDown") { - event.preventDefault(); - const nextElement = document.querySelector( - `[data-index="${virtualRow.index + 1}"]`, - ) as HTMLElement; - nextElement?.focus(); - } - if (event.key === "ArrowUp") { - event.preventDefault(); - const prevElement = document.querySelector( - `[data-index="${virtualRow.index - 1}"]`, - ) as HTMLElement; - prevElement?.focus(); - } - }} - className={cn( - "grid text-[13px] leading-[14px] mb-[1px] rounded-[5px] cursor-pointer absolute top-0 left-0 w-full hover:bg-accent-3 pl-1 group", - rowClassName?.(item), - selectedItem && { - "opacity-50": !isSelected, - "opacity-100": isSelected, - }, - selectedClassName?.(item, isSelected), - )} - style={{ - gridTemplateColumns: columns.map((col) => col.width).join(" "), - height: `${rowHeight}px`, - top: `${virtualRow.start}px`, - }} - > - {columns.map((column) => ( -
-
{column.render(item)}
-
- ))} -
- ); - })} -
- - {isFetchingNextPage && ( -
-
-
- Loading more data -
-
- )} - - {selectedItem && - renderDetails && - renderDetails(selectedItem, () => onRowClick?.(null as any), tableDistanceToTop)} -
-
- ); -} diff --git a/apps/dashboard/components/virtual-table/components/empty-state.tsx b/apps/dashboard/components/virtual-table/components/empty-state.tsx new file mode 100644 index 0000000000..476b4d1bfd --- /dev/null +++ b/apps/dashboard/components/virtual-table/components/empty-state.tsx @@ -0,0 +1,15 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { ScrollText } from "lucide-react"; + +export const EmptyState = ({ content }: { content?: React.ReactNode }) => ( +
+ {content || ( + + + +
No data available
+
+
+ )} +
+); diff --git a/apps/dashboard/components/virtual-table/components/loading-indicator.tsx b/apps/dashboard/components/virtual-table/components/loading-indicator.tsx new file mode 100644 index 0000000000..469178a8b2 --- /dev/null +++ b/apps/dashboard/components/virtual-table/components/loading-indicator.tsx @@ -0,0 +1,8 @@ +export const LoadingIndicator = () => ( +
+
+
+ Loading more data +
+
+); diff --git a/apps/dashboard/components/virtual-table/components/loading-row.tsx b/apps/dashboard/components/virtual-table/components/loading-row.tsx new file mode 100644 index 0000000000..bdf69c2070 --- /dev/null +++ b/apps/dashboard/components/virtual-table/components/loading-row.tsx @@ -0,0 +1,18 @@ +import type { Column } from "../types"; + +export const LoadingRow = ({ + columns, +}: { + columns: Column[]; +}) => ( +
col.width).join(" ") }} + > + {columns.map((column) => ( +
+
+
+ ))} +
+); diff --git a/apps/dashboard/components/virtual-table/components/table-header.tsx b/apps/dashboard/components/virtual-table/components/table-header.tsx new file mode 100644 index 0000000000..f864fbce87 --- /dev/null +++ b/apps/dashboard/components/virtual-table/components/table-header.tsx @@ -0,0 +1,23 @@ +import type { Column } from "../types"; + +export const TableHeader = ({ + columns, +}: { + columns: Column[]; +}) => ( + <> +
col.width).join(" "), + }} + > + {columns.map((column) => ( +
+
{column.header}
+
+ ))} +
+
+ +); diff --git a/apps/dashboard/components/virtual-table/components/table-row.tsx b/apps/dashboard/components/virtual-table/components/table-row.tsx new file mode 100644 index 0000000000..eca759d806 --- /dev/null +++ b/apps/dashboard/components/virtual-table/components/table-row.tsx @@ -0,0 +1,83 @@ +import type { VirtualItem } from "@tanstack/react-virtual"; +import { cn } from "@unkey/ui/src/lib/utils"; +import type { Column } from "../types"; + +export const TableRow = ({ + item, + columns, + virtualRow, + rowHeight, + isSelected, + rowClassName, + selectedClassName, + onClick, + measureRef, + onRowClick, +}: { + item: T; + columns: Column[]; + virtualRow: VirtualItem; + rowHeight: number; + isSelected: boolean; + rowClassName?: (item: T) => string; + selectedClassName?: (item: T, isSelected: boolean) => string; + onClick: () => void; + onRowClick?: (item: T | null) => void; + measureRef: (element: HTMLElement | null) => void; +}) => ( +
{ + if (event.key === "Escape") { + event.preventDefault(); + onRowClick?.(null); + const activeElement = document.activeElement as HTMLElement; + activeElement?.blur(); + } + + if (event.key === "ArrowDown" || event.key === "j") { + event.preventDefault(); + const nextElement = document.querySelector( + `[data-index="${virtualRow.index + 1}"]`, + ) as HTMLElement; + if (nextElement) { + nextElement.focus(); + nextElement.click(); // This will trigger onClick which calls handleRowClick + } + } + if (event.key === "ArrowUp" || event.key === "k") { + event.preventDefault(); + const prevElement = document.querySelector( + `[data-index="${virtualRow.index - 1}"]`, + ) as HTMLElement; + if (prevElement) { + prevElement.focus(); + prevElement.click(); // This will trigger onClick which calls handleRowClick + } + } + }} + ref={measureRef} + onClick={onClick} + className={cn( + "grid text-xs cursor-pointer absolute top-0 left-0 w-full", + "transition-all duration-75 ease-in-out", + "hover:bg-accent-3 ", + "group rounded-md", + rowClassName?.(item), + selectedClassName?.(item, isSelected), + )} + style={{ + gridTemplateColumns: columns.map((col) => col.width).join(" "), + height: `${rowHeight}px`, + top: `${virtualRow.start}px`, + }} + > + {columns.map((column) => ( +
+ {column.render(item)} +
+ ))} +
+); diff --git a/apps/dashboard/components/virtual-table/constants.ts b/apps/dashboard/components/virtual-table/constants.ts new file mode 100644 index 0000000000..bb044d355f --- /dev/null +++ b/apps/dashboard/components/virtual-table/constants.ts @@ -0,0 +1,10 @@ +import type { TableConfig } from "./types"; + +export const DEFAULT_CONFIG: TableConfig = { + rowHeight: 26, + loadingRows: 50, + overscan: 5, + tableBorder: 1, + throttleDelay: 350, + headerHeight: 40, +} as const; diff --git a/apps/dashboard/components/virtual-table/hooks/useTableHeight.ts b/apps/dashboard/components/virtual-table/hooks/useTableHeight.ts new file mode 100644 index 0000000000..08c2b8fe97 --- /dev/null +++ b/apps/dashboard/components/virtual-table/hooks/useTableHeight.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; + +export const useTableHeight = ( + containerRef: React.RefObject, + headerHeight: number, + tableBorder: number, +) => { + const [fixedHeight, setFixedHeight] = useState(0); + + useEffect(() => { + const calculateHeight = () => { + if (!containerRef.current) { + return; + } + const rect = containerRef.current.getBoundingClientRect(); + const totalHeaderHeight = headerHeight + tableBorder; + const availableHeight = window.innerHeight - rect.top - totalHeaderHeight; + setFixedHeight(Math.max(availableHeight, 0)); + }; + + calculateHeight(); + const resizeObserver = new ResizeObserver(calculateHeight); + window.addEventListener("resize", calculateHeight); + + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + resizeObserver.disconnect(); + window.removeEventListener("resize", calculateHeight); + }; + }, [containerRef, headerHeight, tableBorder]); + + return fixedHeight; +}; diff --git a/apps/dashboard/components/virtual-table/hooks/useVirtualData.ts b/apps/dashboard/components/virtual-table/hooks/useVirtualData.ts new file mode 100644 index 0000000000..3af3e19832 --- /dev/null +++ b/apps/dashboard/components/virtual-table/hooks/useVirtualData.ts @@ -0,0 +1,65 @@ +import { throttle } from "@/lib/utils"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useCallback, useEffect } from "react"; +import type { TableConfig } from "../types"; + +export const useVirtualData = ({ + data, + isLoading, + config, + onLoadMore, + isFetchingNextPage, + parentRef, +}: { + data: T[]; + isLoading: boolean; + config: TableConfig; + onLoadMore?: () => void; + isFetchingNextPage?: boolean; + parentRef: React.RefObject; +}) => { + // biome-ignore lint/correctness/useExhaustiveDependencies: fine to use config.throttleDelay + const throttledLoadMore = useCallback( + throttle(onLoadMore ?? (() => {}), config.throttleDelay, { + leading: true, + trailing: false, + }), + [onLoadMore, config.throttleDelay], + ); + + useEffect(() => { + return () => { + throttledLoadMore.cancel(); + }; + }, [throttledLoadMore]); + + return useVirtualizer({ + count: isLoading ? config.loadingRows : data.length, + getScrollElement: () => parentRef.current, + estimateSize: () => config.rowHeight, + overscan: config.overscan, + onChange: (instance) => { + const lastItem = instance.getVirtualItems().at(-1); + if (!lastItem || !onLoadMore) { + return; + } + + const scrollElement = instance.scrollElement; + if (!scrollElement) { + return; + } + + const scrollOffset = scrollElement.scrollTop + scrollElement.clientHeight; + const scrollThreshold = scrollElement.scrollHeight - config.rowHeight * 3; + + if ( + !isLoading && + !isFetchingNextPage && + lastItem.index >= data.length - 1 - instance.options.overscan && + scrollOffset >= scrollThreshold + ) { + throttledLoadMore(); + } + }, + }); +}; diff --git a/apps/dashboard/components/virtual-table/index.tsx b/apps/dashboard/components/virtual-table/index.tsx new file mode 100644 index 0000000000..0c80fae929 --- /dev/null +++ b/apps/dashboard/components/virtual-table/index.tsx @@ -0,0 +1,149 @@ +import { cn } from "@unkey/ui/src/lib/utils"; +import { useCallback, useRef, useState } from "react"; +import { useScrollLock } from "usehooks-ts"; +import { EmptyState } from "./components/empty-state"; +import { LoadingIndicator } from "./components/loading-indicator"; +import { LoadingRow } from "./components/loading-row"; +import { TableHeader } from "./components/table-header"; +import { TableRow } from "./components/table-row"; +import { DEFAULT_CONFIG } from "./constants"; +import { useTableHeight } from "./hooks/useTableHeight"; +import { useVirtualData } from "./hooks/useVirtualData"; +import type { VirtualTableProps } from "./types"; + +export function VirtualTable({ + data, + columns, + isLoading = false, + config: userConfig, + onRowClick, + onLoadMore, + emptyState, + keyExtractor, + rowClassName, + selectedClassName, + selectedItem, + renderDetails, + isFetchingNextPage, +}: VirtualTableProps) { + const config = { ...DEFAULT_CONFIG, ...userConfig }; + const parentRef = useRef(null); + const containerRef = useRef(null); + const [tableDistanceToTop, setTableDistanceToTop] = useState(0); + + const fixedHeight = useTableHeight(containerRef, config.headerHeight, config.tableBorder); + const virtualizer = useVirtualData({ + data, + isLoading, + config, + onLoadMore, + isFetchingNextPage, + parentRef, + }); + + useScrollLock({ + autoLock: true, + lockTarget: + typeof window !== "undefined" + ? (document.querySelector("#layout-wrapper") as HTMLElement) + : undefined, + }); + + const handleRowClick = useCallback( + (item: T) => { + if (onRowClick) { + onRowClick(item); + setTableDistanceToTop( + (parentRef.current?.getBoundingClientRect().top ?? 0) + + window.scrollY - + config.tableBorder, + ); + } + }, + [onRowClick, config.tableBorder], + ); + + if (!isLoading && data.length === 0) { + return ( +
+ + +
+ ); + } + + return ( +
+ +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + if (isLoading) { + return ( +
+ +
+ ); + } + + const item = data[virtualRow.index]; + if (!item) { + return null; + } + + const isSelected = selectedItem + ? keyExtractor(selectedItem) === keyExtractor(item) + : false; + + return ( + handleRowClick(item)} + measureRef={virtualizer.measureElement} + onRowClick={onRowClick} + /> + ); + })} +
+ + {isFetchingNextPage && } + + {selectedItem && + renderDetails && + renderDetails(selectedItem, () => onRowClick?.(null as any), tableDistanceToTop)} +
+
+ ); +} diff --git a/apps/dashboard/components/virtual-table/types.ts b/apps/dashboard/components/virtual-table/types.ts new file mode 100644 index 0000000000..9fd4949cf3 --- /dev/null +++ b/apps/dashboard/components/virtual-table/types.ts @@ -0,0 +1,33 @@ +export type Column = { + key: string; + header: string; + headerClassName?: string; + width: string; + render: (item: T) => React.ReactNode; +}; + +export type TableConfig = { + rowHeight: number; + loadingRows: number; + overscan: number; + tableBorder: number; + throttleDelay: number; + headerHeight: number; +}; + +export type VirtualTableProps = { + data: T[]; + columns: Column[]; + isLoading?: boolean; + config?: Partial; + onRowClick?: (item: T | null) => void; + onLoadMore?: () => void; + emptyState?: React.ReactNode; + keyExtractor: (item: T) => string | number; + rowClassName?: (item: T) => string; + focusClassName?: (item: T) => string; + selectedClassName?: (item: T, isSelected: boolean) => string; + selectedItem?: T | null; + isFetchingNextPage?: boolean; + renderDetails?: (item: T, onClose: () => void, distanceToTop: number) => React.ReactNode; +}; diff --git a/apps/engineering/content/design/icons.mdx b/apps/engineering/content/design/icons.mdx index 3647f7a5ad..52ece61868 100644 --- a/apps/engineering/content/design/icons.mdx +++ b/apps/engineering/content/design/icons.mdx @@ -2,13 +2,14 @@ title: Icons description: Available icons for Unkey's apps. --- + import { RenderComponentWithSnippet } from "@/app/components/render"; import { Row } from "@/app/components/row"; import { Icon } from "@/app/components/icon-swatch"; import { Icon as XXX } from "@unkey/icons"; -import { TypeTable } from 'fumadocs-ui/components/type-table'; -import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { TypeTable } from "fumadocs-ui/components/type-table"; +import { Step, Steps } from "fumadocs-ui/components/steps"; import { Bolt, @@ -30,81 +31,153 @@ import { Trash, TriangleWarning, Ufo, -} from "@unkey/icons" - - - + BarsFilter, + Calendar, + CircleCarretRight, + Magnifier, + Refresh3, + Sliders, + TriangleWarning2, + XMark, + Grid, +} from "@unkey/icons"; - - - ## Customize As a rule of thumb, you should only customize the color, but there's always an edge case. - + -`}> - - - - - +`} +> + + + + + ## Icons These are all the icons available to import. + ```tsx -import { IconName } from "@unkey/icons" +import { IconName } from "@unkey/icons"; ```
- - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
- ## Adding new icons Importing icons is a manual process. + - Open the icon in the `Nucleo UI Essential` collection and select the icon(s) you want to export. + Open the icon in the `Nucleo UI Essential` collection and select the icon(s) + you want to export. - Export it as `jsx`, and remove the title. - ![Nucleo Export Settings](./icons-export-settings.png) + Export it as `jsx`, and remove the title. ![Nucleo Export + Settings](./icons-export-settings.png) @@ -152,5 +225,4 @@ Importing icons is a manual process. - diff --git a/internal/icons/src/icons/bars-filter.tsx b/internal/icons/src/icons/bars-filter.tsx new file mode 100644 index 0000000000..fb86d83acd --- /dev/null +++ b/internal/icons/src/icons/bars-filter.tsx @@ -0,0 +1,55 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import type { IconProps } from "../props"; + +export const BarsFilter: React.FC = (props) => { + return ( + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/calendar.tsx b/internal/icons/src/icons/calendar.tsx new file mode 100644 index 0000000000..b30fb157f8 --- /dev/null +++ b/internal/icons/src/icons/calendar.tsx @@ -0,0 +1,60 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import type { IconProps } from "../props"; + +export const Calendar: React.FC = (props) => { + return ( + + + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/circle-carret-right.tsx b/internal/icons/src/icons/circle-carret-right.tsx new file mode 100644 index 0000000000..99352564bd --- /dev/null +++ b/internal/icons/src/icons/circle-carret-right.tsx @@ -0,0 +1,37 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import type { IconProps } from "../props"; + +export const CircleCarretRight: React.FC = (props) => { + return ( + + + + + + + ); +}; diff --git a/internal/icons/src/icons/grid.tsx b/internal/icons/src/icons/grid.tsx new file mode 100644 index 0000000000..75fd3d65d2 --- /dev/null +++ b/internal/icons/src/icons/grid.tsx @@ -0,0 +1,26 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import type { IconProps } from "../props"; + +export const Grid: React.FC = (props) => { + return ( + + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/magnifier.tsx b/internal/icons/src/icons/magnifier.tsx new file mode 100644 index 0000000000..88c8e19a6d --- /dev/null +++ b/internal/icons/src/icons/magnifier.tsx @@ -0,0 +1,43 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import type { IconProps } from "../props"; + +export const Magnifier: React.FC = (props) => { + return ( + + + + + + + ); +}; diff --git a/internal/icons/src/icons/refresh-3.tsx b/internal/icons/src/icons/refresh-3.tsx new file mode 100644 index 0000000000..4609742197 --- /dev/null +++ b/internal/icons/src/icons/refresh-3.tsx @@ -0,0 +1,38 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import type { IconProps } from "../props"; + +export const Refresh3: React.FC = (props) => { + return ( + + + + + + + ); +}; diff --git a/internal/icons/src/icons/sliders.tsx b/internal/icons/src/icons/sliders.tsx new file mode 100644 index 0000000000..79fdce8c0a --- /dev/null +++ b/internal/icons/src/icons/sliders.tsx @@ -0,0 +1,86 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import type { IconProps } from "../props"; + +export const Sliders: React.FC = (props) => { + return ( + + + + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/triangle-warning-2.tsx b/internal/icons/src/icons/triangle-warning-2.tsx new file mode 100644 index 0000000000..d708f9fa80 --- /dev/null +++ b/internal/icons/src/icons/triangle-warning-2.tsx @@ -0,0 +1,43 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ + +import type React from "react"; + +import type { IconProps } from "../props"; +export const TriangleWarning2: React.FC = (props) => { + return ( + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/xmark.tsx b/internal/icons/src/icons/xmark.tsx new file mode 100644 index 0000000000..70ec35ff94 --- /dev/null +++ b/internal/icons/src/icons/xmark.tsx @@ -0,0 +1,42 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import type { IconProps } from "../props"; + +export const XMark: React.FC = (props) => { + return ( + + + + + + + ); +}; diff --git a/internal/icons/src/index.ts b/internal/icons/src/index.ts index 08e760840e..51056f8522 100644 --- a/internal/icons/src/index.ts +++ b/internal/icons/src/index.ts @@ -17,3 +17,12 @@ export * from "./icons/input-search"; export * from "./icons/shield-key"; export * from "./icons/gauge"; export * from "./icons/gear"; +export * from "./icons/bars-filter"; +export * from "./icons/calendar"; +export * from "./icons/circle-carret-right"; +export * from "./icons/magnifier"; +export * from "./icons/refresh-3"; +export * from "./icons/sliders"; +export * from "./icons/triangle-warning-2"; +export * from "./icons/xmark"; +export * from "./icons/grid"; diff --git a/internal/ui/src/components/button.tsx b/internal/ui/src/components/button.tsx index a9057ffcee..7f047ca9e9 100644 --- a/internal/ui/src/components/button.tsx +++ b/internal/ui/src/components/button.tsx @@ -21,10 +21,10 @@ const buttonVariants = cva( }, size: { default: "h-8 px-3 py-1 text-sm", + icon: "size-6 p-1", }, shape: { square: "size-8 p-1", - // circle: "", }, }, defaultVariants: {