10 Commits

Author SHA1 Message Date
fccview
968fbae13c remove bugfix from runs 2025-09-20 21:06:00 +01:00
fccview
c739d29141 quick fix for script extra character and delete issues 2025-09-20 21:05:10 +01:00
fccview
389ee44e4e Merge pull request #28 from fccview/feature/pwa-and-auth
WIP auth and pwa integration
2025-09-01 19:23:11 +01:00
fccview
33ff5de463 update readme and docker compose to be a bit easier to maintain 2025-09-01 16:44:09 +01:00
fccview
7aeea3f46a add better error handling 2025-09-01 16:10:11 +01:00
fccview
9018f2caed fix pausing jobs without a comment 2025-09-01 15:36:45 +01:00
fccview
7383a13c13 fix pwa fully 2025-09-01 15:23:59 +01:00
fccview
da11d3503e WIP auth and pwa integration 2025-09-01 07:18:42 +01:00
fccview
0b9edc5f11 WIP auth and pwa integration 2025-08-31 21:19:51 +01:00
fccview
44b31a5702 i always forget to update the latest version in the docker compose file 2025-08-31 19:23:15 +01:00
34 changed files with 3751 additions and 641 deletions

View File

@@ -2,7 +2,7 @@ name: Docker
on:
push:
branches: ["main", "legacy", "feature/*", "bugfix/*"]
branches: ["main", "legacy", "feature/*"]
tags: ["*"]
pull_request:
branches: ["main"]

View File

@@ -49,7 +49,7 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
```bash
services:
cronjob-manager:
image: ghcr.io/fccview/cronmaster:1.3.0
image: ghcr.io/fccview/cronmaster:latest
container_name: cronmaster
user: "root"
ports:
@@ -58,17 +58,26 @@ services:
environment:
- NODE_ENV=production
- DOCKER=true
# Legacy used to be NEXT_PUBLIC_HOST_PROJECT_DIR, this was causing issues on runtime.
# --- MAP HOST PROJECT DIRECTORY, THIS IS MANDATORY FOR SCRIPTS TO WORK
- 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/
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=fccview,root,user1,user2
# - HOST_CRONTAB_USER=fccview
# --- PASSWORD PROTECTION
# Uncomment to enable password protection (replace "very_strong_password" with your own)
- AUTH_PASSWORD=very_strong_password
# --- CRONTAB USERS
# This is used to read the crontabs for the specific user.
# replace root with your user - find it with: ls -asl /var/spool/cron/crontabs/
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=root,user1,user2
- HOST_CRONTAB_USER=root
volumes:
# --- MOUNT DOCKER SOCKET
# Mount Docker socket to execute commands on host
- /var/run/docker.sock:/var/run/docker.sock
# --- MOUNT DATA
# 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 folder (thanks to the HOST_PROJECT_DIR variable set above)
@@ -76,14 +85,14 @@ services:
- ./data:/app/data
- ./snippets:/app/snippets
# Use host PID namespace for host command execution
# Run in privileged mode for nsenter access
# --- USE HOST PID NAMESPACE FOR HOST COMMAND EXECUTION
# --- RUN IN PRIVILEGED MODE FOR NSENTER ACCESS
pid: "host"
privileged: true
restart: unless-stopped
restart: always
init: true
# Default platform is set to amd64, uncomment to use arm64.
# --- DEFAULT PLATFORM IS SET TO AMD64, UNCOMMENT TO USE ARM64.
#platform: linux/arm64
```
@@ -136,6 +145,8 @@ The following environment variables can be configured:
| `NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL` | `30000` | Clock update interval in milliseconds (30 seconds) |
| `HOST_PROJECT_DIR` | `N/A` | Mandatory variable to make sure cron runs on the right path. |
| `DOCKER` | `false` | ONLY set this to true if you are runnign the app via docker, in the docker-compose.yml file |
| `HOST_CRONTAB_USER` | `root` | Comma separated list of users that run cronjobs on your host machine |
| `AUTH_PASSWORD` | `N/A` | If you set a password the application will be password protected with basic next-auth |
**Example**: To change the clock update interval to 60 seconds:
@@ -152,7 +163,6 @@ HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
### Important Notes for Docker
- Root user is required for cron operations and direct file access. There is no way around this, if you don't feel comfortable in running it as root feel free to run the app locally with `yarn install`, `yarn build` and `yarn start`
- Crontab files are accessed directly via file system mounts at `/host/cron/crontabs` and `/host/crontab` for real-time reading and writing
- `HOST_PROJECT_DIR` is required in order for the scripts created within the app to run properly
- The `DOCKER=true` environment variable enables direct file access mode for crontab operations. This is REQUIRED when running the application in docker mode.
@@ -162,9 +172,6 @@ HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
The application automatically detects your operating system and displays:
- Platform
- Hostname
- IP Address
- System Uptime
- Memory Usage
- CPU Information
@@ -244,6 +251,12 @@ I would like to thank the following members for raising issues and help test/deb
<td align="center" valign="top" width="20%">
<a href="https://github.com/ActxLeToucan"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/56509120?u=b0a684dfa1fcf8f3f41c2ead37f6441716d8bd62&v=4&size=100"><br />ActxLeToucan</a>
</td>
<td align="center" valign="top" width="20%">
<a href="https://github.com/mrtimothyduong"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/34667840?u=b54354da56681c17ca58366a68a6a94c80f77a1d&v=4&size=100"><br />mrtimothyduong</a>
</td>
<td align="center" valign="top" width="20%">
<a href="https://github.com/cerede2000"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/38144752?v=4&size=100"><br />cerede2000</a>
</td>
</tr>
</tbody>
</table>
@@ -255,3 +268,7 @@ This project is licensed under the MIT License.
## Support
For issues and questions, please open an issue on the GitHub repository.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=fccview/cronmaster&type=Date)](https://www.star-history.com/#fccview/cronmaster&Date)

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { CronJobList } from "./CronJobList";
import { CronJobList } from "./features/Cronjobs/CronJobList";
import { ScriptsManager } from "./ScriptsManager";
import { CronJob } from "@/app/_utils/system";
import { type Script } from "@/app/_server/actions/scripts";
@@ -12,7 +12,10 @@ interface TabbedInterfaceProps {
scripts: Script[];
}
export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) => {
export const TabbedInterface = ({
cronJobs,
scripts,
}: TabbedInterfaceProps) => {
const [activeTab, setActiveTab] = useState<"cronjobs" | "scripts">(
"cronjobs"
);
@@ -23,10 +26,11 @@ export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) =>
<div className="flex">
<button
onClick={() => setActiveTab("cronjobs")}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${activeTab === "cronjobs"
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${
activeTab === "cronjobs"
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
}`}
>
<Clock className="h-4 w-4" />
Cron Jobs
@@ -36,10 +40,11 @@ export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) =>
</button>
<button
onClick={() => setActiveTab("scripts")}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${activeTab === "scripts"
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${
activeTab === "scripts"
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
}`}
>
<FileText className="h-4 w-4" />
Scripts
@@ -59,4 +64,4 @@ export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) =>
</div>
</div>
);
}
};

View File

