diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 00000000..647e2a07 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,2 @@ +LEMON_SQUEEZY_API_KEY="" +LEMON_SQUEEZY_STORE_ID="" \ No newline at end of file diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 00000000..36fabb6c --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,28 @@ +# dev +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ + +# env +.env +.env.production + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# misc +.DS_Store diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 00000000..e12b31db --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,8 @@ +``` +npm install +npm run dev +``` + +``` +open http://localhost:3000 +``` diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 00000000..5450ceab --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,15 @@ +{ + "name": "my-app", + "scripts": { + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@hono/node-server": "^1.12.1", + "hono": "^4.5.8", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "@types/node": "^20.11.17", + "tsx": "^4.7.1" + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 00000000..866c1f3f --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,66 @@ +import { serve } from "@hono/node-server"; +import { config } from "dotenv"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { validateLemonSqueezyLicense } from "./utils"; + +config(); + +const app = new Hono(); + +app.use( + "/*", + cors({ + origin: ["http://localhost:3000", "http://localhost:3001"], // Ajusta esto a los orígenes de tu aplicación Next.js + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization"], + exposeHeaders: ["Content-Length", "X-Kuma-Revision"], + maxAge: 600, + credentials: true, + }), +); + +export const LEMON_SQUEEZY_API_KEY = process.env.LEMON_SQUEEZY_API_KEY; +export const LEMON_SQUEEZY_STORE_ID = process.env.LEMON_SQUEEZY_STORE_ID; + +app.get("/v1/health", (c) => { + return c.text("Hello Hono!"); +}); + +app.post("/v1/validate-license", async (c) => { + const { licenseKey } = await c.req.json(); + + if (!licenseKey) { + return c.json({ error: "License key is required" }, 400); + } + + try { + const licenseValidation = await validateLemonSqueezyLicense(licenseKey); + + if (licenseValidation.valid) { + return c.json({ + valid: true, + message: "License is valid", + metadata: licenseValidation.meta, + }); + } + return c.json( + { + valid: false, + message: licenseValidation.error || "Invalid license", + }, + 400, + ); + } catch (error) { + console.error("Error during license validation:", error); + return c.json({ error: "Internal server error" }, 500); + } +}); + +const port = 4000; +console.log(`Server is running on port ${port}`); + +serve({ + fetch: app.fetch, + port, +}); diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts new file mode 100644 index 00000000..2547432b --- /dev/null +++ b/apps/api/src/types.ts @@ -0,0 +1,16 @@ +export interface LemonSqueezyLicenseResponse { + valid: boolean; + error?: string; + meta?: { + store_id: string; + order_id: number; + order_item_id: number; + product_id: number; + product_name: string; + variant_id: number; + variant_name: string; + customer_id: number; + customer_name: string; + customer_email: string; + }; +} diff --git a/apps/api/src/utils.ts b/apps/api/src/utils.ts new file mode 100644 index 00000000..79263955 --- /dev/null +++ b/apps/api/src/utils.ts @@ -0,0 +1,28 @@ +import { LEMON_SQUEEZY_API_KEY, LEMON_SQUEEZY_STORE_ID } from "."; +import type { LemonSqueezyLicenseResponse } from "./types"; + +export const validateLemonSqueezyLicense = async ( + licenseKey: string, +): Promise => { + try { + const response = await fetch( + "https://api.lemonsqueezy.com/v1/licenses/validate", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": LEMON_SQUEEZY_API_KEY as string, + }, + body: JSON.stringify({ + license_key: licenseKey, + store_id: LEMON_SQUEEZY_STORE_ID as string, + }), + }, + ); + + return response.json(); + } catch (error) { + console.error("Error validating license:", error); + return { valid: false, error: "Error validating license" }; + } +}; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 00000000..68a9e8f0 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": ["node"], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + } +} diff --git a/apps/dokploy/__test__/compose/network/network.test.ts b/apps/dokploy/__test__/compose/network/network.test.ts index c8a9e9f3..ba1b1395 100644 --- a/apps/dokploy/__test__/compose/network/network.test.ts +++ b/apps/dokploy/__test__/compose/network/network.test.ts @@ -330,6 +330,5 @@ test("Expect don't add suffix to dokploy-network in compose file with multiple s const suffix = "testhash"; const updatedComposeData = addSuffixToAllNetworks(composeData, suffix); - console.log(updatedComposeData); expect(updatedComposeData).toEqual(expectedComposeFile4); }); diff --git a/apps/dokploy/__test__/requests/request.test.ts b/apps/dokploy/__test__/requests/request.test.ts new file mode 100644 index 00000000..0c2e5f67 --- /dev/null +++ b/apps/dokploy/__test__/requests/request.test.ts @@ -0,0 +1,56 @@ +import { parseRawConfig, processLogs } from "@/server/utils/access-log/utils"; +import { describe, expect, it } 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.data[0]).toHaveProperty("ClientAddr", "172.19.0.1:56732"); + // expect(result.data[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.data).toHaveLength(2); + + for (const entry of result.data) { + 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.data).toHaveLength(2); + }); +}); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index 9ffa832c..8dd5dbc0 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -22,6 +22,7 @@ const baseAdmin: Admin = { letsEncryptEmail: null, sshPrivateKey: null, enableDockerCleanup: false, + enableLogRotation: false, }; beforeEach(() => { diff --git a/apps/dokploy/components/dashboard/requests/columns.tsx b/apps/dokploy/components/dashboard/requests/columns.tsx new file mode 100644 index 00000000..523e97bf --- /dev/null +++ b/apps/dokploy/components/dashboard/requests/columns.tsx @@ -0,0 +1,92 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { ColumnDef } from "@tanstack/react-table"; +import { format } from "date-fns"; +import { ArrowUpDown } from "lucide-react"; +import * as React from "react"; +import type { LogEntry } from "./show-requests"; + +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", + header: ({ column }) => { + return ; + }, + cell: ({ row }) => { + return
{row.original.level}
; + }, + }, + { + accessorKey: "RequestPath", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const log = row.original; + return ( +
+
+ {log.RequestMethod} {log.RequestPath} +
+
+ + Status: {log.OriginStatus} + + + Exec Time: {`${log.Duration / 1000000000}s`} + + IP: {log.ClientAddr} +
+
+ ); + }, + }, + { + accessorKey: "time", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const log = row.original; + return ( +
+
+ {format(new Date(log.StartUTC), "yyyy-MM-dd HH:mm:ss")} +
+
+ ); + }, + }, +]; 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..fd47ddb7 --- /dev/null +++ b/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx @@ -0,0 +1,80 @@ +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { api } from "@/utils/api"; +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + XAxis, + YAxis, +} from "recharts"; + +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(undefined, { + refetchInterval: 1333, + }); + + 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..cd2949c3 --- /dev/null +++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx @@ -0,0 +1,370 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; +import { + type ColumnFiltersState, + type PaginationState, + type SortingState, + type VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import copy from "copy-to-clipboard"; +import { + CheckCircle2Icon, + ChevronDown, + Copy, + Download, + Globe, + InfoIcon, + Server, + TrendingUpIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { columns, getStatusColor } from "./columns"; +import type { LogEntry } from "./show-requests"; +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, + }, + { + refetchInterval: 1333, + }, + ); + + 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(), + )} + + ))} + + )) + ) : ( + + + {statsLogs?.data.length === 0 && ( +
+ + 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} + { + copy(value); + toast.success("Copied to clipboard"); + }} + className="h-4 w-4 text-muted-foreground cursor-pointer" + /> +
+ ) : ( + formatValue(key, value) + )} +
+
+ ))} +
+
+
+
+
+ +
+
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/requests/show-requests.tsx b/apps/dokploy/components/dashboard/requests/show-requests.tsx new file mode 100644 index 00000000..e510488a --- /dev/null +++ b/apps/dokploy/components/dashboard/requests/show-requests.tsx @@ -0,0 +1,78 @@ +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { type RouterOutputs, api } from "@/utils/api"; +import * as React from "react"; +import { toast } from "sonner"; +import { RequestDistributionChart } from "./request-distribution-chart"; +import { RequestsTable } from "./requests-table"; + +export type LogEntry = NonNullable< + RouterOutputs["settings"]["readStatsLogs"]["data"] +>[0]; + +export const ShowRequests = () => { + const { data: isLogRotateActive, refetch: refetchLogRotate } = + api.settings.getLogRotateStatus.useQuery(); + + const { mutateAsync } = api.settings.activateLogRotate.useMutation(); + const { mutateAsync: deactivateLogRotate } = + api.settings.deactivateLogRotate.useMutation(); + + return ( + <> + + + Request Distribution +
+ + Showing web and API requests over time + + {!isLogRotateActive && ( + + )} + + {isLogRotateActive && ( + + )} +
+
+ + + +
+ + + ); +}; 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..2dd8f059 --- /dev/null +++ b/apps/dokploy/components/dashboard/requests/status-request-filter.tsx @@ -0,0 +1,138 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +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 { cn } from "@/lib/utils"; +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/layouts/navigation-tabs.tsx b/apps/dokploy/components/layouts/navigation-tabs.tsx index b251a279..decab28a 100644 --- a/apps/dokploy/components/layouts/navigation-tabs.tsx +++ b/apps/dokploy/components/layouts/navigation-tabs.tsx @@ -19,6 +19,7 @@ export type TabState = | "monitoring" | "settings" | "traefik" + | "requests" | "docker"; const tabMap: Record = { @@ -49,6 +50,14 @@ const tabMap: Record = { return Boolean(rol === "admin" || user?.canAccessToDocker); }, }, + requests: { + label: "Requests", + description: "Manage your requests", + index: "/dashboard/requests", + isShow: ({ rol, user }) => { + return Boolean(rol === "admin" || user?.canAccessToDocker); + }, + }, settings: { label: "Settings", description: "Manage your settings", diff --git a/apps/dokploy/components/layouts/settings-layout.tsx b/apps/dokploy/components/layouts/settings-layout.tsx index d1a1b224..cfe600f4 100644 --- a/apps/dokploy/components/layouts/settings-layout.tsx +++ b/apps/dokploy/components/layouts/settings-layout.tsx @@ -119,6 +119,7 @@ import { Bell, Database, GitBranch, + KeyIcon, KeyRound, type LucideIcon, Route, diff --git a/apps/dokploy/components/ui/chart.tsx b/apps/dokploy/components/ui/chart.tsx new file mode 100644 index 00000000..05611162 --- /dev/null +++ b/apps/dokploy/components/ui/chart.tsx @@ -0,0 +1,363 @@ +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +import { cn } from "@/lib/utils"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = "Chart"; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +