Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 31 additions & 22 deletions apps/dokploy/components/proprietary/audit-logs/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -118,6 +122,7 @@ interface DataTableProps {
pageIndex: number;
pageSize: number;
filters: AuditLogFilters;
appliedFilters: AuditLogQueryFilters;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
onFilterChange: <K extends keyof AuditLogFilters>(
Expand All @@ -134,6 +139,7 @@ export function DataTable({
pageIndex,
pageSize,
filters,
appliedFilters,
onPageChange,
onPageSizeChange,
onFilterChange,
Expand Down Expand Up @@ -272,28 +278,31 @@ export function DataTable({
Clear
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
className="capitalize"
checked={col.getIsVisible()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
>
{col.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="ml-auto flex items-center gap-2">
<ExportAuditLogs filters={appliedFilters} total={total} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
className="capitalize"
checked={col.getIsVisible()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
>
{col.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>

<div className="rounded-md border overflow-auto">
Expand Down
226 changes: 226 additions & 0 deletions apps/dokploy/components/proprietary/audit-logs/export-audit-logs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"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<ExportField>([
"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;
}

export function ExportAuditLogs({ filters, total }: ExportAuditLogsProps) {
const utils = api.useUtils();
const [isExporting, setIsExporting] = React.useState(false);
const hasFilters = Object.values(filters).some(
(value) => value !== undefined,
);

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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" disabled={isExporting || total === 0}>
{isExporting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Export <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuLabel className="flex flex-col gap-0.5 py-1.5">
<span className="text-sm text-foreground">
Export {total.toLocaleString()} {total === 1 ? "entry" : "entries"}
</span>
<span className="font-normal">
{hasFilters
? "Only entries matching your current filters are included."
: "No filters applied, so all entries are included."}
Comment thread
imrja8 marked this conversation as resolved.
Resolved
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{EXPORT_FORMATS.map((exportFormat) => (
<DropdownMenuItem
key={exportFormat.extension}
onSelect={() => handleExport(exportFormat)}
className="items-start gap-2 py-1.5"
>
<exportFormat.icon className="mt-0.5 text-muted-foreground" />
<div className="flex flex-col">
<span>{exportFormat.label}</span>
<span className="text-xs text-muted-foreground">
{exportFormat.description}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
});
Expand All @@ -73,6 +78,7 @@ function AuditLogsContent() {
pageIndex={pageIndex}
pageSize={pageSize}
filters={filters}
appliedFilters={appliedFilters}
onPageChange={setPageIndex}
onPageSizeChange={handlePageSizeChange}
onFilterChange={handleFilterChange}
Expand Down
Loading
Loading