mirror of
https://github.com/fccview/cronmaster.git
synced 2025-12-24 06:28:26 -05:00
Compare commits
23 Commits
feature/mu
...
1.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e95968ef | ||
|
|
a4ae5ec148 | ||
|
|
8e8069ee92 | ||
|
|
f96c37b55c | ||
|
|
cda9685e6d | ||
|
|
8329c0d030 | ||
|
|
6e34474993 | ||
|
|
65ac81d97c | ||
|
|
968fbae13c | ||
|
|
c739d29141 | ||
|
|
389ee44e4e | ||
|
|
33ff5de463 | ||
|
|
7aeea3f46a | ||
|
|
9018f2caed | ||
|
|
7383a13c13 | ||
|
|
da11d3503e | ||
|
|
0b9edc5f11 | ||
|
|
44b31a5702 | ||
|
|
7fc8cb9edb | ||
|
|
4dfdf8fc53 | ||
|
|
8cfc000893 | ||
|
|
1dde8f839e | ||
|
|
2b7d591a95 |
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -2,7 +2,7 @@ name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "legacy", "feature/*", "bugfix/*"]
|
||||
branches: ["main", "legacy", "feature/*"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,4 +11,6 @@ node_modules
|
||||
.vscode
|
||||
.DS_Store
|
||||
.cursorignore
|
||||
.idea
|
||||
tsconfig.tsbuildinfo
|
||||
docker-compose.test.yml
|
||||
82
README.md
82
README.md
@@ -2,18 +2,6 @@
|
||||
<img src="public/heading.png" width="400px">
|
||||
</p>
|
||||
|
||||
# ATTENTION BREAKING UPDATE!!
|
||||
|
||||
> The latest `main` branch has completely changed the way this app used to run.
|
||||
> The main reason being trying to address some security concerns and make the whole application work
|
||||
> across multiple platform without too much trouble.
|
||||
>
|
||||
> If you came here due to this change trying to figure out why your app stopped working you have two options:
|
||||
>
|
||||
> 1 - Update your `docker-compose.yml` with the new one provided within this readme (or just copy [docker-compose.yml](docker-compose.yml))
|
||||
>
|
||||
> 2 - Keep your `docker-compose.yml` file as it is and use the legacy tag in the image `image: ghcr.io/fccview/cronmaster:legacy`. However bear in mind this will not be supported going forward, any issue regarding the legacy tag will be ignored and I will only support the main branch. Feel free to fork that specific branch in case you want to work on it yourself :)
|
||||
|
||||
## Features
|
||||
|
||||
- **Modern UI**: Beautiful, responsive interface with dark/light mode.
|
||||
@@ -23,6 +11,23 @@
|
||||
- **Docker Support**: Runs entirely from a Docker container.
|
||||
- **Easy Setup**: Quick presets for common cron schedules.
|
||||
|
||||
<br />
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="http://discord.gg/invite/mMuk2WzVZu">
|
||||
<img width="40" src="public/repo-images/discord_icon.webp">
|
||||
</a>
|
||||
<br />
|
||||
<i>Join the discord server for more info</i>
|
||||
<br />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<br />
|
||||
|
||||
## Before we start
|
||||
|
||||
Hey there! 👋 Just a friendly heads-up: I'm a big believer in open source and love sharing my work with the community. Everything you find in my GitHub repos is and always will be 100% free. If someone tries to sell you a "premium" version of any of my projects while claiming to be me, please know that this is not legitimate. 🚫
|
||||
@@ -49,7 +54,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:2.3.0
|
||||
image: ghcr.io/fccview/cronmaster:latest
|
||||
container_name: cronmaster
|
||||
user: "root"
|
||||
ports:
|
||||
@@ -58,17 +63,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 +90,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 +150,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,19 +168,17 @@ 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.
|
||||
|
||||
**Important Note on Root Commands**: When running commands as `root` within Cronmaster, ensure that these commands also function correctly as `root` on your host machine. If a command works as `root` on your host but fails within Cronmaster, please open an issue with detailed information.
|
||||
|
||||
## Usage
|
||||
|
||||
### Viewing System Information
|
||||
|
||||
The application automatically detects your operating system and displays:
|
||||
|
||||
- Platform
|
||||
- Hostname
|
||||
- IP Address
|
||||
- System Uptime
|
||||
- Memory Usage
|
||||
- CPU Information
|
||||
@@ -241,6 +255,18 @@ 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/DVDAndroid"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/6277172?u=78aa9b049a0c1a7ae5408d22219a8a91cfe45095&v=4&size=100"><br />DVDAndroid</a>
|
||||
</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>
|
||||
<td align="center" valign="top" width="20%">
|
||||
<a href="https://github.com/Navino16"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/22234867?v=4&size=100"><br />Navino16</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -252,3 +278,7 @@ This project is licensed under the MIT License.
|
||||
## Support
|
||||
|
||||
For issues and questions, please open an issue on the GitHub repository.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#fccview/cronmaster&Date)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
359
app/_components/features/Cronjobs/helpers/index.tsx
Normal file
359
app/_components/features/Cronjobs/helpers/index.tsx
Normal 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.");
|
||||
}
|
||||
};
|
||||
99
app/_components/features/LoginForm/LoginForm.tsx
Normal file
99
app/_components/features/LoginForm/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
137
app/_components/modals/ErrorDetailsModal.tsx
Normal file
137
app/_components/modals/ErrorDetailsModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
app/_components/ui/ErrorBadge.tsx
Normal file
47
app/_components/ui/ErrorBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
43
app/_components/ui/LogoutButton.tsx
Normal file
43
app/_components/ui/LogoutButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
58
app/_components/ui/PWAInstallPrompt.tsx
Normal file
58
app/_components/ui/PWAInstallPrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
app/_components/ui/ServiceWorkerRegister.tsx
Normal file
23
app/_components/ui/ServiceWorkerRegister.tsx
Normal 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;
|
||||
};
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -225,12 +265,10 @@ export const runCronJob = async (
|
||||
|
||||
if (isDocker) {
|
||||
const userInfo = await getUserInfo(job.user);
|
||||
const executionUser = userInfo ? userInfo.username : "root";
|
||||
const escapedCommand = job.command.replace(/'/g, "'\\''");
|
||||
|
||||
if (userInfo && userInfo.username !== "root") {
|
||||
command = `nsenter -t 1 -m -u -i -n -p --setuid=${userInfo.uid} --setgid=${userInfo.gid} sh -c "${job.command}"`;
|
||||
} else {
|
||||
command = `nsenter -t 1 -m -u -i -n -p sh -c "${job.command}"`;
|
||||
}
|
||||
command = `nsenter -t 1 -m -u -i -n -p su - ${executionUser} -c '${escapedCommand}'`;
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
@@ -243,15 +281,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -21,7 +21,7 @@ const sanitizeScriptName = (name: string): string => {
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.substring(0, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const generateUniqueFilename = async (baseName: string): Promise<string> => {
|
||||
const scripts = await loadAllScripts();
|
||||
@@ -34,14 +34,14 @@ const generateUniqueFilename = async (baseName: string): Promise<string> => {
|
||||
}
|
||||
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureScriptsDirectory = async () => {
|
||||
const scriptsDir = await SCRIPTS_DIR();
|
||||
if (!existsSync(scriptsDir)) {
|
||||
await mkdir(scriptsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureHostScriptsDirectory = async () => {
|
||||
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
|
||||
@@ -50,7 +50,7 @@ const ensureHostScriptsDirectory = async () => {
|
||||
if (!existsSync(hostScriptsDir)) {
|
||||
await mkdir(hostScriptsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const saveScriptFile = async (filename: string, content: string) => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
@@ -59,18 +59,26 @@ const saveScriptFile = async (filename: string, content: string) => {
|
||||
|
||||
const scriptPath = join(scriptsDir, filename);
|
||||
await writeFile(scriptPath, content, "utf8");
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`chmod +x "${scriptPath}"`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to set execute permissions on ${scriptPath}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchScripts = async (): Promise<Script[]> => {
|
||||
return await loadAllScripts();
|
||||
}
|
||||
};
|
||||
|
||||
export const createScript = async (
|
||||
formData: FormData
|
||||
@@ -95,7 +103,8 @@ export const createScript = async (
|
||||
|
||||
`;
|
||||
|
||||
const fullContent = metadataHeader + content;
|
||||
const normalizedContent = normalizeLineEndings(content);
|
||||
const fullContent = metadataHeader + normalizedContent;
|
||||
|
||||
await saveScriptFile(filename, fullContent);
|
||||
revalidatePath("/");
|
||||
@@ -117,7 +126,7 @@ export const createScript = async (
|
||||
console.error("Error creating script:", error);
|
||||
return { success: false, message: "Error creating script" };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateScript = async (
|
||||
formData: FormData
|
||||
@@ -145,7 +154,8 @@ export const updateScript = async (
|
||||
|
||||
`;
|
||||
|
||||
const fullContent = metadataHeader + content;
|
||||
const normalizedContent = normalizeLineEndings(content);
|
||||
const fullContent = metadataHeader + normalizedContent;
|
||||
|
||||
await saveScriptFile(existingScript.filename, fullContent);
|
||||
revalidatePath("/");
|
||||
@@ -155,7 +165,7 @@ export const updateScript = async (
|
||||
console.error("Error updating script:", error);
|
||||
return { success: false, message: "Error updating script" };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteScript = async (
|
||||
id: string
|
||||
@@ -176,7 +186,7 @@ export const deleteScript = async (
|
||||
console.error("Error deleting script:", error);
|
||||
return { success: false, message: "Error deleting script" };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const cloneScript = async (
|
||||
id: string,
|
||||
@@ -203,7 +213,8 @@ export const cloneScript = async (
|
||||
|
||||
`;
|
||||
|
||||
const fullContent = metadataHeader + originalContent;
|
||||
const normalizedContent = normalizeLineEndings(originalContent);
|
||||
const fullContent = metadataHeader + normalizedContent;
|
||||
|
||||
await saveScriptFile(filename, fullContent);
|
||||
revalidatePath("/");
|
||||
@@ -225,7 +236,7 @@ export const cloneScript = async (
|
||||
console.error("Error cloning script:", error);
|
||||
return { success: false, message: "Error cloning script" };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getScriptContent = async (filename: string): Promise<string> => {
|
||||
try {
|
||||
@@ -258,9 +269,11 @@ export const getScriptContent = async (filename: string): Promise<string> => {
|
||||
console.error("Error reading script content:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const executeScript = async (filename: string): Promise<{
|
||||
export const executeScript = async (
|
||||
filename: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
error: string;
|
||||
@@ -296,4 +309,4 @@ export const executeScript = async (filename: string): Promise<{
|
||||
error: error.message || "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,411 +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);
|
||||
i++;
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
i++;
|
||||
} 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);
|
||||
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++;
|
||||
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
63
app/_utils/errorState.ts
Normal 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 {}
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
44
app/api/auth/login/route.ts
Normal file
44
app/api/auth/login/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
26
app/api/auth/logout/route.ts
Normal file
26
app/api/auth/logout/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
14
app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
app/page.tsx
12
app/page.tsx
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
37
middleware.ts
Normal 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).*)",
|
||||
],
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
26
public/manifest.json
Normal 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"
|
||||
}
|
||||
BIN
public/repo-images/discord_icon.webp
Normal file
BIN
public/repo-images/discord_icon.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
1
public/sw.js
Normal file
1
public/sw.js
Normal file
File diff suppressed because one or more lines are too long
1
public/workbox-1bb06f5e.js
Normal file
1
public/workbox-1bb06f5e.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user