mirror of
https://github.com/fccview/cronmaster.git
synced 2025-12-24 06:28:26 -05:00
Compare commits
5 Commits
legacy
...
bugfix/rep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc5cabb342 | ||
|
|
08d37154b4 | ||
|
|
888297c56a | ||
|
|
165f625c65 | ||
|
|
40e8f44564 |
22
Dockerfile
22
Dockerfile
@@ -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
|
||||
|
||||
74
README.md
74
README.md
@@ -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.
|
||||
@@ -38,45 +50,39 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
|
||||
services:
|
||||
cronjob-manager:
|
||||
image: ghcr.io/fccview/cronmaster:main
|
||||
container_name: cronmaster
|
||||
container_name: cronmaster-test
|
||||
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
|
||||
# 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)
|
||||
- ./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.
|
||||
#platform: linux/arm64
|
||||
|
||||
# Default platform is set to amd64, uncomment to use arm64.
|
||||
#platform: linux/arm64
|
||||
```
|
||||
|
||||
### ARM64 Support
|
||||
@@ -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.
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,47 +19,7 @@ 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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { getSystemInfo, type SystemInfo } from "./system/info";
|
||||
export {
|
||||
getCronJobs,
|
||||
addCronJob,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
83
app/_utils/system/hostCrontab.ts
Normal file
83
app/_utils/system/hostCrontab.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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.NEXT_PUBLIC_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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
169
app/api/system-stats/route.ts
Normal file
169
app/api/system-stats/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
app/page.tsx
41
app/page.tsx
@@ -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">
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
services:
|
||||
cronjob-manager:
|
||||
image: ghcr.io/fccview/cronmaster:main
|
||||
container_name: cronmaster
|
||||
container_name: cronmaster-test
|
||||
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
|
||||
# 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)
|
||||
- ./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.
|
||||
#platform: linux/arm64
|
||||
|
||||
# Default platform is set to amd64, uncomment to use arm64.
|
||||
#platform: linux/arm64
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Removed standalone output for traditional Next.js deployment
|
||||
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user