add logs and fix a bunch of major issues

This commit is contained in:
fccview
2025-11-10 11:41:04 +00:00
parent 11c96d0aed
commit 30d856b9ce
26 changed files with 1807 additions and 428 deletions

3
.gitignore vendored
View File

@@ -13,4 +13,5 @@ node_modules
.cursorignore
.idea
tsconfig.tsbuildinfo
docker-compose.test.yml
docker-compose.test.yml
/data

View File

@@ -5,6 +5,15 @@ RUN apt-get update && apt-get install -y \
curl \
iputils-ping \
util-linux \
ca-certificates \
gnupg \
lsb-release \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt-get update \
&& apt-get install -y docker-ce-cli \
&& rm -rf /var/lib/apt/lists/*
FROM base AS deps

138
README.md
View File

@@ -8,6 +8,7 @@
- **System Information**: Display hostname, IP address, uptime, memory, network and CPU info.
- **Cron Job Management**: View, create, and delete cron jobs with comments.
- **Script management**: View, create, and delete bash scripts on the go to use within your cron jobs.
- **Job Execution Logging**: Optional logging for cronjobs with automatic cleanup, capturing stdout, stderr, exit codes, and timestamps.
- **Docker Support**: Runs entirely from a Docker container.
- **Easy Setup**: Quick presets for common cron schedules.
@@ -53,7 +54,7 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
```bash
services:
cronjob-manager:
cronmaster:
image: ghcr.io/fccview/cronmaster:latest
container_name: cronmaster
user: "root"
@@ -63,13 +64,17 @@ services:
environment:
- NODE_ENV=production
- DOCKER=true
# --- MAP HOST PROJECT DIRECTORY, THIS IS MANDATORY FOR SCRIPTS TO WORK
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
# --- SET LOCALE TO ONE OF OUR SUPPORTED LOCALES - see the /app/_translations/ folder for supported locales
# - LOCALE=en
# --- UNCOMMENT TO SET DIFFERENT LOGGING VALUES
# - MAX_LOG_AGE_DAYS=30
# - MAX_LOGS_PER_JOB=50
# --- PASSWORD PROTECTION
# Uncomment to enable password protection (replace "very_strong_password" with your own)
# Uncomment to enable password protection (replace "password" with your own)
- AUTH_PASSWORD=very_strong_password
# --- CRONTAB USERS
@@ -192,6 +197,129 @@ The application automatically detects your operating system and displays:
4. **Add Comments**: Include descriptions for your cron jobs
5. **Delete Jobs**: Remove unwanted cron jobs with the delete button
6. **Clone Jobs**: Clone jobs to quickly edit the command in case it's similar
7. **Enable Logging**: Optionally enable execution logging for any cronjob to capture detailed execution information
### Job Execution Logging
CronMaster includes an optional logging feature that captures detailed execution information for your cronjobs:
#### How It Works
When you enable logging for a cronjob, CronMaster automatically wraps your command with a log wrapper script. This wrapper:
- Captures **stdout** and **stderr** output
- Records the **exit code** of your command
- Timestamps the **start and end** of execution
- Calculates **execution duration**
- Stores all this information in organized log files
#### Enabling Logs
1. When creating or editing a cronjob, check the "Enable Logging" checkbox
2. The wrapper is automatically added to your crontab entry
3. Jobs run independently - they continue to work even if CronMaster is offline
#### Log Storage
Logs are stored in the `./data/logs/` directory with descriptive folder names:
- If a job has a **description/comment**: `{sanitized-description}_{jobId}/`
- If a job has **no description**: `{jobId}/`
Example structure:
```
./data/logs/
├── backup-database_root-0/
│ ├── 2025-11-10_14-30-00.log
│ ├── 2025-11-10_15-30-00.log
│ └── 2025-11-10_16-30-00.log
├── daily-cleanup_root-1/
│ └── 2025-11-10_14-35-00.log
├── root-2/ (no description provided)
│ └── 2025-11-10_14-40-00.log
```
**Note**: Folder names are sanitized to be filesystem-safe (lowercase, alphanumeric with hyphens, max 50 chars for the description part).
#### Log Format
Each log file includes:
```
==========================================
=== CronMaster Job Execution Log ===
==========================================
Log Folder: backup-database_root-0
Command: bash /app/scripts/backup.sh
Started: 2025-11-10 14:30:00
==========================================
[command output here]
==========================================
=== Execution Summary ===
==========================================
Completed: 2025-11-10 14:30:45
Duration: 45 seconds
Exit code: 0
==========================================
```
#### Automatic Cleanup
Logs are automatically cleaned up to prevent disk space issues:
- **Maximum logs per job**: 50 log files
- **Maximum age**: 30 days
- **Cleanup trigger**: When viewing logs or after manual execution
- **Method**: Oldest logs are deleted first when limits are exceeded
#### Custom Wrapper Script
You can override the default log wrapper by creating your own at `./data/wrapper-override.sh`. This allows you to:
- Customize log format
- Add additional metadata
- Integrate with external logging services
- Implement custom retention policies
**Example custom wrapper**:
```bash
#!/bin/bash
JOB_ID="$1"
shift
# Your custom logic here
LOG_FILE="/custom/path/${JOB_ID}_$(date '+%Y%m%d').log"
{
echo "=== Custom Log Format ==="
echo "Job: $JOB_ID"
"$@"
echo "Exit: $?"
} >> "$LOG_FILE" 2>&1
```
#### Docker Considerations
- Mount the `./data` directory to persist logs on the host
- The wrapper script location: `./data/cron-log-wrapper.sh`. This will be generated automatically the first time you enable logging.
#### Non-Docker Considerations
- Logs are stored at `./data/logs/` relative to the project directory
- The codebase wrapper script location: `./app/_scripts/cron-log-wrapper.sh`
- The running wrapper script location: `./data/cron-log-wrapper.sh`
#### Important Notes
- Logging is **optional** and disabled by default
- Jobs with logging enabled are marked with a blue "Logged" badge in the UI
- Logs are captured for both scheduled runs and manual executions
- Commands with file redirections (>, >>) may conflict with logging
- The crontab stores the **wrapped command**, so jobs run independently of CronMaster
### Cron Schedule Format

View File

@@ -11,6 +11,7 @@ import { useCronJobState } from "@/app/_hooks/useCronJobState";
import { CronJobItem } from "@/app/_components/FeatureComponents/Cronjobs/Parts/CronJobItem";
import { CronJobEmptyState } from "@/app/_components/FeatureComponents/Cronjobs/Parts/CronJobEmptyState";
import { CronJobListModals } from "@/app/_components/FeatureComponents/Modals/CronJobListsModals";
import { LogsModal } from "@/app/_components/FeatureComponents/Modals/LogsModal";
import { useTranslations } from "next-intl";
interface CronJobListProps {
@@ -30,6 +31,9 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
setErrorModalOpen,
selectedError,
setSelectedError,
isLogsModalOpen,
setIsLogsModalOpen,
jobForLogs,
filteredJobs,
isNewCronModalOpen,
setIsNewCronModalOpen,
@@ -53,6 +57,8 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
handlePauseLocal,
handleResumeLocal,
handleRunLocal,
handleToggleLoggingLocal,
handleViewLogs,
confirmDelete,
confirmClone,
handleEdit,
@@ -117,6 +123,8 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
onClone={confirmClone}
onResume={handleResumeLocal}
onPause={handlePauseLocal}
onToggleLogging={handleToggleLoggingLocal}
onViewLogs={handleViewLogs}
onDelete={confirmDelete}
onErrorClick={handleErrorClickLocal}
onErrorDismiss={refreshJobErrorsLocal}
@@ -167,6 +175,15 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
}}
selectedError={selectedError}
/>
{jobForLogs && (
<LogsModal
isOpen={isLogsModalOpen}
onClose={() => setIsLogsModalOpen(false)}
jobId={jobForLogs.id}
jobComment={jobForLogs.comment}
/>
)}
</>
);
};

View File

@@ -3,196 +3,252 @@
import { useState, useEffect } from "react";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import {
Trash2,
Edit,
Files,
User,
Play,
Pause,
Code,
Info,
Trash2,
Edit,
Files,
User,
Play,
Pause,
Code,
Info,
FileOutput,
FileX,
FileText,
} from "lucide-react";
import { CronJob } from "@/app/_utils/cronjob-utils";
import { JobError } from "@/app/_utils/error-utils";
import { ErrorBadge } from "@/app/_components/GlobalComponents/Badges/ErrorBadge";
import { parseCronExpression, type CronExplanation } from "@/app/_utils/parser-utils";
import {
parseCronExpression,
type CronExplanation,
} from "@/app/_utils/parser-utils";
import { unwrapCommand } from "@/app/_utils/wrapper-utils-client";
import { useLocale } from "next-intl";
import { useTranslations } from "next-intl";
interface CronJobItemProps {
job: CronJob;
errors: JobError[];
runningJobId: string | null;
deletingId: string | null;
onRun: (id: string) => void;
onEdit: (job: CronJob) => void;
onClone: (job: CronJob) => void;
onResume: (id: string) => void;
onPause: (id: string) => void;
onDelete: (job: CronJob) => void;
onErrorClick: (error: JobError) => void;
onErrorDismiss: () => void;
job: CronJob;
errors: JobError[];
runningJobId: string | null;
deletingId: string | null;
onRun: (id: string) => void;
onEdit: (job: CronJob) => void;
onClone: (job: CronJob) => void;
onResume: (id: string) => void;
onPause: (id: string) => void;
onDelete: (job: CronJob) => void;
onToggleLogging: (id: string) => void;
onViewLogs: (job: CronJob) => void;
onErrorClick: (error: JobError) => void;
onErrorDismiss: () => void;
}
export const CronJobItem = ({
job,
errors,
runningJobId,
deletingId,
onRun,
onEdit,
onClone,
onResume,
onPause,
onDelete,
onErrorClick,
onErrorDismiss,
job,
errors,
runningJobId,
deletingId,
onRun,
onEdit,
onClone,
onResume,
onPause,
onDelete,
onToggleLogging,
onViewLogs,
onErrorClick,
onErrorDismiss,
}: CronJobItemProps) => {
const [cronExplanation, setCronExplanation] = useState<CronExplanation | null>(null);
const locale = useLocale();
const t = useTranslations();
const [cronExplanation, setCronExplanation] =
useState<CronExplanation | null>(null);
const locale = useLocale();
const t = useTranslations();
const displayCommand = unwrapCommand(job.command);
useEffect(() => {
if (job.schedule) {
const explanation = parseCronExpression(job.schedule, locale);
setCronExplanation(explanation);
} else {
setCronExplanation(null);
}
}, [job.schedule]);
return (
<div
key={job.id}
className="glass-card p-4 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors"
>
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<div className="flex-1 min-w-0 order-2 lg:order-1">
<div className="flex items-center gap-3 mb-2">
<code className="text-sm bg-purple-500/10 text-purple-600 dark:text-purple-400 px-2 py-1 rounded font-mono border border-purple-500/20">
{job.schedule}
</code>
<div className="flex-1 min-w-0">
<pre
className="text-sm font-medium text-foreground truncate bg-muted/30 px-2 py-1 rounded border border-border/30"
title={job.command}
>
{job.command}
</pre>
</div>
</div>
{cronExplanation?.isValid && (
<div className="flex items-start gap-1.5 mb-1">
<Info className="h-3 w-3 text-primary mt-0.5 flex-shrink-0" />
<p className="text-xs text-muted-foreground italic">
{cronExplanation.humanReadable}
</p>
</div>
)}
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<User className="h-3 w-3" />
<span>{job.user}</span>
</div>
{job.paused && (
<span className="text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 px-2 py-0.5 rounded border border-yellow-500/20">
{t("cronjobs.paused")}
</span>
)}
<ErrorBadge
errors={errors}
onErrorClick={onErrorClick}
onErrorDismiss={onErrorDismiss}
/>
</div>
{job.comment && (
<p
className="text-xs text-muted-foreground italic truncate"
title={job.comment}
>
{job.comment}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0 order-1 lg:order-2">
<Button
variant="outline"
size="sm"
onClick={() => onRun(job.id)}
disabled={runningJobId === job.id || job.paused}
className="btn-outline h-8 px-3"
title={t("cronjobs.runCronManually")}
aria-label={t("cronjobs.runCronManually")}
>
{runningJobId === job.id ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Code className="h-3 w-3" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onEdit(job)}
className="btn-outline h-8 px-3"
title={t("cronjobs.editCronJob")}
aria-label={t("cronjobs.editCronJob")}
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onClone(job)}
className="btn-outline h-8 px-3"
title={t("cronjobs.cloneCronJob")}
aria-label={t("cronjobs.cloneCronJob")}
>
<Files className="h-3 w-3" />
</Button>
{job.paused ? (
<Button
variant="outline"
size="sm"
onClick={() => onResume(job.id)}
className="btn-outline h-8 px-3"
title={t("cronjobs.resumeCronJob")}
aria-label={t("cronjobs.resumeCronJob")}
>
<Play className="h-3 w-3" />
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => onPause(job.id)}
className="btn-outline h-8 px-3"
title={t("cronjobs.pauseCronJob")}
aria-label={t("cronjobs.pauseCronJob")}
>
<Pause className="h-3 w-3" />
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(job)}
disabled={deletingId === job.id}
className="btn-destructive h-8 px-3"
title={t("cronjobs.deleteCronJob")}
aria-label={t("cronjobs.deleteCronJob")}
>
{deletingId === job.id ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Trash2 className="h-3 w-3" />
)}
</Button>
</div>
useEffect(() => {
if (job.schedule) {
const explanation = parseCronExpression(job.schedule, locale);
setCronExplanation(explanation);
} else {
setCronExplanation(null);
}
}, [job.schedule]);
return (
<div
key={job.id}
className="glass-card p-4 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors"
>
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<div className="flex-1 min-w-0 order-2 lg:order-1">
<div className="flex items-center gap-3 mb-2">
<code className="text-sm bg-purple-500/10 text-purple-600 dark:text-purple-400 px-2 py-1 rounded font-mono border border-purple-500/20">
{job.schedule}
</code>
<div className="flex-1 min-w-0">
<pre
className="text-sm font-medium text-foreground truncate bg-muted/30 px-2 py-1 rounded border border-border/30"
title={displayCommand}
>
{displayCommand}
</pre>
</div>
</div>
{cronExplanation?.isValid && (
<div className="flex items-start gap-1.5 mb-1">
<Info className="h-3 w-3 text-primary mt-0.5 flex-shrink-0" />
<p className="text-xs text-muted-foreground italic">
{cronExplanation.humanReadable}
</p>
</div>
)}
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<User className="h-3 w-3" />
<span>{job.user}</span>
</div>
{job.paused && (
<span className="text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 px-2 py-0.5 rounded border border-yellow-500/20">
{t("cronjobs.paused")}
</span>
)}
{job.logsEnabled && (
<span className="text-xs bg-blue-500/10 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded border border-blue-500/20">
{t("cronjobs.logged")}
</span>
)}
<ErrorBadge
errors={errors}
onErrorClick={onErrorClick}
onErrorDismiss={onErrorDismiss}
/>
</div>
{job.comment && (
<p
className="text-xs text-muted-foreground italic truncate"
title={job.comment}
>
{job.comment}
</p>
)}
</div>
);
};
<div className="flex items-center gap-2 flex-shrink-0 order-1 lg:order-2">
<Button
variant="outline"
size="sm"
onClick={() => onRun(job.id)}
disabled={runningJobId === job.id || job.paused}
className="btn-outline h-8 px-3"
title={t("cronjobs.runCronManually")}
aria-label={t("cronjobs.runCronManually")}
>
{runningJobId === job.id ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Code className="h-3 w-3" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onEdit(job)}
className="btn-outline h-8 px-3"
title={t("cronjobs.editCronJob")}
aria-label={t("cronjobs.editCronJob")}
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onClone(job)}
className="btn-outline h-8 px-3"
title={t("cronjobs.cloneCronJob")}
aria-label={t("cronjobs.cloneCronJob")}
>
<Files className="h-3 w-3" />
</Button>
{job.paused ? (
<Button
variant="outline"
size="sm"
onClick={() => onResume(job.id)}
className="btn-outline h-8 px-3"
title={t("cronjobs.resumeCronJob")}
aria-label={t("cronjobs.resumeCronJob")}
>
<Play className="h-3 w-3" />
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => onPause(job.id)}
className="btn-outline h-8 px-3"
title={t("cronjobs.pauseCronJob")}
aria-label={t("cronjobs.pauseCronJob")}
>
<Pause className="h-3 w-3" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => onToggleLogging(job.id)}
className={`h-8 px-3 ${
job.logsEnabled
? "btn-outline border-blue-500/50 text-blue-600 dark:text-blue-400 hover:bg-blue-500/10"
: "btn-outline"
}`}
title={
job.logsEnabled
? t("cronjobs.disableLogging")
: t("cronjobs.enableLogging")
}
aria-label={
job.logsEnabled
? t("cronjobs.disableLogging")
: t("cronjobs.enableLogging")
}
>
{job.logsEnabled ? (
<FileOutput className="h-3 w-3" />
) : (
<FileX className="h-3 w-3" />
)}
</Button>
{job.logsEnabled && (
<Button
variant="outline"
size="sm"
onClick={() => onViewLogs(job)}
className="btn-outline h-8 px-3"
title={t("cronjobs.viewLogs")}
aria-label={t("cronjobs.viewLogs")}
>
<FileText className="h-3 w-3" />
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(job)}
disabled={deletingId === job.id}
className="btn-destructive h-8 px-3"
title={t("cronjobs.deleteCronJob")}
aria-label={t("cronjobs.deleteCronJob")}
>
{deletingId === job.id ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Trash2 className="h-3 w-3" />
)}
</Button>
</div>
</div>
</div>
);
};

View File

@@ -8,6 +8,7 @@ import {
pauseCronJobAction,
resumeCronJobAction,
runCronJob,
toggleCronJobLogging,
} from "@/app/_server/actions/cronjobs";
import { CronJob } from "@/app/_utils/cronjob-utils";
@@ -30,6 +31,7 @@ interface HandlerProps {
schedule: string;
command: string;
comment: string;
logsEnabled: boolean;
};
newCronForm: {
schedule: string;
@@ -37,6 +39,7 @@ interface HandlerProps {
comment: string;
selectedScriptId: string | null;
user: string;
logsEnabled: boolean;
};
}
@@ -163,6 +166,20 @@ export const handlePause = async (id: string) => {
}
};
export const handleToggleLogging = async (id: string) => {
try {
const result = await toggleCronJobLogging(id);
if (result.success) {
showToast("success", result.message);
} else {
showToast("error", "Failed to toggle logging", result.message);
}
} catch (error: any) {
console.error("Error toggling logging:", error);
showToast("error", "Error toggling logging", error.message);
}
};
export const handleResume = async (id: string) => {
try {
const result = await resumeCronJobAction(id);
@@ -261,6 +278,7 @@ export const handleEditSubmit = async (
formData.append("schedule", editForm.schedule);
formData.append("command", editForm.command);
formData.append("comment", editForm.comment);
formData.append("logsEnabled", editForm.logsEnabled.toString());
const result = await editCronJob(formData);
if (result.success) {
@@ -335,6 +353,7 @@ export const handleNewCronSubmit = async (
formData.append("command", newCronForm.command);
formData.append("comment", newCronForm.comment);
formData.append("user", newCronForm.user);
formData.append("logsEnabled", newCronForm.logsEnabled.toString());
if (newCronForm.selectedScriptId) {
formData.append("selectedScriptId", newCronForm.selectedScriptId);
}
@@ -348,6 +367,7 @@ export const handleNewCronSubmit = async (
comment: "",
selectedScriptId: null,
user: "",
logsEnabled: false,
});
showToast("success", "Cron job created successfully");
} else {

View File

@@ -7,7 +7,7 @@ import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import { CronExpressionHelper } from "@/app/_components/FeatureComponents/Scripts/CronExpressionHelper";
import { SelectScriptModal } from "@/app/_components/FeatureComponents/Modals/SelectScriptModal";
import { UserSwitcher } from "@/app/_components/FeatureComponents/User/UserSwitcher";
import { Plus, Terminal, FileText, X } from "lucide-react";
import { Plus, Terminal, FileText, X, FileOutput } from "lucide-react";
import { getScriptContent } from "@/app/_server/actions/scripts";
import { getHostScriptPath } from "@/app/_server/actions/scripts";
import { useTranslations } from "next-intl";
@@ -31,6 +31,7 @@ interface CreateTaskModalProps {
comment: string;
selectedScriptId: string | null;
user: string;
logsEnabled: boolean;
};
onFormChange: (updates: Partial<CreateTaskModalProps["form"]>) => void;
}
@@ -122,16 +123,21 @@ export const CreateTaskModal = ({
<button
type="button"
onClick={handleCustomCommand}
className={`p-4 rounded-lg border-2 transition-all ${!form.selectedScriptId
? "border-primary bg-primary/5 text-primary"
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
}`}
className={`p-4 rounded-lg border-2 transition-all ${
!form.selectedScriptId
? "border-primary bg-primary/5 text-primary"
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
}`}
>
<div className="flex items-center gap-3">
<Terminal className="h-5 w-5" />
<div className="text-left">
<div className="font-medium">{t("cronjobs.customCommand")}</div>
<div className="text-xs opacity-70">{t("cronjobs.singleCommand")}</div>
<div className="font-medium">
{t("cronjobs.customCommand")}
</div>
<div className="text-xs opacity-70">
{t("cronjobs.singleCommand")}
</div>
</div>
</div>
</button>
@@ -139,15 +145,18 @@ export const CreateTaskModal = ({
<button
type="button"
onClick={() => setIsSelectScriptModalOpen(true)}
className={`p-4 rounded-lg border-2 transition-all ${form.selectedScriptId
? "border-primary bg-primary/5 text-primary"
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
}`}
className={`p-4 rounded-lg border-2 transition-all ${
form.selectedScriptId
? "border-primary bg-primary/5 text-primary"
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
}`}
>
<div className="flex items-center gap-3">
<FileText className="h-5 w-5" />
<div className="text-left">
<div className="font-medium">{t("scripts.savedScript")}</div>
<div className="font-medium">
{t("scripts.savedScript")}
</div>
<div className="text-xs opacity-70">
{t("scripts.selectFromLibrary")}
</div>
@@ -233,7 +242,9 @@ export const CreateTaskModal = ({
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{t("common.description")}
<span className="text-muted-foreground">({t("common.optional")})</span>
<span className="text-muted-foreground">
({t("common.optional")})
</span>
</label>
<Input
value={form.comment}
@@ -243,6 +254,32 @@ export const CreateTaskModal = ({
/>
</div>
<div className="border border-border/30 bg-muted/10 rounded-lg p-4">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="logsEnabled"
checked={form.logsEnabled}
onChange={(e) =>
onFormChange({ logsEnabled: e.target.checked })
}
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary/20 cursor-pointer"
/>
<div className="flex-1">
<label
htmlFor="logsEnabled"
className="flex items-center gap-2 text-sm font-medium text-foreground cursor-pointer"
>
<FileOutput className="h-4 w-4 text-primary" />
{t("cronjobs.enableLogging")}
</label>
<p className="text-xs text-muted-foreground mt-1">
{t("cronjobs.loggingDescription")}
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-border/50">
<Button
type="button"
@@ -269,4 +306,4 @@ export const CreateTaskModal = ({
/>
</>
);
}
};

View File

@@ -4,7 +4,8 @@ import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import { CronExpressionHelper } from "@/app/_components/FeatureComponents/Scripts/CronExpressionHelper";
import { Edit, Terminal } from "lucide-react";
import { Edit, Terminal, FileOutput } from "lucide-react";
import { useTranslations } from "next-intl";
interface EditTaskModalProps {
isOpen: boolean;
@@ -14,6 +15,7 @@ interface EditTaskModalProps {
schedule: string;
command: string;
comment: string;
logsEnabled: boolean;
};
onFormChange: (updates: Partial<EditTaskModalProps["form"]>) => void;
}
@@ -25,11 +27,13 @@ export const EditTaskModal = ({
form,
onFormChange,
}: EditTaskModalProps) => {
const t = useTranslations();
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Edit Scheduled Task"
title={t("cronjobs.editScheduledTask")}
size="xl"
>
<form onSubmit={onSubmit} className="space-y-4">
@@ -67,17 +71,43 @@ export const EditTaskModal = ({
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Description{" "}
<span className="text-muted-foreground">(Optional)</span>
{t("common.description")}{" "}
<span className="text-muted-foreground">
({t("common.optional")})
</span>
</label>
<Input
value={form.comment}
onChange={(e) => onFormChange({ comment: e.target.value })}
placeholder="What does this task do?"
placeholder={t("cronjobs.whatDoesThisTaskDo")}
className="bg-muted/30 border-border/50 focus:border-primary/50"
/>
</div>
<div className="border border-border/30 bg-muted/10 rounded-lg p-4">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="logsEnabled"
checked={form.logsEnabled}
onChange={(e) => onFormChange({ logsEnabled: e.target.checked })}
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary/20 cursor-pointer"
/>
<div className="flex-1">
<label
htmlFor="logsEnabled"
className="flex items-center gap-2 text-sm font-medium text-foreground cursor-pointer"
>
<FileOutput className="h-4 w-4 text-primary" />
{t("cronjobs.enableLogging")}
</label>
<p className="text-xs text-muted-foreground mt-1">
{t("cronjobs.loggingDescription")}
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-border/50">
<Button
type="button"
@@ -95,4 +125,4 @@ export const EditTaskModal = ({
</form>
</Modal>
);
}
};

View File

@@ -0,0 +1,269 @@
"use client";
import { useState, useEffect } from "react";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { FileText, Trash2, Eye, X, RefreshCw } from "lucide-react";
import { useTranslations } from "next-intl";
import {
getJobLogs,
getLogContent,
deleteLogFile,
deleteAllJobLogs,
getJobLogStats,
} from "@/app/_server/actions/logs";
interface LogEntry {
filename: string;
timestamp: string;
fullPath: string;
size: number;
dateCreated: Date;
}
interface LogsModalProps {
isOpen: boolean;
onClose: () => void;
jobId: string;
jobComment?: string;
}
export const LogsModal = ({
isOpen,
onClose,
jobId,
jobComment,
}: LogsModalProps) => {
const t = useTranslations();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [selectedLog, setSelectedLog] = useState<string | null>(null);
const [logContent, setLogContent] = useState<string>("");
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [isLoadingContent, setIsLoadingContent] = useState(false);
const [stats, setStats] = useState<{
count: number;
totalSize: number;
totalSizeMB: number;
} | null>(null);
const loadLogs = async () => {
setIsLoadingLogs(true);
try {
const [logsData, statsData] = await Promise.all([
getJobLogs(jobId),
getJobLogStats(jobId),
]);
setLogs(logsData);
setStats(statsData);
} catch (error) {
console.error("Error loading logs:", error);
} finally {
setIsLoadingLogs(false);
}
};
useEffect(() => {
if (isOpen) {
loadLogs();
setSelectedLog(null);
setLogContent("");
}
}, [isOpen, jobId]);
const handleViewLog = async (filename: string) => {
setIsLoadingContent(true);
setSelectedLog(filename);
try {
const content = await getLogContent(jobId, filename);
setLogContent(content);
} catch (error) {
console.error("Error loading log content:", error);
setLogContent("Error loading log content");
} finally {
setIsLoadingContent(false);
}
};
const handleDeleteLog = async (filename: string) => {
if (!confirm(t("confirmDeleteLog"))) return;
try {
const result = await deleteLogFile(jobId, filename);
if (result.success) {
await loadLogs();
if (selectedLog === filename) {
setSelectedLog(null);
setLogContent("");
}
} else {
alert(result.message);
}
} catch (error) {
console.error("Error deleting log:", error);
alert("Error deleting log file");
}
};
const handleDeleteAllLogs = async () => {
if (!confirm(t("confirmDeleteAllLogs"))) return;
try {
const result = await deleteAllJobLogs(jobId);
if (result.success) {
await loadLogs();
setSelectedLog(null);
setLogContent("");
} else {
alert(result.message);
}
} catch (error) {
console.error("Error deleting all logs:", error);
alert("Error deleting all logs");
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
const formatTimestamp = (timestamp: string): string => {
const [datePart, timePart] = timestamp.split("_");
const [year, month, day] = datePart.split("-");
const [hour, minute, second] = timePart.split("-");
const date = new Date(
parseInt(year),
parseInt(month) - 1,
parseInt(day),
parseInt(hour),
parseInt(minute),
parseInt(second)
);
return date.toLocaleString();
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={t("viewLogs")} size="xl">
<div className="flex flex-col h-[600px]">
<div className="flex items-center justify-between mb-4 pb-4 border-b border-border">
<div>
<h3 className="font-semibold text-lg">{jobComment || jobId}</h3>
{stats && (
<p className="text-sm text-muted-foreground">
{stats.count} {t("logs")} {stats.totalSizeMB} MB
</p>
)}
</div>
<div className="flex gap-2">
<Button
onClick={loadLogs}
disabled={isLoadingLogs}
className="btn-primary glow-primary"
size="sm"
>
<RefreshCw
className={`w-4 h-4 mr-2 ${
isLoadingLogs ? "animate-spin" : ""
}`}
/>
{t("refresh")}
</Button>
{logs.length > 0 && (
<Button
onClick={handleDeleteAllLogs}
className="btn-destructive glow-primary"
size="sm"
>
<Trash2 className="w-4 h-4 mr-2" />
{t("deleteAll")}
</Button>
)}
</div>
</div>
<div className="flex-1 flex gap-4 overflow-hidden">
<div className="w-1/3 flex flex-col border-r border-border pr-4 overflow-hidden">
<h4 className="font-semibold mb-2">{t("logFiles")}</h4>
<div className="flex-1 overflow-y-auto space-y-2">
{isLoadingLogs ? (
<div className="text-center py-8 text-muted-foreground">
{t("loading")}...
</div>
) : logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("noLogsFound")}
</div>
) : (
logs.map((log) => (
<div
key={log.filename}
className={`p-3 rounded border cursor-pointer transition-colors ${
selectedLog === log.filename
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
onClick={() => handleViewLog(log.filename)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<FileText className="w-4 h-4 flex-shrink-0" />
<span className="text-sm font-medium truncate">
{formatTimestamp(log.timestamp)}
</span>
</div>
<p className="text-xs text-muted-foreground">
{formatFileSize(log.size)}
</p>
</div>
<Button
onClick={(e) => {
e.stopPropagation();
handleDeleteLog(log.filename);
}}
className="btn-destructive glow-primary p-1 h-auto"
size="sm"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
))
)}
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<h4 className="font-semibold mb-2">{t("logContent")}</h4>
<div className="flex-1 overflow-hidden">
{isLoadingContent ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
{t("loading")}...
</div>
) : selectedLog ? (
<pre className="h-full overflow-auto bg-muted/50 p-4 rounded border border-border text-xs font-mono whitespace-pre-wrap">
{logContent}
</pre>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>{t("selectLogToView")}</p>
</div>
</div>
)}
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border flex justify-end">
<Button onClick={onClose} className="btn-primary glow-primary">
<X className="w-4 h-4 mr-2" />
{t("close")}
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -1,3 +1,7 @@
export const NSENTER_RUN_JOB = (executionUser: string, escapedCommand: string) => `nsenter -t 1 -m -u -i -n -p su - ${executionUser} -c '${escapedCommand}'`;
export const NSENTER_RUN_JOB = (
executionUser: string,
escapedCommand: string
) => `nsenter -t 1 -m -u -i -n -p su - ${executionUser} -c '${escapedCommand}'`;
export const NSENTER_HOST_CRONTAB = (command: string) => `nsenter -t 1 -m -u -i -n -p sh -c "${command}"`;
export const NSENTER_HOST_CRONTAB = (command: string) =>
`nsenter -t 1 -m -u -i -n -p sh -c "${command}"`;

View File

@@ -16,6 +16,7 @@ import {
handleRun,
handleEditSubmit,
handleNewCronSubmit,
handleToggleLogging,
} from "@/app/_components/FeatureComponents/Cronjobs/helpers";
interface CronJobListProps {
@@ -38,11 +39,14 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => {
const [jobErrors, setJobErrors] = useState<Record<string, JobError[]>>({});
const [errorModalOpen, setErrorModalOpen] = useState(false);
const [selectedError, setSelectedError] = useState<JobError | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [jobForLogs, setJobForLogs] = useState<CronJob | null>(null);
const [editForm, setEditForm] = useState({
schedule: "",
command: "",
comment: "",
logsEnabled: false,
});
const [newCronForm, setNewCronForm] = useState({
schedule: "",
@@ -50,6 +54,7 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => {
comment: "",
selectedScriptId: null as string | null,
user: "",
logsEnabled: false,
});
useEffect(() => {
@@ -132,6 +137,15 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => {
await handleRun(id, getHelperState());
};
const handleToggleLoggingLocal = async (id: string) => {
await handleToggleLogging(id);
};
const handleViewLogs = (job: CronJob) => {
setJobForLogs(job);
setIsLogsModalOpen(true);
};
const confirmDelete = (job: CronJob) => {
setJobToDelete(job);
setIsDeleteModalOpen(true);
@@ -148,6 +162,7 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => {
schedule: job.schedule,
command: job.command,
comment: job.comment || "",
logsEnabled: job.logsEnabled || false,
});
setIsEditModalOpen(true);
};
@@ -170,6 +185,9 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => {
setErrorModalOpen,
selectedError,
setSelectedError,
isLogsModalOpen,
setIsLogsModalOpen,
jobForLogs,
filteredJobs,
isNewCronModalOpen,
setIsNewCronModalOpen,
@@ -193,6 +211,8 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => {
handlePauseLocal,
handleResumeLocal,
handleRunLocal,
handleToggleLoggingLocal,
handleViewLogs,
confirmDelete,
confirmClone,
handleEdit,

View File

@@ -0,0 +1,75 @@
#!/bin/bash
# CronMaster Log Wrapper Script
# Captures stdout, stderr, exit code, and timestamps for cronjob executions
# This script is automatically copied to the data directory when logging is enabled
# You can customize it by editing ./data/cron-log-wrapper.sh
#
# Usage: cron-log-wrapper.sh <logFolderName> <command...>
#
# Example: cron-log-wrapper.sh "backup-database_root-0" bash /app/scripts/backup.sh
# Exits if no arguments are provided
if [ $# -lt 2 ]; then
echo "ERROR: Usage: $0 <logFolderName> <command...>" >&2
exit 1
fi
# Extracts the log folder name from the first argument
LOG_FOLDER_NAME="$1"
shift # Remove logFolderName from arguments, rest is the command
# Determines the base directory (Docker vs non-Docker)
if [ -d "/app/data" ]; then
# Docker environment
BASE_DIR="/app/data"
else
# Non-Docker environment - script is in app/_scripts, we need to go up two levels to project root
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
BASE_DIR="${PROJECT_ROOT}/data"
fi
# Creates the logs directory structure
LOG_DIR="${BASE_DIR}/logs/${LOG_FOLDER_NAME}"
mkdir -p "$LOG_DIR"
# Generates the timestamped log filename
TIMESTAMP=$(date '+%Y-%m-%d_%H-%M-%S')
LOG_FILE="${LOG_DIR}/${TIMESTAMP}.log"
# Executes the command and captures the output
{
echo "=========================================="
echo "====== CronMaster Job Execution Log ======"
echo "=========================================="
echo "Log Folder: ${LOG_FOLDER_NAME}"
echo "Command: $*"
echo "Started: $(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="
echo ""
# Executes the command, capturing the start time
START_TIME=$(date +%s)
"$@"
EXIT_CODE=$?
END_TIME=$(date +%s)
# Calculates the duration
DURATION=$((END_TIME - START_TIME))
echo ""
echo "=========================================="
echo "====== End log - Execution Summary ======="
echo "=========================================="
echo "Completed: $(date '+%Y-%m-%d %H:%M:%S')"
echo "Duration: ${DURATION} seconds"
echo "Exit code: ${EXIT_CODE}"
echo "=========================================="
# Exits with the same code as the command
exit $EXIT_CODE
} >> "$LOG_FILE" 2>&1
# Preserves the exit code for cron
exit $?

View File

@@ -10,12 +10,9 @@ import {
cleanupCrontab,
type CronJob,
} from "@/app/_utils/cronjob-utils";
import {
getAllTargetUsers,
getUserInfo,
} from "@/app/_utils/crontab-utils";
import { getAllTargetUsers, getUserInfo } from "@/app/_utils/crontab-utils";
import { revalidatePath } from "next/cache";
import { getScriptPath } from "@/app/_server/actions/scripts";
import { getScriptPathForCron } from "@/app/_server/actions/scripts";
import { exec } from "child_process";
import { promisify } from "util";
import { isDocker } from "@/app/_server/actions/global";
@@ -41,6 +38,7 @@ export const createCronJob = async (
const comment = formData.get("comment") as string;
const selectedScriptId = formData.get("selectedScriptId") as string;
const user = formData.get("user") as string;
const logsEnabled = formData.get("logsEnabled") === "true";
if (!schedule) {
return { success: false, message: "Schedule is required" };
@@ -54,7 +52,7 @@ export const createCronJob = async (
const selectedScript = scripts.find((s) => s.id === selectedScriptId);
if (selectedScript) {
finalCommand = await getScriptPath(selectedScript.filename);
finalCommand = await getScriptPathForCron(selectedScript.filename);
} else {
return { success: false, message: "Selected script not found" };
}
@@ -65,7 +63,13 @@ export const createCronJob = async (
};
}
const success = await addCronJob(schedule, finalCommand, comment, user);
const success = await addCronJob(
schedule,
finalCommand,
comment,
user,
logsEnabled
);
if (success) {
revalidatePath("/");
return { success: true, message: "Cron job created successfully" };
@@ -111,12 +115,19 @@ export const editCronJob = async (
const schedule = formData.get("schedule") as string;
const command = formData.get("command") as string;
const comment = formData.get("comment") as string;
const logsEnabled = formData.get("logsEnabled") === "true";
if (!id || !schedule || !command) {
return { success: false, message: "Missing required fields" };
}
const success = await updateCronJob(id, schedule, command, comment);
const success = await updateCronJob(
id,
schedule,
command,
comment,
logsEnabled
);
if (success) {
revalidatePath("/");
return { success: true, message: "Cron job updated successfully" };
@@ -242,6 +253,48 @@ export const cleanupCrontabAction = async (): Promise<{
}
};
export const toggleCronJobLogging = async (
id: string
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const cronJobs = await getCronJobs();
const job = cronJobs.find((j) => j.id === id);
if (!job) {
return { success: false, message: "Cron job not found" };
}
const newLogsEnabled = !job.logsEnabled;
const success = await updateCronJob(
id,
job.schedule,
job.command,
job.comment || "",
newLogsEnabled
);
if (success) {
revalidatePath("/");
return {
success: true,
message: newLogsEnabled
? "Logging enabled successfully"
: "Logging disabled successfully",
};
} else {
return { success: false, message: "Failed to toggle logging" };
}
} catch (error: any) {
console.error("Error toggling logging:", error);
return {
success: false,
message: error.message || "Error toggling logging",
details: error.stack,
};
}
};
export const runCronJob = async (
id: string
): Promise<{
@@ -298,4 +351,4 @@ export const runCronJob = async (
details: error.stack,
};
}
};
};

View File

@@ -1,20 +1,86 @@
"use server";
import { existsSync, readFileSync } from "fs";
import { execSync } from "child_process";
export const isDocker = async (): Promise<boolean> => {
try {
if (existsSync("/.dockerenv")) {
return true;
}
if (existsSync("/proc/1/cgroup")) {
const cgroupContent = readFileSync("/proc/1/cgroup", "utf8");
return cgroupContent.includes("/docker/");
}
return false;
} catch (error) {
return false;
try {
if (existsSync("/.dockerenv")) {
return true;
}
};
if (existsSync("/proc/1/cgroup")) {
const cgroupContent = readFileSync("/proc/1/cgroup", "utf8");
return cgroupContent.includes("/docker/");
}
return false;
} catch (error) {
return false;
}
};
export const getContainerIdentifier = async (): Promise<string | null> => {
try {
const docker = await isDocker();
if (!docker) {
return null;
}
const containerId = execSync("hostname").toString().trim();
return containerId;
} catch (error) {
console.error("Failed to get container identifier:", error);
return null;
}
};
export const getHostDataPath = async (): Promise<string | null> => {
try {
const docker = await isDocker();
if (!docker) {
return null;
}
const containerId = await getContainerIdentifier();
if (!containerId) {
return null;
}
const stdout = execSync(
`docker inspect --format '{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Source}}{{end}}{{end}}' ${containerId}`,
{ encoding: "utf8" }
);
const hostPath = stdout.trim();
return hostPath || null;
} catch (error) {
console.error("Failed to get host data path:", error);
return null;
}
};
export const getHostScriptsPath = async (): Promise<string | null> => {
try {
const docker = await isDocker();
if (!docker) {
return null;
}
const containerId = await getContainerIdentifier();
if (!containerId) {
return null;
}
const stdout = execSync(
`docker inspect --format '{{range .Mounts}}{{if eq .Destination "/app/scripts"}}{{.Source}}{{end}}{{end}}' ${containerId}`,
{ encoding: "utf8" }
);
const hostPath = stdout.trim();
return hostPath || null;
} catch (error) {
console.error("Failed to get host scripts path:", error);
return null;
}
};

View File

@@ -0,0 +1,250 @@
"use server";
import { readdir, readFile, unlink, stat } from "fs/promises";
import path from "path";
import { isDocker } from "@/app/_server/actions/global";
import { existsSync } from "fs";
import { DATA_DIR } from "@/app/_consts/file";
export interface LogEntry {
filename: string;
timestamp: string;
fullPath: string;
size: number;
dateCreated: Date;
}
const MAX_LOGS_PER_JOB = process.env.MAX_LOGS_PER_JOB
? parseInt(process.env.MAX_LOGS_PER_JOB)
: 50;
const MAX_LOG_AGE_DAYS = process.env.MAX_LOG_AGE_DAYS
? parseInt(process.env.MAX_LOG_AGE_DAYS)
: 30;
const getLogBasePath = async (): Promise<string> => {
return path.join(process.cwd(), DATA_DIR, "logs");
};
const getJobLogPath = async (jobId: string): Promise<string | null> => {
const basePath = await getLogBasePath();
if (!existsSync(basePath)) {
return null;
}
try {
const allFolders = await readdir(basePath);
const matchingFolder = allFolders.find(
(folder) => folder === jobId || folder.endsWith(`_${jobId}`)
);
if (matchingFolder) {
return path.join(basePath, matchingFolder);
}
return path.join(basePath, jobId);
} catch (error) {
console.error("Error finding log path:", error);
return path.join(basePath, jobId);
}
};
export const getJobLogs = async (
jobId: string,
skipCleanup: boolean = false
): Promise<LogEntry[]> => {
try {
const logDir = await getJobLogPath(jobId);
if (!logDir || !existsSync(logDir)) {
return [];
}
if (!skipCleanup) {
await cleanupJobLogs(jobId);
}
const files = await readdir(logDir);
const logFiles = files.filter((f) => f.endsWith(".log"));
const entries: LogEntry[] = [];
for (const file of logFiles) {
const fullPath = path.join(logDir, file);
const stats = await stat(fullPath);
entries.push({
filename: file,
timestamp: file.replace(".log", ""), // Parse timestamp from filename
fullPath,
size: stats.size,
dateCreated: stats.birthtime,
});
}
// Sort by date (newest first)
return entries.sort(
(a, b) => b.dateCreated.getTime() - a.dateCreated.getTime()
);
} catch (error) {
console.error(`Error reading logs for job ${jobId}:`, error);
return [];
}
};
export const getLogContent = async (
jobId: string,
filename: string
): Promise<string> => {
try {
const logDir = await getJobLogPath(jobId);
if (!logDir) {
return "Log directory not found";
}
const logPath = path.join(logDir, filename);
const content = await readFile(logPath, "utf-8");
return content;
} catch (error) {
console.error(`Error reading log file ${filename}:`, error);
return "Error reading log file";
}
};
export const deleteLogFile = async (
jobId: string,
filename: string
): Promise<{ success: boolean; message: string }> => {
try {
const logDir = await getJobLogPath(jobId);
if (!logDir) {
return {
success: false,
message: "Log directory not found",
};
}
const logPath = path.join(logDir, filename);
await unlink(logPath);
return {
success: true,
message: "Log file deleted successfully",
};
} catch (error: any) {
console.error(`Error deleting log file ${filename}:`, error);
return {
success: false,
message: error.message || "Error deleting log file",
};
}
};
export const deleteAllJobLogs = async (
jobId: string
): Promise<{ success: boolean; message: string; deletedCount: number }> => {
try {
const logs = await getJobLogs(jobId);
let deletedCount = 0;
for (const log of logs) {
const result = await deleteLogFile(jobId, log.filename);
if (result.success) {
deletedCount++;
}
}
return {
success: true,
message: `Deleted ${deletedCount} log files`,
deletedCount,
};
} catch (error: any) {
console.error(`Error deleting all logs for job ${jobId}:`, error);
return {
success: false,
message: error.message || "Error deleting log files",
deletedCount: 0,
};
}
};
export const cleanupJobLogs = async (
jobId: string
): Promise<{ success: boolean; message: string; deletedCount: number }> => {
try {
const logs = await getJobLogs(jobId, true);
if (logs.length === 0) {
return {
success: true,
message: "No logs to clean up",
deletedCount: 0,
};
}
let deletedCount = 0;
const now = new Date();
const maxAgeMs = MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000;
for (const log of logs) {
const ageMs = now.getTime() - log.dateCreated.getTime();
if (ageMs > maxAgeMs) {
const result = await deleteLogFile(jobId, log.filename);
if (result.success) {
deletedCount++;
}
}
}
const remainingLogs = await getJobLogs(jobId, true);
if (remainingLogs.length > MAX_LOGS_PER_JOB) {
const logsToDelete = remainingLogs.slice(MAX_LOGS_PER_JOB);
for (const log of logsToDelete) {
const result = await deleteLogFile(jobId, log.filename);
if (result.success) {
deletedCount++;
}
}
}
return {
success: true,
message: `Cleaned up ${deletedCount} log files`,
deletedCount,
};
} catch (error: any) {
console.error(`Error cleaning up logs for job ${jobId}:`, error);
return {
success: false,
message: error.message || "Error cleaning up log files",
deletedCount: 0,
};
}
};
export const getJobLogStats = async (
jobId: string
): Promise<{ count: number; totalSize: number; totalSizeMB: number }> => {
try {
const logs = await getJobLogs(jobId);
const totalSize = logs.reduce((sum, log) => sum + log.size, 0);
const totalSizeMB = totalSize / (1024 * 1024);
return {
count: logs.length,
totalSize,
totalSizeMB: Math.round(totalSizeMB * 100) / 100,
};
} catch (error) {
console.error(`Error getting log stats for job ${jobId}:`, error);
return {
count: 0,
totalSize: 0,
totalSizeMB: 0,
};
}
};

View File

@@ -9,6 +9,7 @@ import { promisify } from "util";
import { SCRIPTS_DIR } from "@/app/_consts/file";
import { loadAllScripts, Script } from "@/app/_utils/scripts-utils";
import { MAKE_SCRIPT_EXECUTABLE, RUN_SCRIPT } from "@/app/_consts/commands";
import { isDocker, getHostScriptsPath } from "@/app/_server/actions/global";
const execAsync = promisify(exec);
@@ -16,15 +17,30 @@ export const getScriptPath = (filename: string): string => {
return join(process.cwd(), SCRIPTS_DIR, filename);
};
export const getScriptPathForCron = async (
filename: string
): Promise<string> => {
const docker = await isDocker();
if (docker) {
const hostScriptsPath = await getHostScriptsPath();
if (hostScriptsPath) {
return `bash ${join(hostScriptsPath, filename)}`;
}
console.warn("Could not determine host scripts path, using container path");
}
return `bash ${join(process.cwd(), SCRIPTS_DIR, filename)}`;
};
export const getHostScriptPath = (filename: string): string => {
return `bash ${join(process.cwd(), SCRIPTS_DIR, filename)}`;
};
export const normalizeLineEndings = (content: string): string => {
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
};
const sanitizeScriptName = (name: string): string => {
return name
.toLowerCase()

View File

@@ -33,7 +33,24 @@
"singleCommand": "Single command",
"command": "Command",
"whatDoesThisTaskDo": "What does this task do?",
"createTask": "Create Task"
"createTask": "Create Task",
"editScheduledTask": "Edit Scheduled Task",
"enableLogging": "Enable Logging",
"disableLogging": "Disable Logging",
"loggingDescription": "Capture stdout, stderr, exit codes, and timestamps for job executions. Logs are stored in ./data/logs and automatically cleaned up (defaults to 50 logs per job and 30 days retention, you can change these values in the environment variables).",
"logged": "Logged",
"viewLogs": "View Logs",
"logs": "logs",
"logFiles": "Log Files",
"logContent": "Log Content",
"selectLogToView": "Select a log file to view its content",
"noLogsFound": "No logs found for this job",
"confirmDeleteLog": "Are you sure you want to delete this log file?",
"confirmDeleteAllLogs": "Are you sure you want to delete all log files for this job? This action cannot be undone.",
"deleteAll": "Delete All",
"refresh": "Refresh",
"loading": "Loading",
"close": "Close"
},
"scripts": {
"scripts": "Scripts",

View File

@@ -33,7 +33,24 @@
"singleCommand": "Comando singolo",
"command": "Comando",
"whatDoesThisTaskDo": "Cosa fa questa operazione?",
"createTask": "Crea Operazione"
"createTask": "Crea Operazione",
"editScheduledTask": "Modifica Operazione Pianificata",
"enableLogging": "Abilita Logging",
"disableLogging": "Disabilita Logging",
"loggingDescription": "Cattura stdout, stderr, codici di uscita e timestamp per le esecuzioni dei job. I log sono memorizzati in ./data/logs e automaticamente puliti (per impostazione predefinita 50 log per job e 30 giorni di conservazione, puoi modificare questi valori nelle env variables).",
"logged": "Loggato",
"viewLogs": "Visualizza Log",
"logs": "log",
"logFiles": "File",
"logContent": "Contenuto Log",
"selectLogToView": "Seleziona un file per visualizzarne il contenuto",
"noLogsFound": "Nessun log trovato per questa operazione",
"confirmDeleteLog": "Sei sicuro di voler eliminare questo file?",
"confirmDeleteAllLogs": "Sei sicuro di voler eliminare tutti i file per questa operazione? Questa azione non può essere annullata.",
"deleteAll": "Elimina Tutto",
"refresh": "Aggiorna",
"loading": "Caricamento",
"close": "Chiudi"
},
"scripts": {
"scripts": "Script",

View File

@@ -4,10 +4,26 @@ import {
readAllHostCrontabs,
writeHostCrontabForUser,
} from "@/app/_utils/crontab-utils";
import { parseJobsFromLines, deleteJobInLines, updateJobInLines, pauseJobInLines, resumeJobInLines } from "@/app/_utils/line-manipulation-utils";
import { cleanCrontabContent, readCronFiles, writeCronFiles } from "@/app/_utils/files-manipulation-utils";
import {
parseJobsFromLines,
deleteJobInLines,
updateJobInLines,
pauseJobInLines,
resumeJobInLines,
formatCommentWithMetadata,
} from "@/app/_utils/line-manipulation-utils";
import {
cleanCrontabContent,
readCronFiles,
writeCronFiles,
} from "@/app/_utils/files-manipulation-utils";
import { isDocker } from "@/app/_server/actions/global";
import { READ_CRONTAB, WRITE_CRONTAB } from "@/app/_consts/commands";
import {
wrapCommandWithLogger,
unwrapCommand,
isCommandWrapped,
} from "@/app/_utils/wrapper-utils";
const execAsync = promisify(exec);
@@ -18,6 +34,7 @@ export interface CronJob {
comment?: string;
user: string;
paused?: boolean;
logsEnabled?: boolean;
}
const readUserCrontab = async (user: string): Promise<string> => {
@@ -28,14 +45,15 @@ const readUserCrontab = async (user: string): Promise<string> => {
const targetUserCrontab = userCrontabs.find((uc) => uc.user === user);
return targetUserCrontab?.content || "";
} else {
const { stdout } = await execAsync(
READ_CRONTAB(user)
);
const { stdout } = await execAsync(READ_CRONTAB(user));
return stdout;
}
};
const writeUserCrontab = async (user: string, content: string): Promise<boolean> => {
const writeUserCrontab = async (
user: string,
content: string
): Promise<boolean> => {
const docker = await isDocker();
if (docker) {
@@ -93,20 +111,35 @@ export const getCronJobs = async (): Promise<CronJob[]> => {
console.error("Error getting cron jobs:", error);
return [];
}
}
};
export const addCronJob = async (
schedule: string,
command: string,
comment: string = "",
user?: string
user?: string,
logsEnabled: boolean = false
): Promise<boolean> => {
try {
if (user) {
const cronContent = await readUserCrontab(user);
const newEntry = comment
? `# ${comment}\n${schedule} ${command}`
: `${schedule} ${command}`;
const lines = cronContent.split("\n");
const existingJobs = parseJobsFromLines(lines, user);
const nextJobIndex = existingJobs.length;
const jobId = `${user}-${nextJobIndex}`;
let finalCommand = command;
if (logsEnabled) {
const docker = await isDocker();
finalCommand = await wrapCommandWithLogger(jobId, command, docker, comment);
}
const formattedComment = formatCommentWithMetadata(comment, logsEnabled);
const newEntry = formattedComment
? `# ${formattedComment}\n${schedule} ${finalCommand}`
: `${schedule} ${finalCommand}`;
let newCron;
if (cronContent.trim() === "") {
@@ -120,9 +153,23 @@ export const addCronJob = async (
} else {
const cronContent = await readCronFiles();
const newEntry = comment
? `# ${comment}\n${schedule} ${command}`
: `${schedule} ${command}`;
const currentUser = process.env.USER || "user";
const lines = cronContent.split("\n");
const existingJobs = parseJobsFromLines(lines, currentUser);
const nextJobIndex = existingJobs.length;
const jobId = `${currentUser}-${nextJobIndex}`;
let finalCommand = command;
if (logsEnabled) {
const docker = await isDocker();
finalCommand = await wrapCommandWithLogger(jobId, command, docker, comment);
}
const formattedComment = formatCommentWithMetadata(comment, logsEnabled);
const newEntry = formattedComment
? `# ${formattedComment}\n${schedule} ${finalCommand}`
: `${schedule} ${finalCommand}`;
let newCron;
if (cronContent.trim() === "") {
@@ -138,7 +185,7 @@ export const addCronJob = async (
console.error("Error adding cron job:", error);
return false;
}
}
};
export const deleteCronJob = async (id: string): Promise<boolean> => {
try {
@@ -155,13 +202,14 @@ export const deleteCronJob = async (id: string): Promise<boolean> => {
console.error("Error deleting cron job:", error);
return false;
}
}
};
export const updateCronJob = async (
id: string,
schedule: string,
command: string,
comment: string = ""
comment: string = "",
logsEnabled: boolean = false
): Promise<boolean> => {
try {
const [user, jobIndexStr] = id.split("-");
@@ -169,7 +217,37 @@ export const updateCronJob = async (
const cronContent = await readUserCrontab(user);
const lines = cronContent.split("\n");
const newCronEntries = updateJobInLines(lines, jobIndex, schedule, command, comment);
const existingJobs = parseJobsFromLines(lines, user);
const currentJob = existingJobs[jobIndex];
if (!currentJob) {
console.error(`Job with index ${jobIndex} not found`);
return false;
}
const wasLogsEnabled = currentJob.logsEnabled || false;
let finalCommand = command;
if (!wasLogsEnabled && logsEnabled) {
const docker = await isDocker();
finalCommand = await wrapCommandWithLogger(id, command, docker, comment);
} else if (wasLogsEnabled && !logsEnabled) {
finalCommand = unwrapCommand(command);
} else if (wasLogsEnabled && logsEnabled) {
const unwrapped = unwrapCommand(command);
const docker = await isDocker();
finalCommand = await wrapCommandWithLogger(id, unwrapped, docker, comment);
}
const newCronEntries = updateJobInLines(
lines,
jobIndex,
schedule,
finalCommand,
comment,
logsEnabled
);
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
return await writeUserCrontab(user, newCron);
@@ -177,7 +255,7 @@ export const updateCronJob = async (
console.error("Error updating cron job:", error);
return false;
}
}
};
export const pauseCronJob = async (id: string): Promise<boolean> => {
try {
@@ -194,7 +272,7 @@ export const pauseCronJob = async (id: string): Promise<boolean> => {
console.error("Error pausing cron job:", error);
return false;
}
}
};
export const resumeCronJob = async (id: string): Promise<boolean> => {
try {
@@ -211,7 +289,7 @@ export const resumeCronJob = async (id: string): Promise<boolean> => {
console.error("Error resuming cron job:", error);
return false;
}
}
};
export const cleanupCrontab = async (): Promise<boolean> => {
try {
@@ -229,4 +307,4 @@ export const cleanupCrontab = async (): Promise<boolean> => {
console.error("Error cleaning crontab:", error);
return false;
}
}
};

View File

@@ -154,12 +154,50 @@ export const resumeJobInLines = (
return newCronEntries;
};
export const parseCommentMetadata = (
commentText: string
): { comment: string; logsEnabled: boolean } => {
if (!commentText) {
return { comment: "", logsEnabled: false };
}
const parts = commentText.split("|").map((p) => p.trim());
const comment = parts[0] || "";
let logsEnabled = false;
if (parts.length > 1) {
const metadata = parts[1];
const logsMatch = metadata.match(/logsEnabled:\s*(true|false)/i);
if (logsMatch) {
logsEnabled = logsMatch[1].toLowerCase() === "true";
}
}
return { comment, logsEnabled };
};
export const formatCommentWithMetadata = (
comment: string,
logsEnabled: boolean
): string => {
const trimmedComment = comment.trim();
if (logsEnabled) {
return trimmedComment
? `${trimmedComment} | logsEnabled: true`
: `logsEnabled: true`;
}
return trimmedComment;
};
export const parseJobsFromLines = (
lines: string[],
user: string
): CronJob[] => {
const jobs: CronJob[] = [];
let currentComment = "";
let currentLogsEnabled = false;
let jobIndex = 0;
let i = 0;
@@ -181,7 +219,8 @@ export const parseJobsFromLines = (
}
if (trimmedLine.startsWith("# PAUSED:")) {
const comment = trimmedLine.substring(9).trim();
const commentText = trimmedLine.substring(9).trim();
const { comment, logsEnabled } = parseCommentMetadata(commentText);
if (i + 1 < lines.length) {
const nextLine = lines[i + 1].trim();
@@ -199,6 +238,7 @@ export const parseJobsFromLines = (
comment: comment || undefined,
user,
paused: true,
logsEnabled,
});
jobIndex++;
@@ -217,7 +257,10 @@ export const parseJobsFromLines = (
!lines[i + 1].trim().startsWith("#") &&
lines[i + 1].trim()
) {
currentComment = trimmedLine.substring(1).trim();
const commentText = trimmedLine.substring(1).trim();
const { comment, logsEnabled } = parseCommentMetadata(commentText);
currentComment = comment;
currentLogsEnabled = logsEnabled;
i++;
continue;
} else {
@@ -247,10 +290,12 @@ export const parseJobsFromLines = (
comment: currentComment || undefined,
user,
paused: false,
logsEnabled: currentLogsEnabled,
});
jobIndex++;
currentComment = "";
currentLogsEnabled = false;
}
i++;
}
@@ -345,7 +390,8 @@ export const updateJobInLines = (
targetJobIndex: number,
schedule: string,
command: string,
comment: string = ""
comment: string = "",
logsEnabled: boolean = false
): string[] => {
const newCronEntries: string[] = [];
let currentJobIndex = 0;
@@ -377,8 +423,12 @@ export const updateJobInLines = (
if (trimmedLine.startsWith("# PAUSED:")) {
if (currentJobIndex === targetJobIndex) {
const newEntry = comment
? `# PAUSED: ${comment}\n# ${schedule} ${command}`
const formattedComment = formatCommentWithMetadata(
comment,
logsEnabled
);
const newEntry = formattedComment
? `# PAUSED: ${formattedComment}\n# ${schedule} ${command}`
: `# PAUSED:\n# ${schedule} ${command}`;
newCronEntries.push(newEntry);
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
@@ -406,8 +456,12 @@ export const updateJobInLines = (
lines[i + 1].trim()
) {
if (currentJobIndex === targetJobIndex) {
const newEntry = comment
? `# ${comment}\n${schedule} ${command}`
const formattedComment = formatCommentWithMetadata(
comment,
logsEnabled
);
const newEntry = formattedComment
? `# ${formattedComment}\n${schedule} ${command}`
: `${schedule} ${command}`;
newCronEntries.push(newEntry);
i += 2;
@@ -425,8 +479,9 @@ export const updateJobInLines = (
}
if (currentJobIndex === targetJobIndex) {
const newEntry = comment
? `# ${comment}\n${schedule} ${command}`
const formattedComment = formatCommentWithMetadata(comment, logsEnabled);
const newEntry = formattedComment
? `# ${formattedComment}\n${schedule} ${command}`
: `${schedule} ${command}`;
newCronEntries.push(newEntry);
} else {

View File

@@ -0,0 +1,30 @@
export const unwrapCommand = (command: string): string => {
const wrapperPattern = /^(.+\/cron-log-wrapper\.sh)\s+"([^"]+)"\s+(.+)$/;
const match = command.match(wrapperPattern);
if (match && match[3]) {
return match[3];
}
return command;
};
export const isCommandWrapped = (command: string): boolean => {
const wrapperPattern = /\/cron-log-wrapper\.sh\s+"[^"]+"\s+/;
return wrapperPattern.test(command);
};
export const extractJobIdFromWrappedCommand = (
command: string
): string | null => {
const wrapperPattern = /\/cron-log-wrapper\.sh\s+"([^"]+)"\s+/;
const match = command.match(wrapperPattern);
if (match && match[1]) {
return match[1];
}
return null;
};

107
app/_utils/wrapper-utils.ts Normal file
View File

@@ -0,0 +1,107 @@
import { existsSync, copyFileSync } from "fs";
import path from "path";
import { DATA_DIR } from "../_consts/file";
import { getHostDataPath } from "../_server/actions/global";
const sanitizeForFilesystem = (input: string): string => {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.substring(0, 50);
};
export const generateLogFolderName = (
jobId: string,
comment?: string
): string => {
if (comment && comment.trim()) {
const sanitized = sanitizeForFilesystem(comment.trim());
return sanitized ? `${sanitized}_${jobId}` : jobId;
}
return jobId;
};
export const ensureWrapperScriptInData = (): string => {
const sourceScriptPath = path.join(
process.cwd(),
"app",
"_scripts",
"cron-log-wrapper.sh"
);
const dataScriptPath = path.join(
process.cwd(),
DATA_DIR,
"cron-log-wrapper.sh"
);
if (!existsSync(dataScriptPath)) {
try {
copyFileSync(sourceScriptPath, dataScriptPath);
console.log(`Copied wrapper script to ${dataScriptPath}`);
} catch (error) {
console.error("Failed to copy wrapper script to data directory:", error);
return sourceScriptPath;
}
}
return dataScriptPath;
};
export const wrapCommandWithLogger = async (
jobId: string,
command: string,
isDocker: boolean,
comment?: string
): Promise<string> => {
ensureWrapperScriptInData();
const logFolderName = generateLogFolderName(jobId, comment);
if (isDocker) {
const hostDataPath = await getHostDataPath();
if (hostDataPath) {
const hostWrapperPath = path.join(hostDataPath, "cron-log-wrapper.sh");
return `${hostWrapperPath} "${logFolderName}" ${command}`;
}
console.warn("Could not determine host data path, using container path");
}
const localWrapperPath = path.join(
process.cwd(),
DATA_DIR,
"cron-log-wrapper.sh"
);
return `${localWrapperPath} "${logFolderName}" ${command}`;
};
export const unwrapCommand = (command: string): string => {
const wrapperPattern = /^(.+\/cron-log-wrapper\.sh)\s+"([^"]+)"\s+(.+)$/;
const match = command.match(wrapperPattern);
if (match && match[3]) {
return match[3];
}
return command;
};
export const isCommandWrapped = (command: string): boolean => {
const wrapperPattern = /\/cron-log-wrapper\.sh\s+"[^"]+"\s+/;
return wrapperPattern.test(command);
};
export const extractJobIdFromWrappedCommand = (
command: string
): string | null => {
const wrapperPattern = /\/cron-log-wrapper\.sh\s+"([^"]+)"\s+/;
const match = command.match(wrapperPattern);
if (match && match[1]) {
return match[1];
}
return null;
};

View File

@@ -1,172 +1,185 @@
import { NextRequest, NextResponse } from 'next/server';
import * as si from 'systeminformation';
import { getTranslations } from '@/app/_utils/global-utils';
export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from "next/server";
import * as si from "systeminformation";
import { getTranslations } from "@/app/_utils/global-utils";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
try {
const t = await getTranslations();
try {
const t = await getTranslations();
const [
memInfo,
cpuInfo,
diskInfo,
loadInfo,
uptimeInfo,
networkInfo
] = await Promise.all([
si.mem(),
si.cpu(),
si.fsSize(),
si.currentLoad(),
si.time(),
si.networkStats()
]);
const [memInfo, cpuInfo, diskInfo, loadInfo, uptimeInfo, networkInfo] =
await Promise.all([
si.mem(),
si.cpu(),
si.fsSize(),
si.currentLoad(),
si.time(),
si.networkStats(),
]);
const formatBytes = (bytes: number) => {
const sizes = ["B", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 B";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
};
const formatBytes = (bytes: number) => {
const sizes = ["B", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 B";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
};
const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days} days, ${hours} hours`;
} else if (hours > 0) {
return `${hours} hours, ${minutes} minutes`;
} else {
return `${minutes} minutes`;
}
};
if (days > 0) {
return `${days} days, ${hours} hours`;
} else if (hours > 0) {
return `${hours} hours, ${minutes} minutes`;
} else {
return `${minutes} minutes`;
}
};
const actualUsed = memInfo.active || memInfo.used;
const actualFree = memInfo.available || memInfo.free;
const memUsage = ((actualUsed / memInfo.total) * 100);
let memStatus = t("system.optimal");
if (memUsage > 90) memStatus = t("system.critical");
else if (memUsage > 80) memStatus = t("system.high");
else if (memUsage > 70) memStatus = t("system.moderate");
const actualUsed = memInfo.active || memInfo.used;
const actualFree = memInfo.available || memInfo.free;
const memUsage = (actualUsed / memInfo.total) * 100;
let memStatus = t("system.optimal");
if (memUsage > 90) memStatus = t("system.critical");
else if (memUsage > 80) memStatus = t("system.high");
else if (memUsage > 70) memStatus = t("system.moderate");
const rootDisk = diskInfo.find(disk => disk.mount === '/') || diskInfo[0];
const diskUsage = rootDisk ? ((rootDisk.used / rootDisk.size) * 100) : 0;
let diskStatus = t("system.optimal");
if (diskUsage > 90) diskStatus = t("system.critical");
else if (diskUsage > 80) diskStatus = t("system.high");
else if (diskUsage > 70) diskStatus = t("system.moderate");
const rootDisk = diskInfo.find((disk) => disk.mount === "/") || diskInfo[0];
const cpuStatus = loadInfo.currentLoad > 80 ? t("system.high") :
loadInfo.currentLoad > 60 ? t("system.moderate") : t("system.optimal");
const cpuStatus =
loadInfo.currentLoad > 80
? t("system.high")
: loadInfo.currentLoad > 60
? t("system.moderate")
: t("system.optimal");
const criticalThreshold = 90;
const warningThreshold = 80;
let overallStatus = t("system.optimal");
let statusDetails = t("system.allSystemsRunningNormally");
const criticalThreshold = 90;
const warningThreshold = 80;
let overallStatus = t("system.optimal");
let statusDetails = t("system.allSystemsRunningNormally");
if (memUsage > criticalThreshold || loadInfo.currentLoad > criticalThreshold || diskUsage > criticalThreshold) {
overallStatus = t("system.critical");
statusDetails = t("system.highResourceUsageDetectedImmediateAttentionRequired");
} else if (memUsage > warningThreshold || loadInfo.currentLoad > warningThreshold || diskUsage > warningThreshold) {
overallStatus = t("system.warning");
statusDetails = t("system.moderateResourceUsageMonitoringRecommended");
}
let mainInterface: any = null;
if (Array.isArray(networkInfo) && networkInfo.length > 0) {
mainInterface = networkInfo.find(net =>
net.iface && !net.iface.includes('lo') && net.operstate === 'up'
) || networkInfo.find(net =>
net.iface && !net.iface.includes('lo')
) || networkInfo[0];
}
const networkSpeed = mainInterface && 'rx_sec' in mainInterface && 'tx_sec' in mainInterface
? `${Math.round(((mainInterface.rx_sec || 0) + (mainInterface.tx_sec || 0)) / 1024 / 1024)} Mbps`
: t("system.unknown");
let latency = 0;
try {
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const { stdout } = await execAsync('ping -c 1 -W 1000 8.8.8.8 2>/dev/null || echo "timeout"');
const match = stdout.match(/time=(\d+\.?\d*)/);
if (match) {
latency = Math.round(parseFloat(match[1]));
}
} catch (error) {
latency = 0;
}
const systemStats: any = {
uptime: formatUptime(uptimeInfo.uptime),
memory: {
total: formatBytes(memInfo.total),
used: formatBytes(actualUsed),
free: formatBytes(actualFree),
usage: Math.round(memUsage),
status: memStatus,
},
cpu: {
model: `${cpuInfo.manufacturer} ${cpuInfo.brand}`,
cores: cpuInfo.cores,
usage: Math.round(loadInfo.currentLoad),
status: cpuStatus,
},
disk: {
total: rootDisk ? formatBytes(rootDisk.size) : t("system.unknown"),
used: rootDisk ? formatBytes(rootDisk.used) : t("system.unknown"),
free: rootDisk ? formatBytes(rootDisk.available) : t("system.unknown"),
usage: Math.round(diskUsage),
status: diskStatus,
},
network: {
speed: networkSpeed,
latency: latency,
downloadSpeed: mainInterface && 'rx_sec' in mainInterface ? Math.round((mainInterface.rx_sec || 0) / 1024 / 1024) : 0,
uploadSpeed: mainInterface && 'tx_sec' in mainInterface ? Math.round((mainInterface.tx_sec || 0) / 1024 / 1024) : 0,
status: mainInterface && 'operstate' in mainInterface && mainInterface.operstate === 'up' ? t("system.connected") : t("system.unknown"),
},
systemStatus: {
overall: overallStatus,
details: statusDetails,
},
};
try {
const graphics = await si.graphics();
if (graphics.controllers && graphics.controllers.length > 0) {
const gpu = graphics.controllers[0];
systemStats.gpu = {
model: gpu.model || t("system.unknownGPU"),
memory: gpu.vram ? `${gpu.vram} MB` : undefined,
status: t("system.available"),
};
} else {
systemStats.gpu = {
model: t("system.noGPUDetected"),
status: t("system.unknown"),
};
}
} catch (error) {
systemStats.gpu = {
model: t("system.gpuDetectionFailed"),
status: t("system.unknown"),
};
}
return NextResponse.json(systemStats);
} catch (error) {
console.error('Error fetching system stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch system stats' },
{ status: 500 }
);
if (
memUsage > criticalThreshold ||
loadInfo.currentLoad > criticalThreshold
) {
overallStatus = t("system.critical");
statusDetails = t(
"system.highResourceUsageDetectedImmediateAttentionRequired"
);
} else if (
memUsage > warningThreshold ||
loadInfo.currentLoad > warningThreshold
) {
overallStatus = t("system.warning");
statusDetails = t("system.moderateResourceUsageMonitoringRecommended");
}
}
let mainInterface: any = null;
if (Array.isArray(networkInfo) && networkInfo.length > 0) {
mainInterface =
networkInfo.find(
(net) =>
net.iface && !net.iface.includes("lo") && net.operstate === "up"
) ||
networkInfo.find((net) => net.iface && !net.iface.includes("lo")) ||
networkInfo[0];
}
const networkSpeed =
mainInterface && "rx_sec" in mainInterface && "tx_sec" in mainInterface
? `${Math.round(
((mainInterface.rx_sec || 0) + (mainInterface.tx_sec || 0)) /
1024 /
1024
)} Mbps`
: t("system.unknown");
let latency = 0;
try {
const { exec } = require("child_process");
const { promisify } = require("util");
const execAsync = promisify(exec);
const { stdout } = await execAsync(
'ping -c 1 -W 1000 8.8.8.8 2>/dev/null || echo "timeout"'
);
const match = stdout.match(/time=(\d+\.?\d*)/);
if (match) {
latency = Math.round(parseFloat(match[1]));
}
} catch (error) {
latency = 0;
}
const systemStats: any = {
uptime: formatUptime(uptimeInfo.uptime),
memory: {
total: formatBytes(memInfo.total),
used: formatBytes(actualUsed),
free: formatBytes(actualFree),
usage: Math.round(memUsage),
status: memStatus,
},
cpu: {
model: `${cpuInfo.manufacturer} ${cpuInfo.brand}`,
cores: cpuInfo.cores,
usage: Math.round(loadInfo.currentLoad),
status: cpuStatus,
},
network: {
speed: networkSpeed,
latency: latency,
downloadSpeed:
mainInterface && "rx_sec" in mainInterface
? Math.round((mainInterface.rx_sec || 0) / 1024 / 1024)
: 0,
uploadSpeed:
mainInterface && "tx_sec" in mainInterface
? Math.round((mainInterface.tx_sec || 0) / 1024 / 1024)
: 0,
status:
mainInterface &&
"operstate" in mainInterface &&
mainInterface.operstate === "up"
? t("system.connected")
: t("system.unknown"),
},
systemStatus: {
overall: overallStatus,
details: statusDetails,
},
};
try {
const graphics = await si.graphics();
if (graphics.controllers && graphics.controllers.length > 0) {
const gpu = graphics.controllers[0];
systemStats.gpu = {
model: gpu.model || t("system.unknownGPU"),
memory: gpu.vram ? `${gpu.vram} MB` : undefined,
status: t("system.available"),
};
} else {
systemStats.gpu = {
model: t("system.noGPUDetected"),
status: t("system.unknown"),
};
}
} catch (error) {
systemStats.gpu = {
model: t("system.gpuDetectionFailed"),
status: t("system.unknown"),
};
}
return NextResponse.json(systemStats);
} catch (error) {
console.error("Error fetching system stats:", error);
return NextResponse.json(
{ error: "Failed to fetch system stats" },
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,5 @@
services:
cronjob-manager:
cronmaster:
image: ghcr.io/fccview/cronmaster:latest
container_name: cronmaster
user: "root"
@@ -9,11 +9,15 @@ services:
environment:
- NODE_ENV=production
- DOCKER=true
# --- MAP HOST PROJECT DIRECTORY, THIS IS MANDATORY FOR SCRIPTS TO WORK
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
# --- SET LOCALE TO ONE OF OUR SUPPORTED LOCALES - see the /app/_translations/ folder for supported locales
# - LOCALE=en
# --- UNCOMMENT TO SET DIFFERENT LOGGING VALUES
# - MAX_LOG_AGE_DAYS=30
# - MAX_LOGS_PER_JOB=50
# --- PASSWORD PROTECTION
# Uncomment to enable password protection (replace "password" with your own)
- AUTH_PASSWORD=very_strong_password

View File

File diff suppressed because one or more lines are too long

7
scripts/hello-world.sh Executable file
View File

@@ -0,0 +1,7 @@
# @id: script_1762773565914_sjuilgih3
# @title: hello-world
# @description: echoes test
#!/bin/bash
# Your script here
echo 'Hello World'