feat(monitoring): add date range filtering and log cleanup scheduling

- Implement date range filtering for access logs and request statistics
- Add log cleanup scheduling with configurable cron expression
- Update UI components to support date range selection
- Refactor log processing and parsing to handle date filtering
- Add new database migration for log cleanup cron configuration
- Remove deprecated log rotation management logic
This commit is contained in:
Mauricio Siu
2025-03-08 14:20:27 -06:00
parent b64ddf1119
commit 673e0a6880
15 changed files with 5620 additions and 202 deletions

View File

@@ -1,10 +1,10 @@
import { api } from "@/utils/api";
import { import {
type ChartConfig, type ChartConfig,
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from "@/components/ui/chart"; } from "@/components/ui/chart";
import { api } from "@/utils/api";
import { import {
Area, Area,
AreaChart, AreaChart,
@@ -14,6 +14,13 @@ import {
YAxis, YAxis,
} from "recharts"; } from "recharts";
export interface RequestDistributionChartProps {
dateRange?: {
from: Date | undefined;
to: Date | undefined;
};
}
const chartConfig = { const chartConfig = {
views: { views: {
label: "Page Views", label: "Page Views",
@@ -24,10 +31,22 @@ const chartConfig = {
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
export const RequestDistributionChart = () => { export const RequestDistributionChart = ({
const { data: stats } = api.settings.readStats.useQuery(undefined, { dateRange,
}: RequestDistributionChartProps) => {
const { data: stats } = api.settings.readStats.useQuery(
{
dateRange: dateRange
? {
start: dateRange.from?.toISOString(),
end: dateRange.to?.toISOString(),
}
: undefined,
},
{
refetchInterval: 1333, refetchInterval: 1333,
}); },
);
return ( return (
<ResponsiveContainer width="100%" height={200}> <ResponsiveContainer width="100%" height={200}>

View File

@@ -79,7 +79,15 @@ export const priorities = [
icon: Server, icon: Server,
}, },
]; ];
export const RequestsTable = () => {
export interface RequestsTableProps {
dateRange?: {
from: Date | undefined;
to: Date | undefined;
};
}
export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
const [statusFilter, setStatusFilter] = useState<string[]>([]); const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedRow, setSelectedRow] = useState<LogEntry>(); const [selectedRow, setSelectedRow] = useState<LogEntry>();
@@ -98,6 +106,12 @@ export const RequestsTable = () => {
page: pagination, page: pagination,
search, search,
status: statusFilter, status: statusFilter,
dateRange: dateRange
? {
start: dateRange.from?.toISOString(),
end: dateRange.to?.toISOString(),
}
: undefined,
}, },
{ {
refetchInterval: 1333, refetchInterval: 1333,

View File

@@ -1,6 +1,7 @@
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { import {
Card, Card,
CardContent, CardContent,
@@ -8,9 +9,29 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { type RouterOutputs, api } from "@/utils/api"; import { type RouterOutputs, api } from "@/utils/api";
import { ArrowDownUp } from "lucide-react"; import { format } from "date-fns";
import {
ArrowDownUp,
AlertCircle,
InfoIcon,
Calendar as CalendarIcon,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { RequestDistributionChart } from "./request-distribution-chart"; import { RequestDistributionChart } from "./request-distribution-chart";
import { RequestsTable } from "./requests-table"; import { RequestsTable } from "./requests-table";
@@ -20,17 +41,30 @@ export type LogEntry = NonNullable<
>[0]; >[0];
export const ShowRequests = () => { export const ShowRequests = () => {
const { data: isLogRotateActive, refetch: refetchLogRotate } =
api.settings.getLogRotateStatus.useQuery();
const { mutateAsync: toggleLogRotate } =
api.settings.toggleLogRotate.useMutation();
const { data: isActive, refetch } = const { data: isActive, refetch } =
api.settings.haveActivateRequests.useQuery(); api.settings.haveActivateRequests.useQuery();
const { mutateAsync: toggleRequests } = const { mutateAsync: toggleRequests } =
api.settings.toggleRequests.useMutation(); api.settings.toggleRequests.useMutation();
const { data: logCleanupStatus } =
api.settings.getLogCleanupStatus.useQuery();
const { mutateAsync: updateLogCleanup } =
api.settings.updateLogCleanup.useMutation();
const [cronExpression, setCronExpression] = useState<string | null>(null);
const [dateRange, setDateRange] = useState<{
from: Date | undefined;
to: Date | undefined;
}>({
from: undefined,
to: undefined,
});
useEffect(() => {
if (logCleanupStatus) {
setCronExpression(logCleanupStatus.cronExpression || "0 0 * * *");
}
}, [logCleanupStatus]);
return ( return (
<> <>
<div className="w-full"> <div className="w-full">
@@ -57,7 +91,60 @@ export const ShowRequests = () => {
</AlertBlock> </AlertBlock>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 py-8 border-t"> <CardContent className="space-y-2 py-8 border-t">
<div className="flex w-full gap-4 justify-end"> <div className="flex w-full gap-4 justify-end items-center">
<div className="flex-1 flex items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="cron" className="min-w-32">
Log Cleanup Schedule
</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p className="max-w-80">
At the scheduled time, the cleanup job will keep
only the last 1000 entries in the access log file
and signal Traefik to reopen its log files. The
default schedule is daily at midnight (0 0 * * *).
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex-1 flex gap-4">
<Input
id="cron"
placeholder="0 0 * * *"
value={cronExpression || ""}
onChange={(e) => setCronExpression(e.target.value)}
className="max-w-60"
required
/>
<Button
variant="outline"
onClick={async () => {
if (!cronExpression?.trim()) {
toast.error("Please enter a valid cron expression");
return;
}
try {
await updateLogCleanup({
cronExpression: cronExpression,
});
toast.success("Log cleanup schedule updated");
} catch (error) {
toast.error(
`Failed to update log cleanup schedule: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}}
>
Update Schedule
</Button>
</div>
</div>
<DialogAction <DialogAction
title={isActive ? "Deactivate Requests" : "Activate Requests"} title={isActive ? "Deactivate Requests" : "Activate Requests"}
description="You will also need to restart Traefik to apply the changes" description="You will also need to restart Traefik to apply the changes"
@@ -77,53 +164,81 @@ export const ShowRequests = () => {
> >
<Button>{isActive ? "Deactivate" : "Activate"}</Button> <Button>{isActive ? "Deactivate" : "Activate"}</Button>
</DialogAction> </DialogAction>
</div>
<DialogAction {isActive ? (
title={ <>
isLogRotateActive <div className="flex justify-end mb-4 gap-2">
? "Activate Log Rotate" {(dateRange.from || dateRange.to) && (
: "Deactivate Log Rotate" <Button
variant="outline"
onClick={() =>
setDateRange({ from: undefined, to: undefined })
} }
description={ className="px-3"
isLogRotateActive >
? "This will make the logs rotate on interval 1 day and maximum size of 100 MB and maximum 6 logs" Clear dates
: "The log rotation will be disabled" </Button>
} )}
onClick={() => { <Popover>
toggleLogRotate({ <PopoverTrigger asChild>
enable: !isLogRotateActive, <Button
}) variant="outline"
.then(() => { className="w-[300px] justify-start text-left font-normal"
toast.success( >
`Log rotate ${isLogRotateActive ? "activated" : "deactivated"}`, <CalendarIcon className="mr-2 h-4 w-4" />
); {dateRange.from ? (
refetchLogRotate(); dateRange.to ? (
}) <>
.catch((err) => { {format(dateRange.from, "LLL dd, y")} -{" "}
toast.error(err.message); {format(dateRange.to, "LLL dd, y")}
</>
) : (
format(dateRange.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange.from}
selected={{
from: dateRange.from,
to: dateRange.to,
}}
onSelect={(range) => {
setDateRange({
from: range?.from,
to: range?.to,
}); });
}} }}
> numberOfMonths={2}
<Button variant="secondary"> />
{isLogRotateActive </PopoverContent>
? "Activate Log Rotate" </Popover>
: "Deactivate Log Rotate"}
</Button>
</DialogAction>
</div> </div>
<RequestDistributionChart dateRange={dateRange} />
<div> <RequestsTable dateRange={dateRange} />
{isActive ? ( </>
<RequestDistributionChart />
) : ( ) : (
<div className="flex items-center justify-center min-h-[25vh]"> <div className="flex flex-col items-center justify-center py-12 gap-4 text-muted-foreground">
<span className="text-muted-foreground py-6"> <AlertCircle className="size-12 text-muted-foreground/50" />
You need to activate requests <div className="text-center space-y-2">
</span> <h3 className="text-lg font-medium">
Requests are not activated
</h3>
<p className="text-sm max-w-md">
Activate requests to see incoming traffic statistics and
monitor your application's usage. After activation, you'll
need to reload Traefik for the changes to take effect.
</p>
</div>
</div> </div>
)} )}
{isActive && <RequestsTable />}
</div>
</CardContent> </CardContent>
</div> </div>
</Card> </Card>

View File

@@ -0,0 +1,68 @@
import type * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100",
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -0,0 +1 @@
ALTER TABLE "user_temp" ADD COLUMN "logCleanupCron" text;

File diff suppressed because it is too large Load Diff

View File

@@ -498,6 +498,13 @@
"when": 1741322697251, "when": 1741322697251,
"tag": "0070_useful_serpent_society", "tag": "0070_useful_serpent_society",
"breakpoints": true "breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1741460060541,
"tag": "0071_flaky_black_queen",
"breakpoints": true
} }
] ]
} }

View File

@@ -28,7 +28,6 @@ import {
getDokployImageTag, getDokployImageTag,
getUpdateData, getUpdateData,
initializeTraefik, initializeTraefik,
logRotationManager,
parseRawConfig, parseRawConfig,
paths, paths,
prepareEnvironmentVariables, prepareEnvironmentVariables,
@@ -53,6 +52,9 @@ import {
writeConfig, writeConfig,
writeMainConfig, writeMainConfig,
writeTraefikConfigInPath, writeTraefikConfigInPath,
startLogCleanup,
stopLogCleanup,
getLogCleanupStatus,
} from "@dokploy/server"; } from "@dokploy/server";
import { checkGPUStatus, setupGPUSupport } from "@dokploy/server"; import { checkGPUStatus, setupGPUSupport } from "@dokploy/server";
import { generateOpenApiDocument } from "@dokploy/trpc-openapi"; import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
@@ -577,49 +579,44 @@ export const settingsRouter = createTRPCRouter({
totalCount: 0, totalCount: 0,
}; };
} }
const rawConfig = readMonitoringConfig(); const rawConfig = readMonitoringConfig(
!!input.dateRange?.start && !!input.dateRange?.end,
);
const parsedConfig = parseRawConfig( const parsedConfig = parseRawConfig(
rawConfig as string, rawConfig as string,
input.page, input.page,
input.sort, input.sort,
input.search, input.search,
input.status, input.status,
input.dateRange,
); );
return parsedConfig; return parsedConfig;
}), }),
readStats: adminProcedure.query(() => { readStats: adminProcedure
.input(
z
.object({
dateRange: z
.object({
start: z.string().optional(),
end: z.string().optional(),
})
.optional(),
})
.optional(),
)
.query(({ input }) => {
if (IS_CLOUD) { if (IS_CLOUD) {
return []; return [];
} }
const rawConfig = readMonitoringConfig(); const rawConfig = readMonitoringConfig(
const processedLogs = processLogs(rawConfig as string); !!input?.dateRange?.start || !!input?.dateRange?.end,
);
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
return processedLogs || []; return processedLogs || [];
}), }),
getLogRotateStatus: adminProcedure.query(async () => {
if (IS_CLOUD) {
return true;
}
return await logRotationManager.getStatus();
}),
toggleLogRotate: adminProcedure
.input(
z.object({
enable: z.boolean(),
}),
)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
return true;
}
if (input.enable) {
await logRotationManager.activate();
} else {
await logRotationManager.deactivate();
}
return true;
}),
haveActivateRequests: adminProcedure.query(async () => { haveActivateRequests: adminProcedure.query(async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
return true; return true;
@@ -820,10 +817,20 @@ export const settingsRouter = createTRPCRouter({
}); });
} }
}), }),
updateLogCleanup: adminProcedure
.input(
z.object({
cronExpression: z.string().nullable(),
}),
)
.mutation(async ({ input }) => {
if (input.cronExpression) {
return startLogCleanup(input.cronExpression);
}
return stopLogCleanup();
}),
getLogCleanupStatus: adminProcedure.query(async () => {
return getLogCleanupStatus();
}),
}); });
// {
// "Parallelism": 1,
// "Delay": 10000000000,
// "FailureAction": "rollback",
// "Order": "start-first"
// }

View File

@@ -53,7 +53,7 @@ export const users_temp = pgTable("user_temp", {
letsEncryptEmail: text("letsEncryptEmail"), letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"), sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false), enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
enableLogRotation: boolean("enableLogRotation").notNull().default(false), logCleanupCron: text("logCleanupCron"),
// Metrics // Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false), enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
metricsConfig: jsonb("metricsConfig") metricsConfig: jsonb("metricsConfig")
@@ -250,6 +250,12 @@ export const apiReadStatsLogs = z.object({
status: z.string().array().optional(), status: z.string().array().optional(),
search: z.string().optional(), search: z.string().optional(),
sort: z.object({ id: z.string(), desc: z.boolean() }).optional(), sort: z.object({ id: z.string(), desc: z.boolean() }).optional(),
dateRange: z
.object({
start: z.string().optional(),
end: z.string().optional(),
})
.optional(),
}); });
export const apiUpdateWebServerMonitoring = z.object({ export const apiUpdateWebServerMonitoring = z.object({
@@ -305,4 +311,5 @@ export const apiUpdateUser = createSchema.partial().extend({
}), }),
}) })
.optional(), .optional(),
logCleanupCron: z.string().optional().nullable(),
}); });

View File

@@ -116,3 +116,9 @@ export * from "./db/validations/index";
export * from "./utils/gpu-setup"; export * from "./utils/gpu-setup";
export * from "./lib/auth"; export * from "./lib/auth";
export {
startLogCleanup,
stopLogCleanup,
getLogCleanupStatus,
} from "./utils/access-log/handler";

View File

@@ -1,121 +1,77 @@
import { IS_CLOUD, paths } from "@dokploy/server/constants"; import { paths } from "@dokploy/server/constants";
import { type RotatingFileStream, createStream } from "rotating-file-stream";
import { execAsync } from "../process/execAsync"; import { execAsync } from "../process/execAsync";
import { findAdmin } from "@dokploy/server/services/admin"; import { findAdmin } from "@dokploy/server/services/admin";
import { updateUser } from "@dokploy/server/services/user"; import { updateUser } from "@dokploy/server/services/user";
import { scheduleJob, scheduledJobs } from "node-schedule";
class LogRotationManager { const LOG_CLEANUP_JOB_NAME = "access-log-cleanup";
private static instance: LogRotationManager;
private stream: RotatingFileStream | null = null;
private constructor() { export const startLogCleanup = async (
if (IS_CLOUD) { cronExpression = "0 0 * * *",
return; ): Promise<boolean> => {
}
this.initialize().catch(console.error);
}
public static getInstance(): LogRotationManager {
if (!LogRotationManager.instance) {
LogRotationManager.instance = new LogRotationManager();
}
return LogRotationManager.instance;
}
private async initialize(): Promise<void> {
const isActive = await this.getStateFromDB();
if (isActive) {
await this.activateStream();
}
}
private async getStateFromDB(): Promise<boolean> {
const admin = await findAdmin();
return admin?.user.enableLogRotation ?? false;
}
private async setStateInDB(active: boolean): Promise<void> {
const admin = await findAdmin();
if (!admin) {
return;
}
await updateUser(admin.user.id, {
enableLogRotation: active,
});
}
private async activateStream(): Promise<void> {
const { DYNAMIC_TRAEFIK_PATH } = paths();
if (this.stream) {
await this.deactivateStream();
}
this.stream = createStream("access.log", {
size: "100M",
interval: "1d",
path: DYNAMIC_TRAEFIK_PATH,
rotate: 6,
compress: "gzip",
});
this.stream.on("rotation", this.handleRotation.bind(this));
}
private async deactivateStream(): Promise<void> {
return new Promise<void>((resolve) => {
if (this.stream) {
this.stream.end(() => {
this.stream = null;
resolve();
});
} else {
resolve();
}
});
}
public async activate(): Promise<boolean> {
const currentState = await this.getStateFromDB();
if (currentState) {
return true;
}
await this.setStateInDB(true);
await this.activateStream();
return true;
}
public async deactivate(): Promise<boolean> {
console.log("Deactivating log rotation...");
const currentState = await this.getStateFromDB();
if (!currentState) {
console.log("Log rotation is already inactive in DB");
return true;
}
await this.setStateInDB(false);
await this.deactivateStream();
console.log("Log rotation deactivated successfully");
return true;
}
private async handleRotation() {
try { try {
const status = await this.getStatus(); const { DYNAMIC_TRAEFIK_PATH } = paths();
if (!status) {
await this.deactivateStream(); const existingJob = scheduledJobs[LOG_CLEANUP_JOB_NAME];
if (existingJob) {
existingJob.cancel();
} }
scheduleJob(LOG_CLEANUP_JOB_NAME, cronExpression, async () => {
try {
await execAsync( await execAsync(
"docker kill -s USR1 $(docker ps -q --filter name=dokploy-traefik)", `tail -n 1000 ${DYNAMIC_TRAEFIK_PATH}/access.log > ${DYNAMIC_TRAEFIK_PATH}/access.log.tmp && mv ${DYNAMIC_TRAEFIK_PATH}/access.log.tmp ${DYNAMIC_TRAEFIK_PATH}/access.log`,
); );
console.log("USR1 Signal send to Traefik");
await execAsync("docker exec dokploy-traefik kill -USR1 1");
} catch (error) { } catch (error) {
console.error("Error sending USR1 Signal to Traefik:", error); console.error("Error during log cleanup:", error);
} }
});
const admin = await findAdmin();
if (admin) {
await updateUser(admin.user.id, {
logCleanupCron: cronExpression,
});
} }
public async getStatus(): Promise<boolean> {
const dbState = await this.getStateFromDB(); return true;
return dbState; } catch (_) {
return false;
} }
} };
export const logRotationManager = LogRotationManager.getInstance();
export const stopLogCleanup = async (): Promise<boolean> => {
try {
const existingJob = scheduledJobs[LOG_CLEANUP_JOB_NAME];
if (existingJob) {
existingJob.cancel();
}
// Update database
const admin = await findAdmin();
if (admin) {
await updateUser(admin.user.id, {
logCleanupCron: null,
});
}
return true;
} catch (error) {
console.error("Error stopping log cleanup:", error);
return false;
}
};
export const getLogCleanupStatus = async (): Promise<{
enabled: boolean;
cronExpression: string | null;
}> => {
const admin = await findAdmin();
const cronExpression = admin?.user.logCleanupCron ?? null;
return {
enabled: cronExpression !== null,
cronExpression,
};
};

View File

@@ -6,14 +6,21 @@ interface HourlyData {
count: number; count: number;
} }
export function processLogs(logString: string): HourlyData[] { export function processLogs(
logString: string,
dateRange?: { start?: string; end?: string },
): HourlyData[] {
if (_.isEmpty(logString)) { if (_.isEmpty(logString)) {
return []; return [];
} }
const hourlyData = _(logString) const hourlyData = _(logString)
.split("\n") .split("\n")
.compact() .filter((line) => {
const trimmed = line.trim();
// Check if the line starts with { and ends with } to ensure it's a potential JSON object
return trimmed !== "" && trimmed.startsWith("{") && trimmed.endsWith("}");
})
.map((entry) => { .map((entry) => {
try { try {
const log: LogEntry = JSON.parse(entry); const log: LogEntry = JSON.parse(entry);
@@ -21,6 +28,20 @@ export function processLogs(logString: string): HourlyData[] {
return null; return null;
} }
const date = new Date(log.StartUTC); const date = new Date(log.StartUTC);
if (dateRange?.start || dateRange?.end) {
const logDate = date.getTime();
const start = dateRange?.start
? new Date(dateRange.start).getTime()
: 0;
const end = dateRange?.end
? new Date(dateRange.end).getTime()
: Number.POSITIVE_INFINITY;
if (logDate < start || logDate > end) {
return null;
}
}
return `${date.toISOString().slice(0, 13)}:00:00Z`; return `${date.toISOString().slice(0, 13)}:00:00Z`;
} catch (error) { } catch (error) {
console.error("Error parsing log entry:", error); console.error("Error parsing log entry:", error);
@@ -51,21 +72,46 @@ export function parseRawConfig(
sort?: SortInfo, sort?: SortInfo,
search?: string, search?: string,
status?: string[], status?: string[],
dateRange?: { start?: string; end?: string },
): { data: LogEntry[]; totalCount: number } { ): { data: LogEntry[]; totalCount: number } {
try { try {
if (_.isEmpty(rawConfig)) { if (_.isEmpty(rawConfig)) {
return { data: [], totalCount: 0 }; return { data: [], totalCount: 0 };
} }
// Split logs into chunks to avoid memory issues
let parsedLogs = _(rawConfig) let parsedLogs = _(rawConfig)
.split("\n") .split("\n")
.filter((line) => {
const trimmed = line.trim();
return (
trimmed !== "" && trimmed.startsWith("{") && trimmed.endsWith("}")
);
})
.map((line) => {
try {
return JSON.parse(line) as LogEntry;
} catch (error) {
console.error("Error parsing log line:", error);
return null;
}
})
.compact() .compact()
.map((line) => JSON.parse(line) as LogEntry)
.value(); .value();
parsedLogs = parsedLogs.filter( // Apply date range filter if provided
(log) => log.ServiceName !== "dokploy-service-app@file", if (dateRange?.start || dateRange?.end) {
); parsedLogs = parsedLogs.filter((log) => {
const logDate = new Date(log.StartUTC).getTime();
const start = dateRange?.start
? new Date(dateRange.start).getTime()
: 0;
const end = dateRange?.end
? new Date(dateRange.end).getTime()
: Number.POSITIVE_INFINITY;
return logDate >= start && logDate <= end;
});
}
if (search) { if (search) {
parsedLogs = parsedLogs.filter((log) => parsedLogs = parsedLogs.filter((log) =>
@@ -78,6 +124,7 @@ export function parseRawConfig(
status.some((range) => isStatusInRange(log.DownstreamStatus, range)), status.some((range) => isStatusInRange(log.DownstreamStatus, range)),
); );
} }
const totalCount = parsedLogs.length; const totalCount = parsedLogs.length;
if (sort) { if (sort) {
@@ -101,6 +148,7 @@ export function parseRawConfig(
throw new Error("Failed to parse rawConfig"); throw new Error("Failed to parse rawConfig");
} }
} }
const isStatusInRange = (status: number, range: string) => { const isStatusInRange = (status: number, range: string) => {
switch (range) { switch (range) {
case "info": case "info":

View File

@@ -12,6 +12,7 @@ import { runMongoBackup } from "./mongo";
import { runMySqlBackup } from "./mysql"; import { runMySqlBackup } from "./mysql";
import { runPostgresBackup } from "./postgres"; import { runPostgresBackup } from "./postgres";
import { findAdmin } from "../../services/admin"; import { findAdmin } from "../../services/admin";
import { startLogCleanup } from "../access-log/handler";
export const initCronJobs = async () => { export const initCronJobs = async () => {
console.log("Setting up cron jobs...."); console.log("Setting up cron jobs....");
@@ -168,4 +169,8 @@ export const initCronJobs = async () => {
} }
} }
} }
if (admin?.user.logCleanupCron) {
await startLogCleanup(admin.user.logCleanupCron);
}
}; };

View File

@@ -137,12 +137,44 @@ export const readRemoteConfig = async (serverId: string, appName: string) => {
} }
}; };
export const readMonitoringConfig = () => { export const readMonitoringConfig = (readAll = false) => {
const { DYNAMIC_TRAEFIK_PATH } = paths(); const { DYNAMIC_TRAEFIK_PATH } = paths();
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, "access.log"); const configPath = path.join(DYNAMIC_TRAEFIK_PATH, "access.log");
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const yamlStr = fs.readFileSync(configPath, "utf8"); if (!readAll) {
return yamlStr; // Read first 500 lines
let content = "";
let chunk = "";
let validCount = 0;
for (const char of fs.readFileSync(configPath, "utf8")) {
chunk += char;
if (char === "\n") {
try {
const trimmed = chunk.trim();
if (
trimmed !== "" &&
trimmed.startsWith("{") &&
trimmed.endsWith("}")
) {
const log = JSON.parse(trimmed);
if (log.ServiceName !== "dokploy-service-app@file") {
content += chunk;
validCount++;
if (validCount >= 500) {
break;
}
}
}
} catch {
// Ignore invalid JSON
}
chunk = "";
}
}
return content;
}
return fs.readFileSync(configPath, "utf8");
} }
return null; return null;
}; };

1
pnpm-lock.yaml generated
View File

@@ -6282,6 +6282,7 @@ packages:
oslo@1.2.0: oslo@1.2.0:
resolution: {integrity: sha512-OoFX6rDsNcOQVAD2gQD/z03u4vEjWZLzJtwkmgfRF+KpQUXwdgEXErD7zNhyowmHwHefP+PM9Pw13pgpHMRlzw==} resolution: {integrity: sha512-OoFX6rDsNcOQVAD2gQD/z03u4vEjWZLzJtwkmgfRF+KpQUXwdgEXErD7zNhyowmHwHefP+PM9Pw13pgpHMRlzw==}
deprecated: Package is no longer supported. Please see https://oslojs.dev for the successor project.
otpauth@9.3.4: otpauth@9.3.4:
resolution: {integrity: sha512-qXv+lpsCUO9ewitLYfeDKbLYt7UUCivnU/fwGK2OqhgrCBsRkTUNKWsgKAhkXG3aistOY+jEeuL90JEBu6W3mQ==} resolution: {integrity: sha512-qXv+lpsCUO9ewitLYfeDKbLYt7UUCivnU/fwGK2OqhgrCBsRkTUNKWsgKAhkXG3aistOY+jEeuL90JEBu6W3mQ==}