feat: add Job Monitor page with cron job logging and Selenium queue status
This commit is contained in:
@@ -3,6 +3,7 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { storage } from "../storage";
|
||||
import { backupDatabaseToPath } from "../services/databaseBackupService";
|
||||
import { cronJobLogStorage } from "../storage/cron-job-log-storage";
|
||||
|
||||
// Local backup folder in the app root (apps/Backend/backups)
|
||||
const LOCAL_BACKUP_DIR = path.resolve(process.cwd(), "backups");
|
||||
@@ -45,17 +46,26 @@ export const startBackupCron = () => {
|
||||
|
||||
if (!admin.autoBackupEnabled) {
|
||||
console.log("✅ [8 PM] Auto-backup is disabled for admin, skipped.");
|
||||
const startedAt = new Date();
|
||||
const log = await cronJobLogStorage.createJobLog("local-backup", startedAt);
|
||||
await cronJobLogStorage.completeJobLog(log.id, "skipped", new Date());
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = new Date();
|
||||
const log = await cronJobLogStorage.createJobLog("local-backup", startedAt);
|
||||
|
||||
try {
|
||||
const filename = `dental_backup_${Date.now()}.sql`;
|
||||
await backupDatabaseToPath({ destinationPath: LOCAL_BACKUP_DIR, filename });
|
||||
await storage.createBackup(admin.id);
|
||||
await storage.deleteNotificationsByType(admin.id, "BACKUP");
|
||||
await cronJobLogStorage.completeJobLog(log.id, "success", new Date());
|
||||
console.log(`✅ Local backup done → ${filename}`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error("Local backup failed:", err);
|
||||
await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage);
|
||||
await storage.createNotification(
|
||||
admin.id,
|
||||
"BACKUP",
|
||||
@@ -80,11 +90,19 @@ export const startBackupCron = () => {
|
||||
|
||||
if (!admin.usbBackupEnabled) {
|
||||
console.log("✅ [9 PM] USB backup is disabled for admin, skipped.");
|
||||
const startedAt = new Date();
|
||||
const log = await cronJobLogStorage.createJobLog("usb-backup", startedAt);
|
||||
await cronJobLogStorage.completeJobLog(log.id, "skipped", new Date());
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = new Date();
|
||||
const log = await cronJobLogStorage.createJobLog("usb-backup", startedAt);
|
||||
|
||||
const destination = await storage.getActiveBackupDestination(admin.id);
|
||||
if (!destination) {
|
||||
const errorMessage = "No backup destination configured.";
|
||||
await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage);
|
||||
await storage.createNotification(
|
||||
admin.id,
|
||||
"BACKUP",
|
||||
@@ -96,6 +114,8 @@ export const startBackupCron = () => {
|
||||
const usbBackupPath = path.join(destination.path, USB_BACKUP_FOLDER_NAME);
|
||||
|
||||
if (!fs.existsSync(usbBackupPath)) {
|
||||
const errorMessage = `Folder "${USB_BACKUP_FOLDER_NAME}" not found on the drive.`;
|
||||
await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage);
|
||||
await storage.createNotification(
|
||||
admin.id,
|
||||
"BACKUP",
|
||||
@@ -109,9 +129,12 @@ export const startBackupCron = () => {
|
||||
await backupDatabaseToPath({ destinationPath: usbBackupPath, filename });
|
||||
await storage.createBackup(admin.id);
|
||||
await storage.deleteNotificationsByType(admin.id, "BACKUP");
|
||||
await cronJobLogStorage.completeJobLog(log.id, "success", new Date());
|
||||
console.log(`✅ USB backup done → ${usbBackupPath}/${filename}`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error("USB backup failed:", err);
|
||||
await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage);
|
||||
await storage.createNotification(
|
||||
admin.id,
|
||||
"BACKUP",
|
||||
|
||||
@@ -19,6 +19,7 @@ import paymentOcrRoutes from "./paymentOcrExtraction";
|
||||
import cloudStorageRoutes from "./cloud-storage";
|
||||
import paymentsReportsRoutes from "./payments-reports";
|
||||
import exportPaymentsReportsRoutes from "./export-payments-reports";
|
||||
import jobMonitorRoutes from "./job-monitor";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -42,5 +43,6 @@ router.use("/payment-ocr", paymentOcrRoutes);
|
||||
router.use("/cloud-storage", cloudStorageRoutes);
|
||||
router.use("/payments-reports", paymentsReportsRoutes);
|
||||
router.use("/export-payments-reports", exportPaymentsReportsRoutes);
|
||||
router.use("/job-monitor", jobMonitorRoutes);
|
||||
|
||||
export default router;
|
||||
|
||||
51
apps/Backend/src/routes/job-monitor.ts
Normal file
51
apps/Backend/src/routes/job-monitor.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import axios from "axios";
|
||||
import { cronJobLogStorage } from "../storage/cron-job-log-storage";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const SELENIUM_BASE_URL =
|
||||
process.env.SELENIUM_AGENT_BASE_URL || "http://localhost:5002";
|
||||
|
||||
// GET /api/job-monitor/summary
|
||||
// Returns last run per cron job + recent history
|
||||
router.get("/summary", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const [lastRuns, recentLogs] = await Promise.all([
|
||||
cronJobLogStorage.getLastRunPerJob(),
|
||||
cronJobLogStorage.getRecentLogs(30),
|
||||
]);
|
||||
res.json({ lastRuns, recentLogs });
|
||||
} catch (err) {
|
||||
console.error("job-monitor/summary error:", err);
|
||||
res.status(500).json({ error: "Failed to fetch cron job summary" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/job-monitor/failed
|
||||
// Returns recent failed job logs
|
||||
router.get("/failed", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const failed = await cronJobLogStorage.getFailedLogs(20);
|
||||
res.json(failed);
|
||||
} catch (err) {
|
||||
console.error("job-monitor/failed error:", err);
|
||||
res.status(500).json({ error: "Failed to fetch failed jobs" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/job-monitor/selenium-status
|
||||
// Proxies the Selenium service /status endpoint
|
||||
router.get("/selenium-status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const response = await axios.get(`${SELENIUM_BASE_URL}/status`, {
|
||||
timeout: 4000,
|
||||
});
|
||||
res.json({ online: true, ...response.data });
|
||||
} catch (err) {
|
||||
// Service may be offline — return gracefully
|
||||
res.json({ online: false, active_jobs: 0, queued_jobs: 0, status: "offline" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
61
apps/Backend/src/storage/cron-job-log-storage.ts
Normal file
61
apps/Backend/src/storage/cron-job-log-storage.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export type CronJobStatus = "success" | "failed" | "skipped";
|
||||
|
||||
export interface CronJobLogEntry {
|
||||
id: number;
|
||||
jobName: string;
|
||||
status: string;
|
||||
startedAt: Date;
|
||||
completedAt: Date | null;
|
||||
durationMs: number | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export const cronJobLogStorage = {
|
||||
async createJobLog(
|
||||
jobName: string,
|
||||
startedAt: Date
|
||||
): Promise<CronJobLogEntry> {
|
||||
return db.cronJobLog.create({
|
||||
data: { jobName, status: "running", startedAt },
|
||||
});
|
||||
},
|
||||
|
||||
async completeJobLog(
|
||||
id: number,
|
||||
status: CronJobStatus,
|
||||
completedAt: Date,
|
||||
errorMessage?: string
|
||||
): Promise<CronJobLogEntry> {
|
||||
const durationMs = completedAt.getTime() - (await db.cronJobLog.findUniqueOrThrow({ where: { id } })).startedAt.getTime();
|
||||
return db.cronJobLog.update({
|
||||
where: { id },
|
||||
data: { status, completedAt, durationMs, errorMessage: errorMessage ?? null },
|
||||
});
|
||||
},
|
||||
|
||||
async getRecentLogs(limit = 50): Promise<CronJobLogEntry[]> {
|
||||
return db.cronJobLog.findMany({
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
},
|
||||
|
||||
async getLastRunPerJob(): Promise<CronJobLogEntry[]> {
|
||||
// Get the most recent log entry for each distinct jobName
|
||||
const jobs = await db.cronJobLog.findMany({
|
||||
distinct: ["jobName"],
|
||||
orderBy: { startedAt: "desc" },
|
||||
});
|
||||
return jobs;
|
||||
},
|
||||
|
||||
async getFailedLogs(limit = 20): Promise<CronJobLogEntry[]> {
|
||||
return db.cronJobLog.findMany({
|
||||
where: { status: "failed" },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import { cloudStorageStorage } from './cloudStorage-storage';
|
||||
import { paymentsReportsStorage } from './payments-reports-storage';
|
||||
import { patientDocumentsStorage } from './patientDocuments-storage';
|
||||
import * as exportPaymentsReportsStorage from "./export-payments-reports-storage";
|
||||
import { cronJobLogStorage } from "./cron-job-log-storage";
|
||||
|
||||
|
||||
export const storage = {
|
||||
@@ -34,7 +35,8 @@ export const storage = {
|
||||
...cloudStorageStorage,
|
||||
...paymentsReportsStorage,
|
||||
...patientDocumentsStorage,
|
||||
...exportPaymentsReportsStorage,
|
||||
...exportPaymentsReportsStorage,
|
||||
...cronJobLogStorage,
|
||||
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user