5 Commits

Author SHA1 Message Date
fccview
2a437f3db8 Fix script path bug and add support for pinned versions 2025-08-26 13:44:08 +01:00
fccview
47e19246ce update readme and docker compose file. whopsie. 2025-08-26 10:08:10 +01:00
fccview
29917a4cad Merge pull request #7 from fccview/bugfix/replace-docker-crontab-functionality
Bugfix/replace docker crontab functionality
2025-08-26 09:46:28 +01:00
fccview
ac31906166 Merge pull request #10 from fccview/BUG-2
Fix scripts not working if description is empty
2025-08-26 09:34:43 +01:00
fccview
b267fb9ce6 Fix scripts not working if description is empty 2025-08-26 09:30:58 +01:00
11 changed files with 162 additions and 111 deletions

View File

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

View File

@@ -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>

View File

@@ -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),
});
};

View File

@@ -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">

View File

@@ -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>

View File

@@ -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" };
}

View File

@@ -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");

View File

@@ -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(),
});

View File

@@ -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)}`;
}

View File

@@ -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;
}
}

View File

@@ -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