From 1250949c0554b95cbef11e4ad860958c87bb7ba0 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sun, 25 Aug 2024 16:34:01 -0600 Subject: [PATCH] feat: finish request filter by status code --- .../dokploy/__test__/requests/request.test.ts | 56 +++ .../components/dashboard/requests/columns.tsx | 40 +- .../requests/request-distribution-chart.tsx | 78 ++++ .../dashboard/requests/requests-table.tsx | 340 ++++++++++++++++ .../dashboard/requests/show-requests.tsx | 372 +----------------- .../requests/status-request-filter.tsx | 138 +++++++ apps/dokploy/components/ui/sheet.tsx | 2 +- apps/dokploy/package.json | 4 +- apps/dokploy/server/api/routers/settings.ts | 21 +- apps/dokploy/server/db/schema/admin.ts | 12 + apps/dokploy/server/utils/access-log/utils.ts | 119 ++++-- pnpm-lock.yaml | 12 +- 12 files changed, 766 insertions(+), 428 deletions(-) create mode 100644 apps/dokploy/__test__/requests/request.test.ts create mode 100644 apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx create mode 100644 apps/dokploy/components/dashboard/requests/requests-table.tsx create mode 100644 apps/dokploy/components/dashboard/requests/status-request-filter.tsx diff --git a/apps/dokploy/__test__/requests/request.test.ts b/apps/dokploy/__test__/requests/request.test.ts new file mode 100644 index 00000000..064868d6 --- /dev/null +++ b/apps/dokploy/__test__/requests/request.test.ts @@ -0,0 +1,56 @@ +import { processLogs, parseRawConfig } from "@/server/utils/access-log/utils"; +import { describe, it, expect } from "vitest"; +const sampleLogEntry = `{"ClientAddr":"172.19.0.1:56732","ClientHost":"172.19.0.1","ClientPort":"56732","ClientUsername":"-","DownstreamContentSize":0,"DownstreamStatus":304,"Duration":14729375,"OriginContentSize":0,"OriginDuration":14051833,"OriginStatus":304,"Overhead":677542,"RequestAddr":"s222-umami-c381af.traefik.me","RequestContentSize":0,"RequestCount":122,"RequestHost":"s222-umami-c381af.traefik.me","RequestMethod":"GET","RequestPath":"/dashboard?_rsc=1rugv","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"s222-umami-60e104-47-web@docker","ServiceAddr":"10.0.1.15:3000","ServiceName":"s222-umami-60e104-47-web@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.15:3000","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T04:34:37.306691884Z","StartUTC":"2024-08-25T04:34:37.306691884Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T04:34:37Z"}`; + +describe("processLogs", () => { + it("should process a single log entry correctly", () => { + const result = processLogs(sampleLogEntry); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + hour: "2024-08-25T04:00:00Z", + count: 1, + }); + }); + + it("should process multiple log entries and group by hour", () => { + const sampleLogEntry = `{"ClientAddr":"172.19.0.1:58094","ClientHost":"172.19.0.1","ClientPort":"58094","ClientUsername":"-","DownstreamContentSize":50,"DownstreamStatus":200,"Duration":35914250,"OriginContentSize":50,"OriginDuration":35817959,"OriginStatus":200,"Overhead":96291,"RequestAddr":"s222-pocketbase-f4a6e5.traefik.me","RequestContentSize":0,"RequestCount":991,"RequestHost":"s222-pocketbase-f4a6e5.traefik.me","RequestMethod":"GET","RequestPath":"/api/logs/stats?filter=","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"s222-pocketbase-e94e25-44-web@docker","ServiceAddr":"10.0.1.12:80","ServiceName":"s222-pocketbase-e94e25-44-web@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.12:80","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T17:44:29.274072471Z","StartUTC":"2024-08-25T17:44:29.274072471Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T17:44:29Z"} +{"ClientAddr":"172.19.0.1:58108","ClientHost":"172.19.0.1","ClientPort":"58108","ClientUsername":"-","DownstreamContentSize":30975,"DownstreamStatus":200,"Duration":31406458,"OriginContentSize":30975,"OriginDuration":31046791,"OriginStatus":200,"Overhead":359667,"RequestAddr":"s222-pocketbase-f4a6e5.traefik.me","RequestContentSize":0,"RequestCount":992,"RequestHost":"s222-pocketbase-f4a6e5.traefik.me","RequestMethod":"GET","RequestPath":"/api/logs?page=1\u0026perPage=50\u0026sort=-rowid\u0026skipTotal=1\u0026filter=","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"s222-pocketbase-e94e25-44-web@docker","ServiceAddr":"10.0.1.12:80","ServiceName":"s222-pocketbase-e94e25-44-web@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.12:80","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T17:44:29.278990221Z","StartUTC":"2024-08-25T17:44:29.278990221Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T17:44:29Z"} +`; + + const result = processLogs(sampleLogEntry); + expect(result).toHaveLength(1); + expect(result).toEqual([{ hour: "2024-08-25T17:00:00Z", count: 2 }]); + }); + + it("should return an empty array for empty input", () => { + expect(processLogs("")).toEqual([]); + expect(processLogs(null as any)).toEqual([]); + expect(processLogs(undefined as any)).toEqual([]); + }); + + it("should parse a single log entry correctly", () => { + const result = parseRawConfig(sampleLogEntry); + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty("ClientAddr", "172.19.0.1:56732"); + expect(result[0]).toHaveProperty( + "StartUTC", + "2024-08-25T04:34:37.306691884Z", + ); + }); + + it("should parse multiple log entries", () => { + const multipleEntries = `${sampleLogEntry}\n${sampleLogEntry}`; + const result = parseRawConfig(multipleEntries); + expect(result).toHaveLength(2); + + for (const entry of result) { + expect(entry).toHaveProperty("ClientAddr", "172.19.0.1:56732"); + } + }); + + it("should handle whitespace and empty lines", () => { + const entryWithWhitespace = `\n${sampleLogEntry}\n\n${sampleLogEntry}\n`; + const result = parseRawConfig(entryWithWhitespace); + expect(result).toHaveLength(2); + }); +}); diff --git a/apps/dokploy/components/dashboard/requests/columns.tsx b/apps/dokploy/components/dashboard/requests/columns.tsx index 7a3c1939..afcd32dd 100644 --- a/apps/dokploy/components/dashboard/requests/columns.tsx +++ b/apps/dokploy/components/dashboard/requests/columns.tsx @@ -1,18 +1,27 @@ import type { ColumnDef } from "@tanstack/react-table"; -import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { ArrowUpDown } from "lucide-react"; import * as React from "react"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; - import { Badge } from "@/components/ui/badge"; import type { LogEntry } from "./show-requests"; import { format } from "date-fns"; +export const getStatusColor = (status: number) => { + if (status >= 100 && status < 200) { + return "outline"; + } + if (status >= 200 && status < 300) { + return "default"; + } + if (status >= 300 && status < 400) { + return "outline"; + } + if (status >= 400 && status < 500) { + return "destructive"; + } + return "destructive"; +}; + export const columns: ColumnDef[] = [ { accessorKey: "level", @@ -52,13 +61,11 @@ export const columns: ColumnDef[] = [ {log.RequestMethod} {log.RequestPath}
- + Status: {log.OriginStatus} - Exec Time: {convertMicroseconds(log.Duration)} + Exec Time: {`${log.Duration / 1000000000}s`} IP: {log.ClientAddr}
@@ -91,12 +98,3 @@ export const columns: ColumnDef[] = [ }, }, ]; -function convertMicroseconds(microseconds: number): string { - if (microseconds < 1000) { - return `${microseconds} µs`; - } - if (microseconds < 1000000) { - return `${(microseconds / 1000).toFixed(2)} ms`; - } - return `${(microseconds / 1000000).toFixed(2)} s`; -} diff --git a/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx b/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx new file mode 100644 index 00000000..f4720ba3 --- /dev/null +++ b/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx @@ -0,0 +1,78 @@ +import { api } from "@/utils/api"; +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + XAxis, + YAxis, +} from "recharts"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +const chartConfig = { + views: { + label: "Page Views", + }, + count: { + label: "Count", + color: "hsl(var(--chart-1))", + }, +} satisfies ChartConfig; + +export const RequestDistributionChart = () => { + const { data: stats } = api.settings.readStats.useQuery(); + + return ( + + + + + + new Date(value).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + } + /> + + } + labelFormatter={(value) => + new Date(value).toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + } + /> + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/requests/requests-table.tsx b/apps/dokploy/components/dashboard/requests/requests-table.tsx new file mode 100644 index 00000000..0914c8b0 --- /dev/null +++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx @@ -0,0 +1,340 @@ +import { api } from "@/utils/api"; +import { + type SortingState, + type PaginationState, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, + type ColumnFiltersState, + type VisibilityState, + flexRender, +} from "@tanstack/react-table"; +import { useMemo, useState } from "react"; +import type { LogEntry } from "./show-requests"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { columns, getStatusColor } from "./columns"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + ChevronDown, + Copy, + Download, + InfoIcon, + CheckCircle2Icon, + TrendingUpIcon, + Globe, + Server, +} from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { DataTableFacetedFilter } from "./status-request-filter"; + +export const priorities = [ + { + label: "100 - 199", + value: "info", + icon: InfoIcon, + }, + { + label: "200 - 299", + value: "success", + icon: CheckCircle2Icon, + }, + { + label: "300 - 399", + value: "redirect", + icon: TrendingUpIcon, + }, + { + label: "400 - 499", + value: "client", + icon: Globe, + }, + { + label: "500 - 599", + value: "server", + icon: Server, + }, +]; +export const RequestsTable = () => { + const [statusFilter, setStatusFilter] = useState([]); + const [search, setSearch] = useState(""); + const [selectedRow, setSelectedRow] = useState(); + const [sorting, setSorting] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [rowSelection, setRowSelection] = useState({}); + const [columnFilters, setColumnFilters] = useState([]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: statsLogs, isLoading } = api.settings.readStatsLogs.useQuery({ + sort: sorting[0], + page: pagination, + search, + status: statusFilter, + }); + + const pageCount = useMemo(() => { + if (statsLogs?.totalCount) { + return Math.ceil(statsLogs.totalCount / pagination.pageSize); + } + return -1; + }, [statsLogs?.totalCount, pagination.pageSize]); + + const table = useReactTable({ + data: statsLogs?.data ?? [], + columns, + onPaginationChange: setPagination, + onSortingChange: setSorting, + pageCount: pageCount, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + manualPagination: true, + state: { + pagination, + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }); + + const formatValue = (key: string, value: any) => { + if (typeof value === "object" && value !== null) { + return JSON.stringify(value, null, 2); + } + if (key === "Duration" || key === "OriginDuration" || key === "Overhead") { + return `${value / 1000000000} s`; + } + if (key === "level") { + return {value}; + } + if (key === "RequestMethod") { + return {value}; + } + if (key === "DownstreamStatus" || key === "OriginStatus") { + return {value}; + } + return value; + }; + + return ( + <> +
+
+
+
+ setSearch(event.target.value)} + className="md:max-w-sm" + /> + + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + { + setSelectedRow(row.original); + }} + data-state={row.getIsSelected() && "selected"} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + {isLoading ? ( +
+ + Loading... + +
+ ) : ( + <>No results. + )} +
+
+ )} +
+
+
+
+ {statsLogs?.totalCount && ( + + Showing{" "} + {Math.min( + pagination.pageIndex * pagination.pageSize + 1, + statsLogs.totalCount, + )}{" "} + to{" "} + {Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + statsLogs.totalCount, + )}{" "} + of {statsLogs.totalCount} entries + + )} +
+ + +
+
+
+
+
+ setSelectedRow(undefined)} + > + + + Request log + + Details of the request log entry. + + + +
+ + + {Object.entries(selectedRow || {}).map(([key, value]) => ( + + {key} + + {key === "RequestAddr" ? ( +
+ {value} + +
+ ) : ( + formatValue(key, value) + )} +
+
+ ))} +
+
+
+
+
+ +
+
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/requests/show-requests.tsx b/apps/dokploy/components/dashboard/requests/show-requests.tsx index 5100309e..c2906920 100644 --- a/apps/dokploy/components/dashboard/requests/show-requests.tsx +++ b/apps/dokploy/components/dashboard/requests/show-requests.tsx @@ -1,128 +1,22 @@ import { api, type RouterOutputs } from "@/utils/api"; -import { - type ColumnFiltersState, - type SortingState, - type VisibilityState, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, -} from "@tanstack/react-table"; import * as React from "react"; -import { - Area, - AreaChart, - Bar, - BarChart, - CartesianGrid, - ResponsiveContainer, - XAxis, - YAxis, -} from "recharts"; import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; -import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - ChevronDown, - Copy, - Download, - MoreHorizontal, - TrendingUp, -} from "lucide-react"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { columns } from "./columns"; -import { Input } from "@/components/ui/input"; import { toast } from "sonner"; -import { useEffect, useState } from "react"; -import { ScrollArea } from "@/components/ui/scroll-area"; +import { RequestDistributionChart } from "./request-distribution-chart"; +import { RequestsTable } from "./requests-table"; export type LogEntry = NonNullable< - RouterOutputs["settings"]["readMonitoringConfig"]["data"] + RouterOutputs["settings"]["readStatsLogs"]["data"] >[0]; -const chartConfig = { - views: { - label: "Page Views", - }, - count: { - label: "Count", - color: "hsl(var(--chart-1))", - }, -} satisfies ChartConfig; - -const requestLog = { - id: "zagp0jxukx0mw7h", - level: "ERROR (8)", - created: "2024-08-25 05:33:45.366 UTC", - "data.execTime": "0.056928ms", - "data.type": "request", - "data.auth": "guest", - "data.status": "404", - "data.method": "GET", - "data.url": "/favicon.ico", - "data.referer": "http://testing2-pocketbase-8d9cd5-5-161-87-31.traefik.me/", - "data.remoteIp": "10.0.1.184", - "data.userIp": "179.49.119.201", - "data.userAgent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - "data.error": "Not Found.", - "data.details": "code=404, message=Not Found", -}; export const ShowRequests = () => { - const { data, isLoading } = api.settings.readMonitoringConfig.useQuery( - undefined, - { - refetchInterval: 1000, - }, - ); - const [selectedRow, setSelectedRow] = useState(); const { data: isLogRotateActive, refetch: refetchLogRotate } = api.settings.getLogRotateStatus.useQuery(); @@ -130,32 +24,6 @@ export const ShowRequests = () => { const { mutateAsync: deactivateLogRotate } = api.settings.deactivateLogRotate.useMutation(); - const [sorting, setSorting] = React.useState([]); - const [columnFilters, setColumnFilters] = React.useState( - [], - ); - const [columnVisibility, setColumnVisibility] = - React.useState({}); - const [rowSelection, setRowSelection] = React.useState({}); - - const table = useReactTable({ - data: data?.data ?? [], - columns, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - onColumnVisibilityChange: setColumnVisibility, - onRowSelectionChange: setRowSelection, - state: { - sorting, - columnFilters, - columnVisibility, - rowSelection, - }, - }); return ( <> @@ -201,240 +69,10 @@ export const ShowRequests = () => { - - - - - - new Date(value).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - } - /> - - } - labelFormatter={(value) => - new Date(value).toLocaleString([], { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }) - } - /> - - - - + - -
-
-
-
- - table - .getColumn("RequestPath") - ?.setFilterValue(event.target.value) - } - className="md:max-w-sm" - /> - - - - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {column.id} - - ); - })} - - -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - { - setSelectedRow(row.original); - }} - data-state={row.getIsSelected() && "selected"} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - )) - ) : ( - - - {isLoading ? ( -
- - Loading... - -
- ) : ( - <>No results. - )} -
-
- )} -
-
-
-
-
- - -
-
-
-
-
- setSelectedRow(undefined)} - > - - - Request log - - Details of the request log entry. - - - -
- - - {Object.entries(requestLog).map(([key, value]) => ( - - {key} - - {key === "id" ? ( -
- {value} - -
- ) : key === "level" ? ( - {value} - ) : key === "data.error" ? ( - - {value} - - ) : key === "data.details" ? ( -
- {value} -
- ) : ( - value - )} -
-
- ))} -
-
-
-
-
- -
-
-
+ ); }; diff --git a/apps/dokploy/components/dashboard/requests/status-request-filter.tsx b/apps/dokploy/components/dashboard/requests/status-request-filter.tsx new file mode 100644 index 00000000..f9474536 --- /dev/null +++ b/apps/dokploy/components/dashboard/requests/status-request-filter.tsx @@ -0,0 +1,138 @@ +import { cn } from "@/lib/utils"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CheckIcon, PlusCircle } from "lucide-react"; + +interface DataTableFacetedFilterProps { + value?: string[]; + setValue?: (value: string[]) => void; + title?: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; +} + +export function DataTableFacetedFilter({ + value = [], + setValue, + title, + options, +}: DataTableFacetedFilterProps) { + const selectedValues = new Set(value as string[]); + + return ( + + + + + + + + + No results found. + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( + { + if (isSelected) { + selectedValues.delete(option.value); + } else { + selectedValues.add(option.value); + } + const filterValues = Array.from(selectedValues); + setValue?.(filterValues.length ? filterValues : []); + }} + > +
+ +
+ {option.icon && ( + + )} + {option.label} +
+ ); + })} +
+ {selectedValues.size > 0 && ( + <> + + + setValue?.([])} + className="justify-center text-center" + > + Clear filters + + + + )} +
+
+
+
+ ); +} diff --git a/apps/dokploy/components/ui/sheet.tsx b/apps/dokploy/components/ui/sheet.tsx index 7ee0bada..71395d8f 100644 --- a/apps/dokploy/components/ui/sheet.tsx +++ b/apps/dokploy/components/ui/sheet.tsx @@ -29,7 +29,7 @@ const SheetOverlay = React.forwardRef< SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; const sheetVariants = cva( - "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-300", { variants: { side: { diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index fff09ec7..6e3e75f7 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -128,7 +128,9 @@ "ws": "8.16.0", "xterm-addon-fit": "^0.8.0", "zod": "^3.23.4", - "zod-form-data": "^2.0.2" + "zod-form-data": "^2.0.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0" }, "devDependencies": { "@biomejs/biome": "1.8.3", diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 7744c3b8..4a4373b4 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -3,6 +3,7 @@ import { apiAssignDomain, apiEnableDashboard, apiModifyTraefikConfig, + apiReadStatsLogs, apiReadTraefikConfig, apiSaveSSHKey, apiTraefikConfig, @@ -350,14 +351,22 @@ export const settingsRouter = createTRPCRouter({ return false; }), - readMonitoringConfig: adminProcedure.query(() => { + readStatsLogs: adminProcedure.input(apiReadStatsLogs).query(({ input }) => { + const rawConfig = readMonitoringConfig(); + const parsedConfig = parseRawConfig( + rawConfig as string, + input.page, + input.sort, + input.search, + input.status, + ); + + return parsedConfig; + }), + readStats: adminProcedure.query(() => { const rawConfig = readMonitoringConfig(); - const parsedConfig = parseRawConfig(rawConfig as string); const processedLogs = processLogs(rawConfig as string); - return { - data: parsedConfig || [], - hourlyData: processedLogs || [], - }; + return processedLogs || []; }), activateLogRotate: adminProcedure.mutation(async () => { await logRotationManager.activate(); diff --git a/apps/dokploy/server/db/schema/admin.ts b/apps/dokploy/server/db/schema/admin.ts index 215d0764..c18d2ec7 100644 --- a/apps/dokploy/server/db/schema/admin.ts +++ b/apps/dokploy/server/db/schema/admin.ts @@ -98,3 +98,15 @@ export const apiReadTraefikConfig = z.object({ export const apiEnableDashboard = z.object({ enableDashboard: z.boolean().optional(), }); + +export const apiReadStatsLogs = z.object({ + page: z + .object({ + pageIndex: z.number(), + pageSize: z.number(), + }) + .optional(), + status: z.string().array().optional(), + search: z.string().optional(), + sort: z.object({ id: z.string(), desc: z.boolean() }).optional(), +}); diff --git a/apps/dokploy/server/utils/access-log/utils.ts b/apps/dokploy/server/utils/access-log/utils.ts index 42a99d5f..94e4be24 100644 --- a/apps/dokploy/server/utils/access-log/utils.ts +++ b/apps/dokploy/server/utils/access-log/utils.ts @@ -1,6 +1,5 @@ -interface LogEntry { - StartUTC: string; -} +import _ from "lodash"; +import type { LogEntry } from "./types"; interface HourlyData { hour: string; @@ -8,45 +7,107 @@ interface HourlyData { } export function processLogs(logString: string): HourlyData[] { - const hourlyData: Record = {}; - - if (!logString || logString?.length === 0) { + if (_.isEmpty(logString)) { return []; } - const logEntries = logString.trim().split("\n"); + const hourlyData = _(logString) + .split("\n") + .compact() + .map((entry) => { + try { + const log: LogEntry = JSON.parse(entry); + const date = new Date(log.StartUTC); + return `${date.toISOString().slice(0, 13)}:00:00Z`; + } catch (error) { + console.error("Error parsing log entry:", error); + return null; + } + }) + .compact() + .countBy() + .map((count, hour) => ({ hour, count })) + .value(); - for (const entry of logEntries) { - try { - const log: LogEntry = JSON.parse(entry); - const date = new Date(log.StartUTC); - const hourKey = `${date.toISOString().slice(0, 13)}:00:00Z`; // Agrupa por hora - - hourlyData[hourKey] = (hourlyData[hourKey] || 0) + 1; - } catch (error) { - console.error("Error parsing log entry:", error); - } - } - - return Object.entries(hourlyData) - .map(([hour, count]) => ({ hour, count })) - .sort((a, b) => new Date(a.hour).getTime() - new Date(b.hour).getTime()); + return _.sortBy(hourlyData, (entry) => new Date(entry.hour).getTime()); } -export function parseRawConfig(rawConfig: string): LogEntry[] { +interface PageInfo { + pageIndex: number; + pageSize: number; +} + +interface SortInfo { + id: string; + desc: boolean; +} + +export function parseRawConfig( + rawConfig: string, + page?: PageInfo, + sort?: SortInfo, + search?: string, + status?: string[], +): { data: LogEntry[]; totalCount: number } { try { - if (!rawConfig || rawConfig?.length === 0) { - return []; + if (_.isEmpty(rawConfig)) { + return { data: [], totalCount: 0 }; } - const jsonLines = rawConfig + + let parsedLogs = _(rawConfig) .split("\n") - .filter((line) => line.trim() !== ""); + .compact() + .map((line) => JSON.parse(line) as LogEntry) + .value(); - const parsedConfig = jsonLines.map((line) => JSON.parse(line) as LogEntry); + if (search) { + parsedLogs = parsedLogs.filter((log) => + log.RequestPath.toLowerCase().includes(search.toLowerCase()), + ); + } - return parsedConfig; + if (status && status.length > 0) { + parsedLogs = parsedLogs.filter((log) => + status.some((range) => isStatusInRange(log.DownstreamStatus, range)), + ); + } + + const totalCount = parsedLogs.length; + + if (sort) { + parsedLogs = _.orderBy( + parsedLogs, + [sort.id], + [sort.desc ? "desc" : "asc"], + ); + } else { + parsedLogs = _.orderBy(parsedLogs, ["time"], ["desc"]); + } + + if (page) { + const startIndex = page.pageIndex * page.pageSize; + parsedLogs = parsedLogs.slice(startIndex, startIndex + page.pageSize); + } + + return { data: parsedLogs, totalCount }; } catch (error) { console.error("Error parsing rawConfig:", error); throw new Error("Failed to parse rawConfig"); } } +const isStatusInRange = (status: number, range: string) => { + switch (range) { + case "info": + return status >= 100 && status <= 199; + case "success": + return status >= 200 && status <= 299; + case "redirect": + return status >= 300 && status <= 399; + case "client": + return status >= 400 && status <= 499; + case "server": + return status >= 500 && status <= 599; + default: + return false; + } +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a4b47c7..a51e69cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.0.7 version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': + specifier: 2.0.0 + version: 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-progress': specifier: ^1.0.3 version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -189,6 +192,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.0.7 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-use-controllable-state': + specifier: 1.1.0 + version: 1.1.0(@types/react@18.3.3)(react@18.2.0) '@react-email/components': specifier: ^0.0.21 version: 0.0.21(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -14047,7 +14053,7 @@ snapshots: eslint: 8.45.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.45.0) eslint-plugin-react: 7.35.0(eslint@8.45.0) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.45.0) @@ -14071,7 +14077,7 @@ snapshots: enhanced-resolve: 5.17.1 eslint: 8.45.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.15.0 @@ -14093,7 +14099,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5