From 8fd7d0d80f4ee417fab1ad3409ca272bfe896db4 Mon Sep 17 00:00:00 2001 From: fccview Date: Tue, 26 Aug 2025 16:44:05 +0100 Subject: [PATCH 1/7] Start working on multi users functionalities --- app/_components/CronJobList.tsx | 117 ++- app/_components/modals/CreateTaskModal.tsx | 12 + app/_components/ui/UserFilter.tsx | 109 +++ app/_components/ui/UserSwitcher.tsx | 86 ++ app/_server/actions/cronjobs/index.ts | 52 +- app/_utils/system.ts | 4 +- app/_utils/system/cron.ts | 899 +++++++++++++++++---- app/_utils/system/hostCrontab.ts | 76 ++ docker-compose.yml | 1 + 9 files changed, 1171 insertions(+), 185 deletions(-) create mode 100644 app/_components/ui/UserFilter.tsx create mode 100644 app/_components/ui/UserSwitcher.tsx diff --git a/app/_components/CronJobList.tsx b/app/_components/CronJobList.tsx index 2deaff1..63eb9d5 100644 --- a/app/_components/CronJobList.tsx +++ b/app/_components/CronJobList.tsx @@ -2,19 +2,31 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card"; import { Button } from "./ui/Button"; -import { Trash2, Clock, Edit, Plus, Files } from "lucide-react"; +import { + Trash2, + Clock, + Edit, + Plus, + Files, + User, + Play, + Pause, +} from "lucide-react"; import { CronJob } from "@/app/_utils/system"; import { removeCronJob, editCronJob, createCronJob, cloneCronJob, + pauseCronJobAction, + resumeCronJobAction, } from "@/app/_server/actions/cronjobs"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { CreateTaskModal } from "./modals/CreateTaskModal"; import { EditTaskModal } from "./modals/EditTaskModal"; import { DeleteTaskModal } from "./modals/DeleteTaskModal"; import { CloneTaskModal } from "./modals/CloneTaskModal"; +import { UserFilter } from "./ui/UserFilter"; import { type Script } from "@/app/_server/actions/scripts"; import { showToast } from "./ui/Toast"; @@ -33,6 +45,7 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) { const [jobToDelete, setJobToDelete] = useState(null); const [jobToClone, setJobToClone] = useState(null); const [isCloning, setIsCloning] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); const [editForm, setEditForm] = useState({ schedule: "", command: "", @@ -43,8 +56,14 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) { command: "", comment: "", selectedScriptId: null as string | null, + user: "", }); + const filteredJobs = useMemo(() => { + if (!selectedUser) return cronJobs; + return cronJobs.filter((job) => job.user === selectedUser); + }, [cronJobs, selectedUser]); + const handleDelete = async (id: string) => { setDeletingId(id); try { @@ -85,6 +104,36 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) { } }; + const handlePause = async (id: string) => { + try { + const result = await pauseCronJobAction(id); + if (result.success) { + showToast("success", "Cron job paused successfully"); + } else { + showToast("error", "Failed to pause cron job", result.message); + } + } catch (error) { + showToast("error", "Failed to pause cron job", "Please try again later."); + } + }; + + const handleResume = async (id: string) => { + try { + const result = await resumeCronJobAction(id); + if (result.success) { + showToast("success", "Cron job resumed successfully"); + } else { + showToast("error", "Failed to resume cron job", result.message); + } + } catch (error) { + showToast( + "error", + "Failed to resume cron job", + "Please try again later." + ); + } + }; + const confirmDelete = (job: CronJob) => { setJobToDelete(job); setIsDeleteModalOpen(true); @@ -135,11 +184,13 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) { const handleNewCronSubmit = async (e: React.FormEvent) => { e.preventDefault(); + try { const formData = new FormData(); formData.append("schedule", newCronForm.schedule); formData.append("command", newCronForm.command); formData.append("comment", newCronForm.comment); + formData.append("user", newCronForm.user); if (newCronForm.selectedScriptId) { formData.append("selectedScriptId", newCronForm.selectedScriptId); } @@ -152,6 +203,7 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) { command: "", comment: "", selectedScriptId: null, + user: "", }); showToast("success", "Cron job created successfully"); } else { @@ -180,8 +232,9 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) { Scheduled Tasks

- {cronJobs.length} scheduled job - {cronJobs.length !== 1 ? "s" : ""} + {filteredJobs.length} of {cronJobs.length} scheduled job + {filteredJobs.length !== 1 ? "s" : ""} + {selectedUser && ` for ${selectedUser}`}

