9 Commits
BUG-2 ... BUG-3

Author SHA1 Message Date
fccview
2a437f3db8 Fix script path bug and add support for pinned versions 2025-08-26 13:44:08 +01:00
fccview
47e19246ce update readme and docker compose file. whopsie. 2025-08-26 10:08:10 +01:00
fccview
29917a4cad Merge pull request #7 from fccview/bugfix/replace-docker-crontab-functionality
Bugfix/replace docker crontab functionality
2025-08-26 09:46:28 +01:00
fccview
fc5cabb342 Merge branch 'main' into bugfix/replace-docker-crontab-functionality 2025-08-26 09:42:52 +01:00
fccview
ac31906166 Merge pull request #10 from fccview/BUG-2
Fix scripts not working if description is empty
2025-08-26 09:34:43 +01:00
fccview
08d37154b4 update readme, clean up code, add contributions 2025-08-26 07:59:08 +01:00
fccview
888297c56a final product for testers 2025-08-26 07:10:19 +01:00
fccview
165f625c65 final product for testers 2025-08-25 21:11:55 +01:00
fccview
40e8f44564 Fix major issue with cron logic 2025-08-25 20:13:05 +01:00
21 changed files with 521 additions and 1140 deletions

View File

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

View File

@@ -1,17 +1,15 @@
FROM node:20-slim AS base
# Install system utilities for system information
RUN apt-get update && apt-get install -y \
pciutils \
curl \
iputils-ping \
util-linux \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
@@ -20,20 +18,15 @@ RUN \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED=1
RUN yarn build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
@@ -43,33 +36,20 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN groupadd --system --gid 1001 nodejs
RUN useradd --system --uid 1001 nextjs
# Create directories for mounted volumes with proper permissions
RUN mkdir -p /app/scripts /app/data /app/snippets && \
chown -R nextjs:nodejs /app/scripts /app/data /app/snippets
# Create cron directories that will be mounted (this is the key fix!)
RUN mkdir -p /var/spool/cron/crontabs /etc/crontab && \
chown -R root:root /var/spool/cron/crontabs /etc/crontab
# Copy public directory
COPY --from=builder /app/public ./public
# Copy the entire .next directory
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
# Copy app directory for builtin snippets and other app files
COPY --from=builder --chown=nextjs:nodejs /app/app ./app
# Copy package.json and yarn.lock for yarn start
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/yarn.lock ./yarn.lock
# Copy node_modules for production dependencies
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
# Don't set default user - let docker-compose decide
# USER nextjs
EXPOSE 3000
ENV PORT=3000

View File