@@ -1,7 +1,7 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
import { Button } from "./ui/Button";
import { Card, CardContent, CardHeader, CardTitle } from "../../ui/Card";
import { Button } from "../../ui/Button";
import {
Trash2,
Clock,
@@ -24,13 +24,31 @@ import {
runCronJob,
} from "@/app/_server/actions/cronjobs";
import { useState, useMemo, useEffect } 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 { 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 { ErrorBadge } from "../../ui/ErrorBadge";
import { ErrorDetailsModal } from "../../modals/ErrorDetailsModal";
import { type Script } from "@/app/_server/actions/scripts";
import { showToast } from "./ui/Toast";
import { showToast } from "../../ui/Toast";
import {
getJobErrorsByJobId,
setJobError,
JobError,
} from "@/app/_utils/errorState";
import {
handleErrorClick,
refreshJobErrors,
handleDelete,
handleClone,
handlePause,
handleResume,
handleRun,
handleEditSubmit,
handleNewCronSubmit,
} from "./helpers";
interface CronJobListProps {
cronJobs: CronJob[];
@@ -49,6 +67,9 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
const [isCloning, setIsCloning] = useState(false);
const [runningJobId, setRunningJobId] = useState<string | null>(null);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [jobErrors, setJobErrors] = useState<Record<string, JobError[]>>({});
const [errorModalOpen, setErrorModalOpen] = useState(false);
const [selectedError, setSelectedError] = useState<JobError | null>(null);
useEffect(() => {
const savedUser = localStorage.getItem("selectedCronUser");
@@ -83,100 +104,95 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
return cronJobs.filter((job) => job.user === selectedUser);
}, [cronJobs, selectedUser]);
const handleDelete = async (id: string) => {
setDeletingId(id);
try {
const result = await removeCronJob(id);
if (result.success) {
showToast("success", "Cron job deleted successfully");
} else {
showToast("error", "Failed to delete cron job", result.message);
}
} catch (error) {
showToast(
"error",
"Failed to delete cron job",
"Please try again later."
);
} finally {
setDeletingId(null);
setIsDeleteModalOpen(false);
setJobToDelete(null);
}
useEffect(() => {
const errors: Record<string, JobError[]> = {};
filteredJobs.forEach((job) => {
errors[job.id] = getJobErrorsByJobId(job.id);
});
setJobErrors(errors);
}, [filteredJobs]);
const handleErrorClickLocal = (error: JobError) => {
handleErrorClick(error, setSelectedError, setErrorModalOpen);
};
const handleClone = async (newComment: string) => {
if (!jobToClone) return;
setIsCloning(true);
try {
const result = await cloneCronJob(jobToClone.id, newComment);
if (result.success) {
setIsCloneModalOpen(false);
setJobToClone(null);
showToast("success", "Cron job cloned successfully");
} else {
showToast("error", "Failed to clone cron job", result.message);
}
} finally {
setIsCloning(false);
}
const refreshJobErrorsLocal = () => {
const errors: Record<string, JobError[]> = {};
filteredJobs.forEach((job) => {
errors[job.id] = getJobErrorsByJobId(job.id);
});
setJobErrors(errors);
};
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 handleDeleteLocal = async (id: string) => {
await handleDelete(id, {
setDeletingId,
setIsDeleteModalOpen,
setJobToDelete,
setIsCloneModalOpen,
setJobToClone,
setIsCloning,
setIsEditModalOpen,
setEditingJob,
setIsNewCronModalOpen,
setNewCronForm,
setRunningJobId,
refreshJobErrors: refreshJobErrorsLocal,
jobToClone,
editingJob,
editForm,
newCronForm,
});
};
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 handleCloneLocal = async (newComment: string) => {
await handleClone(newComment, {
setDeletingId,
setIsDeleteModalOpen,
setJobToDelete,
setIsCloneModalOpen,
setJobToClone,
setIsCloning,
setIsEditModalOpen,
setEditingJob,
setIsNewCronModalOpen,
setNewCronForm,
setRunningJobId,
refreshJobErrors: refreshJobErrorsLocal,
jobToClone,
editingJob,
editForm,
newCronForm,
});
};
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 handlePauseLocal = async (id: string) => {
await handlePause(id);
};
const handleResumeLocal = async (id: string) => {
await handleResume(id);
};
const handleRunLocal = async (id: string) => {
await handleRun(id, {
setDeletingId,
setIsDeleteModalOpen,
setJobToDelete,
setIsCloneModalOpen,
setJobToClone,
setIsCloning,
setIsEditModalOpen,
setEditingJob,
setIsNewCronModalOpen,
setNewCronForm,
setRunningJobId,
refreshJobErrors: refreshJobErrorsLocal,
jobToClone,
editingJob,
editForm,
newCronForm,
});
};
const confirmDelete = (job: CronJob) => {
@@ -199,68 +215,46 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
setIsEditModalOpen(true);
};
const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingJob) return;
try {
const formData = new FormData();
formData.append("id", editingJob.id);
formData.append("schedule", editForm.schedule);
formData.append("command", editForm.command);
formData.append("comment", editForm.comment);
const result = await editCronJob(formData);
if (result.success) {
setIsEditModalOpen(false);
setEditingJob(null);
showToast("success", "Cron job updated successfully");
} else {
showToast("error", "Failed to update cron job", result.message);
}
} catch (error) {
showToast(
"error",
"Failed to update cron job",
"Please try again later."
);
}
const handleEditSubmitLocal = async (e: React.FormEvent) => {
await handleEditSubmit(e, {
setDeletingId,
setIsDeleteModalOpen,
setJobToDelete,
setIsCloneModalOpen,
setJobToClone,
setIsCloning,
setIsEditModalOpen,
setEditingJob,
setIsNewCronModalOpen,
setNewCronForm,
setRunningJobId,
refreshJobErrors: refreshJobErrorsLocal,
jobToClone,
editingJob,
editForm,
newCronForm,
});
};
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);
}
const result = await createCronJob(formData);
if (result.success) {
setIsNewCronModalOpen(false);
setNewCronForm({
schedule: "",
command: "",
comment: "",
selectedScriptId: null,
user: "",
});
showToast("success", "Cron job created successfully");
} else {
showToast("error", "Failed to create cron job", result.message);
}
} catch (error) {
showToast(
"error",
"Failed to create cron job",
"Please try again later."
);
}
const handleNewCronSubmitLocal = async (e: React.FormEvent) => {
await handleNewCronSubmit(e, {
setDeletingId,
setIsDeleteModalOpen,
setJobToDelete,
setIsCloneModalOpen,
setJobToClone,
setIsCloning,
setIsEditModalOpen,
setEditingJob,
setIsNewCronModalOpen,
setNewCronForm,
setRunningJobId,
refreshJobErrors: refreshJobErrorsLocal,
jobToClone,
editingJob,
editForm,
newCronForm,
});
};
return (
@@ -358,6 +352,11 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
Paused
</span>
)}
<ErrorBadge
errors={jobErrors[job.id] || []}
onErrorClick={handleErrorClickLocal}
onErrorDismiss={refreshJobErrorsLocal}
/>
</div>
{job.comment && (
@@ -374,7 +373,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
<Button
variant="outline"
size="sm"
onClick={() => handleRun(job.id)}
onClick={() => handleRunLocal(job.id)}
disabled={runningJobId === job.id || job.paused}
className="btn-outline h-8 px-3"
title="Run cron job manually"
@@ -410,7 +409,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
<Button
variant="outline"
size="sm"
onClick={() => handleResume(job.id)}
onClick={() => handleResumeLocal(job.id)}
className="btn-outline h-8 px-3"
title="Resume cron job"
aria-label="Resume cron job"
@@ -421,7 +420,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
<Button
variant="outline"
size="sm"
onClick={() => handlePause(job.id)}
onClick={() => handlePauseLocal(job.id)}
className="btn-outline h-8 px-3"
title="Pause cron job"
aria-label="Pause cron job"
@@ -456,7 +455,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
<CreateTaskModal
isOpen={isNewCronModalOpen}
onClose={() => setIsNewCronModalOpen(false)}
onSubmit={handleNewCronSubmit}
onSubmit={handleNewCronSubmitLocal}
scripts={scripts}
form={newCronForm}
onFormChange={(updates) =>
@@ -467,7 +466,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
<EditTaskModal
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
onSubmit={handleEditSubmit}
onSubmit={handleEditSubmitLocal}
form={editForm}
onFormChange={(updates) =>
setEditForm((prev) => ({ ...prev, ...updates }))
@@ -478,7 +477,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={() =>
jobToDelete ? handleDelete(jobToDelete.id) : undefined
jobToDelete ? handleDeleteLocal(jobToDelete.id) : undefined
}
job={jobToDelete}
/>
@@ -487,9 +486,29 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
cronJob={jobToClone}
isOpen={isCloneModalOpen}
onClose={() => setIsCloneModalOpen(false)}
onConfirm={handleClone}
onConfirm={handleCloneLocal}
isCloning={isCloning}
/>
{errorModalOpen && selectedError && (
<ErrorDetailsModal
isOpen={errorModalOpen}
onClose={() => {
setErrorModalOpen(false);
setSelectedError(null);
}}
error={{
title: selectedError.title,
message: selectedError.message,
details: selectedError.details,
command: selectedError.command,
output: selectedError.output,
stderr: selectedError.stderr,
timestamp: selectedError.timestamp,
jobId: selectedError.jobId,
}}
/>
)}
</>
);
}
};

View File

@@ -0,0 +1,359 @@
import { JobError, setJobError } from "@/app/_utils/errorState";
import { showToast } from "@/app/_components/ui/Toast";
import {
removeCronJob,
editCronJob,
createCronJob,
cloneCronJob,
pauseCronJobAction,
resumeCronJobAction,
runCronJob,
} from "@/app/_server/actions/cronjobs";
import { CronJob } from "@/app/_utils/system";
interface HandlerProps {
setDeletingId: (id: string | null) => void;
setIsDeleteModalOpen: (open: boolean) => void;
setJobToDelete: (job: CronJob | null) => void;
setIsCloneModalOpen: (open: boolean) => void;
setJobToClone: (job: CronJob | null) => void;
setIsCloning: (cloning: boolean) => void;
setIsEditModalOpen: (open: boolean) => void;
setEditingJob: (job: CronJob | null) => void;
setIsNewCronModalOpen: (open: boolean) => void;
setNewCronForm: (form: any) => void;
setRunningJobId: (id: string | null) => void;
refreshJobErrors: () => void;
jobToClone: CronJob | null;
editingJob: CronJob | null;
editForm: {
schedule: string;
command: string;
comment: string;
};
newCronForm: {
schedule: string;
command: string;
comment: string;
selectedScriptId: string | null;
user: string;
};
}
export const handleErrorClick = (
error: JobError,
setSelectedError: (error: JobError | null) => void,
setErrorModalOpen: (open: boolean) => void
) => {
setSelectedError(error);
setErrorModalOpen(true);
};
export const refreshJobErrors = (
filteredJobs: CronJob[],
getJobErrorsByJobId: (jobId: string) => JobError[],
setJobErrors: (errors: Record<string, JobError[]>) => void
) => {
const errors: Record<string, JobError[]> = {};
filteredJobs.forEach((job) => {
errors[job.id] = getJobErrorsByJobId(job.id);
});
setJobErrors(errors);
};
export const handleDelete = async (id: string, props: HandlerProps) => {
const {
setDeletingId,
setIsDeleteModalOpen,
setJobToDelete,
refreshJobErrors,
} = props;
setDeletingId(id);
try {
const result = await removeCronJob(id);
if (result.success) {
showToast("success", "Cron job deleted successfully");
} else {
const errorId = `delete-${id}-${Date.now()}`;
const jobError: JobError = {
id: errorId,
title: "Failed to delete cron job",
message: result.message,
timestamp: new Date().toISOString(),
jobId: id,
};
setJobError(jobError);
refreshJobErrors();
showToast(
"error",
"Failed to delete cron job",
result.message,
undefined,
{
title: jobError.title,
message: jobError.message,
timestamp: jobError.timestamp,
jobId: jobError.jobId,
}
);
}
} catch (error: any) {
const errorId = `delete-${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,
};
setJobError(jobError);
showToast(
"error",
"Failed to delete cron job",
"Please try again later.",
undefined,
{
title: jobError.title,
message: jobError.message,
details: jobError.details,
timestamp: jobError.timestamp,
jobId: jobError.jobId,
}
);
} finally {
setDeletingId(null);
setIsDeleteModalOpen(false);
setJobToDelete(null);
}
};
export const handleClone = async (newComment: string, props: HandlerProps) => {
const { jobToClone, setIsCloneModalOpen, setJobToClone, setIsCloning } =
props;
if (!jobToClone) return;
setIsCloning(true);
try {
const result = await cloneCronJob(jobToClone.id, newComment);
if (result.success) {
setIsCloneModalOpen(false);
setJobToClone(null);
showToast("success", "Cron job cloned successfully");
} else {
showToast("error", "Failed to clone cron job", result.message);
}
} finally {
setIsCloning(false);
}
};
export 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.");
}
};
export 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.");
}
};
export const handleRun = async (id: string, props: HandlerProps) => {
const { setRunningJobId, refreshJobErrors } = props;
setRunningJobId(id);
try {
const result = await runCronJob(id);
if (result.success) {
showToast("success", "Cron job executed successfully");
} else {
const errorId = `run-${id}-${Date.now()}`;
const jobError: JobError = {
id: errorId,
title: "Failed to execute cron job",
message: result.message,
output: result.output,
timestamp: new Date().toISOString(),
jobId: id,
};
setJobError(jobError);
refreshJobErrors();
showToast(
"error",
"Failed to execute cron job",
result.message,
undefined,
{
title: jobError.title,
message: jobError.message,
output: jobError.output,
timestamp: jobError.timestamp,
jobId: jobError.jobId,
}
);
}
} catch (error: any) {
const errorId = `run-${id}-${Date.now()}`;
const jobError: JobError = {
id: errorId,
title: "Failed to execute cron job",
message: error.message || "Please try again later.",
details: error.stack,
timestamp: new Date().toISOString(),
jobId: id,
};
setJobError(jobError);
refreshJobErrors();
showToast(
"error",
"Failed to execute cron job",
"Please try again later.",
undefined,
{
title: jobError.title,
message: jobError.message,
details: jobError.details,
timestamp: jobError.timestamp,
jobId: jobError.jobId,
}
);
} finally {
setRunningJobId(null);
}
};
export const handleEditSubmit = async (
e: React.FormEvent,
props: HandlerProps
) => {
const {
editingJob,
editForm,
setIsEditModalOpen,
setEditingJob,
refreshJobErrors,
} = props;
e.preventDefault();
if (!editingJob) return;
try {
const formData = new FormData();
formData.append("id", editingJob.id);
formData.append("schedule", editForm.schedule);
formData.append("command", editForm.command);
formData.append("comment", editForm.comment);
const result = await editCronJob(formData);
if (result.success) {
setIsEditModalOpen(false);
setEditingJob(null);
showToast("success", "Cron job updated successfully");
} else {
const errorId = `edit-${editingJob.id}-${Date.now()}`;
const jobError: JobError = {
id: errorId,
title: "Failed to update cron job",
message: result.message,
details: result.details,
timestamp: new Date().toISOString(),
jobId: editingJob.id,
};
setJobError(jobError);
refreshJobErrors();
showToast(
"error",
"Failed to update cron job",
result.message,
undefined,
{
title: jobError.title,
message: jobError.message,
details: jobError.details,
timestamp: jobError.timestamp,
jobId: jobError.jobId,
}
);
}
} catch (error: any) {
const errorId = `edit-${editingJob?.id || "unknown"}-${Date.now()}`;
const jobError: JobError = {
id: errorId,
title: "Failed to update cron job",
message: error.message || "Please try again later.",
details: error.stack,
timestamp: new Date().toISOString(),
jobId: editingJob?.id || "unknown",
};
setJobError(jobError);
refreshJobErrors();
showToast(
"error",
"Failed to update cron job",
"Please try again later.",
undefined,
{
title: jobError.title,
message: jobError.message,
details: jobError.details,
timestamp: jobError.timestamp,
jobId: jobError.jobId,
}
);
}
};
export const handleNewCronSubmit = async (
e: React.FormEvent,
props: HandlerProps
) => {
const { newCronForm, setIsNewCronModalOpen, setNewCronForm } = props;
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);
}
const result = await createCronJob(formData);
if (result.success) {
setIsNewCronModalOpen(false);
setNewCronForm({
schedule: "",
command: "",
comment: "",
selectedScriptId: null,
user: "",
});
showToast("success", "Cron job created successfully");
} else {
showToast("error", "Failed to create cron job", result.message);
}
} catch (error) {
showToast("error", "Failed to create cron job", "Please try again later.");
}
};

View File

@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "../../ui/Button";
import { Input } from "../../ui/Input";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../../ui/Card";
import { Lock, Eye, EyeOff } from "lucide-react";
export const LoginForm = () => {
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ password }),
});
const result = await response.json();
if (result.success) {
router.push("/");
} else {
setError(result.message || "Login failed");
}
} catch (error) {
setError("An error occurred. Please try again.");
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full max-w-md shadow-xl">
<CardHeader className="text-center">
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<Lock className="w-8 h-8 text-primary" />
</div>
<CardTitle>Welcome to Cr*nMaster</CardTitle>
<CardDescription>Enter your password to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
className="pr-10"
required
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
disabled={isLoading}
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
{error && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-md p-3">
{error}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading || !password.trim()}
>
{isLoading ? "Signing in..." : "Sign In"}
</Button>
</form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,137 @@
"use client";
import { Modal } from "../ui/Modal";
import { Button } from "../ui/Button";
import { AlertCircle, Copy, X } from "lucide-react";
import { showToast } from "../ui/Toast";
interface ErrorDetails {
title: string;
message: string;
details?: string;
command?: string;
output?: string;
stderr?: string;
timestamp: string;
jobId?: string;
}
interface ErrorDetailsModalProps {
isOpen: boolean;
onClose: () => void;
error: ErrorDetails | null;
}
export const ErrorDetailsModal = ({
isOpen,
onClose,
error,
}: ErrorDetailsModalProps) => {
if (!isOpen || !error) return null;
const handleCopyDetails = async () => {
const detailsText = `
Error Details:
Title: ${error.title}
Message: ${error.message}
${error.details ? `Details: ${error.details}` : ""}
${error.command ? `Command: ${error.command}` : ""}
${error.output ? `Output: ${error.output}` : ""}
${error.stderr ? `Stderr: ${error.stderr}` : ""}
Timestamp: ${error.timestamp}
`.trim();
try {
await navigator.clipboard.writeText(detailsText);
showToast("success", "Error details copied to clipboard");
} catch (err) {
showToast("error", "Failed to copy error details");
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Error Details" size="xl">
<div className="space-y-4">
<div className="bg-destructive/5 border border-destructive/20 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1">
<h3 className="font-medium text-destructive mb-1">
{error.title}
</h3>
<p className="text-sm text-muted-foreground">{error.message}</p>
</div>
</div>
</div>
{error.details && (
<div>
<h4 className="text-sm font-medium text-foreground mb-2">
Details
</h4>
<div className="bg-muted/30 p-3 rounded border border-border/30">
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap break-words">
{error.details}
</pre>
</div>
</div>
)}
{error.command && (
<div>
<h4 className="text-sm font-medium text-foreground mb-2">
Command
</h4>
<div className="bg-muted/30 p-3 rounded border border-border/30">
<code className="text-sm font-mono text-foreground break-all">
{error.command}
</code>
</div>
</div>
)}
{error.output && (
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Output</h4>
<div className="bg-muted/30 p-3 rounded border border-border/30 max-h-32 overflow-y-auto">
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">
{error.output}
</pre>
</div>
</div>
)}
{error.stderr && (
<div>
<h4 className="text-sm font-medium text-foreground mb-2">
Error Output
</h4>
<div className="bg-destructive/5 p-3 rounded border border-destructive/20 max-h-32 overflow-y-auto">
<pre className="text-sm font-mono text-destructive whitespace-pre-wrap">
{error.stderr}
</pre>
</div>
</div>
)}
<div className="text-xs text-muted-foreground">
Timestamp: {error.timestamp}
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-border/50">
<Button
variant="outline"
onClick={handleCopyDetails}
className="btn-outline"
>
<Copy className="h-4 w-4 mr-2" />
Copy Details
</Button>
<Button onClick={onClose} className="btn-primary">
Close
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,47 @@
"use client";
import { AlertCircle, X } from "lucide-react";
import { JobError, removeJobError } from "@/app/_utils/errorState";
interface ErrorBadgeProps {
errors: JobError[];
onErrorClick: (error: JobError) => void;
onErrorDismiss?: () => void;
}
export const ErrorBadge = ({
errors,
onErrorClick,
onErrorDismiss,
}: ErrorBadgeProps) => {
if (errors.length === 0) return null;
const handleDismissError = (errorId: string) => {
removeJobError(errorId);
onErrorDismiss?.();
};
return (
<div className="flex items-center gap-1">
{errors.map((error) => (
<div key={error.id} className="flex items-center gap-1">
<button
onClick={() => onErrorClick(error)}
className="flex items-center gap-1 px-2 py-1 bg-destructive/10 text-destructive border border-destructive/20 rounded text-xs hover:bg-destructive/20 transition-colors"
title={error.message}
>
<AlertCircle className="h-3 w-3" />
<span className="hidden sm:inline">Error</span>
</button>
<button
onClick={() => handleDismissError(error.id)}
className="p-1 text-destructive hover:bg-destructive/10 rounded transition-colors"
title="Dismiss error"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "./Button";
import { LogOut } from "lucide-react";
export const LogoutButton = () => {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleLogout = async () => {
setIsLoading(true);
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
if (response.ok) {
router.push("/login");
router.refresh();
}
} catch (error) {
console.error("Logout error:", error);
} finally {
setIsLoading(false);
}
};
return (
<Button
variant="ghost"
size="icon"
onClick={handleLogout}
disabled={isLoading}
title="Logout"
>
<LogOut className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">Logout</span>
</Button>
);
};

View File

@@ -0,0 +1,58 @@
"use client";
import { useCallback, useEffect, useState } from "react";
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
export const PWAInstallPrompt = (): JSX.Element | null => {
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(
null
);
const [isInstalled, setIsInstalled] = useState<boolean>(false);
useEffect(() => {
if (typeof window === "undefined") return;
const onBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setDeferred(e as BeforeInstallPromptEvent);
};
const onAppInstalled = () => {
setDeferred(null);
setIsInstalled(true);
};
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.addEventListener("appinstalled", onAppInstalled);
if (window.matchMedia("(display-mode: standalone)").matches) {
setIsInstalled(true);
}
return () => {
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.removeEventListener("appinstalled", onAppInstalled);
};
}, []);
const onInstall = useCallback(async () => {
if (!deferred) return;
try {
await deferred.prompt();
const choice = await deferred.userChoice;
if (choice.outcome === "accepted") {
setDeferred(null);
}
} catch (_err) {}
}, [deferred]);
if (isInstalled || !deferred) return null;
return (
<button
className="px-3 py-1 rounded-md border border-border/50 bg-background/80 hover:bg-background/60"
onClick={onInstall}
>
Install App
</button>
);
};

View File

@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
export const ServiceWorkerRegister = (): null => {
useEffect(() => {
if (typeof window === "undefined") return;
if (!("serviceWorker" in navigator)) return;
const register = async () => {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
const alreadyRegistered = registrations.some((r) =>
r.scope.endsWith("/")
);
if (alreadyRegistered) return;
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
} catch (_err) {}
};
register();
}, []);
return null;
};

View File

@@ -92,7 +92,7 @@ export const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="absolute -right-3 top-6 w-6 h-6 bg-background border border-border rounded-full items-center justify-center hover:bg-accent transition-colors z-40 hidden lg:flex"
className="absolute -right-3 top-[21.5vh] w-6 h-6 bg-background border border-border rounded-full items-center justify-center hover:bg-accent transition-colors z-40 hidden lg:flex"
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3" />

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
import { cn } from "@/app/_utils/cn";
import { ErrorDetailsModal } from "../modals/ErrorDetailsModal";
export interface Toast {
id: string;
@@ -10,11 +11,22 @@ export interface Toast {
title: string;
message?: string;
duration?: number;
errorDetails?: {
title: string;
message: string;
details?: string;
command?: string;
output?: string;
stderr?: string;
timestamp: string;
jobId?: string;
};
}
interface ToastProps {
toast: Toast;
onRemove: (id: string) => void;
onErrorClick?: (errorDetails: Toast["errorDetails"]) => void;
}
const toastIcons = {
@@ -33,7 +45,7 @@ const toastStyles = {
"border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
};
export const Toast = ({ toast, onRemove }: ToastProps) => {
export const Toast = ({ toast, onRemove, onErrorClick }: ToastProps) => {
const [isVisible, setIsVisible] = useState(false);
const Icon = toastIcons[toast.type];
@@ -56,11 +68,23 @@ export const Toast = ({ toast, onRemove }: ToastProps) => {
)}
>
<Icon className="h-5 w-5 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div
className={`flex-1 min-w-0 ${
toast.type === "error" && toast.errorDetails ? "cursor-pointer" : ""
}`}
onClick={() => {
if (toast.type === "error" && toast.errorDetails && onErrorClick) {
onErrorClick(toast.errorDetails);
}
}}
>
<h4 className="font-medium text-sm">{toast.title}</h4>
{toast.message && (
<p className="text-sm opacity-90 mt-1">{toast.message}</p>
)}
{toast.type === "error" && toast.errorDetails && (
<p className="text-xs opacity-70 mt-1">Click for details</p>
)}
</div>
<button
onClick={() => {
@@ -73,10 +97,14 @@ export const Toast = ({ toast, onRemove }: ToastProps) => {
</button>
</div>
);
}
};
export const ToastContainer = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
const [errorModalOpen, setErrorModalOpen] = useState(false);
const [selectedError, setSelectedError] = useState<
Toast["errorDetails"] | null
>(null);
const addToast = (toast: Omit<Toast, "id">) => {
const id = Math.random().toString(36).substr(2, 9);
@@ -87,6 +115,11 @@ export const ToastContainer = () => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
};
const handleErrorClick = (errorDetails: Toast["errorDetails"]) => {
setSelectedError(errorDetails);
setErrorModalOpen(true);
};
useEffect(() => {
(window as any).showToast = addToast;
return () => {
@@ -95,21 +128,39 @@ export const ToastContainer = () => {
}, []);
return (
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
{toasts.map((toast) => (
<Toast key={toast.id} toast={toast} onRemove={removeToast} />
))}
</div>
<>
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
{toasts.map((toast) => (
<Toast
key={toast.id}
toast={toast}
onRemove={removeToast}
onErrorClick={handleErrorClick}
/>
))}
</div>
{errorModalOpen && (
<ErrorDetailsModal
isOpen={errorModalOpen}
onClose={() => {
setErrorModalOpen(false);
setSelectedError(null);
}}
error={selectedError || null}
/>
)}
</>
);
}
};
export const showToast = (
type: Toast["type"],
title: string,
message?: string,
duration?: number
duration?: number,
errorDetails?: Toast["errorDetails"]
) => {
if (typeof window !== "undefined" && (window as any).showToast) {
(window as any).showToast({ type, title, message, duration });
(window as any).showToast({ type, title, message, duration, errorDetails });
}
}
};

View File

@@ -10,7 +10,10 @@ import {
cleanupCrontab,
type CronJob,
} from "@/app/_utils/system";
import { getAllTargetUsers, getUserInfo } from "@/app/_utils/system/hostCrontab";
import {
getAllTargetUsers,
getUserInfo,
} from "@/app/_utils/system/hostCrontab";
import { revalidatePath } from "next/cache";
import { getScriptPath } from "@/app/_utils/scripts";
import { exec } from "child_process";
@@ -25,11 +28,11 @@ export const fetchCronJobs = async (): Promise<CronJob[]> => {
console.error("Error fetching cron jobs:", error);
return [];
}
}
};
export const createCronJob = async (
formData: FormData
): Promise<{ success: boolean; message: string }> => {
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const schedule = formData.get("schedule") as string;
const command = formData.get("command") as string;
@@ -67,15 +70,19 @@ export const createCronJob = async (
} else {
return { success: false, message: "Failed to create cron job" };
}
} catch (error) {
} catch (error: any) {
console.error("Error creating cron job:", error);
return { success: false, message: "Error creating cron job" };
return {
success: false,
message: error.message || "Error creating cron job",
details: error.stack,
};
}
}
};
export const removeCronJob = async (
id: string
): Promise<{ success: boolean; message: string }> => {
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const success = await deleteCronJob(id);
if (success) {
@@ -84,15 +91,19 @@ export const removeCronJob = async (
} else {
return { success: false, message: "Failed to delete cron job" };
}
} catch (error) {
} catch (error: any) {
console.error("Error deleting cron job:", error);
return { success: false, message: "Error deleting cron job" };
return {
success: false,
message: error.message || "Error deleting cron job",
details: error.stack,
};
}
}
};
export const editCronJob = async (
formData: FormData
): Promise<{ success: boolean; message: string }> => {
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const id = formData.get("id") as string;
const schedule = formData.get("schedule") as string;
@@ -110,16 +121,20 @@ export const editCronJob = async (
} else {
return { success: false, message: "Failed to update cron job" };
}
} catch (error) {
} catch (error: any) {
console.error("Error updating cron job:", error);
return { success: false, message: "Error updating cron job" };
return {
success: false,
message: error.message || "Error updating cron job",
details: error.stack,
};
}
}
};
export const cloneCronJob = async (
id: string,
newComment: string
): Promise<{ success: boolean; message: string }> => {
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const cronJobs = await getCronJobs();
const originalJob = cronJobs.find((job) => job.id === id);
@@ -141,15 +156,19 @@ export const cloneCronJob = async (
} else {
return { success: false, message: "Failed to clone cron job" };
}
} catch (error) {
} catch (error: any) {
console.error("Error cloning cron job:", error);
return { success: false, message: "Error cloning cron job" };
return {
success: false,
message: error.message || "Error cloning cron job",
details: error.stack,
};
}
}
};
export const pauseCronJobAction = async (
id: string
): Promise<{ success: boolean; message: string }> => {
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const success = await pauseCronJob(id);
if (success) {
@@ -158,15 +177,19 @@ export const pauseCronJobAction = async (
} else {
return { success: false, message: "Failed to pause cron job" };
}
} catch (error) {
} catch (error: any) {
console.error("Error pausing cron job:", error);
return { success: false, message: "Error pausing cron job" };
return {
success: false,
message: error.message || "Error pausing cron job",
details: error.stack,
};
}
}
};
export const resumeCronJobAction = async (
id: string
): Promise<{ success: boolean; message: string }> => {
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const success = await resumeCronJob(id);
if (success) {
@@ -175,11 +198,15 @@ export const resumeCronJobAction = async (
} else {
return { success: false, message: "Failed to resume cron job" };
}
} catch (error) {
} catch (error: any) {
console.error("Error resuming cron job:", error);
return { success: false, message: "Error resuming cron job" };
return {
success: false,
message: error.message || "Error resuming cron job",
details: error.stack,
};
}
}
};
export const fetchAvailableUsers = async (): Promise<string[]> => {
try {
@@ -188,9 +215,13 @@ export const fetchAvailableUsers = async (): Promise<string[]> => {
console.error("Error fetching available users:", error);
return [];
}
}
};
export const cleanupCrontabAction = async (): Promise<{ success: boolean; message: string }> => {
export const cleanupCrontabAction = async (): Promise<{
success: boolean;
message: string;
details?: string;
}> => {
try {
const success = await cleanupCrontab();
if (success) {
@@ -199,15 +230,24 @@ export const cleanupCrontabAction = async (): Promise<{ success: boolean; messag
} else {
return { success: false, message: "Failed to clean crontab" };
}
} catch (error) {
} catch (error: any) {
console.error("Error cleaning crontab:", error);
return { success: false, message: "Error cleaning crontab" };
return {
success: false,
message: error.message || "Error cleaning crontab",
details: error.stack,
};
}
}
};
export const runCronJob = async (
id: string
): Promise<{ success: boolean; message: string; output?: string }> => {
): Promise<{
success: boolean;
message: string;
output?: string;
details?: string;
}> => {
try {
const cronJobs = await getCronJobs();
const job = cronJobs.find((j) => j.id === id);
@@ -243,15 +283,17 @@ export const runCronJob = async (
return {
success: true,
message: "Cron job executed successfully",
output: output.trim()
output: output.trim(),
};
} catch (error: any) {
console.error("Error running cron job:", error);
const errorMessage = error.stderr || error.message || "Unknown error occurred";
const errorMessage =
error.stderr || error.message || "Unknown error occurred";
return {
success: false,
message: "Failed to execute cron job",
output: errorMessage
output: errorMessage,
details: error.stack,
};
}
}
};

View File

@@ -6,7 +6,7 @@ import { join } from "path";
import { existsSync } from "fs";
import { exec } from "child_process";
import { promisify } from "util";
import { SCRIPTS_DIR } from "@/app/_utils/scripts";
import { SCRIPTS_DIR, normalizeLineEndings } from "@/app/_utils/scripts";
import { loadAllScripts, type Script } from "@/app/_utils/scriptScanner";
const execAsync = promisify(exec);
@@ -62,7 +62,9 @@ const saveScriptFile = async (filename: string, content: string) => {
}
const deleteScriptFile = async (filename: string) => {
const scriptPath = join(await SCRIPTS_DIR(), filename);
const isDocker = process.env.DOCKER === "true";
const scriptsDir = isDocker ? "/app/scripts" : await SCRIPTS_DIR();
const scriptPath = join(scriptsDir, filename);
if (existsSync(scriptPath)) {
await unlink(scriptPath);
}
@@ -95,7 +97,8 @@ export const createScript = async (
`;
const fullContent = metadataHeader + content;
const normalizedContent = normalizeLineEndings(content);
const fullContent = metadataHeader + normalizedContent;
await saveScriptFile(filename, fullContent);
revalidatePath("/");
@@ -145,7 +148,8 @@ export const updateScript = async (
`;
const fullContent = metadataHeader + content;
const normalizedContent = normalizeLineEndings(content);
const fullContent = metadataHeader + normalizedContent;
await saveScriptFile(existingScript.filename, fullContent);
revalidatePath("/");
@@ -203,7 +207,8 @@ export const cloneScript = async (
`;
const fullContent = metadataHeader + originalContent;
const normalizedContent = normalizeLineEndings(originalContent);
const fullContent = metadataHeader + normalizedContent;
await saveScriptFile(filename, fullContent);
revalidatePath("/");

View File

@@ -1,417 +1,441 @@
import { CronJob } from "../system";
export const pauseJobInLines = (lines: string[], targetJobIndex: number): string[] => {
const newCronEntries: string[] = [];
let currentJobIndex = 0;
let i = 0;
export const 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();
while (i < lines.length) {
const line = lines[i];
const trimmedLine = line.trim();
if (!trimmedLine) {
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
newCronEntries.push("");
}
i++;
continue;
}
if (!trimmedLine) {
if (
newCronEntries.length > 0 &&
newCronEntries[newCronEntries.length - 1] !== ""
) {
newCronEntries.push("");
}
i++;
continue;
}
if (
trimmedLine.startsWith("# User:") ||
trimmedLine.startsWith("# System Crontab")
) {
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);
newCronEntries.push(lines[i + 1]);
i += 2;
currentJobIndex++;
}
} else {
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 pausedEntry = `# PAUSED:\n# ${trimmedLine}`;
newCronEntries.push(pausedEntry);
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);
newCronEntries.push(line);
newCronEntries.push(lines[i + 1]);
i += 2;
currentJobIndex++;
}
currentJobIndex++;
i++;
}
return newCronEntries;
}
export const 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) {
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
newCronEntries.push("");
}
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;
}
} else {
newCronEntries.push(line);
currentJobIndex++;
i++;
}
continue;
}
return newCronEntries;
}
if (currentJobIndex === targetJobIndex) {
const pausedEntry = `# PAUSED:\n# ${trimmedLine}`;
newCronEntries.push(pausedEntry);
} else {
newCronEntries.push(line);
}
export const parseJobsFromLines = (lines: string[], user: string): CronJob[] => {
const jobs: CronJob[] = [];
let currentComment = "";
let jobIndex = 0;
let i = 0;
currentJobIndex++;
i++;
}
while (i < lines.length) {
const line = lines[i];
const trimmedLine = line.trim();
return newCronEntries;
};
if (!trimmedLine) {
i++;
continue;
export const 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) {
if (
newCronEntries.length > 0 &&
newCronEntries[newCronEntries.length - 1] !== ""
) {
newCronEntries.push("");
}
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(9).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++;
}
if (
trimmedLine.startsWith("# User:") ||
trimmedLine.startsWith("# System Crontab")
) {
i++;
continue;
} 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("# PAUSED: ")) {
const comment = trimmedLine.substring(10).trim();
if (trimmedLine.startsWith("#")) {
newCronEntries.push(line);
i++;
continue;
}
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(" ");
newCronEntries.push(line);
currentJobIndex++;
i++;
}
jobs.push({
id: `${user}-${jobIndex}`,
schedule,
command,
comment: comment || undefined,
user,
paused: true,
});
return newCronEntries;
};
jobIndex++;
i += 2;
continue;
}
}
}
i++;
continue;
}
export const parseJobsFromLines = (
lines: string[],
user: string
): CronJob[] => {
const jobs: CronJob[] = [];
let currentComment = "";
let jobIndex = 0;
let i = 0;
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;
}
}
while (i < lines.length) {
const line = lines[i];
const trimmedLine = line.trim();
let schedule, command;
const parts = trimmedLine.split(/(?:\s|\t)+/);
if (!trimmedLine) {
i++;
continue;
}
if (parts[0].startsWith("@")) {
if (parts.length >= 2) {
schedule = parts[0];
command = trimmedLine.slice(trimmedLine.indexOf(parts[1]))
}
} else if (parts.length >= 6) {
schedule = parts.slice(0, 5).join(" ");
command = trimmedLine.slice(trimmedLine.indexOf(parts[5]))
}
if (
trimmedLine.startsWith("# User:") ||
trimmedLine.startsWith("# System Crontab")
) {
i++;
continue;
}
if (trimmedLine.startsWith("# PAUSED:")) {
const comment = trimmedLine.substring(9).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(" ");
if (schedule && command) {
jobs.push({
id: `${user}-${jobIndex}`,
schedule,
command,
comment: currentComment || undefined,
user,
paused: false,
id: `${user}-${jobIndex}`,
schedule,
command,
comment: comment || undefined,
user,
paused: true,
});
jobIndex++;
currentComment = "";
i += 2;
continue;
}
}
i++;
}
i++;
continue;
}
return jobs;
}
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;
}
}
export const deleteJobInLines = (lines: string[], targetJobIndex: number): string[] => {
const newCronEntries: string[] = [];
let currentJobIndex = 0;
let i = 0;
let schedule, command;
const parts = trimmedLine.split(/(?:\s|\t)+/);
while (i < lines.length) {
const line = lines[i];
const trimmedLine = line.trim();
if (parts[0].startsWith("@")) {
if (parts.length >= 2) {
schedule = parts[0];
command = trimmedLine.slice(trimmedLine.indexOf(parts[1]));
}
} else if (parts.length >= 6) {
schedule = parts.slice(0, 5).join(" ");
command = trimmedLine.slice(trimmedLine.indexOf(parts[5]));
}
if (!trimmedLine) {
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
newCronEntries.push("");
}
i++;
continue;
if (schedule && command) {
jobs.push({
id: `${user}-${jobIndex}`,
schedule,
command,
comment: currentComment || undefined,
user,
paused: false,
});
jobIndex++;
currentComment = "";
}
i++;
}
return jobs;
};
export const 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) {
if (
newCronEntries.length > 0 &&
newCronEntries[newCronEntries.length - 1] !== ""
) {
newCronEntries.push("");
}
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++;
}
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);
newCronEntries.push(lines[i + 1]);
}
i += 2;
currentJobIndex++;
} else {
newCronEntries.push(line);
i++;
}
continue;
} 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);
newCronEntries.push(line);
newCronEntries.push(lines[i + 1]);
}
i += 2;
currentJobIndex++;
} else {
newCronEntries.push(line);
i++;
}
continue;
}
return newCronEntries;
}
if (currentJobIndex !== targetJobIndex) {
newCronEntries.push(line);
}
currentJobIndex++;
i++;
}
return newCronEntries;
};
export const updateJobInLines = (
lines: string[],
targetJobIndex: number,
schedule: string,
command: string,
comment: string = ""
lines: string[],
targetJobIndex: number,
schedule: string,
command: string,
comment: string = ""
): string[] => {
const newCronEntries: string[] = [];
let currentJobIndex = 0;
let i = 0;
const newCronEntries: string[] = [];
let currentJobIndex = 0;
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmedLine = line.trim();
while (i < lines.length) {
const line = lines[i];
const trimmedLine = line.trim();
if (!trimmedLine) {
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
newCronEntries.push("");
}
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);
newCronEntries.push(lines[i + 1]);
i += 2;
}
currentJobIndex++;
} 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++;
if (!trimmedLine) {
if (
newCronEntries.length > 0 &&
newCronEntries[newCronEntries.length - 1] !== ""
) {
newCronEntries.push("");
}
i++;
continue;
}
return newCronEntries;
}
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);
newCronEntries.push(lines[i + 1]);
i += 2;
}
currentJobIndex++;
} 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;
};

