feat: add log rotation manager

This commit is contained in:
Mauricio Siu
2024-08-24 22:35:38 -06:00
parent 7b9abef687
commit 67ae2b19df
12 changed files with 3377 additions and 107 deletions

View File

@@ -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}>

View File

@@ -0,0 +1 @@
ALTER TABLE "admin" ADD COLUMN "enableLogRotation" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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"]
}
}

View File

@@ -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");
}
}

View File

@@ -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" }),

View File

@@ -39,6 +39,7 @@ void app.prepare().then(async () => {
setupDeploymentLogsWebSocketServer(server);
setupDockerContainerLogsWebSocketServer(server);
setupDockerContainerTerminalWebSocketServer(server);
// setupTraefikLogsWebSocketServer(server);
setupTerminalWebSocketServer(server);
setupDockerStatsMonitoringSocketServer(server);

View 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();

View 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;
}

View 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");
}
}