feat: add cron job management functionality with status retrieval and action handling
This commit is contained in:
52
setup-cron.sh
Normal file
52
setup-cron.sh
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Manual script to setup/restart cron jobs
|
||||||
|
# Use this if cron wasn't started properly by the docker entrypoint
|
||||||
|
|
||||||
|
echo "🔧 Setting up cron jobs..."
|
||||||
|
|
||||||
|
# Ensure cron service is running
|
||||||
|
if command -v service &> /dev/null; then
|
||||||
|
service cron start 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set up daily backup cron job (runs at 2 AM daily)
|
||||||
|
echo "⏰ Setting up daily backup cron job (2 AM)..."
|
||||||
|
echo "0 2 * * * cd /app && /usr/local/bin/node backup-db.mjs >> /app/data/backup.log 2>&1" > /etc/cron.d/backup-cron
|
||||||
|
chmod 0644 /etc/cron.d/backup-cron
|
||||||
|
|
||||||
|
# Set up daily due date reminders cron job (runs at 3 AM daily)
|
||||||
|
echo "⏰ Setting up daily due date reminders cron job (3 AM)..."
|
||||||
|
echo "0 3 * * * cd /app && /usr/local/bin/node send-due-date-reminders.mjs >> /app/data/reminders.log 2>&1" > /etc/cron.d/reminders-cron
|
||||||
|
chmod 0644 /etc/cron.d/reminders-cron
|
||||||
|
|
||||||
|
# Combine both cron jobs into crontab
|
||||||
|
cat /etc/cron.d/backup-cron /etc/cron.d/reminders-cron > /tmp/combined-cron.tmp
|
||||||
|
crontab /tmp/combined-cron.tmp
|
||||||
|
rm /tmp/combined-cron.tmp
|
||||||
|
|
||||||
|
# Verify cron jobs are installed
|
||||||
|
echo ""
|
||||||
|
echo "✅ Cron jobs installed:"
|
||||||
|
crontab -l
|
||||||
|
|
||||||
|
# Check if cron daemon is running
|
||||||
|
echo ""
|
||||||
|
if pgrep -x "cron" > /dev/null || pgrep -x "crond" > /dev/null; then
|
||||||
|
echo "✅ Cron daemon is running"
|
||||||
|
else
|
||||||
|
echo "⚠️ Cron daemon is NOT running. Starting it..."
|
||||||
|
if command -v cron &> /dev/null; then
|
||||||
|
cron
|
||||||
|
echo "✅ Cron daemon started"
|
||||||
|
elif command -v crond &> /dev/null; then
|
||||||
|
crond
|
||||||
|
echo "✅ Cron daemon started"
|
||||||
|
else
|
||||||
|
echo "❌ Could not start cron daemon"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 Cron setup complete!"
|
||||||
@@ -12,6 +12,9 @@ export default function AdminSettingsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
|
const [cronStatus, setCronStatus] = useState(null);
|
||||||
|
const [cronLoading, setCronLoading] = useState(false);
|
||||||
|
const [cronActionLoading, setCronActionLoading] = useState(null);
|
||||||
|
|
||||||
// Redirect if not admin
|
// Redirect if not admin
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,6 +25,7 @@ export default function AdminSettingsPage() {
|
|||||||
}
|
}
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
fetchCronStatus();
|
||||||
}, [session, status, router]);
|
}, [session, status, router]);
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
@@ -50,6 +54,44 @@ export default function AdminSettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchCronStatus = async () => {
|
||||||
|
setCronLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/cron");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCronStatus(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching cron status:", error);
|
||||||
|
} finally {
|
||||||
|
setCronLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCronAction = async (action) => {
|
||||||
|
setCronActionLoading(action);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/cron", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
alert(data.message);
|
||||||
|
fetchCronStatus();
|
||||||
|
} else {
|
||||||
|
alert("Error: " + data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error performing cron action:", error);
|
||||||
|
alert("Error performing action");
|
||||||
|
} finally {
|
||||||
|
setCronActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateSetting = async (key, value) => {
|
const updateSetting = async (key, value) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -166,6 +208,116 @@ export default function AdminSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Future settings can be added here */}
|
{/* Future settings can be added here */}
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
|
Cron Jobs Status
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={fetchCronStatus}
|
||||||
|
disabled={cronLoading}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
{cronLoading ? "Refreshing..." : "↻ Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cronLoading && !cronStatus ? (
|
||||||
|
<p className="text-sm text-gray-500">Loading cron status...</p>
|
||||||
|
) : cronStatus ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status indicators */}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<div className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
cronStatus.available
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-yellow-100 text-yellow-800"
|
||||||
|
}`}>
|
||||||
|
{cronStatus.available ? "✓ Cron Available" : "⚠ Cron Unavailable"}
|
||||||
|
</div>
|
||||||
|
{cronStatus.available && (
|
||||||
|
<div className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
cronStatus.running
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-red-100 text-red-800"
|
||||||
|
}`}>
|
||||||
|
{cronStatus.running ? "✓ Daemon Running" : "✗ Daemon Not Running"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cronStatus.available && (
|
||||||
|
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||||
|
{cronStatus.jobCount || 0} Job(s) Scheduled
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scheduled jobs */}
|
||||||
|
{cronStatus.jobs && cronStatus.jobs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-700 mb-1">Scheduled Jobs:</p>
|
||||||
|
<div className="bg-gray-50 rounded p-2 font-mono text-xs">
|
||||||
|
{cronStatus.jobs.map((job, idx) => (
|
||||||
|
<div key={idx} className="py-0.5">{job}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Last backup info */}
|
||||||
|
{cronStatus.lastBackup && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium text-gray-700">Last Backup: </span>
|
||||||
|
{cronStatus.lastBackup.exists ? (
|
||||||
|
<span className="text-green-600">
|
||||||
|
{cronStatus.lastBackup.filename} ({new Date(cronStatus.lastBackup.date).toLocaleString()})
|
||||||
|
<span className="text-gray-500 ml-2">
|
||||||
|
({cronStatus.lastBackup.count} total backups)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">{cronStatus.lastBackup.message || "No backups"}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message for non-Linux environments */}
|
||||||
|
{cronStatus.message && (
|
||||||
|
<p className="text-sm text-yellow-600">{cronStatus.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{cronStatus.available && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCronAction("restart")}
|
||||||
|
disabled={cronActionLoading === "restart"}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cronActionLoading === "restart" ? "Restarting..." : "🔄 Restart Cron Jobs"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleCronAction("run-backup")}
|
||||||
|
disabled={cronActionLoading === "run-backup"}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cronActionLoading === "run-backup" ? "Running..." : "💾 Run Backup Now"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCronAction("run-reminders")}
|
||||||
|
disabled={cronActionLoading === "run-reminders"}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded hover:bg-purple-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cronActionLoading === "run-reminders" ? "Running..." : "📧 Send Reminders Now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-red-500">Failed to load cron status</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Information */}
|
||||||
<div className="border rounded-lg p-4">
|
<div className="border rounded-lg p-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
System Information
|
System Information
|
||||||
|
|||||||
191
src/app/api/admin/cron/route.js
Normal file
191
src/app/api/admin/cron/route.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withAdminAuth } from "@/lib/middleware/auth";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
// Check if we're running in a Linux/Docker environment
|
||||||
|
const isLinux = process.platform === "linux";
|
||||||
|
|
||||||
|
async function getCronStatus() {
|
||||||
|
if (!isLinux) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
running: false,
|
||||||
|
jobs: [],
|
||||||
|
message: "Cron is only available in Linux/Docker environment",
|
||||||
|
lastBackup: getLastBackupInfo(),
|
||||||
|
lastReminder: getLastReminderInfo()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if cron daemon is running
|
||||||
|
let cronRunning = false;
|
||||||
|
try {
|
||||||
|
await execAsync("pgrep -x cron || pgrep -x crond");
|
||||||
|
cronRunning = true;
|
||||||
|
} catch {
|
||||||
|
cronRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current crontab
|
||||||
|
let jobs = [];
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("crontab -l 2>/dev/null");
|
||||||
|
jobs = stdout.trim().split("\n").filter(line => line && !line.startsWith("#"));
|
||||||
|
} catch {
|
||||||
|
jobs = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
running: cronRunning,
|
||||||
|
jobs: jobs,
|
||||||
|
jobCount: jobs.length,
|
||||||
|
lastBackup: getLastBackupInfo(),
|
||||||
|
lastReminder: getLastReminderInfo()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
running: false,
|
||||||
|
jobs: [],
|
||||||
|
error: error.message,
|
||||||
|
lastBackup: getLastBackupInfo(),
|
||||||
|
lastReminder: getLastReminderInfo()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastBackupInfo() {
|
||||||
|
try {
|
||||||
|
const backupDir = path.join(process.cwd(), "backups");
|
||||||
|
if (!fs.existsSync(backupDir)) {
|
||||||
|
return { exists: false, message: "No backups directory" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(backupDir)
|
||||||
|
.filter(f => f.startsWith("backup-") && f.endsWith(".sqlite"))
|
||||||
|
.map(f => ({
|
||||||
|
name: f,
|
||||||
|
path: path.join(backupDir, f),
|
||||||
|
mtime: fs.statSync(path.join(backupDir, f)).mtime
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return { exists: false, message: "No backups found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = files[0];
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
filename: latest.name,
|
||||||
|
date: latest.mtime.toISOString(),
|
||||||
|
count: files.length
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { exists: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastReminderInfo() {
|
||||||
|
try {
|
||||||
|
const logPath = path.join(process.cwd(), "data", "reminders.log");
|
||||||
|
if (!fs.existsSync(logPath)) {
|
||||||
|
return { exists: false, message: "No reminders log" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(logPath);
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
lastModified: stats.mtime.toISOString()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { exists: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHandler() {
|
||||||
|
const status = await getCronStatus();
|
||||||
|
return NextResponse.json(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postHandler(request) {
|
||||||
|
const { action } = await request.json();
|
||||||
|
|
||||||
|
if (!isLinux) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: "Cron operations are only available in Linux/Docker environment"
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === "restart") {
|
||||||
|
// Run the setup-cron.sh script
|
||||||
|
const scriptPath = path.join(process.cwd(), "setup-cron.sh");
|
||||||
|
|
||||||
|
if (!fs.existsSync(scriptPath)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: "setup-cron.sh script not found"
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure script is executable
|
||||||
|
await execAsync(`chmod +x ${scriptPath}`);
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
const { stdout, stderr } = await execAsync(`bash ${scriptPath}`);
|
||||||
|
|
||||||
|
// Get updated status
|
||||||
|
const status = await getCronStatus();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Cron jobs restarted successfully",
|
||||||
|
output: stdout,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
} else if (action === "run-backup") {
|
||||||
|
// Manually trigger backup
|
||||||
|
const backupScript = path.join(process.cwd(), "backup-db.mjs");
|
||||||
|
const { stdout } = await execAsync(`cd ${process.cwd()} && node ${backupScript}`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Backup completed",
|
||||||
|
output: stdout
|
||||||
|
});
|
||||||
|
} else if (action === "run-reminders") {
|
||||||
|
// Manually trigger reminders
|
||||||
|
const reminderScript = path.join(process.cwd(), "send-due-date-reminders.mjs");
|
||||||
|
const { stdout } = await execAsync(`cd ${process.cwd()} && node ${reminderScript}`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Reminders sent",
|
||||||
|
output: stdout
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: "Unknown action"
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
stderr: error.stderr
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withAdminAuth(getHandler);
|
||||||
|
export const POST = withAdminAuth(postHandler);
|
||||||
Reference in New Issue
Block a user