63
app/_utils/errorState.ts Normal file
View File

@@ -0,0 +1,63 @@
export interface JobError {
id: string;
title: string;
message: string;
details?: string;
command?: string;
output?: string;
stderr?: string;
timestamp: string;
jobId: string;
}
const STORAGE_KEY = "cronmaster-job-errors";
export const getJobErrors = (): JobError[] => {
if (typeof window === "undefined") return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
};
export const setJobError = (error: JobError) => {
if (typeof window === "undefined") return;
try {
const errors = getJobErrors();
const existingIndex = errors.findIndex((e) => e.id === error.id);
if (existingIndex >= 0) {
errors[existingIndex] = error;
} else {
errors.push(error);
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(errors));
} catch {}
};
export const removeJobError = (errorId: string) => {
if (typeof window === "undefined") return;
try {
const errors = getJobErrors();
const filtered = errors.filter((e) => e.id !== errorId);
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
} catch {}
};
export const getJobErrorsByJobId = (jobId: string): JobError[] => {
return getJobErrors().filter((error) => error.jobId === jobId);
};
export const clearAllJobErrors = () => {
if (typeof window === "undefined") return;
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
};

View File

@@ -21,4 +21,8 @@ export const getHostScriptPath = async (filename: string): Promise<string> => {
return `bash ${join(hostScriptsDir, filename)}`;
}
export const normalizeLineEndings = (content: string): string => {
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
};
export { SCRIPTS_DIR };

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
try {
const { password } = await request.json()
const authPassword = process.env.AUTH_PASSWORD
if (!authPassword) {
return NextResponse.json(
{ success: false, message: 'Authentication not configured' },
{ status: 400 }
)
}
if (password !== authPassword) {
return NextResponse.json(
{ success: false, message: 'Invalid password' },
{ status: 401 }
)
}
const response = NextResponse.json(
{ success: true, message: 'Login successful' },
{ status: 200 }
)
response.cookies.set('cronmaster-auth', 'authenticated', {
httpOnly: true,
secure: request.url.startsWith('https://'),
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return response
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ success: false, message: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
try {
const response = NextResponse.json(
{ success: true, message: 'Logout successful' },
{ status: 200 }
)
response.cookies.set('cronmaster-auth', '', {
httpOnly: true,
secure: request.url.startsWith('https://'),
sameSite: 'lax',
maxAge: 0,
path: '/',
})
return response
} catch (error) {
console.error('Logout error:', error)
return NextResponse.json(
{ success: false, message: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -74,7 +74,7 @@ export async function GET(request: NextRequest) {
statusDetails = "Moderate resource usage - monitoring recommended";
}
let mainInterface = null;
let mainInterface: any = null;
if (Array.isArray(networkInfo) && networkInfo.length > 0) {
mainInterface = networkInfo.find(net =>
net.iface && !net.iface.includes('lo') && net.operstate === 'up'

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { JetBrains_Mono, Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "./_components/ui/ThemeProvider";
import { ServiceWorkerRegister } from "./_components/ui/ServiceWorkerRegister";
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
@@ -17,8 +18,16 @@ const inter = Inter({
export const metadata: Metadata = {
title: "Cr*nMaster - Cron Management made easy",
description:
"The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
description: "The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Cr*nMaster",
},
formatDetection: {
telephone: false,
},
icons: {
icon: "/logo.png",
shortcut: "/logo.png",
@@ -26,6 +35,14 @@ export const metadata: Metadata = {
},
};
export const viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: "#3b82f6",
};
export default function RootLayout({
children,
}: {
@@ -33,15 +50,24 @@ export default function RootLayout({
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta name="application-name" content="Cr*nMaster" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Cr*nMaster" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="/logo.png" />
</head>
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
<ServiceWorkerRegister />
</body>
</html>
);

14
app/login/page.tsx Normal file
View File

@@ -0,0 +1,14 @@
'use server';
import { LoginForm } from "../_components/features/LoginForm/LoginForm";
export default async function LoginPage() {
return (
<div className="min-h-screen relative">
<div className="hero-gradient absolute inset-0 -z-10"></div>
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
<LoginForm />
</div>
</div>
);
}

View File

@@ -3,7 +3,9 @@ import { TabbedInterface } from "./_components/TabbedInterface";
import { getCronJobs } from "./_utils/system";
import { fetchScripts } from "./_server/actions/scripts";
import { ThemeToggle } from "./_components/ui/ThemeToggle";
import { LogoutButton } from "./_components/ui/LogoutButton";
import { ToastContainer } from "./_components/ui/Toast";
import { PWAInstallPrompt } from "./_components/ui/PWAInstallPrompt";
export const dynamic = "force-dynamic";
export default async function Home() {
@@ -52,7 +54,7 @@ export default async function Home() {
<div className="relative z-10">
<header className="border-b border-border/50 bg-background/80 backdrop-blur-md sticky top-0 z-20 shadow-sm lg:h-[90px]">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-center">
<div className="flex items-center justify-between lg:justify-center">
<div className="flex items-center gap-4">
<div className="relative">
<img src="/logo.png" alt="logo" className="w-14 h-14" />
@@ -67,6 +69,11 @@ export default async function Home() {
</p>
</div>
</div>
{process.env.AUTH_PASSWORD && (
<div className="lg:absolute lg:right-10">
<LogoutButton />
</div>
)}
</div>
</div>
</header>
@@ -82,8 +89,9 @@ export default async function Home() {
<ToastContainer />
<div className="flex items-center gap-2 fixed bottom-4 left-4 lg:right-4 lg:left-auto z-10 bg-background rounded-lg">
<div className="flex items-center gap-2 fixed bottom-4 left-4 lg:right-4 lg:left-auto z-10 bg-background/80 backdrop-blur-md border border-border/50 rounded-lg p-1">
<ThemeToggle />
<PWAInstallPrompt />
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
services:
cronjob-manager:
image: ghcr.io/fccview/cronmaster:1.3.0
image: ghcr.io/fccview/cronmaster:latest
container_name: cronmaster
user: "root"
ports:
@@ -9,17 +9,26 @@ services:
environment:
- NODE_ENV=production
- DOCKER=true
# Legacy used to be NEXT_PUBLIC_HOST_PROJECT_DIR, this was causing issues on runtime.
# --- MAP HOST PROJECT DIRECTORY, THIS IS MANDATORY FOR SCRIPTS TO WORK
- 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/
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=fccview,root,user1,user2
# - HOST_CRONTAB_USER=fccview
# --- PASSWORD PROTECTION
# Uncomment to enable password protection (replace "password" with your own)
- AUTH_PASSWORD=very_strong_password
# --- CRONTAB USERS
# This is used to read the crontabs for the specific user.
# replace root with your user - find it with: ls -asl /var/spool/cron/crontabs/
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=root,user1,user2
- HOST_CRONTAB_USER=root
volumes:
# --- MOUNT DOCKER SOCKET
# Mount Docker socket to execute commands on host
- /var/run/docker.sock:/var/run/docker.sock
# --- MOUNT DATA
# 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 folder (thanks to the HOST_PROJECT_DIR variable set above)
@@ -27,12 +36,12 @@ services:
- ./data:/app/data
- ./snippets:/app/snippets
# Use host PID namespace for host command execution
# Run in privileged mode for nsenter access
# --- USE HOST PID NAMESPACE FOR HOST COMMAND EXECUTION
# --- RUN IN PRIVILEGED MODE FOR NSENTER ACCESS
pid: "host"
privileged: true
restart: unless-stopped
restart: always
init: true
# Default platform is set to amd64, uncomment to use arm64.
# --- DEFAULT PLATFORM IS SET TO AMD64, UNCOMMENT TO USE ARM64.
#platform: linux/arm64

37
middleware.ts Normal file
View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname.startsWith('/api/') || pathname.startsWith('/_next/') || pathname.includes('.')) {
return NextResponse.next()
}
const authPassword = process.env.AUTH_PASSWORD
if (!authPassword) {
return NextResponse.next()
}
const isAuthenticated = request.cookies.has('cronmaster-auth')
if (pathname === '/login') {
if (isAuthenticated || !authPassword) {
return NextResponse.redirect(new URL('/', request.url))
}
return NextResponse.next()
}
if (!isAuthenticated) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|site.webmanifest|sw.js|app-icons).*)",
],
}

View File

@@ -1,6 +1,30 @@
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
buildExcludes: [/middleware-manifest\.json$/]
})
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/manifest.json',
headers: [
{ key: 'Content-Type', value: 'application/manifest+json' },
],
},
{
source: '/sw.js',
headers: [
{ key: 'Service-Worker-Allowed', value: '/' },
{ key: 'Cache-Control', value: 'no-cache' },
],
},
]
},
}
module.exports = nextConfig
module.exports = withPWA(nextConfig)

View File

@@ -23,12 +23,15 @@
"@types/react-dom": "^18",
"@types/react-syntax-highlighter": "^15.5.13",
"autoprefixer": "^10.0.1",
"bcryptjs": "^2.4.3",
"clsx": "^2.0.0",
"codemirror": "^6.0.2",
"cron-parser": "^5.3.0",
"cronstrue": "^3.2.0",
"lucide-react": "^0.294.0",
"minimatch": "^10.0.3",
"next": "14.0.4",
"next-pwa": "^5.6.0",
"next-themes": "^0.2.1",
"postcss": "^8",
"react": "^18",
@@ -40,6 +43,8 @@
"typescript": "^5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/minimatch": "^6.0.0",
"eslint": "^8",
"eslint-config-next": "14.0.4"
}

26
public/manifest.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "Cr*nMaster",
"short_name": "Cr*nMaster",
"description": "The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
"start_url": "/",
"display": "standalone",
"background_color": "#0f0f23",
"theme_color": "#3b82f6",
"orientation": "portrait-primary",
"icons": [
{
"src": "/logo.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/logo.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["productivity", "utilities"],
"lang": "en"
}

1
public/sw.js Normal file
View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

@@ -2,5 +2,5 @@
# @title: Hi, this is a demo script
# @description: This script logs a "hello world" to teach you how scripts work.
#!/bin/bash
#!/bin/bash
echo 'Hello World' > hello.txt

View File

@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "es6"],
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -19,9 +23,18 @@
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

1912
yarn.lock
View File

File diff suppressed because it is too large Load Diff