diff --git a/apps/dokploy/components/proprietary/audit-logs/data-table.tsx b/apps/dokploy/components/proprietary/audit-logs/data-table.tsx index f163df29b99..c6ab194fa8b 100644 --- a/apps/dokploy/components/proprietary/audit-logs/data-table.tsx +++ b/apps/dokploy/components/proprietary/audit-logs/data-table.tsx @@ -43,6 +43,10 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + type AuditLogQueryFilters, + ExportAuditLogs, +} from "./export-audit-logs"; const ACTION_OPTIONS = [ { value: "create", label: "Created" }, @@ -115,9 +119,11 @@ interface DataTableProps { columns: ColumnDef[]; data: AuditLog[]; total: number; + exportLimit?: number; pageIndex: number; pageSize: number; filters: AuditLogFilters; + appliedFilters: AuditLogQueryFilters; onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; onFilterChange: ( @@ -131,9 +137,11 @@ export function DataTable({ columns, data, total, + exportLimit, pageIndex, pageSize, filters, + appliedFilters, onPageChange, onPageSizeChange, onFilterChange, @@ -272,28 +280,35 @@ export function DataTable({ Clear )} - - - - - - {table - .getAllColumns() - .filter((col) => col.getCanHide()) - .map((col) => ( - col.toggleVisibility(!!value)} - > - {col.id} - - ))} - - +
+ + + + + + + {table + .getAllColumns() + .filter((col) => col.getCanHide()) + .map((col) => ( + col.toggleVisibility(!!value)} + > + {col.id} + + ))} + + +
diff --git a/apps/dokploy/components/proprietary/audit-logs/export-audit-logs.tsx b/apps/dokploy/components/proprietary/audit-logs/export-audit-logs.tsx new file mode 100644 index 00000000000..ad6acee09d7 --- /dev/null +++ b/apps/dokploy/components/proprietary/audit-logs/export-audit-logs.tsx @@ -0,0 +1,235 @@ +"use client"; + +import type { AuditLog } from "@dokploy/server/db/schema"; +import { format } from "date-fns"; +import { + ChevronDown, + Download, + FileJson, + FileSpreadsheet, + FileText, + Loader2, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { api, type RouterInputs } from "@/utils/api"; + +export type AuditLogQueryFilters = RouterInputs["auditLog"]["export"]; + +const EXPORT_FIELDS = [ + "id", + "createdAt", + "userId", + "userEmail", + "userRole", + "action", + "resourceType", + "resourceId", + "resourceName", + "metadata", +] as const satisfies readonly (keyof AuditLog)[]; + +type ExportField = (typeof EXPORT_FIELDS)[number]; + +// Spreadsheet apps evaluate cells starting with these characters as formulas. +// Only user-controlled text is guarded so IDs (nanoid can start with "-") stay intact. +const FORMULA_PREFIX = /^[=+\-@\t\r]/; +const FORMULA_GUARDED_FIELDS = new Set([ + "userEmail", + "userRole", + "resourceName", + "metadata", +]); + +const fieldToString = (log: AuditLog, field: ExportField) => { + if (field === "createdAt") return log.createdAt.toISOString(); + return log[field] ?? ""; +}; + +const toDelimited = (logs: AuditLog[], delimiter: string) => { + const escapeCell = (field: ExportField, raw: string) => { + const value = + FORMULA_GUARDED_FIELDS.has(field) && FORMULA_PREFIX.test(raw) + ? `'${raw}` + : raw; + return value.includes(delimiter) || /["\r\n]/.test(value) + ? `"${value.replace(/"/g, '""')}"` + : value; + }; + + const rows = logs.map((log) => + EXPORT_FIELDS.map((field) => + escapeCell(field, fieldToString(log, field)), + ).join(delimiter), + ); + // BOM so Excel opens the file as UTF-8 instead of the system code page. + return `\uFEFF${[EXPORT_FIELDS.join(delimiter), ...rows].join("\r\n")}`; +}; + +const parseMetadata = (metadata: string | null) => { + if (!metadata) return null; + try { + return JSON.parse(metadata); + } catch { + return metadata; + } +}; + +const toJson = (logs: AuditLog[]) => + JSON.stringify( + logs.map((log) => ({ + ...Object.fromEntries(EXPORT_FIELDS.map((field) => [field, log[field]])), + metadata: parseMetadata(log.metadata), + })), + null, + 2, + ); + +const EXPORT_FORMATS = [ + { + extension: "csv", + label: "CSV", + description: "Excel, Google Sheets, Numbers", + icon: FileSpreadsheet, + mimeType: "text/csv;charset=utf-8", + build: (logs: AuditLog[]) => toDelimited(logs, ","), + }, + { + extension: "tsv", + label: "TSV", + description: "Tab-separated values", + icon: FileText, + mimeType: "text/tab-separated-values;charset=utf-8", + build: (logs: AuditLog[]) => toDelimited(logs, "\t"), + }, + { + extension: "json", + label: "JSON", + description: "Full records with parsed metadata", + icon: FileJson, + mimeType: "application/json", + build: toJson, + }, +]; + +type ExportFormat = (typeof EXPORT_FORMATS)[number]; + +const downloadFile = (content: string, filename: string, mimeType: string) => { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +}; + +interface ExportAuditLogsProps { + filters: AuditLogQueryFilters; + total: number; + exportLimit?: number; +} + +export function ExportAuditLogs({ + filters, + total, + exportLimit, +}: ExportAuditLogsProps) { + const utils = api.useUtils(); + const [isExporting, setIsExporting] = React.useState(false); + const hasFilters = Object.values(filters).some( + (value) => value !== undefined, + ); + const isCapped = exportLimit !== undefined && total > exportLimit; + const exportCount = isCapped ? exportLimit : total; + const scopeNote = isCapped + ? `Only the newest ${exportCount.toLocaleString()} of ${total.toLocaleString()} entries are included. Narrow the filters or date range to export older ones.` + : hasFilters + ? "Only entries matching your current filters are included." + : "No filters applied, so all entries are included."; + + const handleExport = async (exportFormat: ExportFormat) => { + setIsExporting(true); + try { + const { logs, total: matching } = await utils.auditLog.export.fetch( + filters, + { staleTime: 0, gcTime: 0 }, + ); + downloadFile( + exportFormat.build(logs), + `audit-logs-${format(new Date(), "yyyyMMdd-HHmmss")}.${exportFormat.extension}`, + exportFormat.mimeType, + ); + if (logs.length < matching) { + toast.warning( + `Exported the newest ${logs.length.toLocaleString()} of ${matching.toLocaleString()} entries`, + { + description: + "Narrow the filters or date range to export older entries.", + }, + ); + } else { + toast.success( + `Exported ${logs.length.toLocaleString()} ${logs.length === 1 ? "entry" : "entries"}`, + ); + } + } catch (error) { + toast.error("Failed to export audit logs", { + description: error instanceof Error ? error.message : undefined, + }); + } finally { + setIsExporting(false); + } + }; + + return ( + + + + + + + + Export {exportCount.toLocaleString()}{" "} + {exportCount === 1 ? "entry" : "entries"} + + {scopeNote} + + + {EXPORT_FORMATS.map((exportFormat) => ( + handleExport(exportFormat)} + className="items-start gap-2 py-1.5" + > + +
+ {exportFormat.label} + + {exportFormat.description} + +
+
+ ))} +
+
+ ); +} diff --git a/apps/dokploy/components/proprietary/audit-logs/show-audit-logs.tsx b/apps/dokploy/components/proprietary/audit-logs/show-audit-logs.tsx index 7f185149363..d51eee4bd16 100644 --- a/apps/dokploy/components/proprietary/audit-logs/show-audit-logs.tsx +++ b/apps/dokploy/components/proprietary/audit-logs/show-audit-logs.tsx @@ -11,6 +11,7 @@ import { import { api } from "@/utils/api"; import { columns } from "./columns"; import { type AuditLogFilters, DataTable } from "./data-table"; +import type { AuditLogQueryFilters } from "./export-audit-logs"; function AuditLogsContent() { const [pageIndex, setPageIndex] = React.useState(0); @@ -54,13 +55,17 @@ function AuditLogsContent() { setPageIndex(0); }; - const { data, isLoading } = api.auditLog.all.useQuery({ + const appliedFilters: AuditLogQueryFilters = { userEmail: debouncedText.userEmail || undefined, resourceName: debouncedText.resourceName || undefined, action: filters.action || undefined, resourceType: filters.resourceType || undefined, from: filters.dateRange?.from, to: filters.dateRange?.to, + }; + + const { data, isLoading } = api.auditLog.all.useQuery({ + ...appliedFilters, limit: pageSize, offset: pageIndex * pageSize, }); @@ -70,9 +75,11 @@ function AuditLogsContent() { columns={columns} data={data?.logs ?? []} total={data?.total ?? 0} + exportLimit={data?.exportLimit} pageIndex={pageIndex} pageSize={pageSize} filters={filters} + appliedFilters={appliedFilters} onPageChange={setPageIndex} onPageSizeChange={handlePageSizeChange} onFilterChange={handleFilterChange} diff --git a/apps/dokploy/server/api/routers/proprietary/audit-log.ts b/apps/dokploy/server/api/routers/proprietary/audit-log.ts index 18c223a70e0..adc285ab26e 100644 --- a/apps/dokploy/server/api/routers/proprietary/audit-log.ts +++ b/apps/dokploy/server/api/routers/proprietary/audit-log.ts @@ -4,65 +4,83 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createTRPCRouter, withPermission } from "../../trpc"; +const MAX_EXPORT_ROWS = 10_000; + +const auditLogFiltersSchema = z.object({ + userId: z.string().optional(), + userEmail: z.string().optional(), + resourceName: z.string().optional(), + action: z + .enum([ + "create", + "update", + "delete", + "deploy", + "cancel", + "redeploy", + "login", + "logout", + "download", + ]) + .optional(), + resourceType: z + .enum([ + "project", + "service", + "environment", + "deployment", + "user", + "customRole", + "domain", + "certificate", + "registry", + "server", + "sshKey", + "gitProvider", + "notification", + "settings", + "session", + ]) + .optional(), + from: z.date().optional(), + to: z.date().optional(), +}); + +const licensedAuditLogProcedure = withPermission("auditLog", "read").use( + async ({ ctx, next }) => { + const licensed = await hasValidLicense(ctx.session.activeOrganizationId); + if (!licensed) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Valid enterprise license required", + }); + } + return next(); + }, +); + export const auditLogRouter = createTRPCRouter({ - all: withPermission("auditLog", "read") - .use(async ({ ctx, next }) => { - const licensed = await hasValidLicense(ctx.session.activeOrganizationId); - if (!licensed) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Valid enterprise license required", - }); - } - return next(); - }) + all: licensedAuditLogProcedure .input( - z.object({ - userId: z.string().optional(), - userEmail: z.string().optional(), - resourceName: z.string().optional(), - action: z - .enum([ - "create", - "update", - "delete", - "deploy", - "cancel", - "redeploy", - "login", - "logout", - "download", - ]) - .optional(), - resourceType: z - .enum([ - "project", - "service", - "environment", - "deployment", - "user", - "customRole", - "domain", - "certificate", - "registry", - "server", - "sshKey", - "gitProvider", - "notification", - "settings", - "session", - ]) - .optional(), - from: z.date().optional(), - to: z.date().optional(), + auditLogFiltersSchema.extend({ limit: z.number().min(1).max(500).default(50), offset: z.number().min(0).default(0), }), ) + .query(async ({ ctx, input }) => { + const result = await getAuditLogs({ + organizationId: ctx.session.activeOrganizationId, + ...input, + }); + return { ...result, exportLimit: MAX_EXPORT_ROWS }; + }), + export: licensedAuditLogProcedure + .input(auditLogFiltersSchema) .query(async ({ ctx, input }) => { return getAuditLogs({ organizationId: ctx.session.activeOrganizationId, ...input, + limit: MAX_EXPORT_ROWS, }); }), });