mirror of
https://github.com/fccview/cronmaster.git
synced 2025-12-24 06:28:26 -05:00
Compare commits
5 Commits
bugfix/rep
...
BUG-3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a437f3db8 | ||
|
|
47e19246ce | ||
|
|
29917a4cad | ||
|
|
ac31906166 | ||
|
|
b267fb9ce6 |
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
@@ -2,8 +2,8 @@ name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "legacy", "feature/*", "bugfix/*"]
|
||||
tags: ["v*.*.*"]
|
||||
branches: ["main", "legacy"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
|
||||
18
README.md
18
README.md
@@ -49,8 +49,8 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
|
||||
```bash
|
||||
services:
|
||||
cronjob-manager:
|
||||
image: ghcr.io/fccview/cronmaster:main
|
||||
container_name: cronmaster-test
|
||||
image: ghcr.io/fccview/cronmaster:1.2.1
|
||||
container_name: cronmaster
|
||||
user: "root"
|
||||
ports:
|
||||
# Feel free to change port, 3000 is very common so I like to map it to something else
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
- NODE_ENV=production
|
||||
- DOCKER=true
|
||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||
- NEXT_PUBLIC_HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
||||
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
||||
# If docker struggles to find your crontab user, update this variable with it.
|
||||
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
|
||||
# - HOST_CRONTAB_USER=fccview
|
||||
@@ -69,7 +69,7 @@ services:
|
||||
|
||||
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
|
||||
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
|
||||
# will target this foler (thanks to the NEXT_PUBLIC_HOST_PROJECT_DIR variable set above)
|
||||
# will target this foler (thanks to the HOST_PROJECT_DIR variable set above)
|
||||
- ./scripts:/app/scripts
|
||||
- ./data:/app/data
|
||||
- ./snippets:/app/snippets
|
||||
@@ -82,7 +82,7 @@ services:
|
||||
init: true
|
||||
|
||||
# Default platform is set to amd64, uncomment to use arm64.
|
||||
#platform: linux/arm64
|
||||
#platform: linux/arm64
|
||||
```
|
||||
|
||||
### ARM64 Support
|
||||
@@ -132,7 +132,7 @@ The following environment variables can be configured:
|
||||
| Variable | Default | Description |
|
||||
| ----------------------------------- | ------- | ------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL` | `30000` | Clock update interval in milliseconds (30 seconds) |
|
||||
| `NEXT_PUBLIC_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 |
|
||||
|
||||
**Example**: To change the clock update interval to 60 seconds:
|
||||
@@ -144,14 +144,14 @@ NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=60000 docker-compose up
|
||||
**Example**: Your `docker-compose.yml` file or repository are in `~/homelab/cronmaster/`
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
|
||||
HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
|
||||
```
|
||||
|
||||
### Important Notes for Docker
|
||||
|
||||
- Root user is required for cron operations and direct file access. There is no way around this, if you don't feel comfortable in running it as root feel free to run the app locally with `yarn install`, `yarn build` and `yarn start`
|
||||
- Crontab files are accessed directly via file system mounts at `/host/cron/crontabs` and `/host/crontab` for real-time reading and writing
|
||||
- `NEXT_PUBLIC_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.
|
||||
|
||||
## Usage
|
||||
@@ -214,7 +214,7 @@ The application uses standard cron format: `* * * * *`
|
||||
|
||||
## Community shouts
|
||||
|
||||
I would like to thank the following members for raising issues and help test/debug them!
|
||||
I would like to thank the following members for raising issues and help test/debug them!
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
|
||||
@@ -58,10 +58,10 @@ export function CreateTaskModal({
|
||||
loadScriptContent();
|
||||
}, [selectedScript]);
|
||||
|
||||
const handleScriptSelect = (script: Script) => {
|
||||
const handleScriptSelect = async (script: Script) => {
|
||||
onFormChange({
|
||||
selectedScriptId: script.id,
|
||||
command: getHostScriptPath(script.filename),
|
||||
command: await getHostScriptPath(script.filename),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -39,10 +39,21 @@ export function ScriptModal({
|
||||
}: ScriptModalProps) {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.name.trim()) {
|
||||
showToast("error", "Validation Error", "Script name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.content.trim()) {
|
||||
showToast("error", "Validation Error", "Script content is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("name", form.name);
|
||||
formData.append("description", form.description);
|
||||
formData.append("content", form.content);
|
||||
formData.append("name", form.name.trim());
|
||||
formData.append("description", form.description.trim());
|
||||
formData.append("content", form.content.trim());
|
||||
|
||||
Object.entries(additionalFormData).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
@@ -66,18 +77,24 @@ export function ScriptModal({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Script Name
|
||||
Script Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => onFormChange({ name: e.target.value })}
|
||||
placeholder="My Script"
|
||||
required
|
||||
className={
|
||||
!form.name.trim()
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Description
|
||||
Description{" "}
|
||||
<span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.description}
|
||||
@@ -102,7 +119,7 @@ export function ScriptModal({
|
||||
<div className="flex items-center gap-2 mb-4 flex-shrink-0">
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
Script Content
|
||||
Script Content <span className="text-red-500">*</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
@@ -27,6 +27,18 @@ export function SelectScriptModal({
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [previewScript, setPreviewScript] = useState<Script | null>(null);
|
||||
const [previewContent, setPreviewContent] = useState<string>("");
|
||||
const [hostScriptPath, setHostScriptPath] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHostScriptPath = async () => {
|
||||
const path = await getHostScriptPath(previewScript?.filename || "");
|
||||
setHostScriptPath(path);
|
||||
};
|
||||
|
||||
if (previewScript) {
|
||||
fetchHostScriptPath();
|
||||
}
|
||||
}, [previewScript]);
|
||||
|
||||
const filteredScripts = scripts.filter(
|
||||
(script) =>
|
||||
@@ -156,7 +168,7 @@ export function SelectScriptModal({
|
||||
</div>
|
||||
<div className="bg-muted/30 p-3 rounded border border-border/30">
|
||||
<code className="text-sm font-mono text-foreground break-all">
|
||||
{getHostScriptPath(previewScript.filename)}
|
||||
{hostScriptPath}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,6 @@ export async function fetchCronJobs(): Promise<CronJob[]> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function createCronJob(
|
||||
formData: FormData
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
@@ -42,7 +40,7 @@ export async function createCronJob(
|
||||
const selectedScript = scripts.find((s) => s.id === selectedScriptId);
|
||||
|
||||
if (selectedScript) {
|
||||
finalCommand = getScriptPath(selectedScript.filename);
|
||||
finalCommand = await getScriptPath(selectedScript.filename);
|
||||
} else {
|
||||
return { success: false, message: "Selected script not found" };
|
||||
}
|
||||
|
||||
@@ -37,29 +37,32 @@ async function generateUniqueFilename(baseName: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function ensureScriptsDirectory() {
|
||||
if (!existsSync(SCRIPTS_DIR)) {
|
||||
await mkdir(SCRIPTS_DIR, { recursive: true });
|
||||
const scriptsDir = await SCRIPTS_DIR();
|
||||
if (!existsSync(scriptsDir)) {
|
||||
await mkdir(scriptsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureHostScriptsDirectory() {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const hostScriptsDir = isDocker
|
||||
? "/app/scripts"
|
||||
: join(process.cwd(), "scripts");
|
||||
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
|
||||
|
||||
const hostScriptsDir = join(hostProjectDir, "scripts");
|
||||
if (!existsSync(hostScriptsDir)) {
|
||||
await mkdir(hostScriptsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScriptFile(filename: string, content: string) {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const scriptsDir = isDocker ? "/app/scripts" : await SCRIPTS_DIR();
|
||||
await ensureScriptsDirectory();
|
||||
const scriptPath = join(SCRIPTS_DIR, filename);
|
||||
|
||||
const scriptPath = join(scriptsDir, filename);
|
||||
await writeFile(scriptPath, content, "utf8");
|
||||
}
|
||||
|
||||
async function deleteScriptFile(filename: string) {
|
||||
const scriptPath = join(SCRIPTS_DIR, filename);
|
||||
const scriptPath = join(await SCRIPTS_DIR(), filename);
|
||||
if (existsSync(scriptPath)) {
|
||||
await unlink(scriptPath);
|
||||
}
|
||||
@@ -226,7 +229,11 @@ export async function cloneScript(
|
||||
|
||||
export async function getScriptContent(filename: string): Promise<string> {
|
||||
try {
|
||||
const scriptPath = join(SCRIPTS_DIR, filename);
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const scriptPath = isDocker
|
||||
? join("/app/scripts", filename)
|
||||
: join(process.cwd(), "scripts", filename);
|
||||
|
||||
if (existsSync(scriptPath)) {
|
||||
const content = await readFile(scriptPath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
@@ -46,13 +46,13 @@ async function scanScriptsDirectory(dirPath: string): Promise<Script[]> {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const metadata = parseMetadata(content);
|
||||
|
||||
if (metadata.id && metadata.title && metadata.description) {
|
||||
if (metadata.id && metadata.title) {
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
scripts.push({
|
||||
id: metadata.id,
|
||||
name: metadata.title,
|
||||
description: metadata.description,
|
||||
description: metadata.description || "",
|
||||
filename: file,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
});
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
"use server";
|
||||
|
||||
import { join } from "path";
|
||||
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const SCRIPTS_DIR = isDocker ? "/app/scripts" : join(process.cwd(), "scripts");
|
||||
const SCRIPTS_DIR = async () => {
|
||||
if (isDocker && process.env.HOST_PROJECT_DIR) {
|
||||
return `${process.env.HOST_PROJECT_DIR}/scripts`;
|
||||
}
|
||||
return join(process.cwd(), "scripts");
|
||||
};
|
||||
|
||||
export function getScriptPath(filename: string): string {
|
||||
return join(SCRIPTS_DIR, filename);
|
||||
export async function getScriptPath(filename: string): Promise<string> {
|
||||
return join(await SCRIPTS_DIR(), filename);
|
||||
}
|
||||
|
||||
export function getHostScriptPath(filename: string): string {
|
||||
const hostProjectDir =
|
||||
process.env.NEXT_PUBLIC_HOST_PROJECT_DIR || process.cwd();
|
||||
export async function getHostScriptPath(filename: string): Promise<string> {
|
||||
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
|
||||
|
||||
const hostScriptsDir = join(hostProjectDir, "scripts");
|
||||
return `bash ${join(hostScriptsDir, filename)}`;
|
||||
}
|
||||
|
||||
@@ -4,80 +4,89 @@ import { promisify } from "util";
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
async function execHostCrontab(command: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`nsenter -t 1 -m -u -i -n -p sh -c "${command}"`
|
||||
);
|
||||
return stdout;
|
||||
} catch (error: any) {
|
||||
console.error("Error executing host crontab command:", error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`nsenter -t 1 -m -u -i -n -p sh -c "${command}"`
|
||||
);
|
||||
return stdout;
|
||||
} catch (error: any) {
|
||||
console.error("Error executing host crontab command:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTargetUser(): Promise<string> {
|
||||
try {
|
||||
if (process.env.HOST_CRONTAB_USER) {
|
||||
return process.env.HOST_CRONTAB_USER;
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync('stat -c "%U" /var/run/docker.sock');
|
||||
const dockerSocketOwner = stdout.trim();
|
||||
|
||||
if (dockerSocketOwner === 'root') {
|
||||
try {
|
||||
const projectDir = process.env.NEXT_PUBLIC_HOST_PROJECT_DIR;
|
||||
if (projectDir) {
|
||||
const dirOwner = await execHostCrontab(`stat -c "%U" "${projectDir}"`);
|
||||
return dirOwner.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not detect user from project directory:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await execHostCrontab('getent passwd | grep ":/home/" | head -1 | cut -d: -f1');
|
||||
const firstUser = users.trim();
|
||||
if (firstUser) {
|
||||
return firstUser;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not detect user from passwd:", error);
|
||||
}
|
||||
|
||||
return 'root';
|
||||
}
|
||||
|
||||
return dockerSocketOwner;
|
||||
} catch (error) {
|
||||
console.error("Error detecting target user:", error);
|
||||
return 'root';
|
||||
try {
|
||||
if (process.env.HOST_CRONTAB_USER) {
|
||||
return process.env.HOST_CRONTAB_USER;
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync('stat -c "%U" /var/run/docker.sock');
|
||||
const dockerSocketOwner = stdout.trim();
|
||||
|
||||
if (dockerSocketOwner === "root") {
|
||||
try {
|
||||
const projectDir = process.env.HOST_PROJECT_DIR;
|
||||
|
||||
if (projectDir) {
|
||||
const dirOwner = await execHostCrontab(
|
||||
`stat -c "%U" "${projectDir}"`
|
||||
);
|
||||
return dirOwner.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not detect user from project directory:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await execHostCrontab(
|
||||
'getent passwd | grep ":/home/" | head -1 | cut -d: -f1'
|
||||
);
|
||||
const firstUser = users.trim();
|
||||
if (firstUser) {
|
||||
return firstUser;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not detect user from passwd:", error);
|
||||
}
|
||||
|
||||
return "root";
|
||||
}
|
||||
|
||||
return dockerSocketOwner;
|
||||
} catch (error) {
|
||||
console.error("Error detecting target user:", error);
|
||||
return "root";
|
||||
}
|
||||
}
|
||||
|
||||
export async function readHostCrontab(): Promise<string> {
|
||||
try {
|
||||
const user = await getTargetUser();
|
||||
return await execHostCrontab(`crontab -l -u ${user} 2>/dev/null || echo ""`);
|
||||
} catch (error) {
|
||||
console.error("Error reading host crontab:", error);
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const user = await getTargetUser();
|
||||
return await execHostCrontab(
|
||||
`crontab -l -u ${user} 2>/dev/null || echo ""`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error reading host crontab:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeHostCrontab(content: string): Promise<boolean> {
|
||||
try {
|
||||
const user = await getTargetUser();
|
||||
let finalContent = content;
|
||||
if (!finalContent.endsWith('\n')) {
|
||||
finalContent += '\n';
|
||||
}
|
||||
|
||||
const base64Content = Buffer.from(finalContent).toString('base64');
|
||||
await execHostCrontab(`echo '${base64Content}' | base64 -d | crontab -u ${user} -`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error writing host crontab:", error);
|
||||
return false;
|
||||
try {
|
||||
const user = await getTargetUser();
|
||||
let finalContent = content;
|
||||
if (!finalContent.endsWith("\n")) {
|
||||
finalContent += "\n";
|
||||
}
|
||||
|
||||
const base64Content = Buffer.from(finalContent).toString("base64");
|
||||
await execHostCrontab(
|
||||
`echo '${base64Content}' | base64 -d | crontab -u ${user} -`
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error writing host crontab:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
services:
|
||||
cronjob-manager:
|
||||
image: ghcr.io/fccview/cronmaster:main
|
||||
container_name: cronmaster-test
|
||||
container_name: cronmaster
|
||||
user: "root"
|
||||
ports:
|
||||
# Feel free to change port, 3000 is very common so I like to map it to something else
|
||||
- "40124:3000"
|
||||
- "40123:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DOCKER=true
|
||||
# Legacy used to be NEXT_PUBLIC_HOST_PROJECT_DIR, this was causing issues on runtime.
|
||||
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||
- NEXT_PUBLIC_HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
||||
# If docker struggles to find your crontab user, update this variable with it.
|
||||
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
|
||||
# - HOST_CRONTAB_USER=fccview
|
||||
@@ -20,7 +21,7 @@ services:
|
||||
|
||||
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
|
||||
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
|
||||
# will target this foler (thanks to the NEXT_PUBLIC_HOST_PROJECT_DIR variable set above)
|
||||
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
|
||||
- ./scripts:/app/scripts
|
||||
- ./data:/app/data
|
||||
- ./snippets:/app/snippets
|
||||
@@ -33,4 +34,4 @@ services:
|
||||
init: true
|
||||
|
||||
# Default platform is set to amd64, uncomment to use arm64.
|
||||
#platform: linux/arm64
|
||||
#platform: linux/arm64
|
||||
|
||||
Reference in New Issue
Block a user