mirror of
https://github.com/fccview/cronmaster.git
synced 2025-12-23 22:18:20 -05:00
add logs and fix a bunch of major issues
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,4 +13,5 @@ node_modules
|
||||
.cursorignore
|
||||
.idea
|
||||
tsconfig.tsbuildinfo
|
||||
docker-compose.test.yml
|
||||
docker-compose.test.yml
|
||||
/data
|
||||
@@ -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
138
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
269
app/_components/FeatureComponents/Modals/LogsModal.tsx
Normal file
269
app/_components/FeatureComponents/Modals/LogsModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}"`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
75
app/_scripts/cron-log-wrapper.sh
Executable file
75
app/_scripts/cron-log-wrapper.sh
Executable 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 $?
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
250
app/_server/actions/logs/index.ts
Normal file
250
app/_server/actions/logs/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
30
app/_utils/wrapper-utils-client.ts
Normal file
30
app/_utils/wrapper-utils-client.ts
Normal 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
107
app/_utils/wrapper-utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
7
scripts/hello-world.sh
Executable file
7
scripts/hello-world.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
# @id: script_1762773565914_sjuilgih3
|
||||
# @title: hello-world
|
||||
# @description: echoes test
|
||||
|
||||
#!/bin/bash
|
||||
# Your script here
|
||||
echo 'Hello World'
|
||||
Reference in New Issue
Block a user