major improvements to the wrapper and fix api route mess

This commit is contained in:
fccview
2025-11-11 07:27:03 +00:00
parent 30d856b9ce
commit 1f82e85833
11 changed files with 383 additions and 182 deletions

View File

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

View File

@@ -182,6 +182,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
onClose={() => setIsLogsModalOpen(false)}
jobId={jobForLogs.id}
jobComment={jobForLogs.comment}
preSelectedLog={jobForLogs.logError?.lastFailedLog}
/>
)}
</>

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -50,7 +50,9 @@
"deleteAll": "Delete All",
"refresh": "Refresh",
"loading": "Loading",
"close": "Close"
"close": "Close",
"healthy": "Healthy",
"failed": "Failed (Exit: {exitCode})"
},
"scripts": {
"scripts": "Scripts",

View File

@@ -50,7 +50,9 @@
"deleteAll": "Elimina Tutto",
"refresh": "Aggiorna",
"loading": "Caricamento",
"close": "Chiudi"
"close": "Chiudi",
"healthy": "Sano",
"failed": "Fallito (Exit: {exitCode})"
},
"scripts": {
"scripts": "Script",

View File

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

View File

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

View File

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