diff --git a/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx b/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx index 6fe6459..4ba8091 100644 --- a/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx +++ b/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx @@ -215,7 +215,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => { onNewTaskClick={() => setIsNewCronModalOpen(true)} /> ) : ( -
+
{filteredJobs.map((job) => ( { +export const handleDelete = async (job: CronJob, props: HandlerProps) => { const { setDeletingId, setIsDeleteModalOpen, @@ -77,19 +77,25 @@ export const handleDelete = async (id: string, props: HandlerProps) => { refreshJobErrors, } = props; - setDeletingId(id); + setDeletingId(job.id); try { - const result = await removeCronJob(id); + const result = await removeCronJob({ + id: job.id, + schedule: job.schedule, + command: job.command, + comment: job.comment, + user: job.user, + }); if (result.success) { showToast("success", "Cron job deleted successfully"); } else { - const errorId = `delete-${id}-${Date.now()}`; + const errorId = `delete-${job.id}-${Date.now()}`; const jobError: JobError = { id: errorId, title: "Failed to delete cron job", message: result.message, timestamp: new Date().toISOString(), - jobId: id, + jobId: job.id, }; setJobError(jobError); refreshJobErrors(); @@ -107,14 +113,14 @@ export const handleDelete = async (id: string, props: HandlerProps) => { ); } } catch (error: any) { - const errorId = `delete-${id}-${Date.now()}`; + const errorId = `delete-${job.id}-${Date.now()}`; const jobError: JobError = { id: errorId, title: "Failed to delete cron job", message: error.message || "Please try again later.", details: error.stack, timestamp: new Date().toISOString(), - jobId: id, + jobId: job.id, }; setJobError(jobError); showToast( @@ -158,9 +164,15 @@ export const handleClone = async (newComment: string, props: HandlerProps) => { } }; -export const handlePause = async (id: string) => { +export const handlePause = async (job: any) => { try { - const result = await pauseCronJobAction(id); + const result = await pauseCronJobAction({ + id: job.id, + schedule: job.schedule, + command: job.command, + comment: job.comment, + user: job.user, + }); if (result.success) { showToast("success", "Cron job paused successfully"); } else { @@ -171,9 +183,16 @@ export const handlePause = async (id: string) => { } }; -export const handleToggleLogging = async (id: string) => { +export const handleToggleLogging = async (job: any) => { try { - const result = await toggleCronJobLogging(id); + const result = await toggleCronJobLogging({ + id: job.id, + schedule: job.schedule, + command: job.command, + comment: job.comment, + user: job.user, + logsEnabled: job.logsEnabled, + }); if (result.success) { showToast("success", result.message); } else { @@ -185,9 +204,15 @@ export const handleToggleLogging = async (id: string) => { } }; -export const handleResume = async (id: string) => { +export const handleResume = async (job: any) => { try { - const result = await resumeCronJobAction(id); + const result = await resumeCronJobAction({ + id: job.id, + schedule: job.schedule, + command: job.command, + comment: job.comment, + user: job.user, + }); if (result.success) { showToast("success", "Cron job resumed successfully"); } else { @@ -401,9 +426,9 @@ export const handleNewCronSubmit = async ( } }; -export const handleBackup = async (id: string) => { +export const handleBackup = async (job: any) => { try { - const result = await backupCronJob(id); + const result = await backupCronJob(job); if (result.success) { showToast("success", "Job backed up successfully"); } else { diff --git a/app/_hooks/useCronJobState.ts b/app/_hooks/useCronJobState.ts index f6eaf35..dd5ddb6 100644 --- a/app/_hooks/useCronJobState.ts +++ b/app/_hooks/useCronJobState.ts @@ -127,7 +127,10 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => { }; const handleDeleteLocal = async (id: string) => { - await handleDelete(id, getHelperState()); + const job = cronJobs.find(j => j.id === id); + if (job) { + await handleDelete(job, getHelperState()); + } }; const handleCloneLocal = async (newComment: string) => { @@ -135,11 +138,17 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => { }; const handlePauseLocal = async (id: string) => { - await handlePause(id); + const job = cronJobs.find(j => j.id === id); + if (job) { + await handlePause(job); + } }; const handleResumeLocal = async (id: string) => { - await handleResume(id); + const job = cronJobs.find(j => j.id === id); + if (job) { + await handleResume(job); + } }; const handleRunLocal = async (id: string) => { @@ -149,7 +158,10 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => { }; const handleToggleLoggingLocal = async (id: string) => { - await handleToggleLogging(id); + const job = cronJobs.find(j => j.id === id); + if (job) { + await handleToggleLogging(job); + } }; const handleViewLogs = (job: CronJob) => { @@ -187,7 +199,10 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => { }; const handleBackupLocal = async (id: string) => { - await handleBackup(id); + const job = cronJobs.find(j => j.id === id); + if (job) { + await handleBackup(job); + } }; return { diff --git a/app/_server/actions/cronjobs/index.ts b/app/_server/actions/cronjobs/index.ts index b611adc..a4788f2 100644 --- a/app/_server/actions/cronjobs/index.ts +++ b/app/_server/actions/cronjobs/index.ts @@ -3,25 +3,27 @@ import { getCronJobs, addCronJob, - deleteCronJob, - updateCronJob, - pauseCronJob, - resumeCronJob, cleanupCrontab, + readUserCrontab, + writeUserCrontab, + findJobIndex, + updateCronJob, type CronJob, } from "@/app/_utils/cronjob-utils"; -import { getAllTargetUsers, getUserInfo } from "@/app/_utils/crontab-utils"; +import { getAllTargetUsers } from "@/app/_utils/crontab-utils"; import { revalidatePath } from "next/cache"; import { getScriptPathForCron } from "@/app/_server/actions/scripts"; -import { exec } from "child_process"; -import { promisify } from "util"; import { isDocker } from "@/app/_server/actions/global"; import { runJobSynchronously, runJobInBackground, } from "@/app/_utils/job-execution-utils"; - -const execAsync = promisify(exec); +import { + pauseJobInLines, + resumeJobInLines, + deleteJobInLines, +} from "@/app/_utils/line-manipulation-utils"; +import { cleanCrontabContent } from "@/app/_utils/files-manipulation-utils"; export const fetchCronJobs = async (): Promise => { try { @@ -90,10 +92,22 @@ export const createCronJob = async ( }; export const removeCronJob = async ( - id: string + jobData: { id: string; schedule: string; command: string; comment?: string; user: string } ): Promise<{ success: boolean; message: string; details?: string }> => { try { - const success = await deleteCronJob(id); + const cronContent = await readUserCrontab(jobData.user); + const lines = cronContent.split("\n"); + + const jobIndex = findJobIndex(jobData, lines, jobData.user); + + if (jobIndex === -1) { + return { success: false, message: "Cron job not found in crontab" }; + } + + const newCronEntries = deleteJobInLines(lines, jobIndex); + const newCron = await cleanCrontabContent(newCronEntries.join("\n")); + const success = await writeUserCrontab(jobData.user, newCron); + if (success) { revalidatePath("/"); return { success: true, message: "Cron job deleted successfully" }; @@ -124,8 +138,15 @@ export const editCronJob = async ( return { success: false, message: "Missing required fields" }; } + const cronJobs = await getCronJobs(false); + const job = cronJobs.find((j) => j.id === id); + + if (!job) { + return { success: false, message: "Cron job not found" }; + } + const success = await updateCronJob( - id, + job, schedule, command, comment, @@ -152,7 +173,7 @@ export const cloneCronJob = async ( newComment: string ): Promise<{ success: boolean; message: string; details?: string }> => { try { - const cronJobs = await getCronJobs(); + const cronJobs = await getCronJobs(false); const originalJob = cronJobs.find((job) => job.id === id); if (!originalJob) { @@ -183,10 +204,22 @@ export const cloneCronJob = async ( }; export const pauseCronJobAction = async ( - id: string + jobData: { id: string; schedule: string; command: string; comment?: string; user: string } ): Promise<{ success: boolean; message: string; details?: string }> => { try { - const success = await pauseCronJob(id); + const cronContent = await readUserCrontab(jobData.user); + const lines = cronContent.split("\n"); + + const jobIndex = findJobIndex(jobData, lines, jobData.user); + + if (jobIndex === -1) { + return { success: false, message: "Cron job not found in crontab" }; + } + + const newCronEntries = pauseJobInLines(lines, jobIndex, jobData.id); + const newCron = await cleanCrontabContent(newCronEntries.join("\n")); + const success = await writeUserCrontab(jobData.user, newCron); + if (success) { revalidatePath("/"); return { success: true, message: "Cron job paused successfully" }; @@ -204,10 +237,22 @@ export const pauseCronJobAction = async ( }; export const resumeCronJobAction = async ( - id: string + jobData: { id: string; schedule: string; command: string; comment?: string; user: string } ): Promise<{ success: boolean; message: string; details?: string }> => { try { - const success = await resumeCronJob(id); + const cronContent = await readUserCrontab(jobData.user); + const lines = cronContent.split("\n"); + + const jobIndex = findJobIndex(jobData, lines, jobData.user); + + if (jobIndex === -1) { + return { success: false, message: "Cron job not found in crontab" }; + } + + const newCronEntries = resumeJobInLines(lines, jobIndex, jobData.id); + const newCron = await cleanCrontabContent(newCronEntries.join("\n")); + const success = await writeUserCrontab(jobData.user, newCron); + if (success) { revalidatePath("/"); return { success: true, message: "Cron job resumed successfully" }; @@ -257,23 +302,16 @@ export const cleanupCrontabAction = async (): Promise<{ }; export const toggleCronJobLogging = async ( - id: string + jobData: { id: string; schedule: string; command: string; comment?: string; user: string; logsEnabled?: boolean } ): Promise<{ success: boolean; message: string; details?: string }> => { try { - const cronJobs = await getCronJobs(); - const job = cronJobs.find((j) => j.id === id); - - if (!job) { - return { success: false, message: "Cron job not found" }; - } - - const newLogsEnabled = !job.logsEnabled; + const newLogsEnabled = !jobData.logsEnabled; const success = await updateCronJob( - id, - job.schedule, - job.command, - job.comment || "", + jobData, + jobData.schedule, + jobData.command, + jobData.comment || "", newLogsEnabled ); @@ -309,7 +347,7 @@ export const runCronJob = async ( mode?: "sync" | "async"; }> => { try { - const cronJobs = await getCronJobs(); + const cronJobs = await getCronJobs(false); const job = cronJobs.find((j) => j.id === id); if (!job) { @@ -356,7 +394,7 @@ export const executeJob = async ( mode?: "sync" | "async"; }> => { try { - const cronJobs = await getCronJobs(); + const cronJobs = await getCronJobs(false); const job = cronJobs.find((j) => j.id === id); if (!job) { @@ -388,13 +426,13 @@ export const executeJob = async ( }; export const backupCronJob = async ( - id: string + job: CronJob ): Promise<{ success: boolean; message: string; details?: string }> => { try { const { backupJobToFile, } = await import("@/app/_utils/backup-utils"); - const success = await backupJobToFile(id); + const success = await backupJobToFile(job); if (success) { return { success: true, message: "Cron job backed up successfully" }; } else { diff --git a/app/_utils/backup-utils.ts b/app/_utils/backup-utils.ts index f3e2cae..c4cdb89 100644 --- a/app/_utils/backup-utils.ts +++ b/app/_utils/backup-utils.ts @@ -17,18 +17,10 @@ const sanitizeFilename = (id: string): string => { return id.replace(/[^a-zA-Z0-9_-]/g, "_"); }; -export const backupJobToFile = async (id: string): Promise => { +export const backupJobToFile = async (job: CronJob): Promise => { try { await ensureBackupDirectoryExists(); - const cronJobs = await getCronJobs(false); - const job = cronJobs.find((j) => j.id === id); - - if (!job) { - console.error(`Job with id ${id} not found`); - return false; - } - const jobData = { id: job.id, schedule: job.schedule, @@ -40,14 +32,14 @@ export const backupJobToFile = async (id: string): Promise => { backedUpAt: new Date().toISOString(), }; - const filename = `${sanitizeFilename(id)}.job`; + const filename = `${sanitizeFilename(job.id)}.job`; const filepath = path.join(BACKUP_DIR, filename); await fs.writeFile(filepath, JSON.stringify(jobData, null, 2), "utf8"); return true; } catch (error) { - console.error(`Error backing up job ${id}:`, error); + console.error(`Error backing up job ${job.id}:`, error); return false; } }; @@ -64,7 +56,7 @@ export const backupAllJobsToFiles = async (): Promise<{ let successCount = 0; for (const job of cronJobs) { - const success = await backupJobToFile(job.id); + const success = await backupJobToFile(job); if (success) { successCount++; } diff --git a/app/_utils/cronjob-utils.ts b/app/_utils/cronjob-utils.ts index f70de39..89c205e 100644 --- a/app/_utils/cronjob-utils.ts +++ b/app/_utils/cronjob-utils.ts @@ -46,7 +46,7 @@ export interface CronJob { }; } -const readUserCrontab = async (user: string): Promise => { +export const readUserCrontab = async (user: string): Promise => { const docker = await isDocker(); if (docker) { @@ -59,7 +59,7 @@ const readUserCrontab = async (user: string): Promise => { } }; -const writeUserCrontab = async ( +export const writeUserCrontab = async ( user: string, content: string ): Promise => { @@ -115,33 +115,6 @@ export const getCronJobs = async ( const lines = content.split("\n"); const jobs = parseJobsFromLines(lines, user); - let needsUpdate = false; - let updatedLines = [...lines]; - - for (let jobIndex = 0; jobIndex < jobs.length; jobIndex++) { - const job = jobs[jobIndex]; - - const cronContent = lines.join("\n"); - if (!cronContent.includes(`id: ${job.id}`)) { - needsUpdate = true; - - updatedLines = updateJobInLines( - updatedLines, - jobIndex, - job.schedule, - job.command, - job.comment || "", - job.logsEnabled || false, - job.id - ); - } - } - - if (needsUpdate) { - const newCron = await cleanCrontabContent(updatedLines.join("\n")); - await writeUserCrontab(user, newCron); - } - allJobs.push(...jobs); } @@ -278,46 +251,38 @@ export const deleteCronJob = async (id: string): Promise => { }; export const updateCronJob = async ( - id: string, + jobData: { id: string; schedule: string; command: string; comment?: string; user: string }, schedule: string, command: string, comment: string = "", logsEnabled: boolean = false ): Promise => { try { - const allJobs = await getCronJobs(false); - const targetJob = allJobs.find((j) => j.id === id); - - if (!targetJob) { - console.error(`Job with id ${id} not found`); - return false; - } - - const user = targetJob.user; + const user = jobData.user; const cronContent = await readUserCrontab(user); const lines = cronContent.split("\n"); - const userJobs = parseJobsFromLines(lines, user); - const jobIndex = userJobs.findIndex((j) => j.id === id); + + const jobIndex = findJobIndex(jobData, lines, user); if (jobIndex === -1) { - console.error(`Job with id ${id} not found in parsed jobs`); + console.error(`Job not found in crontab`); return false; } - const isWrappd = isCommandWrapped(command); + const isWrapped = isCommandWrapped(command); let finalCommand = command; - if (logsEnabled && !isWrappd) { + if (logsEnabled && !isWrapped) { const docker = await isDocker(); - finalCommand = await wrapCommandWithLogger(id, command, docker, comment); - } else if (!logsEnabled && isWrappd) { + finalCommand = await wrapCommandWithLogger(jobData.id, command, docker, comment); + } else if (!logsEnabled && isWrapped) { finalCommand = unwrapCommand(command); - } else if (logsEnabled && isWrappd) { + } else if (logsEnabled && isWrapped) { const unwrapped = unwrapCommand(command); const docker = await isDocker(); finalCommand = await wrapCommandWithLogger( - id, + jobData.id, unwrapped, docker, comment @@ -333,7 +298,7 @@ export const updateCronJob = async ( finalCommand, comment, logsEnabled, - id + jobData.id ); const newCron = await cleanCrontabContent(newCronEntries.join("\n")); @@ -423,3 +388,24 @@ export const cleanupCrontab = async (): Promise => { return false; } }; + +export const findJobIndex = ( + jobData: { id: string; schedule: string; command: string; comment?: string; user: string; paused?: boolean }, + lines: string[], + user: string +): number => { + const cronContentStr = lines.join("\n"); + const userJobs = parseJobsFromLines(lines, user); + + if (cronContentStr.includes(`id: ${jobData.id}`)) { + return userJobs.findIndex((j) => j.id === jobData.id); + } + + return userJobs.findIndex( + (j) => + j.schedule === jobData.schedule && + j.command === jobData.command && + j.user === jobData.user && + (j.comment || "") === (jobData.comment || "") + ); +}; diff --git a/app/_utils/line-manipulation-utils.ts b/app/_utils/line-manipulation-utils.ts index 8673a89..1926654 100644 --- a/app/_utils/line-manipulation-utils.ts +++ b/app/_utils/line-manipulation-utils.ts @@ -175,17 +175,34 @@ export const parseCommentMetadata = ( let uuid: string | undefined; if (parts.length > 1) { - comment = parts[0] || ""; - const metadata = parts.slice(1).join("|").trim(); + const firstPartIsMetadata = parts[0].match(/logsEnabled:\s*(true|false)/i) || parts[0].match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/i); - const logsMatch = metadata.match(/logsEnabled:\s*(true|false)/i); - if (logsMatch) { - logsEnabled = logsMatch[1].toLowerCase() === "true"; - } + if (firstPartIsMetadata) { + comment = ""; + const metadata = parts.join("|").trim(); - const uuidMatch = metadata.match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/i); - if (uuidMatch) { - uuid = uuidMatch[1].toLowerCase(); + const logsMatch = metadata.match(/logsEnabled:\s*(true|false)/i); + if (logsMatch) { + logsEnabled = logsMatch[1].toLowerCase() === "true"; + } + + const uuidMatch = metadata.match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/i); + if (uuidMatch) { + uuid = uuidMatch[1].toLowerCase(); + } + } else { + comment = parts[0] || ""; + const metadata = parts.slice(1).join("|").trim(); + + const logsMatch = metadata.match(/logsEnabled:\s*(true|false)/i); + if (logsMatch) { + logsEnabled = logsMatch[1].toLowerCase() === "true"; + } + + const uuidMatch = metadata.match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/i); + if (uuidMatch) { + uuid = uuidMatch[1].toLowerCase(); + } } } else { const logsMatch = commentText.match(/logsEnabled:\s*(true|false)/i); diff --git a/app/api/cronjobs/[id]/route.ts b/app/api/cronjobs/[id]/route.ts index 32d7f53..a35fbe6 100644 --- a/app/api/cronjobs/[id]/route.ts +++ b/app/api/cronjobs/[id]/route.ts @@ -87,7 +87,7 @@ export async function DELETE( if (authError) return authError; try { - const result = await removeCronJob(params.id); + const result = await removeCronJob({ id: params.id, schedule: "", command: "", user: "" }); if (result.success) { return NextResponse.json(result); diff --git a/app/globals.css b/app/globals.css index a04ca01..a83756b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -76,6 +76,24 @@ scrollbar-width: none; } +.overflow-y-auto { + padding-right: 1em; +} + +.overflow-y-auto::-webkit-scrollbar { + width: 4px; +} +.overflow-y-auto::-webkit-scrollbar-track { + background: transparent; +} +.overflow-y-auto::-webkit-scrollbar-thumb { + background-color: hsl(var(--primary) / 0.8); + border-radius: 3px; +} +.overflow-y-auto::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--primary) / 0.1); +} + @layer base { * { @apply border-border; diff --git a/app/login/page.tsx b/app/login/page.tsx index 29f0cfc..8971d38 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,4 +1,4 @@ -'use server'; +export const dynamic = "force-dynamic"; import { LoginForm } from "@/app/_components/FeatureComponents/LoginForm/LoginForm";