@@ -2,6 +2,18 @@
<img src="public/heading.png" width="400px">
</p>
# ATTENTION BREAKING UPDATE!!
> The latest `main` branch has completely changed the way this app used to run.
> The main reason being trying to address some security concerns and make the whole application work
> across multiple platform without too much trouble.
>
> If you came here due to this change trying to figure out why your app stopped working you have two options:
>
> 1 - Update your `docker-compose.yml` with the new one provided within this readme (or just copy [docker-compose.yml](docker-compose.yml))
>
> 2 - Keep your `docker-compose.yml` file as it is and use the legacy tag in the image `image: ghcr.io/fccview/cronmaster:legacy`. However bear in mind this will not be supported going forward, any issue regarding the legacy tag will be ignored and I will only support the main branch. Feel free to fork that specific branch in case you want to work on it yourself :)
## Features
- **Modern UI**: Beautiful, responsive interface with dark/light mode.
@@ -37,45 +49,39 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
```bash
services:
cronjob-manager:
image: ghcr.io/fccview/cronmaster:main
image: ghcr.io/fccview/cronmaster:1.2.1
container_name: cronmaster
user: "root"
ports:
# Feel free to change port, 3000 is very common so I like to map it to something else
- "40123:3000"
- "40124:3000"
environment:
- NODE_ENV=production
- DOCKER=true
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
- NEXT_PUBLIC_HOST_PROJECT_DIR=/path/to/cronmaster/directory
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
# If docker struggles to find your crontab user, update this variable with it.
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
# - HOST_CRONTAB_USER=fccview
volumes:
# --- CRONTAB MANAGEMENT ---
# We're mounting /etc/crontab to /host/crontab in read-only mode.
# We are thenmounting /var/spool/cron/crontabs with read-write permissions to allow the application
# to manipulate the crontab file - docker does not have access to the crontab command, it's the only
# workaround I could think of.
- /var/spool/cron/crontabs:/host/cron/crontabs
- /etc/crontab:/host/crontab:ro
# Mount Docker socket to execute commands on host
- /var/run/docker.sock:/var/run/docker.sock
# --- HOST SYSTEM STATS ---
# Mounting system specific folders to their /host/ equivalent folders.
# Similar story, we don't want to override docker system folders.
# These are all mounted read-only for security.
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /etc:/host/etc:ro
- /usr:/host/usr:ro
# --- APPLICATION-SPECIFIC MOUNTS ---
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
# will target this foler (thanks to the NEXT_PUBLIC_HOST_PROJECT_DIR variable set above)
# will target this foler (thanks to the HOST_PROJECT_DIR variable set above)
- ./scripts:/app/scripts
- ./data:/app/data
- ./snippets:/app/snippets
# Use host PID namespace for host command execution
# Run in privileged mode for nsenter access
pid: "host"
privileged: true
restart: unless-stopped
init: true
# Default platform is set to amd64, can be overridden by using arm64.
# Default platform is set to amd64, uncomment to use arm64.
#platform: linux/arm64
```
@@ -126,7 +132,7 @@ The following environment variables can be configured:
| Variable | Default | Description |
| ----------------------------------- | ------- | ------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL` | `30000` | Clock update interval in milliseconds (30 seconds) |
| `NEXT_PUBLIC_HOST_PROJECT_DIR` | `N/A` | Mandatory variable to make sure cron runs on the right path. |
| `HOST_PROJECT_DIR` | `N/A` | Mandatory variable to make sure cron runs on the right path. |
| `DOCKER` | `false` | ONLY set this to true if you are runnign the app via docker, in the docker-compose.yml file |
**Example**: To change the clock update interval to 60 seconds:
@@ -138,14 +144,14 @@ NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=60000 docker-compose up
**Example**: Your `docker-compose.yml` file or repository are in `~/homelab/cronmaster/`
```bash
NEXT_PUBLIC_HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
```
### Important Notes for Docker
- Root user is required for cron operations and direct file access. There is no way around this, if you don't feel comfortable in running it as root feel free to run the app locally with `yarn install`, `yarn build` and `yarn start`
- Crontab files are accessed directly via file system mounts at `/host/cron/crontabs` and `/host/crontab` for real-time reading and writing
- `NEXT_PUBLIC_HOST_PROJECT_DIR` is required in order for the scripts created within the app to run properly
- `HOST_PROJECT_DIR` is required in order for the scripts created within the app to run properly
- The `DOCKER=true` environment variable enables direct file access mode for crontab operations. This is REQUIRED when running the application in docker mode.
## Usage
@@ -206,6 +212,32 @@ The application uses standard cron format: `* * * * *`
4. Add tests if applicable
5. Submit a pull request
## Community shouts
I would like to thank the following members for raising issues and help test/debug them!
<table>
<tbody>
<tr>
<td align="center" valign="top" width="20%">
<a href="https://github.com/hermannx5"><img width="100" height="100" alt="hermannx5" src="https://avatars.githubusercontent.com/u/46320338?v=4&s=100"><br/>hermannx5</a>
</td>
<td align="center" valign="top" width="20%">
<a href="https://github.com/edersong"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/64137913?v=4&s=100"><br />edersong</a>
</td>
<td align="center" valign="top" width="20%">
<a href="https://github.com/corasaniti"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/5001932?u=2e8bc25b74eb11f7675d38c8e312374794a7b6e0&v=4&s=100"><br />corasaniti</a>
</td>
<td align="center" valign="top" width="20%">
<a href="https://github.com/abhisheknair"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/5221047?u=313beaabbb4a8e82fe07a2523076b4dafdc0bfec&v=4&s=100"><br />abhisheknair</a>
</td>
<td align="center" valign="top" width="20%">
<a href="https://github.com/mariushosting"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/37554361?u=9007d0600680ac2b267bde2d8c19b05c06285a34&v=4&s=100"><br />mariushosting</a>
</td>
</tr>
</tbody>
</table>
## License
This project is licensed under the MIT License.

View File

@@ -1,22 +1,60 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
import { MetricCard } from "./ui/MetricCard";
import { SystemStatus } from "./ui/SystemStatus";
import { PerformanceSummary } from "./ui/PerformanceSummary";
import { Sidebar } from "./ui/Sidebar";
import {
Monitor,
Globe,
Clock,
HardDrive,
Cpu,
Server,
Monitor,
Wifi,
} from "lucide-react";
import { SystemInfo as SystemInfoType } from "@/app/_utils/system";
interface SystemInfoType {
hostname: string;
platform: string;
ip?: string;
uptime: string;
memory: {
total: string;
used: string;
free: string;
usage: number;
status: string;
};
cpu: {
model: string;
cores: number;
usage: number;
status: string;
};
gpu: {
model: string;
memory?: string;
status: string;
};
network?: {
speed: string;
latency: number;
downloadSpeed: number;
uploadSpeed: number;
status: string;
};
disk: {
total: string;
used: string;
free: string;
usage: number;
status: string;
};
systemStatus: {
overall: string;
details: string;
};
}
import { useState, useEffect } from "react";
import { fetchSystemInfo } from "@/app/_server/actions/cronjobs";
interface SystemInfoCardProps {
systemInfo: SystemInfoType;
@@ -30,10 +68,16 @@ export function SystemInfoCard({
useState<SystemInfoType>(initialSystemInfo);
const [isUpdating, setIsUpdating] = useState(false);
const updateSystemInfo = async () => {
try {
setIsUpdating(true);
const freshData = await fetchSystemInfo();
const response = await fetch('/api/system-stats');
if (!response.ok) {
throw new Error('Failed to fetch system stats');
}
const freshData = await response.json();
setSystemInfo(freshData);
} catch (error) {
console.error("Failed to update system info:", error);
@@ -47,49 +91,39 @@ export function SystemInfoCard({
setCurrentTime(new Date().toLocaleTimeString());
};
const updateStats = () => {
updateSystemInfo();
};
updateTime();
updateStats();
updateSystemInfo();
const updateInterval = parseInt(
process.env.NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL || "30000"
);
const interval = setInterval(() => {
updateTime();
updateStats();
}, updateInterval);
return () => clearInterval(interval);
let mounted = true;
const doUpdate = () => {
if (!mounted) return;
updateTime();
updateSystemInfo().finally(() => {
if (mounted) {
setTimeout(doUpdate, updateInterval);
}
});
};
setTimeout(doUpdate, updateInterval);
return () => {
mounted = false;
};
}, []);
const quickStats = {
cpu: systemInfo.cpu.usage,
memory: systemInfo.memory.usage,
network: `${systemInfo.network.latency}ms`,
network: systemInfo.network ? `${systemInfo.network.latency}ms` : "N/A",
};
const basicInfoItems = [
{
icon: Monitor,
label: "Operating System",
value: systemInfo.platform,
color: "text-blue-500",
},
{
icon: Server,
label: "Hostname",
value: systemInfo.hostname,
color: "text-green-500",
},
{
icon: Globe,
label: "IP Address",
value: systemInfo.ip,
color: "text-purple-500",
},
{
icon: Clock,
label: "Uptime",
@@ -129,14 +163,14 @@ export function SystemInfoCard({
status: systemInfo.gpu.status,
color: "text-indigo-500",
},
{
...(systemInfo.network ? [{
icon: Wifi,
label: "Network",
value: `${systemInfo.network.latency}ms`,
detail: `${systemInfo.network.latency}ms latency • ${systemInfo.network.speed}`,
status: systemInfo.network.status,
color: "text-teal-500",
},
}] : []),
];
const performanceMetrics = [
@@ -150,11 +184,11 @@ export function SystemInfoCard({
value: `${systemInfo.memory.usage}%`,
status: systemInfo.memory.status,
},
{
...(systemInfo.network ? [{
label: "Network Latency",
value: `${systemInfo.network.latency}ms`,
status: systemInfo.network.status,
},
}] : []),
];
return (
@@ -216,7 +250,7 @@ export function SystemInfoCard({
💡 Stats update every{" "}
{Math.round(
parseInt(process.env.NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL || "30000") /
1000
1000
)}
s Network speed estimated from latency
{isUpdating && (

View File

@@ -58,10 +58,10 @@ export function CreateTaskModal({
loadScriptContent();
}, [selectedScript]);
const handleScriptSelect = (script: Script) => {
const handleScriptSelect = async (script: Script) => {
onFormChange({
selectedScriptId: script.id,
command: getHostScriptPath(script.filename),
command: await getHostScriptPath(script.filename),
});
};

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Modal } from "../ui/Modal";
import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
@@ -27,6 +27,18 @@ export function SelectScriptModal({
const [searchQuery, setSearchQuery] = useState("");
const [previewScript, setPreviewScript] = useState<Script | null>(null);
const [previewContent, setPreviewContent] = useState<string>("");
const [hostScriptPath, setHostScriptPath] = useState<string>("");
useEffect(() => {
const fetchHostScriptPath = async () => {
const path = await getHostScriptPath(previewScript?.filename || "");
setHostScriptPath(path);
};
if (previewScript) {
fetchHostScriptPath();
}
}, [previewScript]);
const filteredScripts = scripts.filter(
(script) =>
@@ -156,7 +168,7 @@ export function SelectScriptModal({
</div>
<div className="bg-muted/30 p-3 rounded border border-border/30">
<code className="text-sm font-mono text-foreground break-all">
{getHostScriptPath(previewScript.filename)}
{hostScriptPath}
</code>
</div>
</div>

View File

@@ -5,9 +5,7 @@ import {
addCronJob,
deleteCronJob,
updateCronJob,
getSystemInfo,
type CronJob,
type SystemInfo,
} from "@/app/_utils/system";
import { revalidatePath } from "next/cache";
import { getScriptPath } from "@/app/_utils/scripts";
@@ -21,48 +19,6 @@ export async function fetchCronJobs(): Promise<CronJob[]> {
}
}
export async function fetchSystemInfo(): Promise<SystemInfo> {
try {
return await getSystemInfo();
} catch (error) {
console.error("Error fetching system info:", error);
return {
hostname: "Unknown",
platform: "Unknown",
ip: "Unknown",
uptime: "Unknown",
memory: {
total: "Unknown",
used: "Unknown",
free: "Unknown",
usage: 0,
status: "Unknown",
},
cpu: {
model: "Unknown",
cores: 0,
usage: 0,
status: "Unknown",
},
gpu: {
model: "Unknown",
status: "Unknown",
},
network: {
latency: 0,
speed: "Unknown",
downloadSpeed: 0,
uploadSpeed: 0,
status: "Unknown",
},
systemStatus: {
overall: "Unknown",
details: "Unable to retrieve system information",
},
};
}
}
export async function createCronJob(
formData: FormData
): Promise<{ success: boolean; message: string }> {
@@ -84,7 +40,7 @@ export async function createCronJob(
const selectedScript = scripts.find((s) => s.id === selectedScriptId);
if (selectedScript) {
finalCommand = getScriptPath(selectedScript.filename);
finalCommand = await getScriptPath(selectedScript.filename);
} else {
return { success: false, message: "Selected script not found" };
}

View File

@@ -37,29 +37,32 @@ async function generateUniqueFilename(baseName: string): Promise<string> {
}
async function ensureScriptsDirectory() {
if (!existsSync(SCRIPTS_DIR)) {
await mkdir(SCRIPTS_DIR, { recursive: true });
const scriptsDir = await SCRIPTS_DIR();
if (!existsSync(scriptsDir)) {
await mkdir(scriptsDir, { recursive: true });
}
}
async function ensureHostScriptsDirectory() {
const isDocker = process.env.DOCKER === "true";
const hostScriptsDir = isDocker
? "/app/scripts"
: join(process.cwd(), "scripts");
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
const hostScriptsDir = join(hostProjectDir, "scripts");
if (!existsSync(hostScriptsDir)) {
await mkdir(hostScriptsDir, { recursive: true });
}
}
async function saveScriptFile(filename: string, content: string) {
const isDocker = process.env.DOCKER === "true";
const scriptsDir = isDocker ? "/app/scripts" : await SCRIPTS_DIR();
await ensureScriptsDirectory();
const scriptPath = join(SCRIPTS_DIR, filename);
const scriptPath = join(scriptsDir, filename);
await writeFile(scriptPath, content, "utf8");
}
async function deleteScriptFile(filename: string) {
const scriptPath = join(SCRIPTS_DIR, filename);
const scriptPath = join(await SCRIPTS_DIR(), filename);
if (existsSync(scriptPath)) {
await unlink(scriptPath);
}
@@ -226,7 +229,11 @@ export async function cloneScript(
export async function getScriptContent(filename: string): Promise<string> {
try {
const scriptPath = join(SCRIPTS_DIR, filename);
const isDocker = process.env.DOCKER === "true";
const scriptPath = isDocker
? join("/app/scripts", filename)
: join(process.cwd(), "scripts", filename);
if (existsSync(scriptPath)) {
const content = await readFile(scriptPath, "utf8");
const lines = content.split("\n");

View File

@@ -1,15 +1,22 @@
"use server";
import { join } from "path";
const isDocker = process.env.DOCKER === "true";
const SCRIPTS_DIR = isDocker ? "/app/scripts" : join(process.cwd(), "scripts");
const SCRIPTS_DIR = async () => {
if (isDocker && process.env.HOST_PROJECT_DIR) {
return `${process.env.HOST_PROJECT_DIR}/scripts`;
}
return join(process.cwd(), "scripts");
};
export function getScriptPath(filename: string): string {
return join(SCRIPTS_DIR, filename);
export async function getScriptPath(filename: string): Promise<string> {
return join(await SCRIPTS_DIR(), filename);
}
export function getHostScriptPath(filename: string): string {
const hostProjectDir =
process.env.NEXT_PUBLIC_HOST_PROJECT_DIR || process.cwd();
export async function getHostScriptPath(filename: string): Promise<string> {
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
const hostScriptsDir = join(hostProjectDir, "scripts");
return `bash ${join(hostScriptsDir, filename)}`;
}

View File

@@ -1,4 +1,3 @@
export { getSystemInfo, type SystemInfo } from "./system/info";
export {
getCronJobs,
addCronJob,

View File

@@ -1,6 +1,6 @@
import { exec } from "child_process";
import { promisify } from "util";
import { isDocker, readCronFilesDocker, writeCronFilesDocker } from "./docker";
import { readHostCrontab, writeHostCrontab } from "./hostCrontab";
const execAsync = promisify(exec);
@@ -12,6 +12,8 @@ export interface CronJob {
}
async function readCronFiles(): Promise<string> {
const isDocker = process.env.DOCKER === "true";
if (!isDocker) {
try {
const { stdout } = await execAsync('crontab -l 2>/dev/null || echo ""');
@@ -22,10 +24,12 @@ async function readCronFiles(): Promise<string> {
}
}
return await readCronFilesDocker();
return await readHostCrontab();
}
async function writeCronFiles(content: string): Promise<boolean> {
const isDocker = process.env.DOCKER === "true";
if (!isDocker) {
try {
await execAsync('echo "' + content + '" | crontab -');
@@ -36,7 +40,7 @@ async function writeCronFiles(content: string): Promise<boolean> {
}
}
return await writeCronFilesDocker(content);
return await writeHostCrontab(content);
}
export async function getCronJobs(): Promise<CronJob[]> {
@@ -105,52 +109,19 @@ export async function addCronJob(
try {
const cronContent = await readCronFiles();
if (isDocker) {
const lines = cronContent.split("\n");
let hasUserSection = false;
let userSectionEnd = -1;
const newEntry = comment
? `# ${comment}\n${schedule} ${command}`
: `${schedule} ${command}`;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("# User: ")) {
hasUserSection = true;
userSectionEnd = i;
for (let j = i + 1; j < lines.length; j++) {
if (lines[j].startsWith("# User: ") || lines[j].startsWith("# System Crontab")) {
userSectionEnd = j - 1;
break;
}
userSectionEnd = j;
}
break;
}
}
if (!hasUserSection) {
const newEntry = comment
? `# User: root\n# ${comment}\n${schedule} ${command}`
: `# User: root\n${schedule} ${command}`;
const newCron = cronContent + "\n" + newEntry;
await writeCronFiles(newCron);
} else {
const newEntry = comment
? `# ${comment}\n${schedule} ${command}`
: `${schedule} ${command}`;
const beforeSection = lines.slice(0, userSectionEnd + 1).join("\n");
const afterSection = lines.slice(userSectionEnd + 1).join("\n");
const newCron = beforeSection + "\n" + newEntry + "\n" + afterSection;
await writeCronFiles(newCron);
}
let newCron;
if (cronContent.trim() === "") {
newCron = newEntry;
} else {
const newEntry = comment
? `# ${comment}\n${schedule} ${command}`
: `${schedule} ${command}`;
const newCron = cronContent + "\n" + newEntry;
await writeCronFiles(newCron);
const existingContent = cronContent.endsWith('\n') ? cronContent : cronContent + '\n';
newCron = existingContent + newEntry;
}
return true;
return await writeCronFiles(newCron);
} catch (error) {
console.error("Error adding cron job:", error);
return false;

View File

@@ -1,432 +0,0 @@
import { exec } from "child_process";
import { promisify } from "util";
import fs from "fs/promises";
import path from "path";
const execAsync = promisify(exec);
const isDocker = process.env.DOCKER === "true";
export async function getHostInfo(): Promise<{ hostname: string; ip: string; uptime: string }> {
if (isDocker) {
try {
const hostname = await fs.readFile("/host/etc/hostname", "utf-8");
let ipOutput = "";
try {
const { stdout } = await execAsync("hostname -I | awk '{print $1}'");
ipOutput = stdout;
} catch (error) {
try {
const fibInfo = await fs.readFile("/host/proc/net/fib_trie", "utf-8");
const lines = fibInfo.split("\n");
for (const line of lines) {
const match = line.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);
if (match && !match[1].startsWith("127.") && !match[1].startsWith("0.")) {
ipOutput = match[1];
break;
}
}
} catch (fibError) {
console.error("Could not determine IP address:", fibError);
}
}
const uptimeContent = await fs.readFile("/host/proc/uptime", "utf-8");
const uptimeSeconds = parseFloat(uptimeContent.split(" ")[0]);
const uptime = formatUptime(uptimeSeconds);
return {
hostname: hostname.trim(),
ip: ipOutput.trim(),
uptime: uptime
};
} catch (error) {
console.error("Error reading host info:", error);
const { stdout: hostname } = await execAsync("hostname");
const { stdout: ip } = await execAsync("hostname -I | awk '{print $1}'");
const { stdout: uptime } = await execAsync("uptime");
return {
hostname: hostname.trim(),
ip: ip.trim(),
uptime: parseUptimeOutput(uptime)
};
}
} else {
const { stdout: hostname } = await execAsync("hostname");
const { stdout: ip } = await execAsync("hostname -I | awk '{print $1}'");
const { stdout: uptime } = await execAsync("uptime");
return {
hostname: hostname.trim(),
ip: ip.trim(),
uptime: parseUptimeOutput(uptime)
};
}
}
export function 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`;
}
}
export function parseUptimeOutput(uptimeOutput: string): string {
const cleanOutput = uptimeOutput.trim();
const match = cleanOutput.match(/up\s+([^,]+)/);
if (!match) {
return "Unknown";
}
const timePart = match[1].trim();
const timeMatch = timePart.match(/^(\d+):(\d+)$/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
if (hours > 24) {
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
if (remainingHours > 0) {
return `${days} days, ${remainingHours} hours`;
} else {
return `${days} days`;
}
} else if (hours > 0) {
return `${hours} hours, ${minutes} minutes`;
} else {
return `${minutes} minutes`;
}
}
return timePart;
}
export function getSystemPath(originalPath: string): string {
if (isDocker) {
switch (originalPath) {
case "/etc/os-release":
return "/host/etc/os-release";
case "/proc/stat":
return "/host/proc/stat";
default:
return originalPath;
}
}
return originalPath;
}
export async function readCronFilesDocker(): Promise<string> {
try {
const crontabDir = "/host/cron/crontabs";
const files = await fs.readdir(crontabDir);
let allCronContent = "";
for (const file of files) {
if (file === "." || file === "..") continue;
if (file.includes("docker") || file.includes("container") || file === "root") {
continue;
}
try {
const filePath = path.join(crontabDir, file);
const content = await fs.readFile(filePath, "utf-8");
allCronContent += `# User: ${file}\n`;
allCronContent += content;
allCronContent += "\n\n";
} catch (fileError) {
console.error(`Error reading crontab for user ${file}:`, fileError);
}
}
return allCronContent;
} catch (error) {
console.error("Error reading host crontab files:", error);
return "";
}
}
export async function writeCronFilesDocker(cronContent: string): Promise<boolean> {
try {
const lines = cronContent.split("\n");
const userCrontabs: { [key: string]: string[] } = {};
let currentUser = "root";
let currentContent: string[] = [];
for (const line of lines) {
if (line.startsWith("# User:")) {
if (currentUser && currentContent.length > 0) {
userCrontabs[currentUser] = [...currentContent];
}
currentUser = line.substring(8).trim();
currentContent = [];
} else if (line.startsWith("# System Crontab")) {
if (currentUser && currentContent.length > 0) {
userCrontabs[currentUser] = [...currentContent];
}
currentUser = "system";
currentContent = [];
} else if (currentUser && line.trim()) {
currentContent.push(line);
}
}
if (currentUser && currentContent.length > 0) {
userCrontabs[currentUser] = [...currentContent];
}
for (const [username, cronJobs] of Object.entries(userCrontabs)) {
if (username === "system") {
const systemContent = cronJobs.join("\n") + "\n";
try {
await fs.writeFile("/host/crontab", systemContent);
} catch (error) {
console.error("Failed to write system crontab:", error);
return false;
}
} else {
const userCrontabPath = `/host/cron/crontabs/${username}`;
const userContent = cronJobs.join("\n") + "\n";
try {
await execAsync(`chown root:root ${userCrontabPath}`);
await execAsync(`chmod 666 ${userCrontabPath}`);
await fs.writeFile(userCrontabPath, userContent);
await execAsync(`chown 1000:105 ${userCrontabPath}`);
await execAsync(`chmod 600 ${userCrontabPath}`);
} catch (error) {
console.error(`Failed to write crontab for user ${username}:`, error);
return false;
}
}
}
return true;
} catch (error) {
console.error("Error writing cron files:", error);
return false;
}
}
export async function getMemoryInfoDocker() {
try {
const meminfo = await fs.readFile("/host/proc/meminfo", "utf-8");
const lines = meminfo.split("\n");
let total = 0;
let available = 0;
for (const line of lines) {
if (line.startsWith("MemTotal:")) {
total = parseInt(line.split(/\s+/)[1]) * 1024;
} else if (line.startsWith("MemAvailable:")) {
available = parseInt(line.split(/\s+/)[1]) * 1024;
}
}
if (total === 0) {
throw new Error("Could not read memory info from /proc/meminfo");
}
const actualUsed = total - available;
const usage = (actualUsed / total) * 100;
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]}`;
};
let status = "Optimal";
if (usage > 90) status = "Critical";
else if (usage > 80) status = "High";
else if (usage > 70) status = "Moderate";
return {
total: formatBytes(total),
used: formatBytes(actualUsed),
free: formatBytes(available),
usage: Math.round(usage),
status,
};
} catch (error) {
console.error("Error reading host memory info:", error);
throw error;
}
}
export async function getCPUInfoDocker() {
try {
const cpuinfo = await fs.readFile("/host/proc/cpuinfo", "utf-8");
const lines = cpuinfo.split("\n");
let model = "Unknown";
for (const line of lines) {
if (line.startsWith("model name")) {
model = line.split(":")[1]?.trim() || "Unknown";
break;
}
}
const cores = lines.filter(line => line.startsWith("processor")).length;
return { model, cores };
} catch (error) {
console.error("Error reading host CPU info:", error);
throw error;
}
}
export async function getGPUInfoDocker() {
try {
let gpuInfo = "Unknown GPU";
try {
const { stdout } = await execAsync("lspci | grep -i vga");
const gpuLines = stdout.split("\n").filter((line) => line.trim());
if (gpuLines.length > 0) {
gpuInfo = gpuLines[0].split(":")[2]?.trim() || "Unknown GPU";
}
} catch (lspciError) {
try {
const { stdout } = await execAsync("find /host/sys/devices -name 'card*' -type d | head -1");
if (stdout.trim()) {
const cardPath = stdout.trim();
const { stdout: nameOutput } = await execAsync(`cat ${cardPath}/name 2>/dev/null || echo "Unknown GPU"`);
gpuInfo = nameOutput.trim();
}
} catch (sysfsError) {
try {
const pciInfo = await fs.readFile("/host/proc/bus/pci/devices", "utf-8");
const lines = pciInfo.split("\n");
for (const line of lines) {
if (line.includes("0300")) {
const parts = line.split(/\s+/);
if (parts.length > 1) {
gpuInfo = `PCI Device ${parts[0]}`;
break;
}
}
}
} catch (pciError) {
console.log("Could not read GPU info from PCI devices:", pciError);
}
}
}
if (gpuInfo === "Unknown GPU") {
return {
model: "No dedicated GPU detected",
status: "Integrated",
};
}
return {
model: gpuInfo,
status: "Available",
};
} catch (error) {
return {
model: "Unknown",
status: "Unknown",
};
}
}
export async function getNetworkInfoDocker() {
try {
let latency = 0;
let pingOutput = "";
try {
const { stdout } = await execAsync(
'ping -c 1 -W 1 8.8.8.8 2>/dev/null || echo "timeout"'
);
pingOutput = stdout;
const lines = pingOutput.split("\n");
const timeLine = lines.find((line) => line.includes("time="));
if (timeLine) {
const match = timeLine.match(/time=(\d+\.?\d*)/);
if (match) {
latency = parseFloat(match[1]);
}
}
} catch (pingError) {
console.log("Ping failed:", pingError);
pingOutput = "timeout";
}
if (pingOutput.includes("timeout") || pingOutput.includes("100% packet loss")) {
return {
speed: "No connection",
latency: 0,
downloadSpeed: 0,
uploadSpeed: 0,
status: "Offline",
};
}
if (latency > 0) {
let downloadSpeed = 0;
let speed = "Unknown";
let status = "Stable";
if (latency < 10) {
downloadSpeed = 50;
speed = "Excellent";
status = "Optimal";
} else if (latency < 30) {
downloadSpeed = 25;
speed = "Good";
status = "Stable";
} else if (latency < 100) {
downloadSpeed = 10;
speed = "Fair";
status = "Slow";
} else {
downloadSpeed = 2;
speed = "Poor";
status = "Poor";
}
return {
speed,
latency: Math.round(latency),
downloadSpeed: Math.round(downloadSpeed * 100) / 100,
uploadSpeed: 0,
status,
};
}
return {
speed: "Unknown",
latency: 0,
downloadSpeed: 0,
uploadSpeed: 0,
status: "Unknown",
};
} catch (error) {
console.error("Network error:", error);
return {
speed: "Unknown",
latency: 0,
downloadSpeed: 0,
uploadSpeed: 0,
status: "Unknown",
};
}
}
export { isDocker };

View File

@@ -0,0 +1,92 @@
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
async function execHostCrontab(command: string): Promise<string> {
try {
const { stdout } = await execAsync(
`nsenter -t 1 -m -u -i -n -p sh -c "${command}"`
);
return stdout;
} catch (error: any) {
console.error("Error executing host crontab command:", error);
throw error;
}
}
async function getTargetUser(): Promise<string> {
try {
if (process.env.HOST_CRONTAB_USER) {
return process.env.HOST_CRONTAB_USER;
}
const { stdout } = await execAsync('stat -c "%U" /var/run/docker.sock');
const dockerSocketOwner = stdout.trim();
if (dockerSocketOwner === "root") {
try {
const projectDir = process.env.HOST_PROJECT_DIR;
if (projectDir) {
const dirOwner = await execHostCrontab(
`stat -c "%U" "${projectDir}"`
);
return dirOwner.trim();
}
} catch (error) {
console.warn("Could not detect user from project directory:", error);
}
try {
const users = await execHostCrontab(
'getent passwd | grep ":/home/" | head -1 | cut -d: -f1'
);
const firstUser = users.trim();
if (firstUser) {
return firstUser;
}
} catch (error) {
console.warn("Could not detect user from passwd:", error);
}
return "root";
}
return dockerSocketOwner;
} catch (error) {
console.error("Error detecting target user:", error);
return "root";
}
}
export async function readHostCrontab(): Promise<string> {
try {
const user = await getTargetUser();
return await execHostCrontab(
`crontab -l -u ${user} 2>/dev/null || echo ""`
);
} catch (error) {
console.error("Error reading host crontab:", error);
return "";
}
}
export async function writeHostCrontab(content: string): Promise<boolean> {
try {
const user = await getTargetUser();
let finalContent = content;
if (!finalContent.endsWith("\n")) {
finalContent += "\n";
}
const base64Content = Buffer.from(finalContent).toString("base64");
await execHostCrontab(
`echo '${base64Content}' | base64 -d | crontab -u ${user} -`
);
return true;
} catch (error) {
console.error("Error writing host crontab:", error);
return false;
}
}

View File

@@ -1,480 +0,0 @@
import { exec } from "child_process";
import { promisify } from "util";
import { readFileSync } from "fs";
import { isDocker, getSystemPath, getMemoryInfoDocker, getCPUInfoDocker, getGPUInfoDocker, getNetworkInfoDocker, getHostInfo } from "./docker";
const execAsync = promisify(exec);
function getCachedData<T>(key: string): T | null {
if (typeof window === 'undefined') return null;
try {
const cached = localStorage.getItem(key);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp > 24 * 60 * 60 * 1000) {
localStorage.removeItem(key);
return null;
}
return data;
} catch {
return null;
}
}
function setCachedData<T>(key: string, data: T): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(key, JSON.stringify({
data,
timestamp: Date.now()
}));
} catch (error) {
console.error("Error setting cached data:", error);
}
}
export interface SystemInfo {
platform: string;
hostname: string;
ip: string;
uptime: string;
memory: {
total: string;
used: string;
free: string;
usage: number;
status: string;
};
cpu: {
model: string;
cores: number;
usage: number;
status: string;
};
gpu: {
model: string;
memory?: string;
status: string;
};
network: {
speed: string;
latency: number;
downloadSpeed: number;
uploadSpeed: number;
status: string;
};
systemStatus: {
overall: string;
details: string;
};
}
async function getOSInfo(): Promise<string> {
try {
const cachedOS = getCachedData<string>('os_info');
if (cachedOS) {
return cachedOS;
}
const osReleasePath = getSystemPath("/etc/os-release");
const osRelease = readFileSync(osReleasePath, "utf8");
const lines = osRelease.split("\n");
let name = "";
let version = "";
for (const line of lines) {
if (line.startsWith("PRETTY_NAME=")) {
const osInfo = line.split("=")[1].replace(/"/g, "");
setCachedData('os_info', osInfo);
return osInfo;
}
if (line.startsWith("NAME=") && !name) {
name = line.split("=")[1].replace(/"/g, "");
}
if (line.startsWith("VERSION=") && !version) {
version = line.split("=")[1].replace(/"/g, "");
}
}
if (name && version) {
const osInfo = `${name} ${version}`;
setCachedData('os_info', osInfo);
return osInfo;
}
const { stdout } = await execAsync("uname -a");
const osInfo = stdout.trim();
setCachedData('os_info', osInfo);
return osInfo;
} catch (error) {
const { stdout } = await execAsync("uname -s -r");
const osInfo = stdout.trim();
setCachedData('os_info', osInfo);
return osInfo;
}
}
async function getMemoryInfo() {
try {
const memPath = isDocker ? "/host/proc/meminfo" : null;
if (isDocker && memPath) {
try {
return await getMemoryInfoDocker();
} catch (error) {
console.error("Error reading host memory info:", error);
}
}
const { stdout } = await execAsync("free -b");
const lines = stdout.split("\n");
const memLine = lines.find((line) => line.trim().startsWith("Mem:"));
if (!memLine) {
throw new Error("Could not find memory line in free output");
}
const parts = memLine.trim().split(/\s+/);
const total = parseInt(parts[1]);
const available = parseInt(parts[6]);
const actualUsed = total - available;
const usage = (actualUsed / total) * 100;
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]}`;
};
let status = "Optimal";
if (usage > 90) status = "Critical";
else if (usage > 80) status = "High";
else if (usage > 70) status = "Moderate";
return {
total: formatBytes(total),
used: formatBytes(actualUsed),
free: formatBytes(available),
usage: Math.round(usage),
status,
};
} catch (error) {
console.error("Error parsing memory info:", error);
return {
total: "Unknown",
used: "Unknown",
free: "Unknown",
usage: 0,
status: "Unknown",
};
}
}
async function getCPUInfo() {
try {
const cachedStatic = getCachedData<{ model: string, cores: number }>('cpu_static');
let model = "Unknown";
let cores = 0;
if (cachedStatic) {
model = cachedStatic.model;
cores = cachedStatic.cores;
} else {
if (isDocker) {
try {
const cpuInfo = await getCPUInfoDocker();
model = cpuInfo.model;
cores = cpuInfo.cores;
} catch (error) {
console.error("Error reading host CPU info:", error);
const { stdout: modelOutput } = await execAsync(
"lscpu | grep 'Model name' | cut -f 2 -d ':'"
);
model = modelOutput.trim();
const { stdout: coresOutput } = await execAsync("nproc");
cores = parseInt(coresOutput.trim());
}
} else {
const { stdout: modelOutput } = await execAsync(
"lscpu | grep 'Model name' | cut -f 2 -d ':'"
);
model = modelOutput.trim();
const { stdout: coresOutput } = await execAsync("nproc");
cores = parseInt(coresOutput.trim());
}
setCachedData('cpu_static', { model, cores });
}
const statPath = getSystemPath("/proc/stat");
const stat1 = readFileSync(statPath, "utf8").split("\n")[0];
await new Promise((resolve) => setTimeout(resolve, 50));
const stat2 = readFileSync(statPath, "utf8").split("\n")[0];
const parseCPU = (line: string) => {
const parts = line.split(/\s+/);
return {
user: parseInt(parts[1]),
nice: parseInt(parts[2]),
system: parseInt(parts[3]),
idle: parseInt(parts[4]),
iowait: parseInt(parts[5]),
irq: parseInt(parts[6]),
softirq: parseInt(parts[7]),
steal: parseInt(parts[8]),
};
};
const cpu1 = parseCPU(stat1);
const cpu2 = parseCPU(stat2);
const total1 = Object.values(cpu1).reduce((a, b) => a + b, 0);
const total2 = Object.values(cpu2).reduce((a, b) => a + b, 0);
const idle1 = cpu1.idle + cpu1.iowait;
const idle2 = cpu2.idle + cpu2.iowait;
const totalDiff = total2 - total1;
const idleDiff = idle2 - idle1;
const usage = ((totalDiff - idleDiff) / totalDiff) * 100;
let status = "Optimal";
if (usage > 90) status = "Critical";
else if (usage > 80) status = "High";
else if (usage > 70) status = "Moderate";
return {
model,
cores,
usage: Math.round(usage),
status,
};
} catch (error) {
return {
model: "Unknown",
cores: 0,
usage: 0,
status: "Unknown",
};
}
}
async function getGPUInfo() {
try {
const cachedGPU = getCachedData<{ model: string, memory?: string }>('gpu_static');
let model = "Unknown";
let memory = "";
if (cachedGPU) {
model = cachedGPU.model;
memory = cachedGPU.memory || "";
} else {
if (isDocker) {
const gpuInfo = await getGPUInfoDocker();
model = gpuInfo.model;
} else {
try {
const { stdout } = await execAsync("lspci | grep -i vga");
const gpuLines = stdout.split("\n").filter((line) => line.trim());
if (gpuLines.length > 0) {
model = gpuLines[0].split(":")[2]?.trim() || "Unknown GPU";
}
} catch (error) {
console.log("lspci not available, using fallback methods");
}
try {
const { stdout: nvidiaOutput } = await execAsync(
"nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null"
);
if (nvidiaOutput.trim()) {
const memMB = parseInt(nvidiaOutput.trim());
memory = `${Math.round(memMB / 1024)} GB`;
}
} catch (e) { }
}
setCachedData('gpu_static', { model, memory });
}
let status = "Unknown";
if (model !== "Unknown" && model !== "No dedicated GPU detected") {
status = "Available";
} else if (model === "No dedicated GPU detected") {
status = "Integrated";
}
return {
model,
memory,
status,
};
} catch (error) {
return {
model: "Unknown",
status: "Unknown",
};
}
}
async function getNetworkInfo() {
try {
if (isDocker) {
return await getNetworkInfoDocker();
} else {
const { stdout: pingOutput } = await execAsync(
'ping -c 1 -W 1 8.8.8.8 2>/dev/null || echo "timeout"'
);
if (
pingOutput.includes("timeout") ||
pingOutput.includes("100% packet loss")
) {
return {
speed: "No connection",
latency: 0,
downloadSpeed: 0,
uploadSpeed: 0,
status: "Offline",
};
}
const lines = pingOutput.split("\n");
const timeLine = lines.find((line) => line.includes("time="));
let latency = 0;
if (timeLine) {
const match = timeLine.match(/time=(\d+\.?\d*)/);
if (match) {
latency = parseFloat(match[1]);
}
}
let downloadSpeed = 0;
let speed = "Unknown";
let status = "Stable";
if (latency < 10) {
downloadSpeed = 50;
speed = "Excellent";
status = "Optimal";
} else if (latency < 30) {
downloadSpeed = 25;
speed = "Good";
status = "Stable";
} else if (latency < 100) {
downloadSpeed = 10;
speed = "Fair";
status = "Slow";
} else {
downloadSpeed = 2;
speed = "Poor";
status = "Poor";
}
return {
speed,
latency: Math.round(latency),
downloadSpeed: Math.round(downloadSpeed * 100) / 100,
uploadSpeed: 0,
status,
};
}
} catch (error) {
return {
speed: "Unknown",
latency: 0,
downloadSpeed: 0,
uploadSpeed: 0,
status: "Unknown",
};
}
}
function getSystemStatus(memory: any, cpu: any, network: any) {
const statuses = [memory.status, cpu.status, network.status];
const criticalCount = statuses.filter((s) => s === "Critical").length;
const highCount = statuses.filter((s) => s === "High").length;
let overall = "Operational";
let details = "All systems running smoothly";
if (criticalCount > 0) {
overall = "Critical";
details = "System performance issues detected";
} else if (highCount > 0) {
overall = "Warning";
details = "Some systems showing high usage";
} else if (statuses.some((s) => s === "Moderate")) {
overall = "Stable";
details = "System performance is stable";
}
return { overall, details };
}
export async function getSystemInfo(): Promise<SystemInfo> {
try {
const [
hostInfo,
platform,
memory,
cpu,
gpu,
network,
] = await Promise.all([
getHostInfo(),
getOSInfo(),
getMemoryInfo(),
getCPUInfo(),
getGPUInfo(),
getNetworkInfo(),
]);
const systemStatus = getSystemStatus(memory, cpu, network);
return {
platform,
hostname: hostInfo.hostname,
ip: hostInfo.ip || "Unknown",
uptime: hostInfo.uptime,
memory,
cpu,
gpu,
network,
systemStatus,
};
} catch (error) {
console.error("Error getting system info:", error);
return {
platform: "Unknown",
hostname: "Unknown",
ip: "Unknown",
uptime: "Unknown",
memory: {
total: "Unknown",
used: "Unknown",
free: "Unknown",
usage: 0,
status: "Unknown",
},
cpu: { model: "Unknown", cores: 0, usage: 0, status: "Unknown" },
gpu: { model: "Unknown", status: "Unknown" },
network: {
speed: "Unknown",
latency: 0,
downloadSpeed: 0,
uploadSpeed: 0,
status: "Unknown",
},
systemStatus: {
overall: "Unknown",
details: "Unable to retrieve system information",
},
};
}
}

