mirror of
https://github.com/fccview/cronmaster.git
synced 2026-01-06 04:48:51 -05:00
Compare commits
15 Commits
feature/mu
...
bugfix/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main", "legacy", "feature/*", "bugfix/*"]
|
branches: ["main", "legacy", "feature/*"]
|
||||||
tags: ["*"]
|
tags: ["*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ node_modules
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.cursorignore
|
.cursorignore
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
docker-compose.test.yml
|
||||||
48
README.md
48
README.md
@@ -49,7 +49,7 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
|
|||||||
```bash
|
```bash
|
||||||
services:
|
services:
|
||||||
cronjob-manager:
|
cronjob-manager:
|
||||||
image: ghcr.io/fccview/cronmaster:2.3.0
|
image: ghcr.io/fccview/cronmaster:latest
|
||||||
container_name: cronmaster
|
container_name: cronmaster
|
||||||
user: "root"
|
user: "root"
|
||||||
ports:
|
ports:
|
||||||
@@ -58,17 +58,26 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DOCKER=true
|
- DOCKER=true
|
||||||
# Legacy used to be NEXT_PUBLIC_HOST_PROJECT_DIR, this was causing issues on runtime.
|
|
||||||
|
# --- MAP HOST PROJECT DIRECTORY, THIS IS MANDATORY FOR SCRIPTS TO WORK
|
||||||
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
||||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||||
# If docker struggles to find your crontab user, update this variable with it.
|
|
||||||
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
|
# --- PASSWORD PROTECTION
|
||||||
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=fccview,root,user1,user2
|
# Uncomment to enable password protection (replace "very_strong_password" with your own)
|
||||||
# - HOST_CRONTAB_USER=fccview
|
- AUTH_PASSWORD=very_strong_password
|
||||||
|
|
||||||
|
# --- CRONTAB USERS
|
||||||
|
# This is used to read the crontabs for the specific user.
|
||||||
|
# replace root with your user - find it with: ls -asl /var/spool/cron/crontabs/
|
||||||
|
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=root,user1,user2
|
||||||
|
- HOST_CRONTAB_USER=root
|
||||||
volumes:
|
volumes:
|
||||||
|
# --- MOUNT DOCKER SOCKET
|
||||||
# Mount Docker socket to execute commands on host
|
# Mount Docker socket to execute commands on host
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
# --- MOUNT DATA
|
||||||
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
|
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
|
||||||
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
|
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
|
||||||
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
|
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
|
||||||
@@ -76,14 +85,14 @@ services:
|
|||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./snippets:/app/snippets
|
- ./snippets:/app/snippets
|
||||||
|
|
||||||
# Use host PID namespace for host command execution
|
# --- USE HOST PID NAMESPACE FOR HOST COMMAND EXECUTION
|
||||||
# Run in privileged mode for nsenter access
|
# --- RUN IN PRIVILEGED MODE FOR NSENTER ACCESS
|
||||||
pid: "host"
|
pid: "host"
|
||||||
privileged: true
|
privileged: true
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
init: true
|
init: true
|
||||||
|
|
||||||
# Default platform is set to amd64, uncomment to use arm64.
|
# --- DEFAULT PLATFORM IS SET TO AMD64, UNCOMMENT TO USE ARM64.
|
||||||
#platform: linux/arm64
|
#platform: linux/arm64
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -136,6 +145,8 @@ The following environment variables can be configured:
|
|||||||
| `NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL` | `30000` | Clock update interval in milliseconds (30 seconds) |
|
| `NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL` | `30000` | Clock update interval in milliseconds (30 seconds) |
|
||||||
| `HOST_PROJECT_DIR` | `N/A` | Mandatory variable to make sure cron runs on the right path. |
|
| `HOST_PROJECT_DIR` | `N/A` | Mandatory variable to make sure cron runs on the right path. |
|
||||||
| `DOCKER` | `false` | ONLY set this to true if you are runnign the app via docker, in the docker-compose.yml file |
|
| `DOCKER` | `false` | ONLY set this to true if you are runnign the app via docker, in the docker-compose.yml file |
|
||||||
|
| `HOST_CRONTAB_USER` | `root` | Comma separated list of users that run cronjobs on your host machine |
|
||||||
|
| `AUTH_PASSWORD` | `N/A` | If you set a password the application will be password protected with basic next-auth |
|
||||||
|
|
||||||
**Example**: To change the clock update interval to 60 seconds:
|
**Example**: To change the clock update interval to 60 seconds:
|
||||||
|
|
||||||
@@ -152,7 +163,6 @@ HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
|
|||||||
### Important Notes for Docker
|
### Important Notes for Docker
|
||||||
|
|
||||||
- Root user is required for cron operations and direct file access. There is no way around this, if you don't feel comfortable in running it as root feel free to run the app locally with `yarn install`, `yarn build` and `yarn start`
|
- Root user is required for cron operations and direct file access. There is no way around this, if you don't feel comfortable in running it as root feel free to run the app locally with `yarn install`, `yarn build` and `yarn start`
|
||||||
- Crontab files are accessed directly via file system mounts at `/host/cron/crontabs` and `/host/crontab` for real-time reading and writing
|
|
||||||
- `HOST_PROJECT_DIR` is required in order for the scripts created within the app to run properly
|
- `HOST_PROJECT_DIR` is required in order for the scripts created within the app to run properly
|
||||||
- The `DOCKER=true` environment variable enables direct file access mode for crontab operations. This is REQUIRED when running the application in docker mode.
|
- The `DOCKER=true` environment variable enables direct file access mode for crontab operations. This is REQUIRED when running the application in docker mode.
|
||||||
|
|
||||||
@@ -162,9 +172,6 @@ HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
|
|||||||
|
|
||||||
The application automatically detects your operating system and displays:
|
The application automatically detects your operating system and displays:
|
||||||
|
|
||||||
- Platform
|
|
||||||
- Hostname
|
|
||||||
- IP Address
|
|
||||||
- System Uptime
|
- System Uptime
|
||||||
- Memory Usage
|
- Memory Usage
|
||||||
- CPU Information
|
- CPU Information
|
||||||
@@ -241,6 +248,15 @@ I would like to thank the following members for raising issues and help test/deb
|
|||||||
<td align="center" valign="top" width="20%">
|
<td align="center" valign="top" width="20%">
|
||||||
<a href="https://github.com/DVDAndroid"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/6277172?u=78aa9b049a0c1a7ae5408d22219a8a91cfe45095&v=4&size=100"><br />DVDAndroid</a>
|
<a href="https://github.com/DVDAndroid"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/6277172?u=78aa9b049a0c1a7ae5408d22219a8a91cfe45095&v=4&size=100"><br />DVDAndroid</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center" valign="top" width="20%">
|
||||||
|
<a href="https://github.com/ActxLeToucan"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/56509120?u=b0a684dfa1fcf8f3f41c2ead37f6441716d8bd62&v=4&size=100"><br />ActxLeToucan</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="top" width="20%">
|
||||||
|
<a href="https://github.com/mrtimothyduong"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/34667840?u=b54354da56681c17ca58366a68a6a94c80f77a1d&v=4&size=100"><br />mrtimothyduong</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="top" width="20%">
|
||||||
|
<a href="https://github.com/cerede2000"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/38144752?v=4&size=100"><br />cerede2000</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -252,3 +268,7 @@ This project is licensed under the MIT License.
|
|||||||
## Support
|
## Support
|
||||||
|
|
||||||
For issues and questions, please open an issue on the GitHub repository.
|
For issues and questions, please open an issue on the GitHub repository.
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#fccview/cronmaster&Date)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CronJobList } from "./CronJobList";
|
import { CronJobList } from "./features/Cronjobs/CronJobList";
|
||||||
import { ScriptsManager } from "./ScriptsManager";
|
import { ScriptsManager } from "./ScriptsManager";
|
||||||
import { CronJob } from "@/app/_utils/system";
|
import { CronJob } from "@/app/_utils/system";
|
||||||
import { type Script } from "@/app/_server/actions/scripts";
|
import { type Script } from "@/app/_server/actions/scripts";
|
||||||
@@ -12,7 +12,10 @@ interface TabbedInterfaceProps {
|
|||||||
scripts: Script[];
|
scripts: Script[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) => {
|
export const TabbedInterface = ({
|
||||||
|
cronJobs,
|
||||||
|
scripts,
|
||||||
|
}: TabbedInterfaceProps) => {
|
||||||
const [activeTab, setActiveTab] = useState<"cronjobs" | "scripts">(
|
const [activeTab, setActiveTab] = useState<"cronjobs" | "scripts">(
|
||||||
"cronjobs"
|
"cronjobs"
|
||||||
);
|
);
|
||||||
@@ -23,10 +26,11 @@ export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) =>
|
|||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("cronjobs")}
|
onClick={() => setActiveTab("cronjobs")}
|
||||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${activeTab === "cronjobs"
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${
|
||||||
|
activeTab === "cronjobs"
|
||||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
Cron Jobs
|
Cron Jobs
|
||||||
@@ -36,10 +40,11 @@ export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) =>
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("scripts")}
|
onClick={() => setActiveTab("scripts")}
|
||||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${activeTab === "scripts"
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${
|
||||||
|
activeTab === "scripts"
|
||||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
Scripts
|
Scripts
|
||||||
@@ -59,4 +64,4 @@ export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "../../ui/Card";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "../../ui/Button";
|
||||||
import {
|
import {
|
||||||
Trash2,
|
Trash2,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -24,13 +24,31 @@ import {
|
|||||||
runCronJob,
|
runCronJob,
|
||||||
} from "@/app/_server/actions/cronjobs";
|
} from "@/app/_server/actions/cronjobs";
|
||||||
import { useState, useMemo, useEffect } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { CreateTaskModal } from "./modals/CreateTaskModal";
|
import { CreateTaskModal } from "../../modals/CreateTaskModal";
|
||||||
import { EditTaskModal } from "./modals/EditTaskModal";
|
import { EditTaskModal } from "../../modals/EditTaskModal";
|
||||||
import { DeleteTaskModal } from "./modals/DeleteTaskModal";
|
import { DeleteTaskModal } from "../../modals/DeleteTaskModal";
|
||||||
import { CloneTaskModal } from "./modals/CloneTaskModal";
|
import { CloneTaskModal } from "../../modals/CloneTaskModal";
|
||||||
import { UserFilter } from "./ui/UserFilter";
|
import { UserFilter } from "../../ui/UserFilter";
|
||||||
|
import { ErrorBadge } from "../../ui/ErrorBadge";
|
||||||
|
import { ErrorDetailsModal } from "../../modals/ErrorDetailsModal";
|
||||||
import { type Script } from "@/app/_server/actions/scripts";
|
import { type Script } from "@/app/_server/actions/scripts";
|
||||||
import { showToast } from "./ui/Toast";
|
import { showToast } from "../../ui/Toast";
|
||||||
|
import {
|
||||||
|
getJobErrorsByJobId,
|
||||||
|
setJobError,
|
||||||
|
JobError,
|
||||||
|
} from "@/app/_utils/errorState";
|
||||||
|
import {
|
||||||
|
handleErrorClick,
|
||||||
|
refreshJobErrors,
|
||||||
|
handleDelete,
|
||||||
|
handleClone,
|
||||||
|
handlePause,
|
||||||
|
handleResume,
|
||||||
|
handleRun,
|
||||||
|
handleEditSubmit,
|
||||||
|
handleNewCronSubmit,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
interface CronJobListProps {
|
interface CronJobListProps {
|
||||||
cronJobs: CronJob[];
|
cronJobs: CronJob[];
|
||||||
@@ -49,6 +67,9 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
|||||||
const [isCloning, setIsCloning] = useState(false);
|
const [isCloning, setIsCloning] = useState(false);
|
||||||
const [runningJobId, setRunningJobId] = useState<string | null>(null);
|
const [runningJobId, setRunningJobId] = useState<string | null>(null);
|
||||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||||
|
const [jobErrors, setJobErrors] = useState<Record<string, JobError[]>>({});
|
||||||
|
const [errorModalOpen, setErrorModalOpen] = useState(false);
|
||||||
|
const [selectedError, setSelectedError] = useState<JobError | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedUser = localStorage.getItem("selectedCronUser");
|
const savedUser = localStorage.getItem("selectedCronUser");
|
||||||
@@ -83,100 +104,95 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
|||||||
return cronJobs.filter((job) => job.user === selectedUser);
|
return cronJobs.filter((job) => job.user === selectedUser);
|
||||||
}, [cronJobs, selectedUser]);
|
}, [cronJobs, selectedUser]);
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
useEffect(() => {
|
||||||
setDeletingId(id);
|
const errors: Record<string, JobError[]> = {};
|
||||||
try {
|
filteredJobs.forEach((job) => {
|
||||||
const result = await removeCronJob(id);
|
errors[job.id] = getJobErrorsByJobId(job.id);
|
||||||
if (result.success) {
|
});
|
||||||
showToast("success", "Cron job deleted successfully");
|
setJobErrors(errors);
|
||||||
} else {
|
}, [filteredJobs]);
|
||||||
showToast("error", "Failed to delete cron job", result.message);
|
|
||||||
}
|
const handleErrorClickLocal = (error: JobError) => {
|
||||||
} catch (error) {
|
handleErrorClick(error, setSelectedError, setErrorModalOpen);
|
||||||
showToast(
|
|
||||||
"error",
|
|
||||||
"Failed to delete cron job",
|
|
||||||
"Please try again later."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setDeletingId(null);
|
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
setJobToDelete(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClone = async (newComment: string) => {
|
const refreshJobErrorsLocal = () => {
|
||||||
if (!jobToClone) return;
|
const errors: Record<string, JobError[]> = {};
|
||||||
|
filteredJobs.forEach((job) => {
|
||||||
setIsCloning(true);
|
errors[job.id] = getJobErrorsByJobId(job.id);
|
||||||
try {
|
});
|
||||||
const result = await cloneCronJob(jobToClone.id, newComment);
|
setJobErrors(errors);
|
||||||
if (result.success) {
|
|
||||||
setIsCloneModalOpen(false);
|
|
||||||
setJobToClone(null);
|
|
||||||
showToast("success", "Cron job cloned successfully");
|
|
||||||
} else {
|
|
||||||
showToast("error", "Failed to clone cron job", result.message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsCloning(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePause = async (id: string) => {
|
const handleDeleteLocal = async (id: string) => {
|
||||||
try {
|
await handleDelete(id, {
|
||||||
const result = await pauseCronJobAction(id);
|
setDeletingId,
|
||||||
if (result.success) {
|
setIsDeleteModalOpen,
|
||||||
showToast("success", "Cron job paused successfully");
|
setJobToDelete,
|
||||||
} else {
|
setIsCloneModalOpen,
|
||||||
showToast("error", "Failed to pause cron job", result.message);
|
setJobToClone,
|
||||||
}
|
setIsCloning,
|
||||||
} catch (error) {
|
setIsEditModalOpen,
|
||||||
showToast("error", "Failed to pause cron job", "Please try again later.");
|
setEditingJob,
|
||||||
}
|
setIsNewCronModalOpen,
|
||||||
|
setNewCronForm,
|
||||||
|
setRunningJobId,
|
||||||
|
refreshJobErrors: refreshJobErrorsLocal,
|
||||||
|
jobToClone,
|
||||||
|
editingJob,
|
||||||
|
editForm,
|
||||||
|
newCronForm,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResume = async (id: string) => {
|
const handleCloneLocal = async (newComment: string) => {
|
||||||
try {
|
await handleClone(newComment, {
|
||||||
const result = await resumeCronJobAction(id);
|
setDeletingId,
|
||||||
if (result.success) {
|
setIsDeleteModalOpen,
|
||||||
showToast("success", "Cron job resumed successfully");
|
setJobToDelete,
|
||||||
} else {
|
setIsCloneModalOpen,
|
||||||
showToast("error", "Failed to resume cron job", result.message);
|
setJobToClone,
|
||||||
}
|
setIsCloning,
|
||||||
} catch (error) {
|
setIsEditModalOpen,
|
||||||
showToast(
|
setEditingJob,
|
||||||
"error",
|
setIsNewCronModalOpen,
|
||||||
"Failed to resume cron job",
|
setNewCronForm,
|
||||||
"Please try again later."
|
setRunningJobId,
|
||||||
);
|
refreshJobErrors: refreshJobErrorsLocal,
|
||||||
}
|
jobToClone,
|
||||||
|
editingJob,
|
||||||
|
editForm,
|
||||||
|
newCronForm,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRun = async (id: string) => {
|
const handlePauseLocal = async (id: string) => {
|
||||||
setRunningJobId(id);
|
await handlePause(id);
|
||||||
try {
|
};
|
||||||
const result = await runCronJob(id);
|
|
||||||
if (result.success) {
|
const handleResumeLocal = async (id: string) => {
|
||||||
showToast("success", "Cron job executed successfully");
|
await handleResume(id);
|
||||||
if (result.output) {
|
};
|
||||||
console.log("Command output:", result.output);
|
|
||||||
}
|
const handleRunLocal = async (id: string) => {
|
||||||
} else {
|
await handleRun(id, {
|
||||||
showToast("error", "Failed to execute cron job", result.message);
|
setDeletingId,
|
||||||
if (result.output) {
|
setIsDeleteModalOpen,
|
||||||
console.error("Command error:", result.output);
|
setJobToDelete,
|
||||||
}
|
setIsCloneModalOpen,
|
||||||
}
|
setJobToClone,
|
||||||
} catch (error) {
|
setIsCloning,
|
||||||
showToast(
|
setIsEditModalOpen,
|
||||||
"error",
|
setEditingJob,
|
||||||
"Failed to execute cron job",
|
setIsNewCronModalOpen,
|
||||||
"Please try again later."
|
setNewCronForm,
|
||||||
);
|
setRunningJobId,
|
||||||
} finally {
|
refreshJobErrors: refreshJobErrorsLocal,
|
||||||
setRunningJobId(null);
|
jobToClone,
|
||||||
}
|
editingJob,
|
||||||
|
editForm,
|
||||||
|
newCronForm,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = (job: CronJob) => {
|
const confirmDelete = (job: CronJob) => {
|
||||||
@@ -199,68 +215,46 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
|||||||
setIsEditModalOpen(true);
|
setIsEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
const handleEditSubmitLocal = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
await handleEditSubmit(e, {
|
||||||
if (!editingJob) return;
|
setDeletingId,
|
||||||
|
setIsDeleteModalOpen,
|
||||||
try {
|
setJobToDelete,
|
||||||
const formData = new FormData();
|
setIsCloneModalOpen,
|
||||||
formData.append("id", editingJob.id);
|
setJobToClone,
|
||||||
formData.append("schedule", editForm.schedule);
|
setIsCloning,
|
||||||
formData.append("command", editForm.command);
|
setIsEditModalOpen,
|
||||||
formData.append("comment", editForm.comment);
|
setEditingJob,
|
||||||
|
setIsNewCronModalOpen,
|
||||||
const result = await editCronJob(formData);
|
setNewCronForm,
|
||||||
if (result.success) {
|
setRunningJobId,
|
||||||
setIsEditModalOpen(false);
|
refreshJobErrors: refreshJobErrorsLocal,
|
||||||
setEditingJob(null);
|
jobToClone,
|
||||||
showToast("success", "Cron job updated successfully");
|
editingJob,
|
||||||
} else {
|
editForm,
|
||||||
showToast("error", "Failed to update cron job", result.message);
|
newCronForm,
|
||||||
}
|
});
|
||||||
} catch (error) {
|
|
||||||
showToast(
|
|
||||||
"error",
|
|
||||||
"Failed to update cron job",
|
|
||||||
"Please try again later."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNewCronSubmit = async (e: React.FormEvent) => {
|
const handleNewCronSubmitLocal = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
await handleNewCronSubmit(e, {
|
||||||
|
setDeletingId,
|
||||||
try {
|
setIsDeleteModalOpen,
|
||||||
const formData = new FormData();
|
setJobToDelete,
|
||||||
formData.append("schedule", newCronForm.schedule);
|
setIsCloneModalOpen,
|
||||||
formData.append("command", newCronForm.command);
|
setJobToClone,
|
||||||
formData.append("comment", newCronForm.comment);
|
setIsCloning,
|
||||||
formData.append("user", newCronForm.user);
|
setIsEditModalOpen,
|
||||||
if (newCronForm.selectedScriptId) {
|
setEditingJob,
|
||||||
formData.append("selectedScriptId", newCronForm.selectedScriptId);
|
setIsNewCronModalOpen,
|
||||||
}
|
setNewCronForm,
|
||||||
|
setRunningJobId,
|
||||||
const result = await createCronJob(formData);
|
refreshJobErrors: refreshJobErrorsLocal,
|
||||||
if (result.success) {
|
jobToClone,
|
||||||
setIsNewCronModalOpen(false);
|
editingJob,
|
||||||
setNewCronForm({
|
editForm,
|
||||||
schedule: "",
|
newCronForm,
|
||||||
command: "",
|
});
|
||||||
comment: "",
|
|
||||||
selectedScriptId: null,
|
|
||||||
user: "",
|
|
||||||
});
|
|
||||||
showToast("success", "Cron job created successfully");
|
|
||||||
} else {
|
|
||||||
showToast("error", "Failed to create cron job", result.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast(
|
|
||||||
"error",
|
|
||||||
"Failed to create cron job",
|
|
||||||
"Please try again later."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -358,6 +352,11 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
|||||||
Paused
|
Paused
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<ErrorBadge
|
||||||
|
errors={jobErrors[job.id] || []}
|
||||||
|
onErrorClick={handleErrorClickLocal}
|
||||||
|
onErrorDismiss={refreshJobErrorsLocal}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{job.comment && (
|
{job.comment && (
|
||||||
@@ -374,7 +373,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRun(job.id)}
|
onClick={() => handleRunLocal(job.id)}
|
||||||
disabled={runningJobId === job.id || job.paused}
|
disabled={runningJobId === job.id || job.paused}
|
||||||
className="btn-outline h-8 px-3"
|
className="btn-outline h-8 px-3"
|
||||||
title="Run cron job manually"
|
title="Run cron job manually"
|
||||||
@@ -410,7 +409,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleResume(job.id)}
|
onClick={() => handleResumeLocal(job.id)}
|
||||||
className="btn-outline h-8 px-3"
|
className="btn-outline h-8 px-3"
|
||||||
title="Resume cron job"
|
title="Resume cron job"
|
||||||
aria-label="Resume cron job"
|
aria-label="Resume cron job"
|
||||||
@@ -421,7 +420,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePause(job.id)}
|
onClick={() => handlePauseLocal(job.id)}
|
||||||
className="btn-outline h-8 px-3"
|
className="btn-outline h-8 px-3"
|
||||||
title="Pause cron job"
|
title="Pause cron job"
|
||||||
aria-label="Pause cron job"
|
aria-label="Pause cron job"
|
||||||
@@ -456,7 +455,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
|||||||
<CreateTaskModal
|
<CreateTaskModal
|
||||||
isOpen={isNewCronModalOpen}
|
isOpen={isNewCronModalOpen}
|
||||||
onClose={() => setIsNewCronModalOpen(false)}
|
onClose={() => setIsNewCronModalOpen(false)}
|
||||||
onSubmit={handleNewCronSubmit}
|
onSubmit={handleNewCronSubmitLocal}
|
||||||
scripts={scripts}
|
scripts={scripts}
|
||||||
form={newCronForm}
|
form={newCronForm}
|
||||||
onFormChange={(updates) =>
|
onFormChange={(updates) =>
|
||||||
@@ -467,7 +466,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
|||||||
<EditTaskModal
|
<EditTaskModal
|
||||||
isOpen={isEditModalOpen}
|
isOpen={isEditModalOpen}
|
||||||
onClose={() => setIsEditModalOpen(false)}
|
onClose={() => setIsEditModalOpen(false)}
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmitLocal}
|
||||||
form={editForm}
|
form={editForm}
|
||||||
onFormChange={(updates) =>
|
onFormChange={(updates) =>
|
||||||
setEditForm((prev) => ({ ...prev, ...updates }))
|
setEditForm((prev) => ({ ...prev, ...updates }))
|
||||||
@@ -478,7 +477,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
|||||||
isOpen={isDeleteModalOpen}
|
isOpen={isDeleteModalOpen}
|
||||||
onClose={() => setIsDeleteModalOpen(false)}
|
onClose={() => setIsDeleteModalOpen(false)}
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
jobToDelete ? handleDelete(jobToDelete.id) : undefined
|
jobToDelete ? handleDeleteLocal(jobToDelete.id) : undefined
|
||||||
}
|
}
|
||||||
job={jobToDelete}
|
job={jobToDelete}
|
||||||
/>
|
/>
|
||||||
@@ -487,9 +486,29 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
|||||||
cronJob={jobToClone}
|
cronJob={jobToClone}
|
||||||
isOpen={isCloneModalOpen}
|
isOpen={isCloneModalOpen}
|
||||||
onClose={() => setIsCloneModalOpen(false)}
|
onClose={() => setIsCloneModalOpen(false)}
|
||||||
onConfirm={handleClone}
|
onConfirm={handleCloneLocal}
|
||||||
isCloning={isCloning}
|
isCloning={isCloning}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{errorModalOpen && selectedError && (
|
||||||
|
<ErrorDetailsModal
|
||||||
|
isOpen={errorModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setErrorModalOpen(false);
|
||||||
|
setSelectedError(null);
|
||||||
|
}}
|
||||||
|
error={{
|
||||||
|
title: selectedError.title,
|
||||||
|
message: selectedError.message,
|
||||||
|
details: selectedError.details,
|
||||||
|
command: selectedError.command,
|
||||||
|
output: selectedError.output,
|
||||||
|
stderr: selectedError.stderr,
|
||||||
|
timestamp: selectedError.timestamp,
|
||||||
|
jobId: selectedError.jobId,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
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
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
className="absolute -right-3 top-6 w-6 h-6 bg-background border border-border rounded-full items-center justify-center hover:bg-accent transition-colors z-40 hidden lg:flex"
|
className="absolute -right-3 top-[21.5vh] w-6 h-6 bg-background border border-border rounded-full items-center justify-center hover:bg-accent transition-colors z-40 hidden lg:flex"
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<ChevronRight className="h-3 w-3" />
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||||
import { cn } from "@/app/_utils/cn";
|
import { cn } from "@/app/_utils/cn";
|
||||||
|
import { ErrorDetailsModal } from "../modals/ErrorDetailsModal";
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,11 +11,22 @@ export interface Toast {
|
|||||||
title: string;
|
title: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
errorDetails?: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
details?: string;
|
||||||
|
command?: string;
|
||||||
|
output?: string;
|
||||||
|
stderr?: string;
|
||||||
|
timestamp: string;
|
||||||
|
jobId?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastProps {
|
interface ToastProps {
|
||||||
toast: Toast;
|
toast: Toast;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
onErrorClick?: (errorDetails: Toast["errorDetails"]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastIcons = {
|
const toastIcons = {
|
||||||
@@ -33,7 +45,7 @@ const toastStyles = {
|
|||||||
"border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
|
"border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Toast = ({ toast, onRemove }: ToastProps) => {
|
export const Toast = ({ toast, onRemove, onErrorClick }: ToastProps) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const Icon = toastIcons[toast.type];
|
const Icon = toastIcons[toast.type];
|
||||||
|
|
||||||
@@ -56,11 +68,23 @@ export const Toast = ({ toast, onRemove }: ToastProps) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 flex-shrink-0 mt-0.5" />
|
<Icon className="h-5 w-5 flex-shrink-0 mt-0.5" />
|
||||||
<div className="flex-1 min-w-0">
|
<div
|
||||||
|
className={`flex-1 min-w-0 ${
|
||||||
|
toast.type === "error" && toast.errorDetails ? "cursor-pointer" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (toast.type === "error" && toast.errorDetails && onErrorClick) {
|
||||||
|
onErrorClick(toast.errorDetails);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h4 className="font-medium text-sm">{toast.title}</h4>
|
<h4 className="font-medium text-sm">{toast.title}</h4>
|
||||||
{toast.message && (
|
{toast.message && (
|
||||||
<p className="text-sm opacity-90 mt-1">{toast.message}</p>
|
<p className="text-sm opacity-90 mt-1">{toast.message}</p>
|
||||||
)}
|
)}
|
||||||
|
{toast.type === "error" && toast.errorDetails && (
|
||||||
|
<p className="text-xs opacity-70 mt-1">Click for details</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -73,10 +97,14 @@ export const Toast = ({ toast, onRemove }: ToastProps) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ToastContainer = () => {
|
export const ToastContainer = () => {
|
||||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
const [errorModalOpen, setErrorModalOpen] = useState(false);
|
||||||
|
const [selectedError, setSelectedError] = useState<
|
||||||
|
Toast["errorDetails"] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const addToast = (toast: Omit<Toast, "id">) => {
|
const addToast = (toast: Omit<Toast, "id">) => {
|
||||||
const id = Math.random().toString(36).substr(2, 9);
|
const id = Math.random().toString(36).substr(2, 9);
|
||||||
@@ -87,6 +115,11 @@ export const ToastContainer = () => {
|
|||||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleErrorClick = (errorDetails: Toast["errorDetails"]) => {
|
||||||
|
setSelectedError(errorDetails);
|
||||||
|
setErrorModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(window as any).showToast = addToast;
|
(window as any).showToast = addToast;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -95,21 +128,39 @@ export const ToastContainer = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
|
<>
|
||||||
{toasts.map((toast) => (
|
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
|
||||||
<Toast key={toast.id} toast={toast} onRemove={removeToast} />
|
{toasts.map((toast) => (
|
||||||
))}
|
<Toast
|
||||||
</div>
|
key={toast.id}
|
||||||
|
toast={toast}
|
||||||
|
onRemove={removeToast}
|
||||||
|
onErrorClick={handleErrorClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{errorModalOpen && (
|
||||||
|
<ErrorDetailsModal
|
||||||
|
isOpen={errorModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setErrorModalOpen(false);
|
||||||
|
setSelectedError(null);
|
||||||
|
}}
|
||||||
|
error={selectedError || null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const showToast = (
|
export const showToast = (
|
||||||
type: Toast["type"],
|
type: Toast["type"],
|
||||||
title: string,
|
title: string,
|
||||||
message?: string,
|
message?: string,
|
||||||
duration?: number
|
duration?: number,
|
||||||
|
errorDetails?: Toast["errorDetails"]
|
||||||
) => {
|
) => {
|
||||||
if (typeof window !== "undefined" && (window as any).showToast) {
|
if (typeof window !== "undefined" && (window as any).showToast) {
|
||||||
(window as any).showToast({ type, title, message, duration });
|
(window as any).showToast({ type, title, message, duration, errorDetails });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import {
|
|||||||
cleanupCrontab,
|
cleanupCrontab,
|
||||||
type CronJob,
|
type CronJob,
|
||||||
} from "@/app/_utils/system";
|
} from "@/app/_utils/system";
|
||||||
import { getAllTargetUsers, getUserInfo } from "@/app/_utils/system/hostCrontab";
|
import {
|
||||||
|
getAllTargetUsers,
|
||||||
|
getUserInfo,
|
||||||
|
} from "@/app/_utils/system/hostCrontab";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { getScriptPath } from "@/app/_utils/scripts";
|
import { getScriptPath } from "@/app/_utils/scripts";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
@@ -25,11 +28,11 @@ export const fetchCronJobs = async (): Promise<CronJob[]> => {
|
|||||||
console.error("Error fetching cron jobs:", error);
|
console.error("Error fetching cron jobs:", error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const createCronJob = async (
|
export const createCronJob = async (
|
||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<{ success: boolean; message: string }> => {
|
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||||
try {
|
try {
|
||||||
const schedule = formData.get("schedule") as string;
|
const schedule = formData.get("schedule") as string;
|
||||||
const command = formData.get("command") as string;
|
const command = formData.get("command") as string;
|
||||||
@@ -67,15 +70,19 @@ export const createCronJob = async (
|
|||||||
} else {
|
} else {
|
||||||
return { success: false, message: "Failed to create cron job" };
|
return { success: false, message: "Failed to create cron job" };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error creating cron job:", error);
|
console.error("Error creating cron job:", error);
|
||||||
return { success: false, message: "Error creating cron job" };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || "Error creating cron job",
|
||||||
|
details: error.stack,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const removeCronJob = async (
|
export const removeCronJob = async (
|
||||||
id: string
|
id: string
|
||||||
): Promise<{ success: boolean; message: string }> => {
|
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||||
try {
|
try {
|
||||||
const success = await deleteCronJob(id);
|
const success = await deleteCronJob(id);
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -84,15 +91,19 @@ export const removeCronJob = async (
|
|||||||
} else {
|
} else {
|
||||||
return { success: false, message: "Failed to delete cron job" };
|
return { success: false, message: "Failed to delete cron job" };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error deleting cron job:", error);
|
console.error("Error deleting cron job:", error);
|
||||||
return { success: false, message: "Error deleting cron job" };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || "Error deleting cron job",
|
||||||
|
details: error.stack,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const editCronJob = async (
|
export const editCronJob = async (
|
||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<{ success: boolean; message: string }> => {
|
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||||
try {
|
try {
|
||||||
const id = formData.get("id") as string;
|
const id = formData.get("id") as string;
|
||||||
const schedule = formData.get("schedule") as string;
|
const schedule = formData.get("schedule") as string;
|
||||||
@@ -110,16 +121,20 @@ export const editCronJob = async (
|
|||||||
} else {
|
} else {
|
||||||
return { success: false, message: "Failed to update cron job" };
|
return { success: false, message: "Failed to update cron job" };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error updating cron job:", error);
|
console.error("Error updating cron job:", error);
|
||||||
return { success: false, message: "Error updating cron job" };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || "Error updating cron job",
|
||||||
|
details: error.stack,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const cloneCronJob = async (
|
export const cloneCronJob = async (
|
||||||
id: string,
|
id: string,
|
||||||
newComment: string
|
newComment: string
|
||||||
): Promise<{ success: boolean; message: string }> => {
|
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||||
try {
|
try {
|
||||||
const cronJobs = await getCronJobs();
|
const cronJobs = await getCronJobs();
|
||||||
const originalJob = cronJobs.find((job) => job.id === id);
|
const originalJob = cronJobs.find((job) => job.id === id);
|
||||||
@@ -141,15 +156,19 @@ export const cloneCronJob = async (
|
|||||||
} else {
|
} else {
|
||||||
return { success: false, message: "Failed to clone cron job" };
|
return { success: false, message: "Failed to clone cron job" };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error cloning cron job:", error);
|
console.error("Error cloning cron job:", error);
|
||||||
return { success: false, message: "Error cloning cron job" };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || "Error cloning cron job",
|
||||||
|
details: error.stack,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const pauseCronJobAction = async (
|
export const pauseCronJobAction = async (
|
||||||
id: string
|
id: string
|
||||||
): Promise<{ success: boolean; message: string }> => {
|
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||||
try {
|
try {
|
||||||
const success = await pauseCronJob(id);
|
const success = await pauseCronJob(id);
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -158,15 +177,19 @@ export const pauseCronJobAction = async (
|
|||||||
} else {
|
} else {
|
||||||
return { success: false, message: "Failed to pause cron job" };
|
return { success: false, message: "Failed to pause cron job" };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error pausing cron job:", error);
|
console.error("Error pausing cron job:", error);
|
||||||
return { success: false, message: "Error pausing cron job" };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || "Error pausing cron job",
|
||||||
|
details: error.stack,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const resumeCronJobAction = async (
|
export const resumeCronJobAction = async (
|
||||||
id: string
|
id: string
|
||||||
): Promise<{ success: boolean; message: string }> => {
|
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||||
try {
|
try {
|
||||||
const success = await resumeCronJob(id);
|
const success = await resumeCronJob(id);
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -175,11 +198,15 @@ export const resumeCronJobAction = async (
|
|||||||
} else {
|
} else {
|
||||||
return { success: false, message: "Failed to resume cron job" };
|
return { success: false, message: "Failed to resume cron job" };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error resuming cron job:", error);
|
console.error("Error resuming cron job:", error);
|
||||||
return { success: false, message: "Error resuming cron job" };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || "Error resuming cron job",
|
||||||
|
details: error.stack,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const fetchAvailableUsers = async (): Promise<string[]> => {
|
export const fetchAvailableUsers = async (): Promise<string[]> => {
|
||||||
try {
|
try {
|
||||||
@@ -188,9 +215,13 @@ export const fetchAvailableUsers = async (): Promise<string[]> => {
|
|||||||
console.error("Error fetching available users:", error);
|
console.error("Error fetching available users:", error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const cleanupCrontabAction = async (): Promise<{ success: boolean; message: string }> => {
|
export const cleanupCrontabAction = async (): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
details?: string;
|
||||||
|
}> => {
|
||||||
try {
|
try {
|
||||||
const success = await cleanupCrontab();
|
const success = await cleanupCrontab();
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -199,15 +230,24 @@ export const cleanupCrontabAction = async (): Promise<{ success: boolean; messag
|
|||||||
} else {
|
} else {
|
||||||
return { success: false, message: "Failed to clean crontab" };
|
return { success: false, message: "Failed to clean crontab" };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error cleaning crontab:", error);
|
console.error("Error cleaning crontab:", error);
|
||||||
return { success: false, message: "Error cleaning crontab" };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || "Error cleaning crontab",
|
||||||
|
details: error.stack,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const runCronJob = async (
|
export const runCronJob = async (
|
||||||
id: string
|
id: string
|
||||||
): Promise<{ success: boolean; message: string; output?: string }> => {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
output?: string;
|
||||||
|
details?: string;
|
||||||
|
}> => {
|
||||||
try {
|
try {
|
||||||
const cronJobs = await getCronJobs();
|
const cronJobs = await getCronJobs();
|
||||||
const job = cronJobs.find((j) => j.id === id);
|
const job = cronJobs.find((j) => j.id === id);
|
||||||
@@ -243,15 +283,17 @@ export const runCronJob = async (
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Cron job executed successfully",
|
message: "Cron job executed successfully",
|
||||||
output: output.trim()
|
output: output.trim(),
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error running cron job:", error);
|
console.error("Error running cron job:", error);
|
||||||
const errorMessage = error.stderr || error.message || "Unknown error occurred";
|
const errorMessage =
|
||||||
|
error.stderr || error.message || "Unknown error occurred";
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Failed to execute cron job",
|
message: "Failed to execute cron job",
|
||||||
output: errorMessage
|
output: errorMessage,
|
||||||
|
details: error.stack,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { join } from "path";
|
|||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { SCRIPTS_DIR } from "@/app/_utils/scripts";
|
import { SCRIPTS_DIR, normalizeLineEndings } from "@/app/_utils/scripts";
|
||||||
import { loadAllScripts, type Script } from "@/app/_utils/scriptScanner";
|
import { loadAllScripts, type Script } from "@/app/_utils/scriptScanner";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -62,7 +62,9 @@ const saveScriptFile = async (filename: string, content: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteScriptFile = async (filename: string) => {
|
const deleteScriptFile = async (filename: string) => {
|
||||||
const scriptPath = join(await SCRIPTS_DIR(), filename);
|
const isDocker = process.env.DOCKER === "true";
|
||||||
|
const scriptsDir = isDocker ? "/app/scripts" : await SCRIPTS_DIR();
|
||||||
|
const scriptPath = join(scriptsDir, filename);
|
||||||
if (existsSync(scriptPath)) {
|
if (existsSync(scriptPath)) {
|
||||||
await unlink(scriptPath);
|
await unlink(scriptPath);
|
||||||
}
|
}
|
||||||
@@ -95,7 +97,8 @@ export const createScript = async (
|
|||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const fullContent = metadataHeader + content;
|
const normalizedContent = normalizeLineEndings(content);
|
||||||
|
const fullContent = metadataHeader + normalizedContent;
|
||||||
|
|
||||||
await saveScriptFile(filename, fullContent);
|
await saveScriptFile(filename, fullContent);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
@@ -145,7 +148,8 @@ export const updateScript = async (
|
|||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const fullContent = metadataHeader + content;
|
const normalizedContent = normalizeLineEndings(content);
|
||||||
|
const fullContent = metadataHeader + normalizedContent;
|
||||||
|
|
||||||
await saveScriptFile(existingScript.filename, fullContent);
|
await saveScriptFile(existingScript.filename, fullContent);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
@@ -203,7 +207,8 @@ export const cloneScript = async (
|
|||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const fullContent = metadataHeader + originalContent;
|
const normalizedContent = normalizeLineEndings(originalContent);
|
||||||
|
const fullContent = metadataHeader + normalizedContent;
|
||||||
|
|
||||||
await saveScriptFile(filename, fullContent);
|
await saveScriptFile(filename, fullContent);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
|
|||||||
@@ -1,411 +1,441 @@
|
|||||||
import { CronJob } from "../system";
|
import { CronJob } from "../system";
|
||||||
|
|
||||||
export const pauseJobInLines = (lines: string[], targetJobIndex: number): string[] => {
|
export const pauseJobInLines = (
|
||||||
const newCronEntries: string[] = [];
|
lines: string[],
|
||||||
let currentJobIndex = 0;
|
targetJobIndex: number
|
||||||
let i = 0;
|
): string[] => {
|
||||||
|
const newCronEntries: string[] = [];
|
||||||
|
let currentJobIndex = 0;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
if (!trimmedLine) {
|
if (!trimmedLine) {
|
||||||
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
|
if (
|
||||||
newCronEntries.push("");
|
newCronEntries.length > 0 &&
|
||||||
}
|
newCronEntries[newCronEntries.length - 1] !== ""
|
||||||
i++;
|
) {
|
||||||
continue;
|
newCronEntries.push("");
|
||||||
}
|
}
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
trimmedLine.startsWith("# User:") ||
|
trimmedLine.startsWith("# User:") ||
|
||||||
trimmedLine.startsWith("# System Crontab")
|
trimmedLine.startsWith("# System Crontab")
|
||||||
) {
|
) {
|
||||||
newCronEntries.push(line);
|
newCronEntries.push(line);
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedLine.startsWith("# PAUSED: ")) {
|
if (trimmedLine.startsWith("# PAUSED:")) {
|
||||||
newCronEntries.push(line);
|
newCronEntries.push(line);
|
||||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||||
newCronEntries.push(lines[i + 1]);
|
newCronEntries.push(lines[i + 1]);
|
||||||
i += 2;
|
i += 2;
|
||||||
} else {
|
} else {
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
currentJobIndex++;
|
currentJobIndex++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedLine.startsWith("#")) {
|
|
||||||
if (
|
|
||||||
i + 1 < lines.length &&
|
|
||||||
!lines[i + 1].trim().startsWith("#") &&
|
|
||||||
lines[i + 1].trim()
|
|
||||||
) {
|
|
||||||
if (currentJobIndex === targetJobIndex) {
|
|
||||||
const comment = trimmedLine.substring(1).trim();
|
|
||||||
const nextLine = lines[i + 1].trim();
|
|
||||||
const pausedEntry = `# PAUSED: ${comment}\n# ${nextLine}`;
|
|
||||||
newCronEntries.push(pausedEntry);
|
|
||||||
i += 2;
|
|
||||||
currentJobIndex++;
|
|
||||||
} else {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith("#")) {
|
||||||
|
if (
|
||||||
|
i + 1 < lines.length &&
|
||||||
|
!lines[i + 1].trim().startsWith("#") &&
|
||||||
|
lines[i + 1].trim()
|
||||||
|
) {
|
||||||
if (currentJobIndex === targetJobIndex) {
|
if (currentJobIndex === targetJobIndex) {
|
||||||
const pausedEntry = `# PAUSED:\n# ${trimmedLine}`;
|
const comment = trimmedLine.substring(1).trim();
|
||||||
newCronEntries.push(pausedEntry);
|
const nextLine = lines[i + 1].trim();
|
||||||
|
const pausedEntry = `# PAUSED: ${comment}\n# ${nextLine}`;
|
||||||
|
newCronEntries.push(pausedEntry);
|
||||||
|
i += 2;
|
||||||
|
currentJobIndex++;
|
||||||
} else {
|
} else {
|
||||||
newCronEntries.push(line);
|
newCronEntries.push(line);
|
||||||
|
newCronEntries.push(lines[i + 1]);
|
||||||
|
i += 2;
|
||||||
|
currentJobIndex++;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
currentJobIndex++;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newCronEntries;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const resumeJobInLines = (lines: string[], targetJobIndex: number): string[] => {
|
|
||||||
const newCronEntries: string[] = [];
|
|
||||||
let currentJobIndex = 0;
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < lines.length) {
|
|
||||||
const line = lines[i];
|
|
||||||
const trimmedLine = line.trim();
|
|
||||||
|
|
||||||
if (!trimmedLine) {
|
|
||||||
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
|
|
||||||
newCronEntries.push("");
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
trimmedLine.startsWith("# User:") ||
|
|
||||||
trimmedLine.startsWith("# System Crontab")
|
|
||||||
) {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedLine.startsWith("# PAUSED: ")) {
|
|
||||||
if (currentJobIndex === targetJobIndex) {
|
|
||||||
const comment = trimmedLine.substring(10).trim();
|
|
||||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
|
||||||
const cronLine = lines[i + 1].trim().substring(2);
|
|
||||||
const resumedEntry = comment ? `# ${comment}\n${cronLine}` : cronLine;
|
|
||||||
newCronEntries.push(resumedEntry);
|
|
||||||
i += 2;
|
|
||||||
} else {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
|
||||||
newCronEntries.push(lines[i + 1]);
|
|
||||||
i += 2;
|
|
||||||
} else {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentJobIndex++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedLine.startsWith("#")) {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
newCronEntries.push(line);
|
newCronEntries.push(line);
|
||||||
currentJobIndex++;
|
|
||||||
i++;
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newCronEntries;
|
if (currentJobIndex === targetJobIndex) {
|
||||||
}
|
const pausedEntry = `# PAUSED:\n# ${trimmedLine}`;
|
||||||
|
newCronEntries.push(pausedEntry);
|
||||||
|
} else {
|
||||||
|
newCronEntries.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
export const parseJobsFromLines = (lines: string[], user: string): CronJob[] => {
|
currentJobIndex++;
|
||||||
const jobs: CronJob[] = [];
|
i++;
|
||||||
let currentComment = "";
|
}
|
||||||
let jobIndex = 0;
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < lines.length) {
|
return newCronEntries;
|
||||||
const line = lines[i];
|
};
|
||||||
const trimmedLine = line.trim();
|
|
||||||
|
|
||||||
if (!trimmedLine) {
|
export const resumeJobInLines = (
|
||||||
i++;
|
lines: string[],
|
||||||
continue;
|
targetJobIndex: number
|
||||||
|
): string[] => {
|
||||||
|
const newCronEntries: string[] = [];
|
||||||
|
let currentJobIndex = 0;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
|
if (!trimmedLine) {
|
||||||
|
if (
|
||||||
|
newCronEntries.length > 0 &&
|
||||||
|
newCronEntries[newCronEntries.length - 1] !== ""
|
||||||
|
) {
|
||||||
|
newCronEntries.push("");
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
trimmedLine.startsWith("# User:") ||
|
||||||
|
trimmedLine.startsWith("# System Crontab")
|
||||||
|
) {
|
||||||
|
newCronEntries.push(line);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith("# PAUSED:")) {
|
||||||
|
if (currentJobIndex === targetJobIndex) {
|
||||||
|
const comment = trimmedLine.substring(9).trim();
|
||||||
|
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||||
|
const cronLine = lines[i + 1].trim().substring(2);
|
||||||
|
const resumedEntry = comment ? `# ${comment}\n${cronLine}` : cronLine;
|
||||||
|
newCronEntries.push(resumedEntry);
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
if (
|
newCronEntries.push(line);
|
||||||
trimmedLine.startsWith("# User:") ||
|
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||||
trimmedLine.startsWith("# System Crontab")
|
newCronEntries.push(lines[i + 1]);
|
||||||
) {
|
i += 2;
|
||||||
i++;
|
} else {
|
||||||
continue;
|
i++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
currentJobIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (trimmedLine.startsWith("# PAUSED: ")) {
|
if (trimmedLine.startsWith("#")) {
|
||||||
const comment = trimmedLine.substring(10).trim();
|
newCronEntries.push(line);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (i + 1 < lines.length) {
|
newCronEntries.push(line);
|
||||||
const nextLine = lines[i + 1].trim();
|
currentJobIndex++;
|
||||||
if (nextLine.startsWith("# ")) {
|
i++;
|
||||||
const commentedCron = nextLine.substring(2);
|
}
|
||||||
const parts = commentedCron.split(/\s+/);
|
|
||||||
if (parts.length >= 6) {
|
|
||||||
const schedule = parts.slice(0, 5).join(" ");
|
|
||||||
const command = parts.slice(5).join(" ");
|
|
||||||
|
|
||||||
jobs.push({
|
return newCronEntries;
|
||||||
id: `${user}-${jobIndex}`,
|
};
|
||||||
schedule,
|
|
||||||
command,
|
|
||||||
comment: comment || undefined,
|
|
||||||
user,
|
|
||||||
paused: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
jobIndex++;
|
export const parseJobsFromLines = (
|
||||||
i += 2;
|
lines: string[],
|
||||||
continue;
|
user: string
|
||||||
}
|
): CronJob[] => {
|
||||||
}
|
const jobs: CronJob[] = [];
|
||||||
}
|
let currentComment = "";
|
||||||
i++;
|
let jobIndex = 0;
|
||||||
continue;
|
let i = 0;
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedLine.startsWith("#")) {
|
while (i < lines.length) {
|
||||||
if (
|
const line = lines[i];
|
||||||
i + 1 < lines.length &&
|
const trimmedLine = line.trim();
|
||||||
!lines[i + 1].trim().startsWith("#") &&
|
|
||||||
lines[i + 1].trim()
|
|
||||||
) {
|
|
||||||
currentComment = trimmedLine.substring(1).trim();
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let schedule, command;
|
if (!trimmedLine) {
|
||||||
const parts = trimmedLine.split(/(?:\s|\t)+/);
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (parts[0].startsWith("@")) {
|
if (
|
||||||
if (parts.length >= 2) {
|
trimmedLine.startsWith("# User:") ||
|
||||||
schedule = parts[0];
|
trimmedLine.startsWith("# System Crontab")
|
||||||
command = trimmedLine.slice(trimmedLine.indexOf(parts[1]))
|
) {
|
||||||
}
|
i++;
|
||||||
} else if (parts.length >= 6) {
|
continue;
|
||||||
schedule = parts.slice(0, 5).join(" ");
|
}
|
||||||
command = trimmedLine.slice(trimmedLine.indexOf(parts[5]))
|
|
||||||
}
|
if (trimmedLine.startsWith("# PAUSED:")) {
|
||||||
|
const comment = trimmedLine.substring(9).trim();
|
||||||
|
|
||||||
|
if (i + 1 < lines.length) {
|
||||||
|
const nextLine = lines[i + 1].trim();
|
||||||
|
if (nextLine.startsWith("# ")) {
|
||||||
|
const commentedCron = nextLine.substring(2);
|
||||||
|
const parts = commentedCron.split(/\s+/);
|
||||||
|
if (parts.length >= 6) {
|
||||||
|
const schedule = parts.slice(0, 5).join(" ");
|
||||||
|
const command = parts.slice(5).join(" ");
|
||||||
|
|
||||||
if (schedule && command) {
|
|
||||||
jobs.push({
|
jobs.push({
|
||||||
id: `${user}-${jobIndex}`,
|
id: `${user}-${jobIndex}`,
|
||||||
schedule,
|
schedule,
|
||||||
command,
|
command,
|
||||||
comment: currentComment || undefined,
|
comment: comment || undefined,
|
||||||
user,
|
user,
|
||||||
paused: false,
|
paused: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
jobIndex++;
|
jobIndex++;
|
||||||
currentComment = "";
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
i++;
|
}
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return jobs;
|
if (trimmedLine.startsWith("#")) {
|
||||||
}
|
if (
|
||||||
|
i + 1 < lines.length &&
|
||||||
|
!lines[i + 1].trim().startsWith("#") &&
|
||||||
|
lines[i + 1].trim()
|
||||||
|
) {
|
||||||
|
currentComment = trimmedLine.substring(1).trim();
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const deleteJobInLines = (lines: string[], targetJobIndex: number): string[] => {
|
let schedule, command;
|
||||||
const newCronEntries: string[] = [];
|
const parts = trimmedLine.split(/(?:\s|\t)+/);
|
||||||
let currentJobIndex = 0;
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < lines.length) {
|
if (parts[0].startsWith("@")) {
|
||||||
const line = lines[i];
|
if (parts.length >= 2) {
|
||||||
const trimmedLine = line.trim();
|
schedule = parts[0];
|
||||||
|
command = trimmedLine.slice(trimmedLine.indexOf(parts[1]));
|
||||||
|
}
|
||||||
|
} else if (parts.length >= 6) {
|
||||||
|
schedule = parts.slice(0, 5).join(" ");
|
||||||
|
command = trimmedLine.slice(trimmedLine.indexOf(parts[5]));
|
||||||
|
}
|
||||||
|
|
||||||
if (!trimmedLine) {
|
if (schedule && command) {
|
||||||
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
|
jobs.push({
|
||||||
newCronEntries.push("");
|
id: `${user}-${jobIndex}`,
|
||||||
}
|
schedule,
|
||||||
i++;
|
command,
|
||||||
continue;
|
comment: currentComment || undefined,
|
||||||
|
user,
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
jobIndex++;
|
||||||
|
currentComment = "";
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteJobInLines = (
|
||||||
|
lines: string[],
|
||||||
|
targetJobIndex: number
|
||||||
|
): string[] => {
|
||||||
|
const newCronEntries: string[] = [];
|
||||||
|
let currentJobIndex = 0;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
|
if (!trimmedLine) {
|
||||||
|
if (
|
||||||
|
newCronEntries.length > 0 &&
|
||||||
|
newCronEntries[newCronEntries.length - 1] !== ""
|
||||||
|
) {
|
||||||
|
newCronEntries.push("");
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
trimmedLine.startsWith("# User:") ||
|
||||||
|
trimmedLine.startsWith("# System Crontab")
|
||||||
|
) {
|
||||||
|
newCronEntries.push(line);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith("# PAUSED:")) {
|
||||||
|
if (currentJobIndex !== targetJobIndex) {
|
||||||
|
newCronEntries.push(line);
|
||||||
|
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||||
|
newCronEntries.push(lines[i + 1]);
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
if (
|
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||||
trimmedLine.startsWith("# User:") ||
|
i += 2;
|
||||||
trimmedLine.startsWith("# System Crontab")
|
} else {
|
||||||
) {
|
i++;
|
||||||
newCronEntries.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedLine.startsWith("# PAUSED: ")) {
|
|
||||||
if (currentJobIndex !== targetJobIndex) {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
|
||||||
newCronEntries.push(lines[i + 1]);
|
|
||||||
i += 2;
|
|
||||||
} else {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
|
||||||
i += 2;
|
|
||||||
} else {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentJobIndex++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedLine.startsWith("#")) {
|
|
||||||
if (
|
|
||||||
i + 1 < lines.length &&
|
|
||||||
!lines[i + 1].trim().startsWith("#") &&
|
|
||||||
lines[i + 1].trim()
|
|
||||||
) {
|
|
||||||
if (currentJobIndex !== targetJobIndex) {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
currentJobIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith("#")) {
|
||||||
|
if (
|
||||||
|
i + 1 < lines.length &&
|
||||||
|
!lines[i + 1].trim().startsWith("#") &&
|
||||||
|
lines[i + 1].trim()
|
||||||
|
) {
|
||||||
if (currentJobIndex !== targetJobIndex) {
|
if (currentJobIndex !== targetJobIndex) {
|
||||||
newCronEntries.push(line);
|
newCronEntries.push(line);
|
||||||
|
newCronEntries.push(lines[i + 1]);
|
||||||
}
|
}
|
||||||
|
i += 2;
|
||||||
currentJobIndex++;
|
currentJobIndex++;
|
||||||
|
} else {
|
||||||
|
newCronEntries.push(line);
|
||||||
i++;
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newCronEntries;
|
if (currentJobIndex !== targetJobIndex) {
|
||||||
}
|
newCronEntries.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentJobIndex++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCronEntries;
|
||||||
|
};
|
||||||
|
|
||||||
export const updateJobInLines = (
|
export const updateJobInLines = (
|
||||||
lines: string[],
|
lines: string[],
|
||||||
targetJobIndex: number,
|
targetJobIndex: number,
|
||||||
schedule: string,
|
schedule: string,
|
||||||
command: string,
|
command: string,
|
||||||
comment: string = ""
|
comment: string = ""
|
||||||
): string[] => {
|
): string[] => {
|
||||||
const newCronEntries: string[] = [];
|
const newCronEntries: string[] = [];
|
||||||
let currentJobIndex = 0;
|
let currentJobIndex = 0;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
if (!trimmedLine) {
|
if (!trimmedLine) {
|
||||||
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
|
if (
|
||||||
newCronEntries.push("");
|
newCronEntries.length > 0 &&
|
||||||
}
|
newCronEntries[newCronEntries.length - 1] !== ""
|
||||||
i++;
|
) {
|
||||||
continue;
|
newCronEntries.push("");
|
||||||
}
|
}
|
||||||
|
i++;
|
||||||
if (
|
continue;
|
||||||
trimmedLine.startsWith("# User:") ||
|
|
||||||
trimmedLine.startsWith("# System Crontab")
|
|
||||||
) {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedLine.startsWith("# PAUSED: ")) {
|
|
||||||
if (currentJobIndex === targetJobIndex) {
|
|
||||||
const newEntry = comment
|
|
||||||
? `# PAUSED: ${comment}\n# ${schedule} ${command}`
|
|
||||||
: `# PAUSED:\n# ${schedule} ${command}`;
|
|
||||||
newCronEntries.push(newEntry);
|
|
||||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
|
||||||
i += 2;
|
|
||||||
} else {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
|
||||||
newCronEntries.push(lines[i + 1]);
|
|
||||||
i += 2;
|
|
||||||
} else {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentJobIndex++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedLine.startsWith("#")) {
|
|
||||||
if (
|
|
||||||
i + 1 < lines.length &&
|
|
||||||
!lines[i + 1].trim().startsWith("#") &&
|
|
||||||
lines[i + 1].trim()
|
|
||||||
) {
|
|
||||||
if (currentJobIndex === targetJobIndex) {
|
|
||||||
const newEntry = comment
|
|
||||||
? `# ${comment}\n${schedule} ${command}`
|
|
||||||
: `${schedule} ${command}`;
|
|
||||||
newCronEntries.push(newEntry);
|
|
||||||
i += 2;
|
|
||||||
} else {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentJobIndex === targetJobIndex) {
|
|
||||||
const newEntry = comment
|
|
||||||
? `# ${comment}\n${schedule} ${command}`
|
|
||||||
: `${schedule} ${command}`;
|
|
||||||
newCronEntries.push(newEntry);
|
|
||||||
} else {
|
|
||||||
newCronEntries.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentJobIndex++;
|
|
||||||
i++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newCronEntries;
|
if (
|
||||||
}
|
trimmedLine.startsWith("# User:") ||
|
||||||
|
trimmedLine.startsWith("# System Crontab")
|
||||||
|
) {
|
||||||
|
newCronEntries.push(line);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith("# PAUSED:")) {
|
||||||
|
if (currentJobIndex === targetJobIndex) {
|
||||||
|
const newEntry = comment
|
||||||
|
? `# PAUSED: ${comment}\n# ${schedule} ${command}`
|
||||||
|
: `# PAUSED:\n# ${schedule} ${command}`;
|
||||||
|
newCronEntries.push(newEntry);
|
||||||
|
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newCronEntries.push(line);
|
||||||
|
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||||
|
newCronEntries.push(lines[i + 1]);
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentJobIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith("#")) {
|
||||||
|
if (
|
||||||
|
i + 1 < lines.length &&
|
||||||
|
!lines[i + 1].trim().startsWith("#") &&
|
||||||
|
lines[i + 1].trim()
|
||||||
|
) {
|
||||||
|
if (currentJobIndex === targetJobIndex) {
|
||||||
|
const newEntry = comment
|
||||||
|
? `# ${comment}\n${schedule} ${command}`
|
||||||
|
: `${schedule} ${command}`;
|
||||||
|
newCronEntries.push(newEntry);
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
newCronEntries.push(line);
|
||||||
|
newCronEntries.push(lines[i + 1]);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
currentJobIndex++;
|
||||||
|
} else {
|
||||||
|
newCronEntries.push(line);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentJobIndex === targetJobIndex) {
|
||||||
|
const newEntry = comment
|
||||||
|
? `# ${comment}\n${schedule} ${command}`
|
||||||
|
: `${schedule} ${command}`;
|
||||||
|
newCronEntries.push(newEntry);
|
||||||
|
} else {
|
||||||
|
newCronEntries.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentJobIndex++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCronEntries;
|
||||||
|
};
|
||||||
|
|||||||
63
app/_utils/errorState.ts
Normal file
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)}`;
|
return `bash ${join(hostScriptsDir, filename)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const normalizeLineEndings = (content: string): string => {
|
||||||
|
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
};
|
||||||
|
|
||||||
export { SCRIPTS_DIR };
|
export { SCRIPTS_DIR };
|
||||||
|
|||||||
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";
|
statusDetails = "Moderate resource usage - monitoring recommended";
|
||||||
}
|
}
|
||||||
|
|
||||||
let mainInterface = null;
|
let mainInterface: any = null;
|
||||||
if (Array.isArray(networkInfo) && networkInfo.length > 0) {
|
if (Array.isArray(networkInfo) && networkInfo.length > 0) {
|
||||||
mainInterface = networkInfo.find(net =>
|
mainInterface = networkInfo.find(net =>
|
||||||
net.iface && !net.iface.includes('lo') && net.operstate === 'up'
|
net.iface && !net.iface.includes('lo') && net.operstate === 'up'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { JetBrains_Mono, Inter } from "next/font/google";
|
import { JetBrains_Mono, Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "./_components/ui/ThemeProvider";
|
import { ThemeProvider } from "./_components/ui/ThemeProvider";
|
||||||
|
import { ServiceWorkerRegister } from "./_components/ui/ServiceWorkerRegister";
|
||||||
|
|
||||||
const jetbrainsMono = JetBrains_Mono({
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -17,8 +18,16 @@ const inter = Inter({
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Cr*nMaster - Cron Management made easy",
|
title: "Cr*nMaster - Cron Management made easy",
|
||||||
description:
|
description: "The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
|
||||||
"The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
|
manifest: "/manifest.json",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "Cr*nMaster",
|
||||||
|
},
|
||||||
|
formatDetection: {
|
||||||
|
telephone: false,
|
||||||
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/logo.png",
|
icon: "/logo.png",
|
||||||
shortcut: "/logo.png",
|
shortcut: "/logo.png",
|
||||||
@@ -26,6 +35,14 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
themeColor: "#3b82f6",
|
||||||
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -33,15 +50,24 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<meta name="application-name" content="Cr*nMaster" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Cr*nMaster" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<link rel="apple-touch-icon" href="/logo.png" />
|
||||||
|
</head>
|
||||||
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans`}>
|
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans`}>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="dark"
|
||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
<ServiceWorkerRegister />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
14
app/login/page.tsx
Normal file
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 { getCronJobs } from "./_utils/system";
|
||||||
import { fetchScripts } from "./_server/actions/scripts";
|
import { fetchScripts } from "./_server/actions/scripts";
|
||||||
import { ThemeToggle } from "./_components/ui/ThemeToggle";
|
import { ThemeToggle } from "./_components/ui/ThemeToggle";
|
||||||
|
import { LogoutButton } from "./_components/ui/LogoutButton";
|
||||||
import { ToastContainer } from "./_components/ui/Toast";
|
import { ToastContainer } from "./_components/ui/Toast";
|
||||||
|
import { PWAInstallPrompt } from "./_components/ui/PWAInstallPrompt";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
@@ -52,7 +54,7 @@ export default async function Home() {
|
|||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<header className="border-b border-border/50 bg-background/80 backdrop-blur-md sticky top-0 z-20 shadow-sm lg:h-[90px]">
|
<header className="border-b border-border/50 bg-background/80 backdrop-blur-md sticky top-0 z-20 shadow-sm lg:h-[90px]">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-between lg:justify-center">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img src="/logo.png" alt="logo" className="w-14 h-14" />
|
<img src="/logo.png" alt="logo" className="w-14 h-14" />
|
||||||
@@ -67,6 +69,11 @@ export default async function Home() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{process.env.AUTH_PASSWORD && (
|
||||||
|
<div className="lg:absolute lg:right-10">
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -82,8 +89,9 @@ export default async function Home() {
|
|||||||
|
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|
||||||
<div className="flex items-center gap-2 fixed bottom-4 left-4 lg:right-4 lg:left-auto z-10 bg-background rounded-lg">
|
<div className="flex items-center gap-2 fixed bottom-4 left-4 lg:right-4 lg:left-auto z-10 bg-background/80 backdrop-blur-md border border-border/50 rounded-lg p-1">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
<PWAInstallPrompt />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
cronjob-manager:
|
cronjob-manager:
|
||||||
image: ghcr.io/fccview/cronmaster:1.3.0
|
image: ghcr.io/fccview/cronmaster:latest
|
||||||
container_name: cronmaster
|
container_name: cronmaster
|
||||||
user: "root"
|
user: "root"
|
||||||
ports:
|
ports:
|
||||||
@@ -9,17 +9,26 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DOCKER=true
|
- DOCKER=true
|
||||||
# Legacy used to be NEXT_PUBLIC_HOST_PROJECT_DIR, this was causing issues on runtime.
|
|
||||||
|
# --- MAP HOST PROJECT DIRECTORY, THIS IS MANDATORY FOR SCRIPTS TO WORK
|
||||||
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
||||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||||
# If docker struggles to find your crontab user, update this variable with it.
|
|
||||||
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
|
# --- PASSWORD PROTECTION
|
||||||
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=fccview,root,user1,user2
|
# Uncomment to enable password protection (replace "password" with your own)
|
||||||
# - HOST_CRONTAB_USER=fccview
|
- AUTH_PASSWORD=very_strong_password
|
||||||
|
|
||||||
|
# --- CRONTAB USERS
|
||||||
|
# This is used to read the crontabs for the specific user.
|
||||||
|
# replace root with your user - find it with: ls -asl /var/spool/cron/crontabs/
|
||||||
|
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=root,user1,user2
|
||||||
|
- HOST_CRONTAB_USER=root
|
||||||
volumes:
|
volumes:
|
||||||
|
# --- MOUNT DOCKER SOCKET
|
||||||
# Mount Docker socket to execute commands on host
|
# Mount Docker socket to execute commands on host
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
# --- MOUNT DATA
|
||||||
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
|
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
|
||||||
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
|
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
|
||||||
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
|
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
|
||||||
@@ -27,12 +36,12 @@ services:
|
|||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./snippets:/app/snippets
|
- ./snippets:/app/snippets
|
||||||
|
|
||||||
# Use host PID namespace for host command execution
|
# --- USE HOST PID NAMESPACE FOR HOST COMMAND EXECUTION
|
||||||
# Run in privileged mode for nsenter access
|
# --- RUN IN PRIVILEGED MODE FOR NSENTER ACCESS
|
||||||
pid: "host"
|
pid: "host"
|
||||||
privileged: true
|
privileged: true
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
init: true
|
init: true
|
||||||
|
|
||||||
# Default platform is set to amd64, uncomment to use arm64.
|
# --- DEFAULT PLATFORM IS SET TO AMD64, UNCOMMENT TO USE ARM64.
|
||||||
#platform: linux/arm64
|
#platform: linux/arm64
|
||||||
|
|||||||
37
middleware.ts
Normal file
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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/manifest.json',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Content-Type', value: 'application/manifest+json' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/sw.js',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Service-Worker-Allowed', value: '/' },
|
||||||
|
{ key: 'Cache-Control', value: 'no-cache' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = withPWA(nextConfig)
|
||||||
|
|||||||
@@ -23,12 +23,15 @@
|
|||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.0.1",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"cron-parser": "^5.3.0",
|
"cron-parser": "^5.3.0",
|
||||||
"cronstrue": "^3.2.0",
|
"cronstrue": "^3.2.0",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
|
"minimatch": "^10.0.3",
|
||||||
"next": "14.0.4",
|
"next": "14.0.4",
|
||||||
|
"next-pwa": "^5.6.0",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
@@ -40,6 +43,8 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/minimatch": "^6.0.0",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.0.4"
|
"eslint-config-next": "14.0.4"
|
||||||
}
|
}
|
||||||
|
|||||||
26
public/manifest.json
Normal file
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"
|
||||||
|
}
|
||||||
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
|
# @title: Hi, this is a demo script
|
||||||
# @description: This script logs a "hello world" to teach you how scripts work.
|
# @description: This script logs a "hello world" to teach you how scripts work.
|
||||||
|
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
echo 'Hello World' > hello.txt
|
echo 'Hello World' > hello.txt
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "es6"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"es6"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -19,9 +23,18 @@
|
|||||||
],
|
],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user