mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: add log rotation manager
This commit is contained in:
@@ -64,6 +64,8 @@ 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 } from "react";
|
||||
|
||||
export type LogEntry = NonNullable<
|
||||
RouterOutputs["settings"]["readMonitoringConfig"]["data"]
|
||||
@@ -79,7 +81,15 @@ const chartConfig = {
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
export const ShowRequests = () => {
|
||||
const { data } = api.settings.readMonitoringConfig.useQuery();
|
||||
const { data } = api.settings.readMonitoringConfig.useQuery(undefined, {
|
||||
refetchInterval: 1000,
|
||||
});
|
||||
const { data: isLogRotateActive, refetch: refetchLogRotate } =
|
||||
api.settings.getLogRotateStatus.useQuery();
|
||||
|
||||
const { mutateAsync } = api.settings.activateLogRotate.useMutation();
|
||||
const { mutateAsync: deactivateLogRotate } =
|
||||
api.settings.deactivateLogRotate.useMutation();
|
||||
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
@@ -112,9 +122,44 @@ export const ShowRequests = () => {
|
||||
<Card className="bg-transparent mt-10">
|
||||
<CardHeader>
|
||||
<CardTitle>Request Distribution</CardTitle>
|
||||
<CardDescription>
|
||||
Showing web and API requests over time
|
||||
</CardDescription>
|
||||
<div className="flex justify-between gap-2">
|
||||
<CardDescription>
|
||||
<span>Showing web and API requests over time</span>
|
||||
</CardDescription>
|
||||
{!isLogRotateActive && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
mutateAsync()
|
||||
.then(() => {
|
||||
toast.success("Log rotate activated");
|
||||
refetchLogRotate();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Activate Log Rotate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isLogRotateActive && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
deactivateLogRotate()
|
||||
.then(() => {
|
||||
toast.success("Log rotate deactivated");
|
||||
refetchLogRotate();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Deactivate Log Rotate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
|
||||
1
apps/dokploy/drizzle/0033_sweet_black_bird.sql
Normal file
1
apps/dokploy/drizzle/0033_sweet_black_bird.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "admin" ADD COLUMN "enableLogRotation" boolean DEFAULT false NOT NULL;
|
||||
3078
apps/dokploy/drizzle/meta/0033_snapshot.json
Normal file
3078
apps/dokploy/drizzle/meta/0033_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -232,6 +232,13 @@
|
||||
"when": 1723705257806,
|
||||
"tag": "0032_flashy_shadow_king",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "6",
|
||||
"when": 1724555040199,
|
||||
"tag": "0033_sweet_black_bird",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
"test": "vitest --config __test__/vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"rotating-file-stream": "3.2.3",
|
||||
"@aws-sdk/client-s3": "3.515.0",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-yaml": "^6.1.1",
|
||||
@@ -180,8 +181,6 @@
|
||||
]
|
||||
},
|
||||
"commitlint": {
|
||||
"extends": [
|
||||
"@commitlint/config-conventional"
|
||||
]
|
||||
"extends": ["@commitlint/config-conventional"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MAIN_TRAEFIK_PATH, MONITORING_PATH, docker } from "@/server/constants";
|
||||
import { MAIN_TRAEFIK_PATH, MONITORING_PATH } from "@/server/constants";
|
||||
import {
|
||||
apiAssignDomain,
|
||||
apiEnableDashboard,
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
} from "../services/settings";
|
||||
import { canAccessToTraefikFiles } from "../services/user";
|
||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { parseRawConfig, processLogs } from "@/server/utils/access-log/utils";
|
||||
import { logRotationManager } from "@/server/utils/access-log/handler";
|
||||
|
||||
export const settingsRouter = createTRPCRouter({
|
||||
reloadServer: adminProcedure.mutation(async () => {
|
||||
@@ -353,105 +355,18 @@ export const settingsRouter = createTRPCRouter({
|
||||
const parsedConfig = parseRawConfig(rawConfig as string);
|
||||
const processedLogs = processLogs(rawConfig as string);
|
||||
return {
|
||||
data: parsedConfig,
|
||||
hourlyData: processedLogs,
|
||||
data: parsedConfig || [],
|
||||
hourlyData: processedLogs || [],
|
||||
};
|
||||
}),
|
||||
activateLogRotate: adminProcedure.mutation(async () => {
|
||||
await logRotationManager.activate();
|
||||
return true;
|
||||
}),
|
||||
deactivateLogRotate: adminProcedure.mutation(async () => {
|
||||
return await logRotationManager.deactivate();
|
||||
}),
|
||||
getLogRotateStatus: adminProcedure.query(async () => {
|
||||
return await logRotationManager.getStatus();
|
||||
}),
|
||||
});
|
||||
interface LogEntry {
|
||||
StartUTC: string;
|
||||
// Otros campos relevantes...
|
||||
}
|
||||
|
||||
interface HourlyData {
|
||||
hour: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function processLogs(logString: string): HourlyData[] {
|
||||
const hourlyData: Record<string, number> = {};
|
||||
|
||||
const logEntries = logString.trim().split("\n");
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
ClientAddr: string;
|
||||
ClientHost: string;
|
||||
ClientPort: string;
|
||||
ClientUsername: string;
|
||||
DownstreamContentSize: number;
|
||||
DownstreamStatus: number;
|
||||
Duration: number;
|
||||
OriginContentSize: number;
|
||||
OriginDuration: number;
|
||||
OriginStatus: number;
|
||||
Overhead: number;
|
||||
RequestAddr: string;
|
||||
RequestContentSize: number;
|
||||
RequestCount: number;
|
||||
RequestHost: string;
|
||||
RequestMethod: string;
|
||||
RequestPath: string;
|
||||
RequestPort: string;
|
||||
RequestProtocol: string;
|
||||
RequestScheme: string;
|
||||
RetryAttempts: number;
|
||||
RouterName: string;
|
||||
ServiceAddr: string;
|
||||
ServiceName: string;
|
||||
ServiceURL: {
|
||||
Scheme: string;
|
||||
Opaque: string;
|
||||
User: null;
|
||||
Host: string;
|
||||
Path: string;
|
||||
RawPath: string;
|
||||
ForceQuery: boolean;
|
||||
RawQuery: string;
|
||||
Fragment: string;
|
||||
RawFragment: string;
|
||||
};
|
||||
StartLocal: string;
|
||||
StartUTC: string;
|
||||
downstream_Content_Type: string;
|
||||
entryPointName: string;
|
||||
level: string;
|
||||
msg: string;
|
||||
origin_Content_Type: string;
|
||||
request_Content_Type: string;
|
||||
request_User_Agent: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
function parseRawConfig(rawConfig: string): LogEntry[] {
|
||||
try {
|
||||
// Dividir el string en líneas y filtrar las líneas vacías
|
||||
const jsonLines = rawConfig
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
|
||||
// Parsear cada línea como un objeto JSON
|
||||
const parsedConfig = jsonLines.map((line) => JSON.parse(line) as LogEntry);
|
||||
|
||||
return parsedConfig;
|
||||
} catch (error) {
|
||||
console.error("Error parsing rawConfig:", error);
|
||||
throw new Error("Failed to parse rawConfig");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const admins = pgTable("admin", {
|
||||
letsEncryptEmail: text("letsEncryptEmail"),
|
||||
sshPrivateKey: text("sshPrivateKey"),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
||||
enableLogRotation: boolean("enableLogRotation").notNull().default(false),
|
||||
authId: text("authId")
|
||||
.notNull()
|
||||
.references(() => auth.id, { onDelete: "cascade" }),
|
||||
|
||||
@@ -39,6 +39,7 @@ void app.prepare().then(async () => {
|
||||
setupDeploymentLogsWebSocketServer(server);
|
||||
setupDockerContainerLogsWebSocketServer(server);
|
||||
setupDockerContainerTerminalWebSocketServer(server);
|
||||
// setupTraefikLogsWebSocketServer(server);
|
||||
setupTerminalWebSocketServer(server);
|
||||
setupDockerStatsMonitoringSocketServer(server);
|
||||
|
||||
|
||||
114
apps/dokploy/server/utils/access-log/handler.ts
Normal file
114
apps/dokploy/server/utils/access-log/handler.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { DYNAMIC_TRAEFIK_PATH } from "@/server/constants";
|
||||
import { createStream, type RotatingFileStream } from "rotating-file-stream";
|
||||
import { execAsync } from "../process/execAsync";
|
||||
import { findAdmin, updateAdmin } from "@/server/api/services/admin";
|
||||
|
||||
class LogRotationManager {
|
||||
private static instance: LogRotationManager;
|
||||
private stream: RotatingFileStream | null = null;
|
||||
|
||||
private constructor() {
|
||||
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();
|
||||
}
|
||||
console.log(`Log rotation initialized. Active: ${isActive}`);
|
||||
}
|
||||
|
||||
private async getStateFromDB(): Promise<boolean> {
|
||||
const setting = await findAdmin();
|
||||
return setting?.enableLogRotation ?? false;
|
||||
}
|
||||
|
||||
private async setStateInDB(active: boolean): Promise<void> {
|
||||
const admin = await findAdmin();
|
||||
await updateAdmin(admin.authId, {
|
||||
enableLogRotation: active,
|
||||
});
|
||||
}
|
||||
|
||||
private async activateStream(): Promise<void> {
|
||||
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 {
|
||||
const status = await this.getStatus();
|
||||
if (!status) {
|
||||
await this.deactivateStream();
|
||||
}
|
||||
await execAsync(
|
||||
"docker kill -s USR1 $(docker ps -q --filter name=traefik)",
|
||||
);
|
||||
console.log("USR1 Signal send to Traefik");
|
||||
} catch (error) {
|
||||
console.error("Error to send USR1 Signal to Traefik:", error);
|
||||
}
|
||||
}
|
||||
public async getStatus(): Promise<boolean> {
|
||||
const dbState = await this.getStateFromDB();
|
||||
return dbState;
|
||||
}
|
||||
}
|
||||
export const logRotationManager = LogRotationManager.getInstance();
|
||||
48
apps/dokploy/server/utils/access-log/types.ts
Normal file
48
apps/dokploy/server/utils/access-log/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export interface LogEntry {
|
||||
ClientAddr: string;
|
||||
ClientHost: string;
|
||||
ClientPort: string;
|
||||
ClientUsername: string;
|
||||
DownstreamContentSize: number;
|
||||
DownstreamStatus: number;
|
||||
Duration: number;
|
||||
OriginContentSize: number;
|
||||
OriginDuration: number;
|
||||
OriginStatus: number;
|
||||
Overhead: number;
|
||||
RequestAddr: string;
|
||||
RequestContentSize: number;
|
||||
RequestCount: number;
|
||||
RequestHost: string;
|
||||
RequestMethod: string;
|
||||
RequestPath: string;
|
||||
RequestPort: string;
|
||||
RequestProtocol: string;
|
||||
RequestScheme: string;
|
||||
RetryAttempts: number;
|
||||
RouterName: string;
|
||||
ServiceAddr: string;
|
||||
ServiceName: string;
|
||||
ServiceURL: {
|
||||
Scheme: string;
|
||||
Opaque: string;
|
||||
User: null;
|
||||
Host: string;
|
||||
Path: string;
|
||||
RawPath: string;
|
||||
ForceQuery: boolean;
|
||||
RawQuery: string;
|
||||
Fragment: string;
|
||||
RawFragment: string;
|
||||
};
|
||||
StartLocal: string;
|
||||
StartUTC: string;
|
||||
downstream_Content_Type: string;
|
||||
entryPointName: string;
|
||||
level: string;
|
||||
msg: string;
|
||||
origin_Content_Type: string;
|
||||
request_Content_Type: string;
|
||||
request_User_Agent: string;
|
||||
time: string;
|
||||
}
|
||||
52
apps/dokploy/server/utils/access-log/utils.ts
Normal file
52
apps/dokploy/server/utils/access-log/utils.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
interface LogEntry {
|
||||
StartUTC: string;
|
||||
}
|
||||
|
||||
interface HourlyData {
|
||||
hour: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function processLogs(logString: string): HourlyData[] {
|
||||
const hourlyData: Record<string, number> = {};
|
||||
|
||||
if (!logString || logString?.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const logEntries = logString.trim().split("\n");
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
export function parseRawConfig(rawConfig: string): LogEntry[] {
|
||||
try {
|
||||
if (!rawConfig || rawConfig?.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const jsonLines = rawConfig
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
|
||||
const parsedConfig = jsonLines.map((line) => JSON.parse(line) as LogEntry);
|
||||
|
||||
return parsedConfig;
|
||||
} catch (error) {
|
||||
console.error("Error parsing rawConfig:", error);
|
||||
throw new Error("Failed to parse rawConfig");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user