View File

@@ -0,0 +1,169 @@
import { NextRequest, NextResponse } from 'next/server';
import * as si from 'systeminformation';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const [
osInfo,
memInfo,
cpuInfo,
diskInfo,
loadInfo,
uptimeInfo,
networkInfo
] = await Promise.all([
si.osInfo(),
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`;
}
};
const actualUsed = memInfo.active || memInfo.used;
const actualFree = memInfo.available || memInfo.free;
const memUsage = ((actualUsed / memInfo.total) * 100);
let memStatus = "Optimal";
if (memUsage > 90) memStatus = "Critical";
else if (memUsage > 80) memStatus = "High";
else if (memUsage > 70) memStatus = "Moderate";
const rootDisk = diskInfo.find(disk => disk.mount === '/') || diskInfo[0];
const diskUsage = rootDisk ? ((rootDisk.used / rootDisk.size) * 100) : 0;
let diskStatus = "Optimal";
if (diskUsage > 90) diskStatus = "Critical";
else if (diskUsage > 80) diskStatus = "High";
else if (diskUsage > 70) diskStatus = "Moderate";
const cpuStatus = loadInfo.currentLoad > 80 ? "High" :
loadInfo.currentLoad > 60 ? "Moderate" : "Optimal";
const criticalThreshold = 90;
const warningThreshold = 80;
let overallStatus = "Optimal";
let statusDetails = "All systems running normally";
if (memUsage > criticalThreshold || loadInfo.currentLoad > criticalThreshold || diskUsage > criticalThreshold) {
overallStatus = "Critical";
statusDetails = "High resource usage detected - immediate attention required";
} else if (memUsage > warningThreshold || loadInfo.currentLoad > warningThreshold || diskUsage > warningThreshold) {
overallStatus = "Warning";
statusDetails = "Moderate resource usage - monitoring recommended";
}
let mainInterface = 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`
: "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) : "Unknown",
used: rootDisk ? formatBytes(rootDisk.used) : "Unknown",
free: rootDisk ? formatBytes(rootDisk.available) : "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' ? "Connected" : "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 || "Unknown GPU",
memory: gpu.vram ? `${gpu.vram} MB` : undefined,
status: "Available",
};
} else {
systemStats.gpu = {
model: "No GPU detected",
status: "Unknown",
};
}
} catch (error) {
systemStats.gpu = {
model: "GPU detection failed",
status: "Unknown",
};
}
return NextResponse.json(systemStats);
} catch (error) {
console.error('Error fetching system stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch system stats' },
{ status: 500 }
);
}
}

