mirror of
https://github.com/fccview/cronmaster.git
synced 2025-12-23 22:18:20 -05:00
major improvements to the wrapper and fix api route mess
This commit is contained in:
15
README.md
15
README.md
@@ -89,8 +89,8 @@ services:
|
||||
|
||||
# --- MOUNT DATA
|
||||
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
|
||||
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
|
||||
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
|
||||
# DO NOT change the location of ./scripts once you create crons as all cronjobs that use custom
|
||||
# scripts created via the app will target this folder
|
||||
- ./scripts:/app/scripts
|
||||
- ./data:/app/data
|
||||
- ./snippets:/app/snippets
|
||||
@@ -158,17 +158,6 @@ The following environment variables can be configured:
|
||||
| `HOST_CRONTAB_USER` | `root` | Comma separated list of users that run cronjobs on your host machine |
|
||||
| `AUTH_PASSWORD` | `N/A` | If you set a password the application will be password protected with basic next-auth |
|
||||
|
||||
**Example**: To change the clock update interval to 60 seconds:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=60000 docker-compose up
|
||||
```
|
||||
|
||||
**Example**: Your `docker-compose.yml` file or repository are in `~/homelab/cronmaster/`
|
||||
|
||||
```bash
|
||||
HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
|
||||
```
|
||||
|
||||
### Important Notes for Docker
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
onClose={() => setIsLogsModalOpen(false)}
|
||||
jobId={jobForLogs.id}
|
||||
jobComment={jobForLogs.comment}
|
||||
preSelectedLog={jobForLogs.logError?.lastFailedLog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
FileOutput,
|
||||
FileX,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { CronJob } from "@/app/_utils/cronjob-utils";
|
||||
import { JobError } from "@/app/_utils/error-utils";
|
||||
@@ -103,26 +106,67 @@ export const CronJobItem = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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}
|
||||
/>
|
||||
|
||||
{job.logsEnabled && job.logError?.hasError && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewLogs(job);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs bg-red-500/10 text-red-600 dark:text-red-400 px-2 py-0.5 rounded border border-red-500/30 hover:bg-red-500/20 transition-colors cursor-pointer"
|
||||
title="Latest execution failed - Click to view error log"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span>{t("cronjobs.failed", { exitCode: job.logError?.exitCode?.toString() ?? "" })}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{job.logsEnabled && !job.logError?.hasError && job.logError?.hasHistoricalFailures && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewLogs(job);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 px-2 py-0.5 rounded border border-yellow-500/30 hover:bg-yellow-500/20 transition-colors cursor-pointer"
|
||||
title="Latest execution succeeded, but has historical failures - Click to view logs"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span>{t("cronjobs.healthy")}</span>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{job.logsEnabled && !job.logError?.hasError && !job.logError?.hasHistoricalFailures && job.logError?.latestExitCode === 0 && (
|
||||
<div className="flex items-center gap-1 text-xs bg-green-500/10 text-green-600 dark:text-green-400 px-2 py-0.5 rounded border border-green-500/30">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span>{t("cronjobs.healthy")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!job.logsEnabled && (
|
||||
<ErrorBadge
|
||||
errors={errors}
|
||||
onErrorClick={onErrorClick}
|
||||
onErrorDismiss={onErrorDismiss}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.comment && (
|
||||
@@ -198,11 +242,10 @@ export const CronJobItem = ({
|
||||
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"
|
||||
}`}
|
||||
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")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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 { FileText, Trash2, Eye, X, RefreshCw, AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
getJobLogs,
|
||||
@@ -19,6 +19,8 @@ interface LogEntry {
|
||||
fullPath: string;
|
||||
size: number;
|
||||
dateCreated: Date;
|
||||
exitCode?: number;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
interface LogsModalProps {
|
||||
@@ -26,6 +28,7 @@ interface LogsModalProps {
|
||||
onClose: () => void;
|
||||
jobId: string;
|
||||
jobComment?: string;
|
||||
preSelectedLog?: string;
|
||||
}
|
||||
|
||||
export const LogsModal = ({
|
||||
@@ -33,6 +36,7 @@ export const LogsModal = ({
|
||||
onClose,
|
||||
jobId,
|
||||
jobComment,
|
||||
preSelectedLog,
|
||||
}: LogsModalProps) => {
|
||||
const t = useTranslations();
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
@@ -50,9 +54,10 @@ export const LogsModal = ({
|
||||
setIsLoadingLogs(true);
|
||||
try {
|
||||
const [logsData, statsData] = await Promise.all([
|
||||
getJobLogs(jobId),
|
||||
getJobLogs(jobId, false, true),
|
||||
getJobLogStats(jobId),
|
||||
]);
|
||||
|
||||
setLogs(logsData);
|
||||
setStats(statsData);
|
||||
} catch (error) {
|
||||
@@ -64,11 +69,17 @@ export const LogsModal = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadLogs();
|
||||
setSelectedLog(null);
|
||||
setLogContent("");
|
||||
loadLogs().then(() => {
|
||||
if (preSelectedLog) {
|
||||
handleViewLog(preSelectedLog);
|
||||
}
|
||||
});
|
||||
if (!preSelectedLog) {
|
||||
setSelectedLog(null);
|
||||
setLogContent("");
|
||||
}
|
||||
}
|
||||
}, [isOpen, jobId]);
|
||||
}, [isOpen, jobId, preSelectedLog]);
|
||||
|
||||
const handleViewLog = async (filename: string) => {
|
||||
setIsLoadingContent(true);
|
||||
@@ -163,9 +174,8 @@ export const LogsModal = ({
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 mr-2 ${
|
||||
isLoadingLogs ? "animate-spin" : ""
|
||||
}`}
|
||||
className={`w-4 h-4 mr-2 ${isLoadingLogs ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
@@ -198,24 +208,43 @@ export const LogsModal = ({
|
||||
logs.map((log) => (
|
||||
<div
|
||||
key={log.filename}
|
||||
className={`p-3 rounded border cursor-pointer transition-colors ${
|
||||
selectedLog === log.filename
|
||||
? "border-primary bg-primary/10"
|
||||
className={`p-3 rounded border cursor-pointer transition-colors ${selectedLog === log.filename
|
||||
? "border-primary bg-primary/10"
|
||||
: log.hasError
|
||||
? "border-red-500/50 hover:border-red-500"
|
||||
: "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" />
|
||||
{log.hasError ? (
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 text-red-500" />
|
||||
) : log.exitCode === 0 ? (
|
||||
<CheckCircle className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
) : (
|
||||
<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 className="flex items-center gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(log.size)}
|
||||
</p>
|
||||
{log.exitCode !== undefined && (
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${log.hasError
|
||||
? "bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
: "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
}`}
|
||||
>
|
||||
Exit: {log.exitCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -1,75 +1,69 @@
|
||||
#!/bin/bash
|
||||
|
||||
# CronMaster Log Wrapper Script
|
||||
# Cr*nmaster 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
|
||||
# Example: cron-log-wrapper.sh "backup-database" bash /app/scripts/backup.sh
|
||||
|
||||
set -u
|
||||
|
||||
# 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
|
||||
shift
|
||||
|
||||
# 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
|
||||
# Get the script's absolute directory path (e.g., ./data)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG_DIR="${SCRIPT_DIR}/logs/${LOG_FOLDER_NAME}"
|
||||
|
||||
# Creates the logs directory structure
|
||||
LOG_DIR="${BASE_DIR}/logs/${LOG_FOLDER_NAME}"
|
||||
# Ensure the log directory exists
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# Generates the timestamped log filename
|
||||
TIMESTAMP=$(date '+%Y-%m-%d_%H-%M-%S')
|
||||
LOG_FILE="${LOG_DIR}/${TIMESTAMP}.log"
|
||||
TIMESTAMP_FILE=$(date '+%Y-%m-%d_%H-%M-%S')
|
||||
HUMAN_START_TIME=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
LOG_FILE="${LOG_DIR}/${TIMESTAMP_FILE}.log"
|
||||
START_SECONDS=$SECONDS
|
||||
|
||||
# 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 "--- [ JOB START ] ----------------------------------------------------"
|
||||
echo "Command : $*"
|
||||
echo "Timestamp : ${HUMAN_START_TIME}"
|
||||
echo "Host : $(hostname)"
|
||||
echo "User : $(whoami)"
|
||||
echo "--- [ JOB OUTPUT ] ---------------------------------------------------"
|
||||
echo ""
|
||||
|
||||
# Executes the command, capturing the start time
|
||||
START_TIME=$(date +%s)
|
||||
# Execute the command, capturing its exit code
|
||||
"$@"
|
||||
EXIT_CODE=$?
|
||||
END_TIME=$(date +%s)
|
||||
|
||||
# Calculates the duration
|
||||
DURATION=$((END_TIME - START_TIME))
|
||||
|
||||
DURATION=$((SECONDS - START_SECONDS))
|
||||
HUMAN_END_TIME=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
STATUS="SUCCESS"
|
||||
else
|
||||
STATUS="FAILED"
|
||||
fi
|
||||
|
||||
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 "=========================================="
|
||||
echo "--- [ JOB SUMMARY ] --------------------------------------------------"
|
||||
echo "Timestamp : ${HUMAN_END_TIME}"
|
||||
echo "Duration : ${DURATION}s"
|
||||
# ⚠️ ATTENTION: DO NOT MODIFY THE EXIT CODE LINE ⚠️
|
||||
# The UI reads this exact format to detect job failures. Keep it as: "Exit Code : ${EXIT_CODE}"
|
||||
echo "Exit Code : ${EXIT_CODE}"
|
||||
echo "Status : ${STATUS}"
|
||||
echo "--- [ JOB END ] ------------------------------------------------------"
|
||||
|
||||
# Exits with the same code as the command
|
||||
exit $EXIT_CODE
|
||||
|
||||
} >> "$LOG_FILE" 2>&1
|
||||
|
||||
# Preserves the exit code for cron
|
||||
exit $?
|
||||
# Pass the command's exit code back to cron
|
||||
exit $?
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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";
|
||||
|
||||
@@ -12,6 +11,17 @@ export interface LogEntry {
|
||||
fullPath: string;
|
||||
size: number;
|
||||
dateCreated: Date;
|
||||
exitCode?: number;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
export interface JobLogError {
|
||||
hasError: boolean;
|
||||
lastFailedLog?: string;
|
||||
lastFailedTimestamp?: Date;
|
||||
exitCode?: number;
|
||||
latestExitCode?: number;
|
||||
hasHistoricalFailures?: boolean;
|
||||
}
|
||||
|
||||
const MAX_LOGS_PER_JOB = process.env.MAX_LOGS_PER_JOB
|
||||
@@ -52,7 +62,8 @@ const getJobLogPath = async (jobId: string): Promise<string | null> => {
|
||||
|
||||
export const getJobLogs = async (
|
||||
jobId: string,
|
||||
skipCleanup: boolean = false
|
||||
skipCleanup: boolean = false,
|
||||
includeExitCodes: boolean = false
|
||||
): Promise<LogEntry[]> => {
|
||||
try {
|
||||
const logDir = await getJobLogPath(jobId);
|
||||
@@ -73,16 +84,28 @@ export const getJobLogs = async (
|
||||
const fullPath = path.join(logDir, file);
|
||||
const stats = await stat(fullPath);
|
||||
|
||||
let exitCode: number | undefined;
|
||||
let hasError: boolean | undefined;
|
||||
|
||||
if (includeExitCodes) {
|
||||
const exitCodeValue = await getExitCodeForLog(fullPath);
|
||||
if (exitCodeValue !== null) {
|
||||
exitCode = exitCodeValue;
|
||||
hasError = exitCode !== 0;
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({
|
||||
filename: file,
|
||||
timestamp: file.replace(".log", ""), // Parse timestamp from filename
|
||||
timestamp: file.replace(".log", ""),
|
||||
fullPath,
|
||||
size: stats.size,
|
||||
dateCreated: stats.birthtime,
|
||||
exitCode,
|
||||
hasError,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
return entries.sort(
|
||||
(a, b) => b.dateCreated.getTime() - a.dateCreated.getTime()
|
||||
);
|
||||
@@ -248,3 +271,84 @@ export const getJobLogStats = async (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getExitCodeForLog = async (logPath: string): Promise<number | null> => {
|
||||
try {
|
||||
const content = await readFile(logPath, "utf-8");
|
||||
const exitCodeMatch = content.match(/Exit Code\s*:\s*(-?\d+)/i);
|
||||
if (exitCodeMatch) {
|
||||
return parseInt(exitCodeMatch[1]);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error getting exit code for ${logPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getJobLogError = async (jobId: string): Promise<JobLogError> => {
|
||||
try {
|
||||
const logs = await getJobLogs(jobId);
|
||||
|
||||
if (logs.length === 0) {
|
||||
return { hasError: false };
|
||||
}
|
||||
|
||||
const latestLog = logs[0];
|
||||
const latestExitCode = await getExitCodeForLog(latestLog.fullPath);
|
||||
|
||||
if (latestExitCode !== null && latestExitCode !== 0) {
|
||||
return {
|
||||
hasError: true,
|
||||
lastFailedLog: latestLog.filename,
|
||||
lastFailedTimestamp: latestLog.dateCreated,
|
||||
exitCode: latestExitCode,
|
||||
latestExitCode,
|
||||
hasHistoricalFailures: false,
|
||||
};
|
||||
}
|
||||
|
||||
let hasHistoricalFailures = false;
|
||||
let lastFailedLog: string | undefined;
|
||||
let lastFailedTimestamp: Date | undefined;
|
||||
let failedExitCode: number | undefined;
|
||||
|
||||
for (let i = 1; i < logs.length; i++) {
|
||||
const exitCode = await getExitCodeForLog(logs[i].fullPath);
|
||||
if (exitCode !== null && exitCode !== 0) {
|
||||
hasHistoricalFailures = true;
|
||||
lastFailedLog = logs[i].filename;
|
||||
lastFailedTimestamp = logs[i].dateCreated;
|
||||
failedExitCode = exitCode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasError: false,
|
||||
latestExitCode: latestExitCode ?? undefined,
|
||||
hasHistoricalFailures,
|
||||
lastFailedLog,
|
||||
lastFailedTimestamp,
|
||||
exitCode: failedExitCode,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error checking log errors for job ${jobId}:`, error);
|
||||
return { hasError: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllJobLogErrors = async (
|
||||
jobIds: string[]
|
||||
): Promise<Map<string, JobLogError>> => {
|
||||
const errorMap = new Map<string, JobLogError>();
|
||||
|
||||
await Promise.all(
|
||||
jobIds.map(async (jobId) => {
|
||||
const error = await getJobLogError(jobId);
|
||||
errorMap.set(jobId, error);
|
||||
})
|
||||
);
|
||||
|
||||
return errorMap;
|
||||
};
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
"deleteAll": "Delete All",
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"healthy": "Healthy",
|
||||
"failed": "Failed (Exit: {exitCode})"
|
||||
},
|
||||
"scripts": {
|
||||
"scripts": "Scripts",
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
"deleteAll": "Elimina Tutto",
|
||||
"refresh": "Aggiorna",
|
||||
"loading": "Caricamento",
|
||||
"close": "Chiudi"
|
||||
"close": "Chiudi",
|
||||
"healthy": "Sano",
|
||||
"failed": "Fallito (Exit: {exitCode})"
|
||||
},
|
||||
"scripts": {
|
||||
"scripts": "Script",
|
||||
|
||||
@@ -35,6 +35,14 @@ export interface CronJob {
|
||||
user: string;
|
||||
paused?: boolean;
|
||||
logsEnabled?: boolean;
|
||||
logError?: {
|
||||
hasError: boolean;
|
||||
lastFailedLog?: string;
|
||||
lastFailedTimestamp?: Date;
|
||||
exitCode?: number;
|
||||
latestExitCode?: number;
|
||||
hasHistoricalFailures?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const readUserCrontab = async (user: string): Promise<string> => {
|
||||
@@ -93,7 +101,7 @@ const getAllUsers = async (): Promise<{ user: string; content: string }[]> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getCronJobs = async (): Promise<CronJob[]> => {
|
||||
export const getCronJobs = async (includeLogErrors: boolean = true): Promise<CronJob[]> => {
|
||||
try {
|
||||
const userCrontabs = await getAllUsers();
|
||||
let allJobs: CronJob[] = [];
|
||||
@@ -106,6 +114,17 @@ export const getCronJobs = async (): Promise<CronJob[]> => {
|
||||
allJobs.push(...jobs);
|
||||
}
|
||||
|
||||
if (includeLogErrors) {
|
||||
const { getAllJobLogErrors } = await import("@/app/_server/actions/logs");
|
||||
const jobIds = allJobs.map(job => job.id);
|
||||
const errorMap = await getAllJobLogErrors(jobIds);
|
||||
|
||||
allJobs = allJobs.map(job => ({
|
||||
...job,
|
||||
logError: errorMap.get(job.id),
|
||||
}));
|
||||
}
|
||||
|
||||
return allJobs;
|
||||
} catch (error) {
|
||||
console.error("Error getting cron jobs:", error);
|
||||
@@ -130,9 +149,11 @@ export const addCronJob = async (
|
||||
const jobId = `${user}-${nextJobIndex}`;
|
||||
|
||||
let finalCommand = command;
|
||||
if (logsEnabled) {
|
||||
if (logsEnabled && !isCommandWrapped(command)) {
|
||||
const docker = await isDocker();
|
||||
finalCommand = await wrapCommandWithLogger(jobId, command, docker, comment);
|
||||
} else if (logsEnabled && isCommandWrapped(command)) {
|
||||
finalCommand = command;
|
||||
}
|
||||
|
||||
const formattedComment = formatCommentWithMetadata(comment, logsEnabled);
|
||||
@@ -160,9 +181,11 @@ export const addCronJob = async (
|
||||
const jobId = `${currentUser}-${nextJobIndex}`;
|
||||
|
||||
let finalCommand = command;
|
||||
if (logsEnabled) {
|
||||
if (logsEnabled && !isCommandWrapped(command)) {
|
||||
const docker = await isDocker();
|
||||
finalCommand = await wrapCommandWithLogger(jobId, command, docker, comment);
|
||||
} else if (logsEnabled && isCommandWrapped(command)) {
|
||||
finalCommand = command;
|
||||
}
|
||||
|
||||
const formattedComment = formatCommentWithMetadata(comment, logsEnabled);
|
||||
@@ -225,20 +248,25 @@ export const updateCronJob = async (
|
||||
return false;
|
||||
}
|
||||
|
||||
const wasLogsEnabled = currentJob.logsEnabled || false;
|
||||
const isWrappd = isCommandWrapped(command);
|
||||
|
||||
let finalCommand = command;
|
||||
|
||||
if (!wasLogsEnabled && logsEnabled) {
|
||||
if (logsEnabled && !isWrappd) {
|
||||
const docker = await isDocker();
|
||||
finalCommand = await wrapCommandWithLogger(id, command, docker, comment);
|
||||
} else if (wasLogsEnabled && !logsEnabled) {
|
||||
}
|
||||
else if (!logsEnabled && isWrappd) {
|
||||
finalCommand = unwrapCommand(command);
|
||||
} else if (wasLogsEnabled && logsEnabled) {
|
||||
}
|
||||
else if (logsEnabled && isWrappd) {
|
||||
const unwrapped = unwrapCommand(command);
|
||||
const docker = await isDocker();
|
||||
finalCommand = await wrapCommandWithLogger(id, unwrapped, docker, comment);
|
||||
}
|
||||
else {
|
||||
finalCommand = command;
|
||||
}
|
||||
|
||||
const newCronEntries = updateJobInLines(
|
||||
lines,
|
||||
|
||||
@@ -162,15 +162,23 @@ export const parseCommentMetadata = (
|
||||
}
|
||||
|
||||
const parts = commentText.split("|").map((p) => p.trim());
|
||||
const comment = parts[0] || "";
|
||||
let comment = parts[0] || "";
|
||||
let logsEnabled = false;
|
||||
|
||||
if (parts.length > 1) {
|
||||
// Format: "fccview absolutely rocks | logsEnabled: true"
|
||||
const metadata = parts[1];
|
||||
const logsMatch = metadata.match(/logsEnabled:\s*(true|false)/i);
|
||||
if (logsMatch) {
|
||||
logsEnabled = logsMatch[1].toLowerCase() === "true";
|
||||
}
|
||||
} else {
|
||||
// Format: logsEnabled: true
|
||||
const logsMatch = commentText.match(/^logsEnabled:\s*(true|false)$/i);
|
||||
if (logsMatch) {
|
||||
logsEnabled = logsMatch[1].toLowerCase() === "true";
|
||||
comment = "";
|
||||
}
|
||||
}
|
||||
|
||||
return { comment, logsEnabled };
|
||||
|
||||
@@ -1,43 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import * as si from "systeminformation";
|
||||
import { getTranslations } from "@/app/_utils/global-utils";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const formatBytes = (bytes: number): string => {
|
||||
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);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days} days, ${hours} hours`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours} hours, ${minutes} minutes`;
|
||||
} else {
|
||||
return `${minutes} minutes`;
|
||||
}
|
||||
};
|
||||
|
||||
async function getPing(): Promise<number> {
|
||||
try {
|
||||
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) {
|
||||
return Math.round(parseFloat(match[1]));
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const t = await getTranslations();
|
||||
|
||||
const [memInfo, cpuInfo, diskInfo, loadInfo, uptimeInfo, networkInfo] =
|
||||
await Promise.all([
|
||||
const [
|
||||
[memInfo, cpuInfo, loadInfo, uptimeInfo, networkInfo],
|
||||
latency,
|
||||
graphics,
|
||||
] = await Promise.all([
|
||||
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 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`;
|
||||
}
|
||||
};
|
||||
]),
|
||||
getPing(),
|
||||
si.graphics().catch(() => null),
|
||||
]);
|
||||
|
||||
const actualUsed = memInfo.active || memInfo.used;
|
||||
const actualFree = memInfo.available || memInfo.free;
|
||||
@@ -47,14 +71,12 @@ export async function GET(request: NextRequest) {
|
||||
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 cpuStatus =
|
||||
loadInfo.currentLoad > 80
|
||||
? t("system.high")
|
||||
: loadInfo.currentLoad > 60
|
||||
? t("system.moderate")
|
||||
: t("system.optimal");
|
||||
? t("system.moderate")
|
||||
: t("system.optimal");
|
||||
|
||||
const criticalThreshold = 90;
|
||||
const warningThreshold = 80;
|
||||
@@ -77,7 +99,7 @@ export async function GET(request: NextRequest) {
|
||||
statusDetails = t("system.moderateResourceUsageMonitoringRecommended");
|
||||
}
|
||||
|
||||
let mainInterface: any = null;
|
||||
let mainInterface: si.Systeminformation.NetworkStatsData | null = null;
|
||||
if (Array.isArray(networkInfo) && networkInfo.length > 0) {
|
||||
mainInterface =
|
||||
networkInfo.find(
|
||||
@@ -89,30 +111,16 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const networkSpeed =
|
||||
mainInterface && "rx_sec" in mainInterface && "tx_sec" in mainInterface
|
||||
mainInterface &&
|
||||
mainInterface.rx_sec != null &&
|
||||
mainInterface.tx_sec != null
|
||||
? `${Math.round(
|
||||
((mainInterface.rx_sec || 0) + (mainInterface.tx_sec || 0)) /
|
||||
1024 /
|
||||
1024
|
||||
)} Mbps`
|
||||
((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: {
|
||||
@@ -131,18 +139,14 @@ export async function GET(request: NextRequest) {
|
||||
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,
|
||||
downloadSpeed: mainInterface
|
||||
? Math.round((mainInterface.rx_sec || 0) / 1024 / 1024)
|
||||
: 0,
|
||||
uploadSpeed: mainInterface
|
||||
? Math.round((mainInterface.tx_sec || 0) / 1024 / 1024)
|
||||
: 0,
|
||||
status:
|
||||
mainInterface &&
|
||||
"operstate" in mainInterface &&
|
||||
mainInterface.operstate === "up"
|
||||
mainInterface && mainInterface.operstate === "up"
|
||||
? t("system.connected")
|
||||
: t("system.unknown"),
|
||||
},
|
||||
@@ -152,22 +156,19 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
};
|
||||
|
||||
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) {
|
||||
if (graphics && 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 if (graphics) {
|
||||
systemStats.gpu = {
|
||||
model: t("system.noGPUDetected"),
|
||||
status: t("system.unknown"),
|
||||
};
|
||||
} else {
|
||||
systemStats.gpu = {
|
||||
model: t("system.gpuDetectionFailed"),
|
||||
status: t("system.unknown"),
|
||||
@@ -182,4 +183,4 @@ export async function GET(request: NextRequest) {
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user