15 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
fccview
7fc8cb9edb update readme 2025-08-31 19:20:10 +01:00
fccview
4dfdf8fc53 Merge pull request #27 from fccview/BUG-4
Fix editing jobs with description too similar
2025-08-31 19:16:55 +01:00
fccview
8cfc000893 Fix editing jobs with description too similar 2025-08-31 19:16:19 +01:00
fccview
1dde8f839e fix readme 2025-08-27 20:23:24 +01:00
fccview
2b7d591a95 Merge pull request #20 from fccview/feature/multi-user-support
Feature/multi user support
2025-08-27 20:21:59 +01:00
35 changed files with 3755 additions and 635 deletions

View File

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

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ node_modules
.DS_Store .DS_Store
.cursorignore .cursorignore
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
docker-compose.test.yml

View File

@@ -49,7 +49,7 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
```bash ```bash
services: services:
cronjob-manager: cronjob-manager:
image: ghcr.io/fccview/cronmaster:2.3.0 image: ghcr.io/fccview/cronmaster:latest
container_name: cronmaster container_name: cronmaster
user: "root" user: "root"
ports: ports:
@@ -58,17 +58,26 @@ services:
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DOCKER=true - 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 - HOST_PROJECT_DIR=/path/to/cronmaster/directory
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000 - 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/ # --- PASSWORD PROTECTION
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=fccview,root,user1,user2 # Uncomment to enable password protection (replace "very_strong_password" with your own)
# - HOST_CRONTAB_USER=fccview - 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: volumes:
# --- MOUNT DOCKER SOCKET
# Mount Docker socket to execute commands on host # Mount Docker socket to execute commands on host
- /var/run/docker.sock:/var/run/docker.sock - /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. # 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 # 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) # will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
@@ -76,14 +85,14 @@ services:
- ./data:/app/data - ./data:/app/data
- ./snippets:/app/snippets - ./snippets:/app/snippets
# Use host PID namespace for host command execution # --- USE HOST PID NAMESPACE FOR HOST COMMAND EXECUTION
# Run in privileged mode for nsenter access # --- RUN IN PRIVILEGED MODE FOR NSENTER ACCESS
pid: "host" pid: "host"
privileged: true privileged: true
restart: unless-stopped restart: always
init: true 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 #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) | | `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. | | `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 | | `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: **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 ### 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` - 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 - `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. - 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: The application automatically detects your operating system and displays:
- Platform
- Hostname
- IP Address
- System Uptime - System Uptime
- Memory Usage - Memory Usage
- CPU Information - CPU Information
@@ -241,6 +248,15 @@ I would like to thank the following members for raising issues and help test/deb
<td align="center" valign="top" width="20%"> <td align="center" valign="top" width="20%">
<a href="https://github.com/DVDAndroid"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/6277172?u=78aa9b049a0c1a7ae5408d22219a8a91cfe45095&v=4&size=100"><br />DVDAndroid</a> <a href="https://github.com/DVDAndroid"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/6277172?u=78aa9b049a0c1a7ae5408d22219a8a91cfe45095&v=4&size=100"><br />DVDAndroid</a>
</td> </td>
<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> </tr>
</tbody> </tbody>
</table> </table>
@@ -252,3 +268,7 @@ This project is licensed under the MIT License.
## Support ## Support
For issues and questions, please open an issue on the GitHub repository. 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"; "use client";
import { useState } from "react"; import { useState } from "react";
import { CronJobList } from "./CronJobList"; import { CronJobList } from "./features/Cronjobs/CronJobList";
import { ScriptsManager } from "./ScriptsManager"; import { ScriptsManager } from "./ScriptsManager";
import { CronJob } from "@/app/_utils/system"; import { CronJob } from "@/app/_utils/system";
import { type Script } from "@/app/_server/actions/scripts"; import { type Script } from "@/app/_server/actions/scripts";
@@ -12,7 +12,10 @@ interface TabbedInterfaceProps {
scripts: Script[]; scripts: Script[];
} }
export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) => { export const TabbedInterface = ({
cronJobs,
scripts,
}: TabbedInterfaceProps) => {
const [activeTab, setActiveTab] = useState<"cronjobs" | "scripts">( const [activeTab, setActiveTab] = useState<"cronjobs" | "scripts">(
"cronjobs" "cronjobs"
); );
@@ -23,10 +26,11 @@ export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) =>
<div className="flex"> <div className="flex">
<button <button
onClick={() => setActiveTab("cronjobs")} 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" ? "bg-primary/10 text-primary shadow-sm border border-primary/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`} }`}
> >
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
Cron Jobs Cron Jobs
@@ -36,10 +40,11 @@ export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) =>
</button> </button>
<button <button
onClick={() => setActiveTab("scripts")} 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" ? "bg-primary/10 text-primary shadow-sm border border-primary/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`} }`}
> >
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
Scripts Scripts
@@ -59,4 +64,4 @@ export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) =>
</div> </div>
</div> </div>
); );
} };