View File

@@ -1,18 +1,51 @@
import { SystemInfoCard } from "./_components/SystemInfo";
import { TabbedInterface } from "./_components/TabbedInterface";
import { getSystemInfo, getCronJobs } from "./_utils/system";
import { getCronJobs } from "./_utils/system";
import { fetchScripts } from "./_server/actions/scripts";
import { ThemeToggle } from "./_components/ui/ThemeToggle";
import { ToastContainer } from "./_components/ui/Toast";
export const dynamic = "force-dynamic";
export default async function Home() {
const [systemInfo, cronJobs, scripts] = await Promise.all([
getSystemInfo(),
const [cronJobs, scripts] = await Promise.all([
getCronJobs(),
fetchScripts(),
]);
const initialSystemInfo = {
hostname: "Loading...",
platform: "Loading...",
uptime: "Loading...",
memory: {
total: "0 B",
used: "0 B",
free: "0 B",
usage: 0,
status: "Loading",
},
cpu: {
model: "Loading...",
cores: 0,
usage: 0,
status: "Loading",
},
gpu: {
model: "Loading...",
status: "Loading",
},
disk: {
total: "0 B",
used: "0 B",
free: "0 B",
usage: 0,
status: "Loading",
},
systemStatus: {
overall: "Loading",
details: "Fetching system information...",
},
};
return (
<div className="min-h-screen relative">
<div className="hero-gradient absolute inset-0 -z-10"></div>
@@ -38,7 +71,7 @@ export default async function Home() {
</div>
</header>
<SystemInfoCard systemInfo={systemInfo} />
<SystemInfoCard systemInfo={initialSystemInfo} />
<main className="lg:ml-80 transition-all duration-300 ml-0 sidebar-collapsed:lg:ml-16">
<div className="container mx-auto px-4 py-8 lg:px-8">

View File

@@ -9,34 +9,29 @@ services:
environment:
- NODE_ENV=production
- DOCKER=true
# Legacy used to be NEXT_PUBLIC_HOST_PROJECT_DIR, this was causing issues on runtime.
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
- NEXT_PUBLIC_HOST_PROJECT_DIR=/path/to/cronmaster/directory
# If docker struggles to find your crontab user, update this variable with it.
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
# - HOST_CRONTAB_USER=fccview
volumes:
# --- CRONTAB MANAGEMENT ---
# We're mounting /etc/crontab to /host/crontab in read-only mode.
# We are thenmounting /var/spool/cron/crontabs with read-write permissions to allow the application
# to manipulate the crontab file - docker does not have access to the crontab command, it's the only
# workaround I could think of.
- /var/spool/cron/crontabs:/host/cron/crontabs
- /etc/crontab:/host/crontab:ro
# Mount Docker socket to execute commands on host
- /var/run/docker.sock:/var/run/docker.sock
# --- HOST SYSTEM STATS ---
# Mounting system specific folders to their /host/ equivalent folders.
# Similar story, we don't want to override docker system folders.
# These are all mounted read-only for security.
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /etc:/host/etc:ro
- /usr:/host/usr:ro
# --- APPLICATION-SPECIFIC MOUNTS ---
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
# will target this foler (thanks to the NEXT_PUBLIC_HOST_PROJECT_DIR variable set above)
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
- ./scripts:/app/scripts
- ./data:/app/data
- ./snippets:/app/snippets
# Use host PID namespace for host command execution
# Run in privileged mode for nsenter access
pid: "host"
privileged: true
restart: unless-stopped
init: true
# Default platform is set to amd64, can be overridden by using arm64.
# Default platform is set to amd64, uncomment to use arm64.
#platform: linux/arm64

View File

@@ -1,6 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Removed standalone output for traditional Next.js deployment
}
module.exports = nextConfig

View File

@@ -34,6 +34,7 @@
"react": "^18",
"react-dom": "^18",
"react-syntax-highlighter": "^15.6.1",
"systeminformation": "^5.27.8",
"tailwind-merge": "^2.0.0",
"tailwindcss": "^3.3.0",
"typescript": "^5"

View File

@@ -1,6 +1,6 @@
# @id: demo-script
# @title: Hi, this is a demo script
# @description: This script logs a "hello world" to teach you how scripts work.
# @id: demo-script
# @title: Hi, this is a demo script
# @description: This script logs a "hello world" to teach you how scripts work.
#!/bin/bash
echo 'Hello World' > hello.txt

View File

@@ -3117,6 +3117,11 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
systeminformation@^5.27.8:
version "5.27.8"
resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.27.8.tgz#f13d180104a0df2e7222c5d4aa85aea147428de5"
integrity sha512-d3Z0gaQO1MlUxzDUKsmXz5y4TOBCMZ8IyijzaYOykV3AcNOTQ7mT+tpndUOXYNSxzLK3la8G32xiUFvZ0/s6PA==
tailwind-merge@^2.0.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"