@@ -195,17 +248,28 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) { - {cronJobs.length === 0 ? ( +
+ +
+ + {filteredJobs.length === 0 ? (

- No scheduled tasks yet + {selectedUser + ? `No tasks for user ${selectedUser}` + : "No scheduled tasks yet"}

- Create your first scheduled task to automate your system - operations and boost productivity. + {selectedUser + ? `No scheduled tasks found for user ${selectedUser}. Try selecting a different user or create a new task.` + : "Create your first scheduled task to automate your system operations and boost productivity."}

) : (
- {cronJobs.map((job) => ( + {filteredJobs.map((job) => (
+
+
+ + {job.user} +
+ {job.paused && ( + + Paused + + )} +
+ {job.comment && (

+ {job.paused ? ( + + ) : ( + + )} + )} + +

+ + + {isOpen && ( +
+ + {users.map((user) => ( + + ))} +
+ )} + + ); +} diff --git a/app/_components/ui/UserSwitcher.tsx b/app/_components/ui/UserSwitcher.tsx new file mode 100644 index 0000000..6cf42a8 --- /dev/null +++ b/app/_components/ui/UserSwitcher.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "./Button"; +import { ChevronDown, User } from "lucide-react"; +import { fetchAvailableUsers } from "@/app/_server/actions/cronjobs"; + +interface UserSwitcherProps { + selectedUser: string; + onUserChange: (user: string) => void; + className?: string; +} + +export function UserSwitcher({ + selectedUser, + onUserChange, + className = "", +}: UserSwitcherProps) { + const [users, setUsers] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadUsers = async () => { + try { + const availableUsers = await fetchAvailableUsers(); + setUsers(availableUsers); + if (availableUsers.length > 0 && !selectedUser) { + onUserChange(availableUsers[0]); + } + } catch (error) { + console.error("Error loading users:", error); + } finally { + setIsLoading(false); + } + }; + + loadUsers(); + }, [selectedUser, onUserChange]); + + if (isLoading) { + return ( +
+ + Loading users... +
+ ); + } + + return ( +
+ + + {isOpen && ( +
+ {users.map((user) => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/_server/actions/cronjobs/index.ts b/app/_server/actions/cronjobs/index.ts index de4894d..d97aa46 100644 --- a/app/_server/actions/cronjobs/index.ts +++ b/app/_server/actions/cronjobs/index.ts @@ -5,8 +5,11 @@ import { addCronJob, deleteCronJob, updateCronJob, + pauseCronJob, + resumeCronJob, type CronJob, } from "@/app/_utils/system"; +import { getAllTargetUsers } from "@/app/_utils/system/hostCrontab"; import { revalidatePath } from "next/cache"; import { getScriptPath } from "@/app/_utils/scripts"; @@ -27,6 +30,7 @@ export async function createCronJob( const command = formData.get("command") as string; const comment = formData.get("comment") as string; const selectedScriptId = formData.get("selectedScriptId") as string; + const user = formData.get("user") as string; if (!schedule) { return { success: false, message: "Schedule is required" }; @@ -51,7 +55,7 @@ export async function createCronJob( }; } - const success = await addCronJob(schedule, finalCommand, comment); + const success = await addCronJob(schedule, finalCommand, comment, user); if (success) { revalidatePath("/"); return { success: true, message: "Cron job created successfully" }; @@ -122,7 +126,8 @@ export async function cloneCronJob( const success = await addCronJob( originalJob.schedule, originalJob.command, - newComment + newComment, + originalJob.user ); if (success) { @@ -136,3 +141,46 @@ export async function cloneCronJob( return { success: false, message: "Error cloning cron job" }; } } + +export async function pauseCronJobAction( + id: string +): Promise<{ success: boolean; message: string }> { + try { + const success = await pauseCronJob(id); + if (success) { + revalidatePath("/"); + return { success: true, message: "Cron job paused successfully" }; + } else { + return { success: false, message: "Failed to pause cron job" }; + } + } catch (error) { + console.error("Error pausing cron job:", error); + return { success: false, message: "Error pausing cron job" }; + } +} + +export async function resumeCronJobAction( + id: string +): Promise<{ success: boolean; message: string }> { + try { + const success = await resumeCronJob(id); + if (success) { + revalidatePath("/"); + return { success: true, message: "Cron job resumed successfully" }; + } else { + return { success: false, message: "Failed to resume cron job" }; + } + } catch (error) { + console.error("Error resuming cron job:", error); + return { success: false, message: "Error resuming cron job" }; + } +} + +export async function fetchAvailableUsers(): Promise { + try { + return await getAllTargetUsers(); + } catch (error) { + console.error("Error fetching available users:", error); + return []; + } +} diff --git a/app/_utils/system.ts b/app/_utils/system.ts index d14f2ac..b57e7c6 100644 --- a/app/_utils/system.ts +++ b/app/_utils/system.ts @@ -3,5 +3,7 @@ export { addCronJob, deleteCronJob, updateCronJob, - type CronJob + pauseCronJob, + resumeCronJob, + type CronJob, } from "./system/cron"; diff --git a/app/_utils/system/cron.ts b/app/_utils/system/cron.ts index a32e282..53c6c8f 100644 --- a/app/_utils/system/cron.ts +++ b/app/_utils/system/cron.ts @@ -1,229 +1,782 @@ import { exec } from "child_process"; import { promisify } from "util"; -import { readHostCrontab, writeHostCrontab } from "./hostCrontab"; +import { + readHostCrontab, + writeHostCrontab, + readAllHostCrontabs, + writeHostCrontabForUser, +} from "./hostCrontab"; const execAsync = promisify(exec); export interface CronJob { - id: string; - schedule: string; - command: string; - comment?: string; + id: string; + schedule: string; + command: string; + comment?: string; + user: string; + paused?: boolean; +} + +function pauseJobInLines(lines: string[], targetJobIndex: number): string[] { + const newCronEntries: string[] = []; + let currentJobIndex = 0; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const trimmedLine = line.trim(); + + if (!trimmedLine) { + newCronEntries.push(line); + i++; + continue; + } + + if ( + trimmedLine.startsWith("# User:") || + trimmedLine.startsWith("# System Crontab") + ) { + newCronEntries.push(line); + i++; + continue; + } + + if (trimmedLine.startsWith("# PAUSED: ")) { + newCronEntries.push(line); + if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) { + newCronEntries.push(lines[i + 1]); + i += 2; + } else { + i++; + } + currentJobIndex++; + continue; + } + + if (trimmedLine.startsWith("#")) { + if ( + i + 1 < lines.length && + !lines[i + 1].trim().startsWith("#") && + lines[i + 1].trim() + ) { + if (currentJobIndex === targetJobIndex) { + const comment = trimmedLine.substring(1).trim(); + const nextLine = lines[i + 1].trim(); + const pausedEntry = `# PAUSED: ${comment}\n# ${nextLine}`; + newCronEntries.push(pausedEntry); + i += 2; + currentJobIndex++; + } else { + newCronEntries.push(line); + i++; + } + } else { + newCronEntries.push(line); + i++; + } + continue; + } + + if (currentJobIndex === targetJobIndex) { + const pausedEntry = `# PAUSED:\n# ${trimmedLine}`; + newCronEntries.push(pausedEntry); + } else { + newCronEntries.push(line); + } + + currentJobIndex++; + i++; + } + + return newCronEntries; +} + +function resumeJobInLines(lines: string[], targetJobIndex: number): string[] { + const newCronEntries: string[] = []; + let currentJobIndex = 0; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const trimmedLine = line.trim(); + + if (!trimmedLine) { + newCronEntries.push(line); + i++; + continue; + } + + if ( + trimmedLine.startsWith("# User:") || + trimmedLine.startsWith("# System Crontab") + ) { + newCronEntries.push(line); + i++; + continue; + } + + if (trimmedLine.startsWith("# PAUSED: ")) { + if (currentJobIndex === targetJobIndex) { + const comment = trimmedLine.substring(10).trim(); + if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) { + const cronLine = lines[i + 1].trim().substring(2); + const resumedEntry = comment ? `# ${comment}\n${cronLine}` : cronLine; + newCronEntries.push(resumedEntry); + i += 2; + } else { + i++; + } + } else { + newCronEntries.push(line); + if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) { + newCronEntries.push(lines[i + 1]); + i += 2; + } else { + i++; + } + } + currentJobIndex++; + continue; + } + + if (trimmedLine.startsWith("#")) { + newCronEntries.push(line); + i++; + continue; + } + + newCronEntries.push(line); + currentJobIndex++; + i++; + } + + return newCronEntries; +} + +function parseJobsFromLines(lines: string[], user: string): CronJob[] { + const jobs: CronJob[] = []; + let currentComment = ""; + let jobIndex = 0; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const trimmedLine = line.trim(); + + if (!trimmedLine) { + i++; + continue; + } + + if ( + trimmedLine.startsWith("# User:") || + trimmedLine.startsWith("# System Crontab") + ) { + i++; + continue; + } + + if (trimmedLine.startsWith("# PAUSED: ")) { + const comment = trimmedLine.substring(10).trim(); + + if (i + 1 < lines.length) { + const nextLine = lines[i + 1].trim(); + if (nextLine.startsWith("# ")) { + const commentedCron = nextLine.substring(2); + const parts = commentedCron.split(/\s+/); + if (parts.length >= 6) { + const schedule = parts.slice(0, 5).join(" "); + const command = parts.slice(5).join(" "); + + jobs.push({ + id: `${user}-${jobIndex}`, + schedule, + command, + comment: comment || undefined, + user, + paused: true, + }); + + jobIndex++; + i += 2; + continue; + } + } + } + i++; + continue; + } + + if (trimmedLine.startsWith("#")) { + if ( + i + 1 < lines.length && + !lines[i + 1].trim().startsWith("#") && + lines[i + 1].trim() + ) { + currentComment = trimmedLine.substring(1).trim(); + i++; + continue; + } else { + i++; + continue; + } + } + + const parts = trimmedLine.split(/\s+/); + if (parts.length >= 6) { + const schedule = parts.slice(0, 5).join(" "); + const command = parts.slice(5).join(" "); + + jobs.push({ + id: `${user}-${jobIndex}`, + schedule, + command, + comment: currentComment || undefined, + user, + paused: false, + }); + + jobIndex++; + currentComment = ""; + } + i++; + } + + return jobs; +} + +function deleteJobInLines(lines: string[], targetJobIndex: number): string[] { + const newCronEntries: string[] = []; + let currentJobIndex = 0; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const trimmedLine = line.trim(); + + if (!trimmedLine) { + newCronEntries.push(line); + i++; + continue; + } + + if ( + trimmedLine.startsWith("# User:") || + trimmedLine.startsWith("# System Crontab") + ) { + newCronEntries.push(line); + i++; + continue; + } + + if (trimmedLine.startsWith("# PAUSED: ")) { + if (currentJobIndex !== targetJobIndex) { + newCronEntries.push(line); + if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) { + newCronEntries.push(lines[i + 1]); + i += 2; + } else { + i++; + } + } else { + if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) { + i += 2; + } else { + i++; + } + } + currentJobIndex++; + continue; + } + + if (trimmedLine.startsWith("#")) { + if ( + i + 1 < lines.length && + !lines[i + 1].trim().startsWith("#") && + lines[i + 1].trim() + ) { + if (currentJobIndex !== targetJobIndex) { + newCronEntries.push(line); + } + i++; + } else { + newCronEntries.push(line); + i++; + } + continue; + } + + if (currentJobIndex !== targetJobIndex) { + newCronEntries.push(line); + } + + currentJobIndex++; + i++; + } + + return newCronEntries; +} + +function updateJobInLines( + lines: string[], + targetJobIndex: number, + schedule: string, + command: string, + comment: string = "" +): string[] { + const newCronEntries: string[] = []; + let currentJobIndex = 0; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const trimmedLine = line.trim(); + + if (!trimmedLine) { + newCronEntries.push(line); + i++; + continue; + } + + if ( + trimmedLine.startsWith("# User:") || + trimmedLine.startsWith("# System Crontab") + ) { + newCronEntries.push(line); + i++; + continue; + } + + if (trimmedLine.startsWith("# PAUSED: ")) { + if (currentJobIndex === targetJobIndex) { + const newEntry = comment + ? `# PAUSED: ${comment}\n# ${schedule} ${command}` + : `# PAUSED:\n# ${schedule} ${command}`; + newCronEntries.push(newEntry); + if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) { + i += 2; + } else { + i++; + } + } else { + newCronEntries.push(line); + if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) { + newCronEntries.push(lines[i + 1]); + i += 2; + } else { + i++; + } + } + currentJobIndex++; + continue; + } + + if (trimmedLine.startsWith("#")) { + if ( + i + 1 < lines.length && + !lines[i + 1].trim().startsWith("#") && + lines[i + 1].trim() + ) { + if (currentJobIndex === targetJobIndex) { + const newEntry = comment + ? `# ${comment}\n${schedule} ${command}` + : `${schedule} ${command}`; + newCronEntries.push(newEntry); + i += 2; + } else { + newCronEntries.push(line); + i++; + } + } else { + newCronEntries.push(line); + i++; + } + continue; + } + + if (currentJobIndex === targetJobIndex) { + const newEntry = comment + ? `# ${comment}\n${schedule} ${command}` + : `${schedule} ${command}`; + newCronEntries.push(newEntry); + } else { + newCronEntries.push(line); + } + + currentJobIndex++; + i++; + } + + return newCronEntries; } async function readCronFiles(): Promise { - const isDocker = process.env.DOCKER === "true"; + const isDocker = process.env.DOCKER === "true"; - if (!isDocker) { - try { - const { stdout } = await execAsync('crontab -l 2>/dev/null || echo ""'); - return stdout; - } catch (error) { - console.error("Error reading crontab:", error); - return ""; - } + if (!isDocker) { + try { + const { stdout } = await execAsync('crontab -l 2>/dev/null || echo ""'); + return stdout; + } catch (error) { + console.error("Error reading crontab:", error); + return ""; } + } - return await readHostCrontab(); + return await readHostCrontab(); } async function writeCronFiles(content: string): Promise { - const isDocker = process.env.DOCKER === "true"; + const isDocker = process.env.DOCKER === "true"; - if (!isDocker) { - try { - await execAsync('echo "' + content + '" | crontab -'); - return true; - } catch (error) { - console.error("Error writing crontab:", error); - return false; - } + if (!isDocker) { + try { + await execAsync('echo "' + content + '" | crontab -'); + return true; + } catch (error) { + console.error("Error writing crontab:", error); + return false; } + } - return await writeHostCrontab(content); + return await writeHostCrontab(content); } export async function getCronJobs(): Promise { - try { - const cronContent = await readCronFiles(); + try { + const isDocker = process.env.DOCKER === "true"; + let allJobs: CronJob[] = []; - if (!cronContent.trim()) { - return []; + if (isDocker) { + const userCrontabs = await readAllHostCrontabs(); + + for (const { user, content } of userCrontabs) { + if (!content.trim()) continue; + + const lines = content.split("\n"); + const jobs = parseJobsFromLines(lines, user); + allJobs.push(...jobs); + } + } else { + const { getAllTargetUsers } = await import("./hostCrontab"); + const users = await getAllTargetUsers(); + + for (const user of users) { + try { + const { stdout } = await execAsync( + `crontab -l -u ${user} 2>/dev/null || echo ""` + ); + const cronContent = stdout; + + if (!cronContent.trim()) continue; + + const lines = cronContent.split("\n"); + const jobs = parseJobsFromLines(lines, user); + allJobs.push(...jobs); + } catch (error) { + console.error(`Error reading crontab for user ${user}:`, error); } - - const lines = cronContent.split("\n"); - const jobs: CronJob[] = []; - let currentComment = ""; - let currentUser = ""; - let jobIndex = 0; - - lines.forEach((line) => { - const trimmedLine = line.trim(); - - if (!trimmedLine) return; - - if (trimmedLine.startsWith("# User: ")) { - currentUser = trimmedLine.substring(8).trim(); - return; - } - - if (trimmedLine.startsWith("# System Crontab")) { - currentUser = "system"; - return; - } - - if (trimmedLine.startsWith("#")) { - currentComment = trimmedLine.substring(1).trim(); - return; - } - - const parts = trimmedLine.split(/\s+/); - if (parts.length >= 6) { - const schedule = parts.slice(0, 5).join(" "); - const command = parts.slice(5).join(" "); - - jobs.push({ - id: `unix-${jobIndex}`, - schedule, - command, - comment: currentComment, - }); - - currentComment = ""; - jobIndex++; - } - }); - - return jobs; - } catch (error) { - console.error("Error getting cron jobs:", error); - return []; + } } + + return allJobs; + } catch (error) { + console.error("Error getting cron jobs:", error); + return []; + } } export async function addCronJob( - schedule: string, - command: string, - comment: string = "" + schedule: string, + command: string, + comment: string = "", + user?: string ): Promise { - try { - const cronContent = await readCronFiles(); + try { + const isDocker = process.env.DOCKER === "true"; + + if (isDocker && user) { + const userCrontabs = await readAllHostCrontabs(); + const targetUserCrontab = userCrontabs.find((uc) => uc.user === user); + + if (!targetUserCrontab) { + console.error(`User ${user} not found in available users`); + return false; + } + + const newEntry = comment + ? `# ${comment}\n${schedule} ${command}` + : `${schedule} ${command}`; + + let newCron; + if (targetUserCrontab.content.trim() === "") { + newCron = newEntry; + } else { + const existingContent = targetUserCrontab.content.endsWith("\n") + ? targetUserCrontab.content + : targetUserCrontab.content + "\n"; + newCron = existingContent + newEntry; + } + + return await writeHostCrontabForUser(user, newCron); + } else if (user) { + try { + const { stdout } = await execAsync( + `crontab -l -u ${user} 2>/dev/null || echo ""` + ); + const cronContent = stdout; const newEntry = comment - ? `# ${comment}\n${schedule} ${command}` - : `${schedule} ${command}`; + ? `# ${comment}\n${schedule} ${command}` + : `${schedule} ${command}`; let newCron; if (cronContent.trim() === "") { - newCron = newEntry; + newCron = newEntry; } else { - const existingContent = cronContent.endsWith('\n') ? cronContent : cronContent + '\n'; - newCron = existingContent + newEntry; + const existingContent = cronContent.endsWith("\n") + ? cronContent + : cronContent + "\n"; + newCron = existingContent + newEntry; } - return await writeCronFiles(newCron); - } catch (error) { - console.error("Error adding cron job:", error); + await execAsync(`echo '${newCron}' | crontab -u ${user} -`); + return true; + } catch (error) { + console.error(`Error adding cron job for user ${user}:`, error); return false; + } + } else { + const cronContent = await readCronFiles(); + + const newEntry = comment + ? `# ${comment}\n${schedule} ${command}` + : `${schedule} ${command}`; + + let newCron; + if (cronContent.trim() === "") { + newCron = newEntry; + } else { + const existingContent = cronContent.endsWith("\n") + ? cronContent + : cronContent + "\n"; + newCron = existingContent + newEntry; + } + + return await writeCronFiles(newCron); } + } catch (error) { + console.error("Error adding cron job:", error); + return false; + } } export async function deleteCronJob(id: string): Promise { - try { - const cronContent = await readCronFiles(); + try { + const isDocker = process.env.DOCKER === "true"; + + if (isDocker && id.includes("-")) { + const [user, jobIndexStr] = id.split("-"); + const jobIndex = parseInt(jobIndexStr); + + const userCrontabs = await readAllHostCrontabs(); + const targetUserCrontab = userCrontabs.find((uc) => uc.user === user); + + if (!targetUserCrontab) { + console.error(`User ${user} not found`); + return false; + } + + const lines = targetUserCrontab.content.split("\n"); + const newCronEntries = deleteJobInLines(lines, jobIndex); + const newCron = newCronEntries.join("\n") + "\n"; + + return await writeHostCrontabForUser(user, newCron); + } else { + const [user, jobIndexStr] = id.split("-"); + const jobIndex = parseInt(jobIndexStr); + + try { + const { stdout } = await execAsync( + `crontab -l -u ${user} 2>/dev/null || echo ""` + ); + const cronContent = stdout; const lines = cronContent.split("\n"); - let currentComment = ""; - let cronEntries: string[] = []; - let jobIndex = 0; - let targetJobIndex = parseInt(id.replace("unix-", "")); + const newCronEntries = deleteJobInLines(lines, jobIndex); + const newCron = newCronEntries.join("\n") + "\n"; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmedLine = line.trim(); - - if (!trimmedLine) continue; - - if (trimmedLine.startsWith("# User:") || trimmedLine.startsWith("# System Crontab")) { - cronEntries.push(trimmedLine); - } else if (trimmedLine.startsWith("#")) { - if (i + 1 < lines.length && !lines[i + 1].trim().startsWith("#") && lines[i + 1].trim()) { - currentComment = trimmedLine; - } else { - cronEntries.push(trimmedLine); - } - } else { - if (jobIndex !== targetJobIndex) { - const entryWithComment = currentComment - ? `${currentComment}\n${trimmedLine}` - : trimmedLine; - cronEntries.push(entryWithComment); - } - jobIndex++; - currentComment = ""; - } - } - - const newCron = cronEntries.join("\n") + "\n"; - await writeCronFiles(newCron); + await execAsync(`echo '${newCron}' | crontab -u ${user} -`); return true; - } catch (error) { - console.error("Error deleting cron job:", error); + } catch (error) { + console.error(`Error deleting cron job for user ${user}:`, error); + return false; + } } + } catch (error) { + console.error("Error deleting cron job:", error); + } - return false; + return false; } export async function updateCronJob( - id: string, - schedule: string, - command: string, - comment: string = "" + id: string, + schedule: string, + command: string, + comment: string = "" ): Promise { - try { - const cronContent = await readCronFiles(); + try { + const isDocker = process.env.DOCKER === "true"; + + if (isDocker && id.includes("-")) { + const [user, jobIndexStr] = id.split("-"); + const jobIndex = parseInt(jobIndexStr); + + const userCrontabs = await readAllHostCrontabs(); + const targetUserCrontab = userCrontabs.find((uc) => uc.user === user); + + if (!targetUserCrontab) { + console.error(`User ${user} not found`); + return false; + } + + const lines = targetUserCrontab.content.split("\n"); + const newCronEntries = updateJobInLines( + lines, + jobIndex, + schedule, + command, + comment + ); + const newCron = newCronEntries.join("\n") + "\n"; + + return await writeHostCrontabForUser(user, newCron); + } else { + const [user, jobIndexStr] = id.split("-"); + const jobIndex = parseInt(jobIndexStr); + + try { + const { stdout } = await execAsync( + `crontab -l -u ${user} 2>/dev/null || echo ""` + ); + const cronContent = stdout; const lines = cronContent.split("\n"); - let currentComment = ""; - let cronEntries: string[] = []; - let jobIndex = 0; - let targetJobIndex = parseInt(id.replace("unix-", "")); + const newCronEntries = updateJobInLines( + lines, + jobIndex, + schedule, + command, + comment + ); + const newCron = newCronEntries.join("\n") + "\n"; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmedLine = line.trim(); - - if (!trimmedLine) continue; - - if (trimmedLine.startsWith("# User:") || trimmedLine.startsWith("# System Crontab")) { - cronEntries.push(trimmedLine); - } else if (trimmedLine.startsWith("#")) { - if (i + 1 < lines.length && !lines[i + 1].trim().startsWith("#") && lines[i + 1].trim()) { - currentComment = trimmedLine; - } else { - cronEntries.push(trimmedLine); - } - } else { - if (jobIndex === targetJobIndex) { - const newEntry = comment - ? `# ${comment}\n${schedule} ${command}` - : `${schedule} ${command}`; - cronEntries.push(newEntry); - } else { - const entryWithComment = currentComment - ? `${currentComment}\n${trimmedLine}` - : trimmedLine; - cronEntries.push(entryWithComment); - } - jobIndex++; - currentComment = ""; - } - } - - const newCron = cronEntries.join("\n") + "\n"; - await writeCronFiles(newCron); + await execAsync(`echo '${newCron}' | crontab -u ${user} -`); return true; - } catch (error) { - console.error("Error updating cron job:", error); + } catch (error) { + console.error(`Error updating cron job for user ${user}:`, error); + return false; + } } + } catch (error) { + console.error("Error updating cron job:", error); + } - return false; + return false; +} + +export async function pauseCronJob(id: string): Promise { + try { + const isDocker = process.env.DOCKER === "true"; + + if (isDocker && id.includes("-")) { + const [user, jobIndexStr] = id.split("-"); + const jobIndex = parseInt(jobIndexStr); + + const userCrontabs = await readAllHostCrontabs(); + const targetUserCrontab = userCrontabs.find((uc) => uc.user === user); + + if (!targetUserCrontab) { + console.error(`User ${user} not found`); + return false; + } + + const lines = targetUserCrontab.content.split("\n"); + const newCronEntries = pauseJobInLines(lines, jobIndex); + const newCron = newCronEntries.join("\n") + "\n"; + + return await writeHostCrontabForUser(user, newCron); + } else { + const [user, jobIndexStr] = id.split("-"); + const jobIndex = parseInt(jobIndexStr); + + try { + const { stdout } = await execAsync( + `crontab -l -u ${user} 2>/dev/null || echo ""` + ); + const cronContent = stdout; + const lines = cronContent.split("\n"); + const newCronEntries = pauseJobInLines(lines, jobIndex); + const newCron = newCronEntries.join("\n") + "\n"; + + await execAsync(`echo '${newCron}' | crontab -u ${user} -`); + return true; + } catch (error) { + console.error(`Error pausing cron job for user ${user}:`, error); + return false; + } + } + } catch (error) { + console.error("Error pausing cron job:", error); + } + + return false; +} + +export async function resumeCronJob(id: string): Promise { + try { + const isDocker = process.env.DOCKER === "true"; + + if (isDocker && id.includes("-")) { + const [user, jobIndexStr] = id.split("-"); + const jobIndex = parseInt(jobIndexStr); + + const userCrontabs = await readAllHostCrontabs(); + const targetUserCrontab = userCrontabs.find((uc) => uc.user === user); + + if (!targetUserCrontab) { + console.error(`User ${user} not found`); + return false; + } + + const lines = targetUserCrontab.content.split("\n"); + const newCronEntries = resumeJobInLines(lines, jobIndex); + const newCron = newCronEntries.join("\n") + "\n"; + + return await writeHostCrontabForUser(user, newCron); + } else { + const [user, jobIndexStr] = id.split("-"); + const jobIndex = parseInt(jobIndexStr); + + try { + const { stdout } = await execAsync( + `crontab -l -u ${user} 2>/dev/null || echo ""` + ); + const cronContent = stdout; + const lines = cronContent.split("\n"); + const newCronEntries = resumeJobInLines(lines, jobIndex); + const newCron = newCronEntries.join("\n") + "\n"; + + await execAsync(`echo '${newCron}' | crontab -u ${user} -`); + return true; + } catch (error) { + console.error(`Error resuming cron job for user ${user}:`, error); + return false; + } + } + } catch (error) { + console.error("Error resuming cron job:", error); + } + + return false; } diff --git a/app/_utils/system/hostCrontab.ts b/app/_utils/system/hostCrontab.ts index 5dde5b1..36f2e5c 100644 --- a/app/_utils/system/hostCrontab.ts +++ b/app/_utils/system/hostCrontab.ts @@ -60,6 +60,35 @@ async function getTargetUser(): Promise { } } +export async function getAllTargetUsers(): Promise { + try { + if (process.env.HOST_CRONTAB_USER) { + return process.env.HOST_CRONTAB_USER.split(",").map((u) => u.trim()); + } + + const isDocker = process.env.DOCKER === "true"; + if (isDocker) { + const singleUser = await getTargetUser(); + return [singleUser]; + } else { + try { + const { stdout } = await execAsync("ls /var/spool/cron/crontabs/"); + const users = stdout + .trim() + .split("\n") + .filter((user) => user.trim()); + return users.length > 0 ? users : ["root"]; + } catch (error) { + console.error("Error detecting users from crontabs directory:", error); + return ["root"]; + } + } + } catch (error) { + console.error("Error getting all target users:", error); + return ["root"]; + } +} + export async function readHostCrontab(): Promise { try { const user = await getTargetUser(); @@ -72,6 +101,32 @@ export async function readHostCrontab(): Promise { } } +export async function readAllHostCrontabs(): Promise< + { user: string; content: string }[] +> { + try { + const users = await getAllTargetUsers(); + const results: { user: string; content: string }[] = []; + + for (const user of users) { + try { + const content = await execHostCrontab( + `crontab -l -u ${user} 2>/dev/null || echo ""` + ); + results.push({ user, content }); + } catch (error) { + console.warn(`Error reading crontab for user ${user}:`, error); + results.push({ user, content: "" }); + } + } + + return results; + } catch (error) { + console.error("Error reading all host crontabs:", error); + return []; + } +} + export async function writeHostCrontab(content: string): Promise { try { const user = await getTargetUser(); @@ -90,3 +145,24 @@ export async function writeHostCrontab(content: string): Promise { return false; } } + +export async function writeHostCrontabForUser( + user: string, + content: string +): Promise { + try { + let finalContent = content; + if (!finalContent.endsWith("\n")) { + finalContent += "\n"; + } + + const base64Content = Buffer.from(finalContent).toString("base64"); + await execHostCrontab( + `echo '${base64Content}' | base64 -d | crontab -u ${user} -` + ); + return true; + } catch (error) { + console.error(`Error writing host crontab for user ${user}:`, error); + return false; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index d2dc8ab..9bd0c1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: - NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000 # If docker struggles to find your crontab user, update this variable with it. # Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/ + # For multiple users, use comma-separated values: HOST_CRONTAB_USER=user1,user2,user3 # - HOST_CRONTAB_USER=fccview volumes: # Mount Docker socket to execute commands on host From 801bcf22a216284ca936f9a6dd96db41a847ccfd Mon Sep 17 00:00:00 2001 From: fccview Date: Tue, 26 Aug 2025 19:46:06 +0100 Subject: [PATCH 2/7] Multi user support and enhancements --- .github/workflows/docker-publish.yml | 2 +- .gitignore | 3 +- README.md | 24 +- app/_components/CronJobList.tsx | 68 ++- app/_server/actions/cronjobs/index.ts | 65 +++ app/_utils/cron/files-manipulation.ts | 59 ++ app/_utils/cron/line-manipulation.ts | 402 +++++++++++++ app/_utils/system.ts | 1 + app/_utils/system/cron.ts | 786 ++++---------------------- app/page.tsx | 2 +- docker-compose.yml | 5 +- tsconfig.tsbuildinfo | 1 - 12 files changed, 736 insertions(+), 682 deletions(-) create mode 100644 app/_utils/cron/files-manipulation.ts create mode 100644 app/_utils/cron/line-manipulation.ts delete mode 100644 tsconfig.tsbuildinfo diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 71e0a69..8cc7b2d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Docker on: push: - branches: ["main", "legacy"] + branches: ["main", "legacy", "feature/*", "bugfix/*"] tags: ["*"] pull_request: branches: ["main"] diff --git a/.gitignore b/.gitignore index 1c4a75c..6a9609a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ node_modules .next .vscode .DS_Store -.cursorignore \ No newline at end of file +.cursorignore +tsconfig.tsbuildinfo diff --git a/README.md b/README.md index d03bc2a..fac85ba 100644 --- a/README.md +++ b/README.md @@ -59,17 +59,31 @@ services: - NODE_ENV=production - DOCKER=true - NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000 + - HOST_PROJECT_DIR=/path/to/cronmaster/directoryservices: + cronjob-manager: + image: ghcr.io/fccview/cronmaster:2.3.0 + container_name: cronmaster + user: "root" + ports: + # Feel free to change port, 3000 is very common so I like to map it to something else + - "40124:3000" + environment: + - NODE_ENV=production + - DOCKER=true + # Legacy used to be NEXT_PUBLIC_HOST_PROJECT_DIR, this was causing issues on runtime. - HOST_PROJECT_DIR=/path/to/cronmaster/directory + - NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000 # If docker struggles to find your crontab user, update this variable with it. # Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/ - # - HOST_CRONTAB_USER=fccview + # For multiple users, use comma-separated values: HOST_CRONTAB_USER=root,fccview,user3 + #- HOST_CRONTAB_USER=fccview volumes: # Mount Docker socket to execute commands on host - /var/run/docker.sock:/var/run/docker.sock # These are needed if you want to keep your data on the host machine and not wihin the docker volume. # DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app - # will target this foler (thanks to the HOST_PROJECT_DIR variable set above) + # will target this folder (thanks to the HOST_PROJECT_DIR variable set above) - ./scripts:/app/scripts - ./data:/app/data - ./snippets:/app/snippets @@ -83,6 +97,7 @@ services: # Default platform is set to amd64, uncomment to use arm64. #platform: linux/arm64 + ``` ### ARM64 Support @@ -235,6 +250,11 @@ I would like to thank the following members for raising issues and help test/deb
mariushosting
+ + +
DVDAndroid
+ + diff --git a/app/_components/CronJobList.tsx b/app/_components/CronJobList.tsx index 63eb9d5..5c0c530 100644 --- a/app/_components/CronJobList.tsx +++ b/app/_components/CronJobList.tsx @@ -11,6 +11,7 @@ import { User, Play, Pause, + Code, } from "lucide-react"; import { CronJob } from "@/app/_utils/system"; import { @@ -20,8 +21,9 @@ import { cloneCronJob, pauseCronJobAction, resumeCronJobAction, + runCronJob, } from "@/app/_server/actions/cronjobs"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { CreateTaskModal } from "./modals/CreateTaskModal"; import { EditTaskModal } from "./modals/EditTaskModal"; import { DeleteTaskModal } from "./modals/DeleteTaskModal"; @@ -45,7 +47,24 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) { const [jobToDelete, setJobToDelete] = useState(null); const [jobToClone, setJobToClone] = useState(null); const [isCloning, setIsCloning] = useState(false); + const [runningJobId, setRunningJobId] = useState(null); const [selectedUser, setSelectedUser] = useState(null); + + useEffect(() => { + const savedUser = localStorage.getItem("selectedCronUser"); + if (savedUser) { + setSelectedUser(savedUser); + } + }, []); + + useEffect(() => { + if (selectedUser) { + localStorage.setItem("selectedCronUser", selectedUser); + } else { + localStorage.removeItem("selectedCronUser"); + } + }, [selectedUser]); + const [editForm, setEditForm] = useState({ schedule: "", command: "", @@ -134,6 +153,32 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) { } }; + const handleRun = async (id: string) => { + setRunningJobId(id); + try { + const result = await runCronJob(id); + if (result.success) { + showToast("success", "Cron job executed successfully"); + if (result.output) { + console.log("Command output:", result.output); + } + } else { + showToast("error", "Failed to execute cron job", result.message); + if (result.output) { + console.error("Command error:", result.output); + } + } + } catch (error) { + showToast( + "error", + "Failed to execute cron job", + "Please try again later." + ); + } finally { + setRunningJobId(null); + } + }; + const confirmDelete = (job: CronJob) => { setJobToDelete(job); setIsDeleteModalOpen(true); @@ -287,8 +332,8 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) { key={job.id} className="glass-card p-4 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors" > -
-
+
+
{job.schedule} @@ -325,7 +370,22 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) { )}
-
+
+