feat: add health check command and tracking
This commit is contained in:
84
src/commands/admin/health.test.ts
Normal file
84
src/commands/admin/health.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { health } from "./health";
|
||||||
|
import { ChatInputCommandInteraction, Colors } from "discord.js";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
|
||||||
|
// Mock DrizzleClient
|
||||||
|
const executeMock = mock(() => Promise.resolve());
|
||||||
|
mock.module("@/lib/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: {
|
||||||
|
execute: executeMock
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock BotClient (already has lastCommandTimestamp if imported, but we might want to control it)
|
||||||
|
AuroraClient.lastCommandTimestamp = 1641481200000; // Fixed timestamp for testing
|
||||||
|
|
||||||
|
describe("Health Command", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
executeMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should execute successfully and return health embed", async () => {
|
||||||
|
const interaction = {
|
||||||
|
deferReply: mock(() => Promise.resolve()),
|
||||||
|
editReply: mock(() => Promise.resolve()),
|
||||||
|
client: {
|
||||||
|
ws: {
|
||||||
|
ping: 42
|
||||||
|
}
|
||||||
|
},
|
||||||
|
user: { id: "123", username: "testuser" },
|
||||||
|
commandName: "health"
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await health.execute(interaction);
|
||||||
|
|
||||||
|
expect(interaction.deferReply).toHaveBeenCalled();
|
||||||
|
expect(executeMock).toHaveBeenCalled();
|
||||||
|
expect(interaction.editReply).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||||
|
const embed = editReplyCall.embeds[0];
|
||||||
|
|
||||||
|
expect(embed.data.title).toBe("System Health Status");
|
||||||
|
expect(embed.data.color).toBe(Colors.Aqua);
|
||||||
|
|
||||||
|
// Check fields
|
||||||
|
const fields = embed.data.fields;
|
||||||
|
expect(fields).toBeDefined();
|
||||||
|
|
||||||
|
// Connectivity field
|
||||||
|
const connectivityField = fields.find((f: any) => f.name === "📡 Connectivity");
|
||||||
|
expect(connectivityField.value).toContain("42ms");
|
||||||
|
expect(connectivityField.value).toContain("Connected");
|
||||||
|
|
||||||
|
// Activity field
|
||||||
|
const activityField = fields.find((f: any) => f.name === "⌨️ Activity");
|
||||||
|
expect(activityField.value).toContain("R>"); // Relative Discord timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle database disconnection", async () => {
|
||||||
|
executeMock.mockImplementationOnce(() => Promise.reject(new Error("DB Down")));
|
||||||
|
|
||||||
|
const interaction = {
|
||||||
|
deferReply: mock(() => Promise.resolve()),
|
||||||
|
editReply: mock(() => Promise.resolve()),
|
||||||
|
client: {
|
||||||
|
ws: {
|
||||||
|
ping: 42
|
||||||
|
}
|
||||||
|
},
|
||||||
|
user: { id: "123", username: "testuser" },
|
||||||
|
commandName: "health"
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await health.execute(interaction);
|
||||||
|
|
||||||
|
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||||
|
const embed = editReplyCall.embeds[0];
|
||||||
|
const connectivityField = embed.data.fields.find((f: any) => f.name === "📡 Connectivity");
|
||||||
|
|
||||||
|
expect(connectivityField.value).toContain("Disconnected");
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/commands/admin/health.ts
Normal file
60
src/commands/admin/health.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { createCommand } from "@lib/utils";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
|
||||||
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
|
export const health = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("health")
|
||||||
|
.setDescription("Check the bot's health status")
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
// 1. Check Discord API latency
|
||||||
|
const wsPing = interaction.client.ws.ping;
|
||||||
|
|
||||||
|
// 2. Verify database connection
|
||||||
|
let dbStatus = "Connected";
|
||||||
|
let dbPing = -1;
|
||||||
|
try {
|
||||||
|
const start = Date.now();
|
||||||
|
await DrizzleClient.execute(sql`SELECT 1`);
|
||||||
|
dbPing = Date.now() - start;
|
||||||
|
} catch (error) {
|
||||||
|
dbStatus = "Disconnected";
|
||||||
|
console.error("Health check DB error:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Uptime
|
||||||
|
const uptime = process.uptime();
|
||||||
|
const days = Math.floor(uptime / 86400);
|
||||||
|
const hours = Math.floor((uptime % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((uptime % 3600) / 60);
|
||||||
|
const seconds = Math.floor(uptime % 60);
|
||||||
|
const uptimeString = `${days}d ${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
|
||||||
|
// 4. Memory usage
|
||||||
|
const memory = process.memoryUsage();
|
||||||
|
const heapUsed = (memory.heapUsed / 1024 / 1024).toFixed(2);
|
||||||
|
const heapTotal = (memory.heapTotal / 1024 / 1024).toFixed(2);
|
||||||
|
const rss = (memory.rss / 1024 / 1024).toFixed(2);
|
||||||
|
|
||||||
|
// 5. Last successful command
|
||||||
|
const lastCommand = AuroraClient.lastCommandTimestamp
|
||||||
|
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
|
||||||
|
: "None since startup";
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("System Health Status", undefined, Colors.Aqua)
|
||||||
|
.addFields(
|
||||||
|
{ name: "📡 Connectivity", value: `**Discord WS:** ${wsPing}ms\n**Database:** ${dbStatus} ${dbPing >= 0 ? `(${dbPing}ms)` : ""}`, inline: true },
|
||||||
|
{ name: "⏱️ Uptime", value: uptimeString, inline: true },
|
||||||
|
{ name: "🧠 Memory Usage", value: `**RSS:** ${rss} MB\n**Heap:** ${heapUsed} / ${heapTotal} MB`, inline: false },
|
||||||
|
{ name: "⌨️ Activity", value: `**Last Command:** ${lastCommand}`, inline: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import { logger } from "@lib/logger";
|
|||||||
export class Client extends DiscordClient {
|
export class Client extends DiscordClient {
|
||||||
|
|
||||||
commands: Collection<string, Command>;
|
commands: Collection<string, Command>;
|
||||||
|
lastCommandTimestamp: number | null = null;
|
||||||
private commandLoader: CommandLoader;
|
private commandLoader: CommandLoader;
|
||||||
private eventLoader: EventLoader;
|
private eventLoader: EventLoader;
|
||||||
|
|
||||||
|
|||||||
59
src/lib/handlers/CommandHandler.test.ts
Normal file
59
src/lib/handlers/CommandHandler.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { CommandHandler } from "./CommandHandler";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
|
|
||||||
|
// Mock UserService
|
||||||
|
mock.module("@/modules/user/user.service", () => ({
|
||||||
|
userService: {
|
||||||
|
getOrCreateUser: mock(() => Promise.resolve())
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CommandHandler", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
AuroraClient.commands.clear();
|
||||||
|
AuroraClient.lastCommandTimestamp = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update lastCommandTimestamp on successful execution", async () => {
|
||||||
|
const executeSuccess = mock(() => Promise.resolve());
|
||||||
|
AuroraClient.commands.set("test", {
|
||||||
|
data: { name: "test" } as any,
|
||||||
|
execute: executeSuccess
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const interaction = {
|
||||||
|
commandName: "test",
|
||||||
|
user: { id: "123", username: "testuser" }
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await CommandHandler.handle(interaction);
|
||||||
|
|
||||||
|
expect(executeSuccess).toHaveBeenCalled();
|
||||||
|
expect(AuroraClient.lastCommandTimestamp).not.toBeNull();
|
||||||
|
expect(AuroraClient.lastCommandTimestamp).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not update lastCommandTimestamp on failed execution", async () => {
|
||||||
|
const executeError = mock(() => Promise.reject(new Error("Command Failed")));
|
||||||
|
AuroraClient.commands.set("fail", {
|
||||||
|
data: { name: "fail" } as any,
|
||||||
|
execute: executeError
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const interaction = {
|
||||||
|
commandName: "fail",
|
||||||
|
user: { id: "123", username: "testuser" },
|
||||||
|
replied: false,
|
||||||
|
deferred: false,
|
||||||
|
reply: mock(() => Promise.resolve()),
|
||||||
|
followUp: mock(() => Promise.resolve())
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await CommandHandler.handle(interaction);
|
||||||
|
|
||||||
|
expect(executeError).toHaveBeenCalled();
|
||||||
|
expect(AuroraClient.lastCommandTimestamp).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ export class CommandHandler {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await command.execute(interaction);
|
await command.execute(interaction);
|
||||||
|
AuroraClient.lastCommandTimestamp = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(String(error));
|
logger.error(String(error));
|
||||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||||
|
|||||||
Reference in New Issue
Block a user