View File

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

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react"; import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
import { cn } from "@/app/_utils/cn"; import { cn } from "@/app/_utils/cn";
import { ErrorDetailsModal } from "../modals/ErrorDetailsModal";
export interface Toast { export interface Toast {
id: string; id: string;
@@ -10,11 +11,22 @@ export interface Toast {
title: string; title: string;
message?: string; message?: string;
duration?: number; duration?: number;
errorDetails?: {
title: string;
message: string;
details?: string;
command?: string;
output?: string;
stderr?: string;
timestamp: string;
jobId?: string;
};
} }
interface ToastProps { interface ToastProps {
toast: Toast; toast: Toast;
onRemove: (id: string) => void; onRemove: (id: string) => void;
onErrorClick?: (errorDetails: Toast["errorDetails"]) => void;
} }
const toastIcons = { const toastIcons = {
@@ -33,7 +45,7 @@ const toastStyles = {
"border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400", "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 [isVisible, setIsVisible] = useState(false);
const Icon = toastIcons[toast.type]; 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" /> <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> <h4 className="font-medium text-sm">{toast.title}</h4>
{toast.message && ( {toast.message && (
<p className="text-sm opacity-90 mt-1">{toast.message}</p> <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> </div>
<button <button
onClick={() => { onClick={() => {
@@ -73,10 +97,14 @@ export const Toast = ({ toast, onRemove }: ToastProps) => {
</button> </button>
</div> </div>
); );
} };
export const ToastContainer = () => { export const ToastContainer = () => {
const [toasts, setToasts] = useState<Toast[]>([]); 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 addToast = (toast: Omit<Toast, "id">) => {
const id = Math.random().toString(36).substr(2, 9); const id = Math.random().toString(36).substr(2, 9);
@@ -87,6 +115,11 @@ export const ToastContainer = () => {
setToasts((prev) => prev.filter((toast) => toast.id !== id)); setToasts((prev) => prev.filter((toast) => toast.id !== id));
}; };
const handleErrorClick = (errorDetails: Toast["errorDetails"]) => {
setSelectedError(errorDetails);
setErrorModalOpen(true);
};
useEffect(() => { useEffect(() => {
(window as any).showToast = addToast; (window as any).showToast = addToast;
return () => { return () => {
@@ -95,21 +128,39 @@ export const ToastContainer = () => {
}, []); }, []);
return ( return (
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm"> <>
{toasts.map((toast) => ( <div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
<Toast key={toast.id} toast={toast} onRemove={removeToast} /> {toasts.map((toast) => (
))} <Toast
</div> 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 = ( export const showToast = (
type: Toast["type"], type: Toast["type"],
title: string, title: string,
message?: string, message?: string,
duration?: number duration?: number,
errorDetails?: Toast["errorDetails"]
) => { ) => {
if (typeof window !== "undefined" && (window as any).showToast) { 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, cleanupCrontab,
type CronJob, type CronJob,
} from "@/app/_utils/system"; } 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 { revalidatePath } from "next/cache";
import { getScriptPath } from "@/app/_utils/scripts"; import { getScriptPath } from "@/app/_utils/scripts";
import { exec } from "child_process"; import { exec } from "child_process";
@@ -25,11 +28,11 @@ export const fetchCronJobs = async (): Promise<CronJob[]> => {
console.error("Error fetching cron jobs:", error); console.error("Error fetching cron jobs:", error);
return []; return [];
} }
} };
export const createCronJob = async ( export const createCronJob = async (
formData: FormData formData: FormData
): Promise<{ success: boolean; message: string }> => { ): Promise<{ success: boolean; message: string; details?: string }> => {
try { try {
const schedule = formData.get("schedule") as string; const schedule = formData.get("schedule") as string;
const command = formData.get("command") as string; const command = formData.get("command") as string;
@@ -67,15 +70,19 @@ export const createCronJob = async (
} else { } else {
return { success: false, message: "Failed to create cron job" }; return { success: false, message: "Failed to create cron job" };
} }
} catch (error) { } catch (error: any) {
console.error("Error creating cron job:", error); 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 ( export const removeCronJob = async (
id: string id: string
): Promise<{ success: boolean; message: string }> => { ): Promise<{ success: boolean; message: string; details?: string }> => {
try { try {
const success = await deleteCronJob(id); const success = await deleteCronJob(id);
if (success) { if (success) {
@@ -84,15 +91,19 @@ export const removeCronJob = async (
} else { } else {
return { success: false, message: "Failed to delete cron job" }; return { success: false, message: "Failed to delete cron job" };
} }
} catch (error) { } catch (error: any) {
console.error("Error deleting cron job:", error); 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 ( export const editCronJob = async (
formData: FormData formData: FormData
): Promise<{ success: boolean; message: string }> => { ): Promise<{ success: boolean; message: string; details?: string }> => {
try { try {
const id = formData.get("id") as string; const id = formData.get("id") as string;
const schedule = formData.get("schedule") as string; const schedule = formData.get("schedule") as string;
@@ -110,16 +121,20 @@ export const editCronJob = async (
} else { } else {
return { success: false, message: "Failed to update cron job" }; return { success: false, message: "Failed to update cron job" };
} }
} catch (error) { } catch (error: any) {
console.error("Error updating cron job:", error); 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 ( export const cloneCronJob = async (
id: string, id: string,
newComment: string newComment: string
): Promise<{ success: boolean; message: string }> => { ): Promise<{ success: boolean; message: string; details?: string }> => {
try { try {
const cronJobs = await getCronJobs(); const cronJobs = await getCronJobs();
const originalJob = cronJobs.find((job) => job.id === id); const originalJob = cronJobs.find((job) => job.id === id);
@@ -141,15 +156,19 @@ export const cloneCronJob = async (
} else { } else {
return { success: false, message: "Failed to clone cron job" }; return { success: false, message: "Failed to clone cron job" };
} }
} catch (error) { } catch (error: any) {
console.error("Error cloning cron job:", error); 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 ( export const pauseCronJobAction = async (
id: string id: string
): Promise<{ success: boolean; message: string }> => { ): Promise<{ success: boolean; message: string; details?: string }> => {
try { try {
const success = await pauseCronJob(id); const success = await pauseCronJob(id);
if (success) { if (success) {
@@ -158,15 +177,19 @@ export const pauseCronJobAction = async (
} else { } else {
return { success: false, message: "Failed to pause cron job" }; return { success: false, message: "Failed to pause cron job" };
} }
} catch (error) { } catch (error: any) {
console.error("Error pausing cron job:", error); 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 ( export const resumeCronJobAction = async (
id: string id: string
): Promise<{ success: boolean; message: string }> => { ): Promise<{ success: boolean; message: string; details?: string }> => {
try { try {
const success = await resumeCronJob(id); const success = await resumeCronJob(id);
if (success) { if (success) {
@@ -175,11 +198,15 @@ export const resumeCronJobAction = async (
} else { } else {
return { success: false, message: "Failed to resume cron job" }; return { success: false, message: "Failed to resume cron job" };
} }
} catch (error) { } catch (error: any) {
console.error("Error resuming cron job:", error); 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[]> => { export const fetchAvailableUsers = async (): Promise<string[]> => {
try { try {
@@ -188,9 +215,13 @@ export const fetchAvailableUsers = async (): Promise<string[]> => {
console.error("Error fetching available users:", error); console.error("Error fetching available users:", error);
return []; return [];
} }
} };
export const cleanupCrontabAction = async (): Promise<{ success: boolean; message: string }> => { export const cleanupCrontabAction = async (): Promise<{
success: boolean;
message: string;
details?: string;
}> => {
try { try {
const success = await cleanupCrontab(); const success = await cleanupCrontab();
if (success) { if (success) {
@@ -199,15 +230,24 @@ export const cleanupCrontabAction = async (): Promise<{ success: boolean; messag
} else { } else {
return { success: false, message: "Failed to clean crontab" }; return { success: false, message: "Failed to clean crontab" };
} }
} catch (error) { } catch (error: any) {
console.error("Error cleaning crontab:", error); 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 ( export const runCronJob = async (
id: string id: string
): Promise<{ success: boolean; message: string; output?: string }> => { ): Promise<{
success: boolean;
message: string;
output?: string;
details?: string;
}> => {
try { try {
const cronJobs = await getCronJobs(); const cronJobs = await getCronJobs();
const job = cronJobs.find((j) => j.id === id); const job = cronJobs.find((j) => j.id === id);
@@ -243,15 +283,17 @@ export const runCronJob = async (
return { return {
success: true, success: true,
message: "Cron job executed successfully", message: "Cron job executed successfully",
output: output.trim() output: output.trim(),
}; };
} catch (error: any) { } catch (error: any) {
console.error("Error running cron job:", error); 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 { return {
success: false, success: false,
message: "Failed to execute cron job", 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 { existsSync } from "fs";
import { exec } from "child_process"; import { exec } from "child_process";
import { promisify } from "util"; 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"; import { loadAllScripts, type Script } from "@/app/_utils/scriptScanner";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -62,7 +62,9 @@ const saveScriptFile = async (filename: string, content: string) => {
} }
const deleteScriptFile = async (filename: 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)) { if (existsSync(scriptPath)) {
await unlink(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); await saveScriptFile(filename, fullContent);
revalidatePath("/"); 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); await saveScriptFile(existingScript.filename, fullContent);
revalidatePath("/"); 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); await saveScriptFile(filename, fullContent);
revalidatePath("/"); revalidatePath("/");

View File

@@ -1,411 +1,441 @@
import { CronJob } from "../system"; import { CronJob } from "../system";
export const pauseJobInLines = (lines: string[], targetJobIndex: number): string[] => { export const pauseJobInLines = (
const newCronEntries: string[] = []; lines: string[],
let currentJobIndex = 0; targetJobIndex: number
let i = 0; ): string[] => {
const newCronEntries: string[] = [];
let currentJobIndex = 0;
let i = 0;
while (i < lines.length) { while (i < lines.length) {
const line = lines[i]; const line = lines[i];
const trimmedLine = line.trim(); const trimmedLine = line.trim();
if (!trimmedLine) { if (!trimmedLine) {
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") { if (
newCronEntries.push(""); newCronEntries.length > 0 &&
} newCronEntries[newCronEntries.length - 1] !== ""
i++; ) {
continue; newCronEntries.push("");
} }
i++;
continue;
}
if ( if (
trimmedLine.startsWith("# User:") || trimmedLine.startsWith("# User:") ||
trimmedLine.startsWith("# System Crontab") trimmedLine.startsWith("# System Crontab")
) { ) {
newCronEntries.push(line); newCronEntries.push(line);
i++; i++;
continue; continue;
} }
if (trimmedLine.startsWith("# PAUSED: ")) { if (trimmedLine.startsWith("# PAUSED:")) {
newCronEntries.push(line); newCronEntries.push(line);
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) { if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
newCronEntries.push(lines[i + 1]); newCronEntries.push(lines[i + 1]);
i += 2; i += 2;
} else { } else {
i++; i++;
} }
currentJobIndex++; currentJobIndex++;
continue; 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 (trimmedLine.startsWith("#")) {
if (
i + 1 < lines.length &&
!lines[i + 1].trim().startsWith("#") &&
lines[i + 1].trim()
) {
if (currentJobIndex === targetJobIndex) { if (currentJobIndex === targetJobIndex) {
const pausedEntry = `# PAUSED:\n# ${trimmedLine}`; const comment = trimmedLine.substring(1).trim();
newCronEntries.push(pausedEntry); const nextLine = lines[i + 1].trim();
const pausedEntry = `# PAUSED: ${comment}\n# ${nextLine}`;
newCronEntries.push(pausedEntry);
i += 2;
currentJobIndex++;
} else { } else {
newCronEntries.push(line); newCronEntries.push(line);
newCronEntries.push(lines[i + 1]);
i += 2;
currentJobIndex++;
} }
} else {
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;
}
newCronEntries.push(line); newCronEntries.push(line);
currentJobIndex++;
i++; 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[] => { currentJobIndex++;
const jobs: CronJob[] = []; i++;
let currentComment = ""; }
let jobIndex = 0;
let i = 0;
while (i < lines.length) { return newCronEntries;
const line = lines[i]; };
const trimmedLine = line.trim();
if (!trimmedLine) { export const resumeJobInLines = (
i++; lines: string[],
continue; 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++;
} }
} else {
if ( newCronEntries.push(line);
trimmedLine.startsWith("# User:") || if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
trimmedLine.startsWith("# System Crontab") newCronEntries.push(lines[i + 1]);
) { i += 2;
i++; } else {
continue; i++;
} }
}
currentJobIndex++;
continue;
}
if (trimmedLine.startsWith("# PAUSED: ")) { if (trimmedLine.startsWith("#")) {
const comment = trimmedLine.substring(10).trim(); newCronEntries.push(line);
i++;
continue;
}
if (i + 1 < lines.length) { newCronEntries.push(line);
const nextLine = lines[i + 1].trim(); currentJobIndex++;
if (nextLine.startsWith("# ")) { i++;
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({ return newCronEntries;
id: `${user}-${jobIndex}`, };
schedule,
command,
comment: comment || undefined,
user,
paused: true,
});
jobIndex++; export const parseJobsFromLines = (
i += 2; lines: string[],
continue; user: string
} ): CronJob[] => {
} const jobs: CronJob[] = [];
} let currentComment = "";
i++; let jobIndex = 0;
continue; let i = 0;
}
if (trimmedLine.startsWith("#")) { while (i < lines.length) {
if ( const line = lines[i];
i + 1 < lines.length && const trimmedLine = line.trim();
!lines[i + 1].trim().startsWith("#") &&
lines[i + 1].trim()
) {
currentComment = trimmedLine.substring(1).trim();
i++;
continue;
} else {
i++;
continue;
}
}
let schedule, command; if (!trimmedLine) {
const parts = trimmedLine.split(/(?:\s|\t)+/); i++;
continue;
}
if (parts[0].startsWith("@")) { if (
if (parts.length >= 2) { trimmedLine.startsWith("# User:") ||
schedule = parts[0]; trimmedLine.startsWith("# System Crontab")
command = trimmedLine.slice(trimmedLine.indexOf(parts[1])) ) {
} i++;
} else if (parts.length >= 6) { continue;
schedule = parts.slice(0, 5).join(" "); }
command = trimmedLine.slice(trimmedLine.indexOf(parts[5]))
} 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({ jobs.push({
id: `${user}-${jobIndex}`, id: `${user}-${jobIndex}`,
schedule, schedule,
command, command,
comment: currentComment || undefined, comment: comment || undefined,
user, user,
paused: false, paused: true,
}); });
jobIndex++; 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[] => { let schedule, command;
const newCronEntries: string[] = []; const parts = trimmedLine.split(/(?:\s|\t)+/);
let currentJobIndex = 0;
let i = 0;
while (i < lines.length) { if (parts[0].startsWith("@")) {
const line = lines[i]; if (parts.length >= 2) {
const trimmedLine = line.trim(); 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 (schedule && command) {
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") { jobs.push({
newCronEntries.push(""); id: `${user}-${jobIndex}`,
} schedule,
i++; command,
continue; 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++;
} }
} else {
if ( if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
trimmedLine.startsWith("# User:") || i += 2;
trimmedLine.startsWith("# System Crontab") } else {
) { i++;
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;
} }
}
currentJobIndex++;
continue;
}
if (trimmedLine.startsWith("#")) {
if (
i + 1 < lines.length &&
!lines[i + 1].trim().startsWith("#") &&
lines[i + 1].trim()
) {
if (currentJobIndex !== targetJobIndex) { if (currentJobIndex !== targetJobIndex) {
newCronEntries.push(line); newCronEntries.push(line);
newCronEntries.push(lines[i + 1]);
} }
i += 2;
currentJobIndex++; currentJobIndex++;
} else {
newCronEntries.push(line);
i++; i++;
}
continue;
} }
return newCronEntries; if (currentJobIndex !== targetJobIndex) {
} newCronEntries.push(line);
}
currentJobIndex++;
i++;
}
return newCronEntries;
};
export const updateJobInLines = ( export const updateJobInLines = (
lines: string[], lines: string[],
targetJobIndex: number, targetJobIndex: number,
schedule: string, schedule: string,
command: string, command: string,
comment: string = "" comment: string = ""
): string[] => { ): string[] => {
const newCronEntries: string[] = []; const newCronEntries: string[] = [];
let currentJobIndex = 0; let currentJobIndex = 0;
let i = 0; let i = 0;
while (i < lines.length) { while (i < lines.length) {
const line = lines[i]; const line = lines[i];
const trimmedLine = line.trim(); const trimmedLine = line.trim();
if (!trimmedLine) { if (!trimmedLine) {
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") { if (
newCronEntries.push(""); newCronEntries.length > 0 &&
} newCronEntries[newCronEntries.length - 1] !== ""
i++; ) {
continue; newCronEntries.push("");
} }
i++;
if ( continue;
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; 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)}`; 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 }; 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"; statusDetails = "Moderate resource usage - monitoring recommended";
} }
let mainInterface = null; let mainInterface: any = null;
if (Array.isArray(networkInfo) && networkInfo.length > 0) { if (Array.isArray(networkInfo) && networkInfo.length > 0) {
mainInterface = networkInfo.find(net => mainInterface = networkInfo.find(net =>
net.iface && !net.iface.includes('lo') && net.operstate === 'up' 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 { JetBrains_Mono, Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "./_components/ui/ThemeProvider"; import { ThemeProvider } from "./_components/ui/ThemeProvider";
import { ServiceWorkerRegister } from "./_components/ui/ServiceWorkerRegister";
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"], subsets: ["latin"],
@@ -17,8 +18,16 @@ const inter = Inter({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Cr*nMaster - Cron Management made easy", title: "Cr*nMaster - Cron Management made easy",
description: description: "The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
"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: { icons: {
icon: "/logo.png", icon: "/logo.png",
shortcut: "/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({ export default function RootLayout({
children, children,
}: { }: {
@@ -33,15 +50,24 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en" suppressHydrationWarning> <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`}> <body className={`${inter.variable} ${jetbrainsMono.variable} font-sans`}>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="system" defaultTheme="dark"
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
{children} {children}
</ThemeProvider> </ThemeProvider>
<ServiceWorkerRegister />
</body> </body>
</html> </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 { getCronJobs } from "./_utils/system";
import { fetchScripts } from "./_server/actions/scripts"; import { fetchScripts } from "./_server/actions/scripts";
import { ThemeToggle } from "./_components/ui/ThemeToggle"; import { ThemeToggle } from "./_components/ui/ThemeToggle";
import { LogoutButton } from "./_components/ui/LogoutButton";
import { ToastContainer } from "./_components/ui/Toast"; import { ToastContainer } from "./_components/ui/Toast";
import { PWAInstallPrompt } from "./_components/ui/PWAInstallPrompt";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function Home() { export default async function Home() {
@@ -52,7 +54,7 @@ export default async function Home() {
<div className="relative z-10"> <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]"> <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="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="flex items-center gap-4">
<div className="relative"> <div className="relative">
<img src="/logo.png" alt="logo" className="w-14 h-14" /> <img src="/logo.png" alt="logo" className="w-14 h-14" />
@@ -67,6 +69,11 @@ export default async function Home() {
</p> </p>
</div> </div>
</div> </div>
{process.env.AUTH_PASSWORD && (
<div className="lg:absolute lg:right-10">
<LogoutButton />
</div>
)}
</div> </div>
</div> </div>
</header> </header>
@@ -82,8 +89,9 @@ export default async function Home() {
<ToastContainer /> <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 /> <ThemeToggle />
<PWAInstallPrompt />
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
services: services:
cronjob-manager: cronjob-manager:
image: ghcr.io/fccview/cronmaster:1.3.0 image: ghcr.io/fccview/cronmaster:latest
container_name: cronmaster container_name: cronmaster
user: "root" user: "root"
ports: ports:
@@ -9,17 +9,26 @@ services:
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DOCKER=true - 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 - HOST_PROJECT_DIR=/path/to/cronmaster/directory
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000 - 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/ # --- PASSWORD PROTECTION
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=fccview,root,user1,user2 # Uncomment to enable password protection (replace "password" with your own)
# - HOST_CRONTAB_USER=fccview - 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: volumes:
# --- MOUNT DOCKER SOCKET
# Mount Docker socket to execute commands on host # Mount Docker socket to execute commands on host
- /var/run/docker.sock:/var/run/docker.sock - /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. # 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 # 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) # will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
@@ -27,12 +36,12 @@ services:
- ./data:/app/data - ./data:/app/data
- ./snippets:/app/snippets - ./snippets:/app/snippets
# Use host PID namespace for host command execution # --- USE HOST PID NAMESPACE FOR HOST COMMAND EXECUTION
# Run in privileged mode for nsenter access # --- RUN IN PRIVILEGED MODE FOR NSENTER ACCESS
pid: "host" pid: "host"
privileged: true privileged: true
restart: unless-stopped restart: always
init: true 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 #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} */ /** @type {import('next').NextConfig} */
const 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-dom": "^18",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"autoprefixer": "^10.0.1", "autoprefixer": "^10.0.1",
"bcryptjs": "^2.4.3",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"cron-parser": "^5.3.0", "cron-parser": "^5.3.0",
"cronstrue": "^3.2.0", "cronstrue": "^3.2.0",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"minimatch": "^10.0.3",
"next": "14.0.4", "next": "14.0.4",
"next-pwa": "^5.6.0",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"postcss": "^8", "postcss": "^8",
"react": "^18", "react": "^18",
@@ -40,6 +43,8 @@
"typescript": "^5" "typescript": "^5"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/minimatch": "^6.0.0",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.0.4" "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 # @title: Hi, this is a demo script
# @description: This script logs a "hello world" to teach you how scripts work. # @description: This script logs a "hello world" to teach you how scripts work.
#!/bin/bash #!/bin/bash
echo 'Hello World' > hello.txt echo 'Hello World' > hello.txt

View File

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