mirror of
https://github.com/fccview/cronmaster.git
synced 2025-12-24 06:28:26 -05:00
Compare commits
36 Commits
legacy
...
pr-43-bugf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8329c0d030 | ||
|
|
6e34474993 | ||
|
|
65ac81d97c | ||
|
|
968fbae13c | ||
|
|
c739d29141 | ||
|
|
389ee44e4e | ||
|
|
33ff5de463 | ||
|
|
7aeea3f46a | ||
|
|
9018f2caed | ||
|
|
7383a13c13 | ||
|
|
da11d3503e | ||
|
|
0b9edc5f11 | ||
|
|
44b31a5702 | ||
|
|
7fc8cb9edb | ||
|
|
4dfdf8fc53 | ||
|
|
8cfc000893 | ||
|
|
1dde8f839e | ||
|
|
2b7d591a95 | ||
|
|
c0a9a74d7e | ||
|
|
376147fda0 | ||
|
|
9445cdeebf | ||
|
|
170ea674c4 | ||
|
|
80bd2e713f | ||
|
|
801bcf22a2 | ||
|
|
8fd7d0d80f | ||
|
|
95f113faa6 | ||
|
|
2a437f3db8 | ||
|
|
47e19246ce | ||
|
|
29917a4cad | ||
|
|
fc5cabb342 | ||
|
|
ac31906166 | ||
|
|
b267fb9ce6 | ||
|
|
08d37154b4 | ||
|
|
888297c56a | ||
|
|
165f625c65 | ||
|
|
40e8f44564 |
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
@@ -2,8 +2,8 @@ name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "legacy", "feature/*", "bugfix/*"]
|
||||
tags: ["v*.*.*"]
|
||||
branches: ["main", "legacy", "feature/*"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,4 +10,7 @@ node_modules
|
||||
.next
|
||||
.vscode
|
||||
.DS_Store
|
||||
.cursorignore
|
||||
.cursorignore
|
||||
.idea
|
||||
tsconfig.tsbuildinfo
|
||||
docker-compose.test.yml
|
||||
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
|
||||
|
||||
124
README.md
124
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.
|
||||
@@ -37,7 +49,7 @@ 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:latest
|
||||
container_name: cronmaster
|
||||
user: "root"
|
||||
ports:
|
||||
@@ -46,36 +58,47 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DOCKER=true
|
||||
|
||||
# --- MAP HOST PROJECT DIRECTORY, THIS IS MANDATORY FOR SCRIPTS TO WORK
|
||||
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||
- NEXT_PUBLIC_HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
||||
|
||||
# --- PASSWORD PROTECTION
|
||||
# Uncomment to enable password protection (replace "very_strong_password" with your own)
|
||||
- AUTH_PASSWORD=very_strong_password
|
||||
|
||||
# --- CRONTAB USERS
|
||||
# This is used to read the crontabs for the specific user.
|
||||
# replace root with your user - find it with: ls -asl /var/spool/cron/crontabs/
|
||||
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=root,user1,user2
|
||||
- HOST_CRONTAB_USER=root
|
||||
|
||||
# --- !! IMPORTANT !!DOCKER EXEC USER
|
||||
# If you do not specify this user to be a valid user on your system,
|
||||
# any cronjob containing a docker command will fail. IDEALLY you should not be running
|
||||
# docker commands as root, so this is only a fallback. ONLY ONE USER IS ALLOWED.
|
||||
- DOCKER_EXEC_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
|
||||
# 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 ---
|
||||
# --- MOUNT DATA
|
||||
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
|
||||
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
|
||||
# will target this 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
|
||||
restart: unless-stopped
|
||||
|
||||
# --- USE HOST PID NAMESPACE FOR HOST COMMAND EXECUTION
|
||||
# --- RUN IN PRIVILEGED MODE FOR NSENTER ACCESS
|
||||
pid: "host"
|
||||
privileged: true
|
||||
restart: always
|
||||
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,8 +149,11 @@ 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 |
|
||||
| `HOST_CRONTAB_USER` | `root` | Comma separated list of users that run cronjobs on your host machine |
|
||||
| `AUTH_PASSWORD` | `N/A` | If you set a password the application will be password protected with basic next-auth |
|
||||
| `DOCKER_EXEC_USER` | `N/A` | If you don't set this user you won't be able to run docker commands as root. |
|
||||
|
||||
**Example**: To change the clock update interval to 60 seconds:
|
||||
|
||||
@@ -138,14 +164,13 @@ 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
|
||||
@@ -154,9 +179,6 @@ NEXT_PUBLIC_HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
|
||||
|
||||
The application automatically detects your operating system and displays:
|
||||
|
||||
- Platform
|
||||
- Hostname
|
||||
- IP Address
|
||||
- System Uptime
|
||||
- Memory Usage
|
||||
- CPU Information
|
||||
@@ -206,6 +228,46 @@ 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>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="20%">
|
||||
<a href="https://github.com/DVDAndroid"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/6277172?u=78aa9b049a0c1a7ae5408d22219a8a91cfe45095&v=4&size=100"><br />DVDAndroid</a>
|
||||
</td>
|
||||
<td align="center" valign="top" width="20%">
|
||||
<a href="https://github.com/ActxLeToucan"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/56509120?u=b0a684dfa1fcf8f3f41c2ead37f6441716d8bd62&v=4&size=100"><br />ActxLeToucan</a>
|
||||
</td>
|
||||
<td align="center" valign="top" width="20%">
|
||||
<a href="https://github.com/mrtimothyduong"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/34667840?u=b54354da56681c17ca58366a68a6a94c80f77a1d&v=4&size=100"><br />mrtimothyduong</a>
|
||||
</td>
|
||||
<td align="center" valign="top" width="20%">
|
||||
<a href="https://github.com/cerede2000"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/38144752?v=4&size=100"><br />cerede2000</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
@@ -213,3 +275,7 @@ This project is licensed under the MIT License.
|
||||
## Support
|
||||
|
||||
For issues and questions, please open an issue on the GitHub repository.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#fccview/cronmaster&Date)
|
||||
|
||||
@@ -16,13 +16,13 @@ interface BashEditorProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function BashEditor({
|
||||
export const BashEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "#!/bin/bash\n# Your bash script here\necho 'Hello World'",
|
||||
className = "",
|
||||
label = "Bash Script",
|
||||
}: BashEditorProps) {
|
||||
}: BashEditorProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const editorViewRef = useRef<EditorView | null>(null);
|
||||
|
||||
@@ -34,7 +34,7 @@ const categoryIcons = {
|
||||
"Custom Scripts": Code,
|
||||
};
|
||||
|
||||
export function BashSnippetHelper({ onInsertSnippet }: BashSnippetHelperProps) {
|
||||
export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
@@ -27,13 +27,13 @@ interface CronExpressionHelperProps {
|
||||
showPatterns?: boolean;
|
||||
}
|
||||
|
||||
export function CronExpressionHelper({
|
||||
export const CronExpressionHelper = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "* * * * *",
|
||||
className = "",
|
||||
showPatterns = true,
|
||||
}: CronExpressionHelperProps) {
|
||||
}: CronExpressionHelperProps) => {
|
||||
const [explanation, setExplanation] = useState<CronExplanation | null>(null);
|
||||
const [showPatternsPanel, setShowPatternsPanel] = useState(false);
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Trash2, Clock, Edit, Plus, Files } from "lucide-react";
|
||||
import { CronJob } from "@/app/_utils/system";
|
||||
import {
|
||||
removeCronJob,
|
||||
editCronJob,
|
||||
createCronJob,
|
||||
cloneCronJob,
|
||||
} from "@/app/_server/actions/cronjobs";
|
||||
import { useState } from "react";
|
||||
import { CreateTaskModal } from "./modals/CreateTaskModal";
|
||||
import { EditTaskModal } from "./modals/EditTaskModal";
|
||||
import { DeleteTaskModal } from "./modals/DeleteTaskModal";
|
||||
import { CloneTaskModal } from "./modals/CloneTaskModal";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { showToast } from "./ui/Toast";
|
||||
|
||||
interface CronJobListProps {
|
||||
cronJobs: CronJob[];
|
||||
scripts: Script[];
|
||||
}
|
||||
|
||||
export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [editingJob, setEditingJob] = useState<CronJob | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isNewCronModalOpen, setIsNewCronModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
||||
const [jobToDelete, setJobToDelete] = useState<CronJob | null>(null);
|
||||
const [jobToClone, setJobToClone] = useState<CronJob | null>(null);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
schedule: "",
|
||||
command: "",
|
||||
comment: "",
|
||||
});
|
||||
const [newCronForm, setNewCronForm] = useState({
|
||||
schedule: "",
|
||||
command: "",
|
||||
comment: "",
|
||||
selectedScriptId: null as string | null,
|
||||
});
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
const result = await removeCronJob(id);
|
||||
if (result.success) {
|
||||
showToast("success", "Cron job deleted successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to delete cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to delete cron job",
|
||||
"Please try again later."
|
||||
);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
setJobToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClone = async (newComment: string) => {
|
||||
if (!jobToClone) return;
|
||||
|
||||
setIsCloning(true);
|
||||
try {
|
||||
const result = await cloneCronJob(jobToClone.id, newComment);
|
||||
if (result.success) {
|
||||
setIsCloneModalOpen(false);
|
||||
setJobToClone(null);
|
||||
showToast("success", "Cron job cloned successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to clone cron job", result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsCloning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (job: CronJob) => {
|
||||
setJobToDelete(job);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmClone = (job: CronJob) => {
|
||||
setJobToClone(job);
|
||||
setIsCloneModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (job: CronJob) => {
|
||||
setEditingJob(job);
|
||||
setEditForm({
|
||||
schedule: job.schedule,
|
||||
command: job.command,
|
||||
comment: job.comment || "",
|
||||
});
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingJob) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("id", editingJob.id);
|
||||
formData.append("schedule", editForm.schedule);
|
||||
formData.append("command", editForm.command);
|
||||
formData.append("comment", editForm.comment);
|
||||
|
||||
const result = await editCronJob(formData);
|
||||
if (result.success) {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingJob(null);
|
||||
showToast("success", "Cron job updated successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to update cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to update cron job",
|
||||
"Please try again later."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewCronSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("schedule", newCronForm.schedule);
|
||||
formData.append("command", newCronForm.command);
|
||||
formData.append("comment", newCronForm.comment);
|
||||
if (newCronForm.selectedScriptId) {
|
||||
formData.append("selectedScriptId", newCronForm.selectedScriptId);
|
||||
}
|
||||
|
||||
const result = await createCronJob(formData);
|
||||
if (result.success) {
|
||||
setIsNewCronModalOpen(false);
|
||||
setNewCronForm({
|
||||
schedule: "",
|
||||
command: "",
|
||||
comment: "",
|
||||
selectedScriptId: null,
|
||||
});
|
||||
showToast("success", "Cron job created successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to create cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to create cron job",
|
||||
"Please try again later."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl brand-gradient">
|
||||
Scheduled Tasks
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{cronJobs.length} scheduled job
|
||||
{cronJobs.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsNewCronModalOpen(true)}
|
||||
className="btn-primary glow-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{cronJobs.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="mx-auto w-20 h-20 bg-gradient-to-br from-primary/20 to-blue-500/20 rounded-full flex items-center justify-center mb-6">
|
||||
<Clock className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3 brand-gradient">
|
||||
No scheduled tasks yet
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
Create your first scheduled task to automate your system
|
||||
operations and boost productivity.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setIsNewCronModalOpen(true)}
|
||||
className="btn-primary glow-primary"
|
||||
size="lg"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Create Your First Task
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{cronJobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="glass-card p-4 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<code className="text-sm bg-purple-500/10 text-purple-600 dark:text-purple-400 px-2 py-1 rounded font-mono border border-purple-500/20">
|
||||
{job.schedule}
|
||||
</code>
|
||||
<div className="flex-1 min-w-0">
|
||||
<pre
|
||||
className="text-sm font-medium text-foreground truncate bg-muted/30 px-2 py-1 rounded border border-border/30"
|
||||
title={job.command}
|
||||
>
|
||||
{job.command}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.comment && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground italic truncate"
|
||||
title={job.comment}
|
||||
>
|
||||
{job.comment}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(job)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Edit cron job"
|
||||
aria-label="Edit cron job"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmClone(job)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Clone cron job"
|
||||
aria-label="Clone cron job"
|
||||
>
|
||||
<Files className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(job)}
|
||||
disabled={deletingId === job.id}
|
||||
className="btn-destructive h-8 px-3"
|
||||
title="Delete cron job"
|
||||
aria-label="Delete cron job"
|
||||
>
|
||||
{deletingId === job.id ? (
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateTaskModal
|
||||
isOpen={isNewCronModalOpen}
|
||||
onClose={() => setIsNewCronModalOpen(false)}
|
||||
onSubmit={handleNewCronSubmit}
|
||||
scripts={scripts}
|
||||
form={newCronForm}
|
||||
onFormChange={(updates) =>
|
||||
setNewCronForm((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
/>
|
||||
|
||||
<EditTaskModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => setIsEditModalOpen(false)}
|
||||
onSubmit={handleEditSubmit}
|
||||
form={editForm}
|
||||
onFormChange={(updates) =>
|
||||
setEditForm((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
/>
|
||||
|
||||
<DeleteTaskModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={() =>
|
||||
jobToDelete ? handleDelete(jobToDelete.id) : undefined
|
||||
}
|
||||
job={jobToDelete}
|
||||
/>
|
||||
|
||||
<CloneTaskModal
|
||||
cronJob={jobToClone}
|
||||
isOpen={isCloneModalOpen}
|
||||
onClose={() => setIsCloneModalOpen(false)}
|
||||
onConfirm={handleClone}
|
||||
isCloning={isCloning}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,9 +31,9 @@ interface ScriptsManagerProps {
|
||||
scripts: Script[];
|
||||
}
|
||||
|
||||
export function ScriptsManager({
|
||||
export const ScriptsManager = ({
|
||||
scripts: initialScripts,
|
||||
}: ScriptsManagerProps) {
|
||||
}: ScriptsManagerProps) => {
|
||||
const [scripts, setScripts] = useState<Script[]>(initialScripts);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
|
||||
@@ -1,39 +1,83 @@
|
||||
"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;
|
||||
}
|
||||
|
||||
export function SystemInfoCard({
|
||||
export const SystemInfoCard = ({
|
||||
systemInfo: initialSystemInfo,
|
||||
}: SystemInfoCardProps) {
|
||||
}: SystemInfoCardProps) => {
|
||||
const [currentTime, setCurrentTime] = useState<string>("");
|
||||
const [systemInfo, setSystemInfo] =
|
||||
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 && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CronJobList } from "./CronJobList";
|
||||
import { CronJobList } from "./features/Cronjobs/CronJobList";
|
||||
import { ScriptsManager } from "./ScriptsManager";
|
||||
import { CronJob } from "@/app/_utils/system";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
@@ -12,7 +12,10 @@ interface TabbedInterfaceProps {
|
||||
scripts: Script[];
|
||||
}
|
||||
|
||||
export function TabbedInterface({ cronJobs, scripts }: TabbedInterfaceProps) {
|
||||
export const TabbedInterface = ({
|
||||
cronJobs,
|
||||
scripts,
|
||||
}: TabbedInterfaceProps) => {
|
||||
const [activeTab, setActiveTab] = useState<"cronjobs" | "scripts">(
|
||||
"cronjobs"
|
||||
);
|
||||
@@ -61,4 +64,4 @@ export function TabbedInterface({ cronJobs, scripts }: TabbedInterfaceProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
514
app/_components/features/Cronjobs/CronJobList.tsx
Normal file
514
app/_components/features/Cronjobs/CronJobList.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../ui/Card";
|
||||
import { Button } from "../../ui/Button";
|
||||
import {
|
||||
Trash2,
|
||||
Clock,
|
||||
Edit,
|
||||
Plus,
|
||||
Files,
|
||||
User,
|
||||
Play,
|
||||
Pause,
|
||||
Code,
|
||||
} from "lucide-react";
|
||||
import { CronJob } from "@/app/_utils/system";
|
||||
import {
|
||||
removeCronJob,
|
||||
editCronJob,
|
||||
createCronJob,
|
||||
cloneCronJob,
|
||||
pauseCronJobAction,
|
||||
resumeCronJobAction,
|
||||
runCronJob,
|
||||
} from "@/app/_server/actions/cronjobs";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { CreateTaskModal } from "../../modals/CreateTaskModal";
|
||||
import { EditTaskModal } from "../../modals/EditTaskModal";
|
||||
import { DeleteTaskModal } from "../../modals/DeleteTaskModal";
|
||||
import { CloneTaskModal } from "../../modals/CloneTaskModal";
|
||||
import { UserFilter } from "../../ui/UserFilter";
|
||||
import { ErrorBadge } from "../../ui/ErrorBadge";
|
||||
import { ErrorDetailsModal } from "../../modals/ErrorDetailsModal";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { showToast } from "../../ui/Toast";
|
||||
import {
|
||||
getJobErrorsByJobId,
|
||||
setJobError,
|
||||
JobError,
|
||||
} from "@/app/_utils/errorState";
|
||||
import {
|
||||
handleErrorClick,
|
||||
refreshJobErrors,
|
||||
handleDelete,
|
||||
handleClone,
|
||||
handlePause,
|
||||
handleResume,
|
||||
handleRun,
|
||||
handleEditSubmit,
|
||||
handleNewCronSubmit,
|
||||
} from "./helpers";
|
||||
|
||||
interface CronJobListProps {
|
||||
cronJobs: CronJob[];
|
||||
scripts: Script[];
|
||||
}
|
||||
|
||||
export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [editingJob, setEditingJob] = useState<CronJob | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isNewCronModalOpen, setIsNewCronModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
||||
const [jobToDelete, setJobToDelete] = useState<CronJob | null>(null);
|
||||
const [jobToClone, setJobToClone] = useState<CronJob | null>(null);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [runningJobId, setRunningJobId] = useState<string | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||
const [jobErrors, setJobErrors] = useState<Record<string, JobError[]>>({});
|
||||
const [errorModalOpen, setErrorModalOpen] = useState(false);
|
||||
const [selectedError, setSelectedError] = useState<JobError | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem("selectedCronUser");
|
||||
if (savedUser) {
|
||||
setSelectedUser(savedUser);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUser) {
|
||||
localStorage.setItem("selectedCronUser", selectedUser);
|
||||
} else {
|
||||
localStorage.removeItem("selectedCronUser");
|
||||
}
|
||||
}, [selectedUser]);
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
schedule: "",
|
||||
command: "",
|
||||
comment: "",
|
||||
});
|
||||
const [newCronForm, setNewCronForm] = useState({
|
||||
schedule: "",
|
||||
command: "",
|
||||
comment: "",
|
||||
selectedScriptId: null as string | null,
|
||||
user: "",
|
||||
});
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
if (!selectedUser) return cronJobs;
|
||||
return cronJobs.filter((job) => job.user === selectedUser);
|
||||
}, [cronJobs, selectedUser]);
|
||||
|
||||
useEffect(() => {
|
||||
const errors: Record<string, JobError[]> = {};
|
||||
filteredJobs.forEach((job) => {
|
||||
errors[job.id] = getJobErrorsByJobId(job.id);
|
||||
});
|
||||
setJobErrors(errors);
|
||||
}, [filteredJobs]);
|
||||
|
||||
const handleErrorClickLocal = (error: JobError) => {
|
||||
handleErrorClick(error, setSelectedError, setErrorModalOpen);
|
||||
};
|
||||
|
||||
const refreshJobErrorsLocal = () => {
|
||||
const errors: Record<string, JobError[]> = {};
|
||||
filteredJobs.forEach((job) => {
|
||||
errors[job.id] = getJobErrorsByJobId(job.id);
|
||||
});
|
||||
setJobErrors(errors);
|
||||
};
|
||||
|
||||
const handleDeleteLocal = async (id: string) => {
|
||||
await handleDelete(id, {
|
||||
setDeletingId,
|
||||
setIsDeleteModalOpen,
|
||||
setJobToDelete,
|
||||
setIsCloneModalOpen,
|
||||
setJobToClone,
|
||||
setIsCloning,
|
||||
setIsEditModalOpen,
|
||||
setEditingJob,
|
||||
setIsNewCronModalOpen,
|
||||
setNewCronForm,
|
||||
setRunningJobId,
|
||||
refreshJobErrors: refreshJobErrorsLocal,
|
||||
jobToClone,
|
||||
editingJob,
|
||||
editForm,
|
||||
newCronForm,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloneLocal = async (newComment: string) => {
|
||||
await handleClone(newComment, {
|
||||
setDeletingId,
|
||||
setIsDeleteModalOpen,
|
||||
setJobToDelete,
|
||||
setIsCloneModalOpen,
|
||||
setJobToClone,
|
||||
setIsCloning,
|
||||
setIsEditModalOpen,
|
||||
setEditingJob,
|
||||
setIsNewCronModalOpen,
|
||||
setNewCronForm,
|
||||
setRunningJobId,
|
||||
refreshJobErrors: refreshJobErrorsLocal,
|
||||
jobToClone,
|
||||
editingJob,
|
||||
editForm,
|
||||
newCronForm,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePauseLocal = async (id: string) => {
|
||||
await handlePause(id);
|
||||
};
|
||||
|
||||
const handleResumeLocal = async (id: string) => {
|
||||
await handleResume(id);
|
||||
};
|
||||
|
||||
const handleRunLocal = async (id: string) => {
|
||||
await handleRun(id, {
|
||||
setDeletingId,
|
||||
setIsDeleteModalOpen,
|
||||
setJobToDelete,
|
||||
setIsCloneModalOpen,
|
||||
setJobToClone,
|
||||
setIsCloning,
|
||||
setIsEditModalOpen,
|
||||
setEditingJob,
|
||||
setIsNewCronModalOpen,
|
||||
setNewCronForm,
|
||||
setRunningJobId,
|
||||
refreshJobErrors: refreshJobErrorsLocal,
|
||||
jobToClone,
|
||||
editingJob,
|
||||
editForm,
|
||||
newCronForm,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDelete = (job: CronJob) => {
|
||||
setJobToDelete(job);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmClone = (job: CronJob) => {
|
||||
setJobToClone(job);
|
||||
setIsCloneModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (job: CronJob) => {
|
||||
setEditingJob(job);
|
||||
setEditForm({
|
||||
schedule: job.schedule,
|
||||
command: job.command,
|
||||
comment: job.comment || "",
|
||||
});
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSubmitLocal = async (e: React.FormEvent) => {
|
||||
await handleEditSubmit(e, {
|
||||
setDeletingId,
|
||||
setIsDeleteModalOpen,
|
||||
setJobToDelete,
|
||||
setIsCloneModalOpen,
|
||||
setJobToClone,
|
||||
setIsCloning,
|
||||
setIsEditModalOpen,
|
||||
setEditingJob,
|
||||
setIsNewCronModalOpen,
|
||||
setNewCronForm,
|
||||
setRunningJobId,
|
||||
refreshJobErrors: refreshJobErrorsLocal,
|
||||
jobToClone,
|
||||
editingJob,
|
||||
editForm,
|
||||
newCronForm,
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewCronSubmitLocal = async (e: React.FormEvent) => {
|
||||
await handleNewCronSubmit(e, {
|
||||
setDeletingId,
|
||||
setIsDeleteModalOpen,
|
||||
setJobToDelete,
|
||||
setIsCloneModalOpen,
|
||||
setJobToClone,
|
||||
setIsCloning,
|
||||
setIsEditModalOpen,
|
||||
setEditingJob,
|
||||
setIsNewCronModalOpen,
|
||||
setNewCronForm,
|
||||
setRunningJobId,
|
||||
refreshJobErrors: refreshJobErrorsLocal,
|
||||
jobToClone,
|
||||
editingJob,
|
||||
editForm,
|
||||
newCronForm,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl brand-gradient">
|
||||
Scheduled Tasks
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredJobs.length} of {cronJobs.length} scheduled job
|
||||
{filteredJobs.length !== 1 ? "s" : ""}
|
||||
{selectedUser && ` for ${selectedUser}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsNewCronModalOpen(true)}
|
||||
className="btn-primary glow-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<UserFilter
|
||||
selectedUser={selectedUser}
|
||||
onUserChange={setSelectedUser}
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredJobs.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="mx-auto w-20 h-20 bg-gradient-to-br from-primary/20 to-blue-500/20 rounded-full flex items-center justify-center mb-6">
|
||||
<Clock className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3 brand-gradient">
|
||||
{selectedUser
|
||||
? `No tasks for user ${selectedUser}`
|
||||
: "No scheduled tasks yet"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
{selectedUser
|
||||
? `No scheduled tasks found for user ${selectedUser}. Try selecting a different user or create a new task.`
|
||||
: "Create your first scheduled task to automate your system operations and boost productivity."}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setIsNewCronModalOpen(true)}
|
||||
className="btn-primary glow-primary"
|
||||
size="lg"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Create Your First Task
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredJobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="glass-card p-4 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<div className="flex-1 min-w-0 order-2 lg:order-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<code className="text-sm bg-purple-500/10 text-purple-600 dark:text-purple-400 px-2 py-1 rounded font-mono border border-purple-500/20">
|
||||
{job.schedule}
|
||||
</code>
|
||||
<div className="flex-1 min-w-0">
|
||||
<pre
|
||||
className="text-sm font-medium text-foreground truncate bg-muted/30 px-2 py-1 rounded border border-border/30"
|
||||
title={job.command}
|
||||
>
|
||||
{job.command}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{job.user}</span>
|
||||
</div>
|
||||
{job.paused && (
|
||||
<span className="text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 px-2 py-0.5 rounded border border-yellow-500/20">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
<ErrorBadge
|
||||
errors={jobErrors[job.id] || []}
|
||||
onErrorClick={handleErrorClickLocal}
|
||||
onErrorDismiss={refreshJobErrorsLocal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{job.comment && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground italic truncate"
|
||||
title={job.comment}
|
||||
>
|
||||
{job.comment}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0 order-1 lg:order-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRunLocal(job.id)}
|
||||
disabled={runningJobId === job.id || job.paused}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Run cron job manually"
|
||||
aria-label="Run cron job manually"
|
||||
>
|
||||
{runningJobId === job.id ? (
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<Code className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(job)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Edit cron job"
|
||||
aria-label="Edit cron job"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmClone(job)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Clone cron job"
|
||||
aria-label="Clone cron job"
|
||||
>
|
||||
<Files className="h-3 w-3" />
|
||||
</Button>
|
||||
{job.paused ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResumeLocal(job.id)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Resume cron job"
|
||||
aria-label="Resume cron job"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePauseLocal(job.id)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Pause cron job"
|
||||
aria-label="Pause cron job"
|
||||
>
|
||||
<Pause className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(job)}
|
||||
disabled={deletingId === job.id}
|
||||
className="btn-destructive h-8 px-3"
|
||||
title="Delete cron job"
|
||||
aria-label="Delete cron job"
|
||||
>
|
||||
{deletingId === job.id ? (
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateTaskModal
|
||||
isOpen={isNewCronModalOpen}
|
||||
onClose={() => setIsNewCronModalOpen(false)}
|
||||
onSubmit={handleNewCronSubmitLocal}
|
||||
scripts={scripts}
|
||||
form={newCronForm}
|
||||
onFormChange={(updates) =>
|
||||
setNewCronForm((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
/>
|
||||
|
||||
<EditTaskModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => setIsEditModalOpen(false)}
|
||||
onSubmit={handleEditSubmitLocal}
|
||||
form={editForm}
|
||||
onFormChange={(updates) =>
|
||||
setEditForm((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
/>
|
||||
|
||||
<DeleteTaskModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={() =>
|
||||
jobToDelete ? handleDeleteLocal(jobToDelete.id) : undefined
|
||||
}
|
||||
job={jobToDelete}
|
||||
/>
|
||||
|
||||
<CloneTaskModal
|
||||
cronJob={jobToClone}
|
||||
isOpen={isCloneModalOpen}
|
||||
onClose={() => setIsCloneModalOpen(false)}
|
||||
onConfirm={handleCloneLocal}
|
||||
isCloning={isCloning}
|
||||
/>
|
||||
|
||||
{errorModalOpen && selectedError && (
|
||||
<ErrorDetailsModal
|
||||
isOpen={errorModalOpen}
|
||||
onClose={() => {
|
||||
setErrorModalOpen(false);
|
||||
setSelectedError(null);
|
||||
}}
|
||||
error={{
|
||||
title: selectedError.title,
|
||||
message: selectedError.message,
|
||||
details: selectedError.details,
|
||||
command: selectedError.command,
|
||||
output: selectedError.output,
|
||||
stderr: selectedError.stderr,
|
||||
timestamp: selectedError.timestamp,
|
||||
jobId: selectedError.jobId,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
359
app/_components/features/Cronjobs/helpers/index.tsx
Normal file
359
app/_components/features/Cronjobs/helpers/index.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { JobError, setJobError } from "@/app/_utils/errorState";
|
||||
import { showToast } from "@/app/_components/ui/Toast";
|
||||
import {
|
||||
removeCronJob,
|
||||
editCronJob,
|
||||
createCronJob,
|
||||
cloneCronJob,
|
||||
pauseCronJobAction,
|
||||
resumeCronJobAction,
|
||||
runCronJob,
|
||||
} from "@/app/_server/actions/cronjobs";
|
||||
import { CronJob } from "@/app/_utils/system";
|
||||
|
||||
interface HandlerProps {
|
||||
setDeletingId: (id: string | null) => void;
|
||||
setIsDeleteModalOpen: (open: boolean) => void;
|
||||
setJobToDelete: (job: CronJob | null) => void;
|
||||
setIsCloneModalOpen: (open: boolean) => void;
|
||||
setJobToClone: (job: CronJob | null) => void;
|
||||
setIsCloning: (cloning: boolean) => void;
|
||||
setIsEditModalOpen: (open: boolean) => void;
|
||||
setEditingJob: (job: CronJob | null) => void;
|
||||
setIsNewCronModalOpen: (open: boolean) => void;
|
||||
setNewCronForm: (form: any) => void;
|
||||
setRunningJobId: (id: string | null) => void;
|
||||
refreshJobErrors: () => void;
|
||||
jobToClone: CronJob | null;
|
||||
editingJob: CronJob | null;
|
||||
editForm: {
|
||||
schedule: string;
|
||||
command: string;
|
||||
comment: string;
|
||||
};
|
||||
newCronForm: {
|
||||
schedule: string;
|
||||
command: string;
|
||||
comment: string;
|
||||
selectedScriptId: string | null;
|
||||
user: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const handleErrorClick = (
|
||||
error: JobError,
|
||||
setSelectedError: (error: JobError | null) => void,
|
||||
setErrorModalOpen: (open: boolean) => void
|
||||
) => {
|
||||
setSelectedError(error);
|
||||
setErrorModalOpen(true);
|
||||
};
|
||||
|
||||
export const refreshJobErrors = (
|
||||
filteredJobs: CronJob[],
|
||||
getJobErrorsByJobId: (jobId: string) => JobError[],
|
||||
setJobErrors: (errors: Record<string, JobError[]>) => void
|
||||
) => {
|
||||
const errors: Record<string, JobError[]> = {};
|
||||
filteredJobs.forEach((job) => {
|
||||
errors[job.id] = getJobErrorsByJobId(job.id);
|
||||
});
|
||||
setJobErrors(errors);
|
||||
};
|
||||
|
||||
export const handleDelete = async (id: string, props: HandlerProps) => {
|
||||
const {
|
||||
setDeletingId,
|
||||
setIsDeleteModalOpen,
|
||||
setJobToDelete,
|
||||
refreshJobErrors,
|
||||
} = props;
|
||||
|
||||
setDeletingId(id);
|
||||
try {
|
||||
const result = await removeCronJob(id);
|
||||
if (result.success) {
|
||||
showToast("success", "Cron job deleted successfully");
|
||||
} else {
|
||||
const errorId = `delete-${id}-${Date.now()}`;
|
||||
const jobError: JobError = {
|
||||
id: errorId,
|
||||
title: "Failed to delete cron job",
|
||||
message: result.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
jobId: id,
|
||||
};
|
||||
setJobError(jobError);
|
||||
refreshJobErrors();
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to delete cron job",
|
||||
result.message,
|
||||
undefined,
|
||||
{
|
||||
title: jobError.title,
|
||||
message: jobError.message,
|
||||
timestamp: jobError.timestamp,
|
||||
jobId: jobError.jobId,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorId = `delete-${id}-${Date.now()}`;
|
||||
const jobError: JobError = {
|
||||
id: errorId,
|
||||
title: "Failed to delete cron job",
|
||||
message: error.message || "Please try again later.",
|
||||
details: error.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
jobId: id,
|
||||
};
|
||||
setJobError(jobError);
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to delete cron job",
|
||||
"Please try again later.",
|
||||
undefined,
|
||||
{
|
||||
title: jobError.title,
|
||||
message: jobError.message,
|
||||
details: jobError.details,
|
||||
timestamp: jobError.timestamp,
|
||||
jobId: jobError.jobId,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
setJobToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleClone = async (newComment: string, props: HandlerProps) => {
|
||||
const { jobToClone, setIsCloneModalOpen, setJobToClone, setIsCloning } =
|
||||
props;
|
||||
|
||||
if (!jobToClone) return;
|
||||
|
||||
setIsCloning(true);
|
||||
try {
|
||||
const result = await cloneCronJob(jobToClone.id, newComment);
|
||||
if (result.success) {
|
||||
setIsCloneModalOpen(false);
|
||||
setJobToClone(null);
|
||||
showToast("success", "Cron job cloned successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to clone cron job", result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsCloning(false);
|
||||
}
|
||||
};
|
||||
|
||||
export const handlePause = async (id: string) => {
|
||||
try {
|
||||
const result = await pauseCronJobAction(id);
|
||||
if (result.success) {
|
||||
showToast("success", "Cron job paused successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to pause cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast("error", "Failed to pause cron job", "Please try again later.");
|
||||
}
|
||||
};
|
||||
|
||||
export const handleResume = async (id: string) => {
|
||||
try {
|
||||
const result = await resumeCronJobAction(id);
|
||||
if (result.success) {
|
||||
showToast("success", "Cron job resumed successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to resume cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast("error", "Failed to resume cron job", "Please try again later.");
|
||||
}
|
||||
};
|
||||
|
||||
export const handleRun = async (id: string, props: HandlerProps) => {
|
||||
const { setRunningJobId, refreshJobErrors } = props;
|
||||
|
||||
setRunningJobId(id);
|
||||
try {
|
||||
const result = await runCronJob(id);
|
||||
if (result.success) {
|
||||
showToast("success", "Cron job executed successfully");
|
||||
} else {
|
||||
const errorId = `run-${id}-${Date.now()}`;
|
||||
const jobError: JobError = {
|
||||
id: errorId,
|
||||
title: "Failed to execute cron job",
|
||||
message: result.message,
|
||||
output: result.output,
|
||||
timestamp: new Date().toISOString(),
|
||||
jobId: id,
|
||||
};
|
||||
setJobError(jobError);
|
||||
refreshJobErrors();
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to execute cron job",
|
||||
result.message,
|
||||
undefined,
|
||||
{
|
||||
title: jobError.title,
|
||||
message: jobError.message,
|
||||
output: jobError.output,
|
||||
timestamp: jobError.timestamp,
|
||||
jobId: jobError.jobId,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorId = `run-${id}-${Date.now()}`;
|
||||
const jobError: JobError = {
|
||||
id: errorId,
|
||||
title: "Failed to execute cron job",
|
||||
message: error.message || "Please try again later.",
|
||||
details: error.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
jobId: id,
|
||||
};
|
||||
setJobError(jobError);
|
||||
refreshJobErrors();
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to execute cron job",
|
||||
"Please try again later.",
|
||||
undefined,
|
||||
{
|
||||
title: jobError.title,
|
||||
message: jobError.message,
|
||||
details: jobError.details,
|
||||
timestamp: jobError.timestamp,
|
||||
jobId: jobError.jobId,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
setRunningJobId(null);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleEditSubmit = async (
|
||||
e: React.FormEvent,
|
||||
props: HandlerProps
|
||||
) => {
|
||||
const {
|
||||
editingJob,
|
||||
editForm,
|
||||
setIsEditModalOpen,
|
||||
setEditingJob,
|
||||
refreshJobErrors,
|
||||
} = props;
|
||||
|
||||
e.preventDefault();
|
||||
if (!editingJob) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("id", editingJob.id);
|
||||
formData.append("schedule", editForm.schedule);
|
||||
formData.append("command", editForm.command);
|
||||
formData.append("comment", editForm.comment);
|
||||
|
||||
const result = await editCronJob(formData);
|
||||
if (result.success) {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingJob(null);
|
||||
showToast("success", "Cron job updated successfully");
|
||||
} else {
|
||||
const errorId = `edit-${editingJob.id}-${Date.now()}`;
|
||||
const jobError: JobError = {
|
||||
id: errorId,
|
||||
title: "Failed to update cron job",
|
||||
message: result.message,
|
||||
details: result.details,
|
||||
timestamp: new Date().toISOString(),
|
||||
jobId: editingJob.id,
|
||||
};
|
||||
setJobError(jobError);
|
||||
refreshJobErrors();
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to update cron job",
|
||||
result.message,
|
||||
undefined,
|
||||
{
|
||||
title: jobError.title,
|
||||
message: jobError.message,
|
||||
details: jobError.details,
|
||||
timestamp: jobError.timestamp,
|
||||
jobId: jobError.jobId,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorId = `edit-${editingJob?.id || "unknown"}-${Date.now()}`;
|
||||
const jobError: JobError = {
|
||||
id: errorId,
|
||||
title: "Failed to update cron job",
|
||||
message: error.message || "Please try again later.",
|
||||
details: error.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
jobId: editingJob?.id || "unknown",
|
||||
};
|
||||
setJobError(jobError);
|
||||
refreshJobErrors();
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to update cron job",
|
||||
"Please try again later.",
|
||||
undefined,
|
||||
{
|
||||
title: jobError.title,
|
||||
message: jobError.message,
|
||||
details: jobError.details,
|
||||
timestamp: jobError.timestamp,
|
||||
jobId: jobError.jobId,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleNewCronSubmit = async (
|
||||
e: React.FormEvent,
|
||||
props: HandlerProps
|
||||
) => {
|
||||
const { newCronForm, setIsNewCronModalOpen, setNewCronForm } = props;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("schedule", newCronForm.schedule);
|
||||
formData.append("command", newCronForm.command);
|
||||
formData.append("comment", newCronForm.comment);
|
||||
formData.append("user", newCronForm.user);
|
||||
if (newCronForm.selectedScriptId) {
|
||||
formData.append("selectedScriptId", newCronForm.selectedScriptId);
|
||||
}
|
||||
|
||||
const result = await createCronJob(formData);
|
||||
if (result.success) {
|
||||
setIsNewCronModalOpen(false);
|
||||
setNewCronForm({
|
||||
schedule: "",
|
||||
command: "",
|
||||
comment: "",
|
||||
selectedScriptId: null,
|
||||
user: "",
|
||||
});
|
||||
showToast("success", "Cron job created successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to create cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast("error", "Failed to create cron job", "Please try again later.");
|
||||
}
|
||||
};
|
||||
99
app/_components/features/LoginForm/LoginForm.tsx
Normal file
99
app/_components/features/LoginForm/LoginForm.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "../../ui/Button";
|
||||
import { Input } from "../../ui/Input";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../../ui/Card";
|
||||
import { Lock, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
|
||||
export const LoginForm = () => {
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
router.push("/");
|
||||
} else {
|
||||
setError(result.message || "Login failed");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("An error occurred. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md shadow-xl">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
||||
<Lock className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Welcome to Cr*nMaster</CardTitle>
|
||||
<CardDescription>Enter your password to continue</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="pr-10"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-md p-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !password.trim()}
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, FileText } from "lucide-react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { Input } from "../ui/Input";
|
||||
@@ -15,18 +15,18 @@ interface CloneScriptModalProps {
|
||||
isCloning: boolean;
|
||||
}
|
||||
|
||||
export function CloneScriptModal({
|
||||
export const CloneScriptModal = ({
|
||||
script,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isCloning,
|
||||
}: CloneScriptModalProps) {
|
||||
}: CloneScriptModalProps) => {
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (newName.trim()) {
|
||||
onConfirm(newName.trim());
|
||||
|
||||
@@ -15,18 +15,18 @@ interface CloneTaskModalProps {
|
||||
isCloning: boolean;
|
||||
}
|
||||
|
||||
export function CloneTaskModal({
|
||||
export const CloneTaskModal = ({
|
||||
cronJob,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isCloning,
|
||||
}: CloneTaskModalProps) {
|
||||
}: CloneTaskModalProps) => {
|
||||
const [newComment, setNewComment] = useState("");
|
||||
|
||||
if (!isOpen || !cronJob) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (newComment.trim()) {
|
||||
onConfirm(newComment.trim());
|
||||
|
||||
@@ -17,13 +17,13 @@ interface CreateScriptModalProps {
|
||||
onFormChange: (updates: Partial<CreateScriptModalProps["form"]>) => void;
|
||||
}
|
||||
|
||||
export function CreateScriptModal({
|
||||
export const CreateScriptModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
form,
|
||||
onFormChange,
|
||||
}: CreateScriptModalProps) {
|
||||
}: CreateScriptModalProps) => {
|
||||
return (
|
||||
<ScriptModal
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { CronExpressionHelper } from "../CronExpressionHelper";
|
||||
import { SelectScriptModal } from "./SelectScriptModal";
|
||||
import { UserSwitcher } from "../ui/UserSwitcher";
|
||||
import { Plus, Terminal, FileText, X } from "lucide-react";
|
||||
import { getScriptContent } from "@/app/_server/actions/scripts";
|
||||
import { getHostScriptPath } from "@/app/_utils/scripts";
|
||||
@@ -21,25 +22,26 @@ interface Script {
|
||||
interface CreateTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
scripts: Script[];
|
||||
form: {
|
||||
schedule: string;
|
||||
command: string;
|
||||
comment: string;
|
||||
selectedScriptId: string | null;
|
||||
user: string;
|
||||
};
|
||||
onFormChange: (updates: Partial<CreateTaskModalProps["form"]>) => void;
|
||||
}
|
||||
|
||||
export function CreateTaskModal({
|
||||
export const CreateTaskModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
scripts,
|
||||
form,
|
||||
onFormChange,
|
||||
}: CreateTaskModalProps) {
|
||||
}: CreateTaskModalProps) => {
|
||||
const [selectedScriptContent, setSelectedScriptContent] =
|
||||
useState<string>("");
|
||||
const [isSelectScriptModalOpen, setIsSelectScriptModalOpen] = useState(false);
|
||||
@@ -58,10 +60,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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -88,6 +90,16 @@ export function CreateTaskModal({
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
User
|
||||
</label>
|
||||
<UserSwitcher
|
||||
selectedUser={form.user}
|
||||
onUserChange={(user) => onFormChange({ user })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Schedule
|
||||
@@ -108,11 +120,10 @@ export function CreateTaskModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomCommand}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
!form.selectedScriptId
|
||||
className={`p-4 rounded-lg border-2 transition-all ${!form.selectedScriptId
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="h-5 w-5" />
|
||||
@@ -126,11 +137,10 @@ export function CreateTaskModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSelectScriptModalOpen(true)}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
form.selectedScriptId
|
||||
className={`p-4 rounded-lg border-2 transition-all ${form.selectedScriptId
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5" />
|
||||
|
||||
@@ -13,13 +13,13 @@ interface DeleteScriptModalProps {
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export function DeleteScriptModal({
|
||||
export const DeleteScriptModal = ({
|
||||
script,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isDeleting,
|
||||
}: DeleteScriptModalProps) {
|
||||
}: DeleteScriptModalProps) => {
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,12 +18,12 @@ interface DeleteTaskModalProps {
|
||||
job: CronJob | null;
|
||||
}
|
||||
|
||||
export function DeleteTaskModal({
|
||||
export const DeleteTaskModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
job,
|
||||
}: DeleteTaskModalProps) {
|
||||
}: DeleteTaskModalProps) => {
|
||||
if (!job) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,14 +19,14 @@ interface EditScriptModalProps {
|
||||
onFormChange: (updates: Partial<EditScriptModalProps["form"]>) => void;
|
||||
}
|
||||
|
||||
export function EditScriptModal({
|
||||
export const EditScriptModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
script,
|
||||
form,
|
||||
onFormChange,
|
||||
}: EditScriptModalProps) {
|
||||
}: EditScriptModalProps) => {
|
||||
if (!script) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Edit, Terminal } from "lucide-react";
|
||||
interface EditTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
form: {
|
||||
schedule: string;
|
||||
command: string;
|
||||
@@ -18,13 +18,13 @@ interface EditTaskModalProps {
|
||||
onFormChange: (updates: Partial<EditTaskModalProps["form"]>) => void;
|
||||
}
|
||||
|
||||
export function EditTaskModal({
|
||||
export const EditTaskModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
form,
|
||||
onFormChange,
|
||||
}: EditTaskModalProps) {
|
||||
}: EditTaskModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
|
||||
137
app/_components/modals/ErrorDetailsModal.tsx
Normal file
137
app/_components/modals/ErrorDetailsModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { AlertCircle, Copy, X } from "lucide-react";
|
||||
import { showToast } from "../ui/Toast";
|
||||
|
||||
interface ErrorDetails {
|
||||
title: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
command?: string;
|
||||
output?: string;
|
||||
stderr?: string;
|
||||
timestamp: string;
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
interface ErrorDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
error: ErrorDetails | null;
|
||||
}
|
||||
|
||||
export const ErrorDetailsModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
error,
|
||||
}: ErrorDetailsModalProps) => {
|
||||
if (!isOpen || !error) return null;
|
||||
|
||||
const handleCopyDetails = async () => {
|
||||
const detailsText = `
|
||||
Error Details:
|
||||
Title: ${error.title}
|
||||
Message: ${error.message}
|
||||
${error.details ? `Details: ${error.details}` : ""}
|
||||
${error.command ? `Command: ${error.command}` : ""}
|
||||
${error.output ? `Output: ${error.output}` : ""}
|
||||
${error.stderr ? `Stderr: ${error.stderr}` : ""}
|
||||
Timestamp: ${error.timestamp}
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(detailsText);
|
||||
showToast("success", "Error details copied to clipboard");
|
||||
} catch (err) {
|
||||
showToast("error", "Failed to copy error details");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Error Details" size="xl">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-destructive/5 border border-destructive/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-destructive mb-1">
|
||||
{error.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error.details && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-2">
|
||||
Details
|
||||
</h4>
|
||||
<div className="bg-muted/30 p-3 rounded border border-border/30">
|
||||
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap break-words">
|
||||
{error.details}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.command && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-2">
|
||||
Command
|
||||
</h4>
|
||||
<div className="bg-muted/30 p-3 rounded border border-border/30">
|
||||
<code className="text-sm font-mono text-foreground break-all">
|
||||
{error.command}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.output && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-2">Output</h4>
|
||||
<div className="bg-muted/30 p-3 rounded border border-border/30 max-h-32 overflow-y-auto">
|
||||
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">
|
||||
{error.output}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.stderr && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-2">
|
||||
Error Output
|
||||
</h4>
|
||||
<div className="bg-destructive/5 p-3 rounded border border-destructive/20 max-h-32 overflow-y-auto">
|
||||
<pre className="text-sm font-mono text-destructive whitespace-pre-wrap">
|
||||
{error.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Timestamp: {error.timestamp}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopyDetails}
|
||||
className="btn-outline"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Details
|
||||
</Button>
|
||||
<Button onClick={onClose} className="btn-primary">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { BashEditor } from "../BashEditor";
|
||||
import { BashSnippetHelper } from "../BashSnippetHelper";
|
||||
import { FileText, Code, Plus, Edit } from "lucide-react";
|
||||
import { FileText, Code } from "lucide-react";
|
||||
import { showToast } from "../ui/Toast";
|
||||
|
||||
interface ScriptModalProps {
|
||||
@@ -26,7 +26,7 @@ interface ScriptModalProps {
|
||||
additionalFormData?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function ScriptModal({
|
||||
export const ScriptModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
@@ -36,13 +36,24 @@ export function ScriptModal({
|
||||
form,
|
||||
onFormChange,
|
||||
additionalFormData = {},
|
||||
}: ScriptModalProps) {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
}: ScriptModalProps) => {
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.name.trim()) {
|
||||
showToast("error", "Validation Error", "Script name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.content.trim()) {
|
||||
showToast("error", "Validation Error", "Script content is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("name", form.name);
|
||||
formData.append("description", form.description);
|
||||
formData.append("content", form.content);
|
||||
formData.append("name", form.name.trim());
|
||||
formData.append("description", form.description.trim());
|
||||
formData.append("content", form.content.trim());
|
||||
|
||||
Object.entries(additionalFormData).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
@@ -66,18 +77,24 @@ export function ScriptModal({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Script Name
|
||||
Script Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => onFormChange({ name: e.target.value })}
|
||||
placeholder="My Script"
|
||||
required
|
||||
className={
|
||||
!form.name.trim()
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Description
|
||||
Description{" "}
|
||||
<span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.description}
|
||||
@@ -102,7 +119,7 @@ export function ScriptModal({
|
||||
<div className="flex items-center gap-2 mb-4 flex-shrink-0">
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
Script Content
|
||||
Script Content <span className="text-red-500">*</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
|
||||
@@ -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";
|
||||
@@ -17,16 +17,28 @@ interface SelectScriptModalProps {
|
||||
selectedScriptId: string | null;
|
||||
}
|
||||
|
||||
export function SelectScriptModal({
|
||||
export const SelectScriptModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
scripts,
|
||||
onScriptSelect,
|
||||
selectedScriptId,
|
||||
}: SelectScriptModalProps) {
|
||||
}: SelectScriptModalProps) => {
|
||||
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) =>
|
||||
@@ -97,11 +109,10 @@ export function SelectScriptModal({
|
||||
<button
|
||||
key={script.id}
|
||||
onClick={() => handleScriptClick(script)}
|
||||
className={`w-full p-4 text-left hover:bg-accent/30 transition-colors ${
|
||||
previewScript?.id === script.id
|
||||
className={`w-full p-4 text-left hover:bg-accent/30 transition-colors ${previewScript?.id === script.id
|
||||
? "bg-primary/5 border-r-2 border-primary"
|
||||
: ""
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -156,7 +167,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>
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
@@ -36,9 +36,3 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from '@/app/_utils/cn';
|
||||
import { HTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -69,4 +69,4 @@ const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
export { CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
|
||||
47
app/_components/ui/ErrorBadge.tsx
Normal file
47
app/_components/ui/ErrorBadge.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, X } from "lucide-react";
|
||||
import { JobError, removeJobError } from "@/app/_utils/errorState";
|
||||
|
||||
interface ErrorBadgeProps {
|
||||
errors: JobError[];
|
||||
onErrorClick: (error: JobError) => void;
|
||||
onErrorDismiss?: () => void;
|
||||
}
|
||||
|
||||
export const ErrorBadge = ({
|
||||
errors,
|
||||
onErrorClick,
|
||||
onErrorDismiss,
|
||||
}: ErrorBadgeProps) => {
|
||||
if (errors.length === 0) return null;
|
||||
|
||||
const handleDismissError = (errorId: string) => {
|
||||
removeJobError(errorId);
|
||||
onErrorDismiss?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{errors.map((error) => (
|
||||
<div key={error.id} className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onErrorClick(error)}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-destructive/10 text-destructive border border-destructive/20 rounded text-xs hover:bg-destructive/20 transition-colors"
|
||||
title={error.message}
|
||||
>
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Error</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDismissError(error.id)}
|
||||
className="p-1 text-destructive hover:bg-destructive/10 rounded transition-colors"
|
||||
title="Dismiss error"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { InputHTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { }
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
@@ -20,9 +20,3 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
43
app/_components/ui/LogoutButton.tsx
Normal file
43
app/_components/ui/LogoutButton.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "./Button";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
|
||||
export const LogoutButton = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="h-[1.2rem] w-[1.2rem]" />
|
||||
<span className="sr-only">Logout</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export interface MetricCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
progressMax?: number;
|
||||
}
|
||||
|
||||
const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(
|
||||
export const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -99,5 +99,3 @@ const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(
|
||||
);
|
||||
|
||||
MetricCard.displayName = "MetricCard";
|
||||
|
||||
export { MetricCard };
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ModalProps {
|
||||
preventCloseOnClickOutside?: boolean;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
export const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
@@ -23,7 +23,7 @@ export function Modal({
|
||||
size = "md",
|
||||
showCloseButton = true,
|
||||
preventCloseOnClickOutside = false,
|
||||
}: ModalProps) {
|
||||
}: ModalProps) => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
58
app/_components/ui/PWAInstallPrompt.tsx
Normal file
58
app/_components/ui/PWAInstallPrompt.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
};
|
||||
|
||||
export const PWAInstallPrompt = (): JSX.Element | null => {
|
||||
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(
|
||||
null
|
||||
);
|
||||
const [isInstalled, setIsInstalled] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferred(e as BeforeInstallPromptEvent);
|
||||
};
|
||||
const onAppInstalled = () => {
|
||||
setDeferred(null);
|
||||
setIsInstalled(true);
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.addEventListener("appinstalled", onAppInstalled);
|
||||
if (window.matchMedia("(display-mode: standalone)").matches) {
|
||||
setIsInstalled(true);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", onAppInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onInstall = useCallback(async () => {
|
||||
if (!deferred) return;
|
||||
try {
|
||||
await deferred.prompt();
|
||||
const choice = await deferred.userChoice;
|
||||
if (choice.outcome === "accepted") {
|
||||
setDeferred(null);
|
||||
}
|
||||
} catch (_err) {}
|
||||
}, [deferred]);
|
||||
|
||||
if (isInstalled || !deferred) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="px-3 py-1 rounded-md border border-border/50 bg-background/80 hover:bg-background/60"
|
||||
onClick={onInstall}
|
||||
>
|
||||
Install App
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -14,7 +14,7 @@ export interface PerformanceSummaryProps
|
||||
metrics: PerformanceMetric[];
|
||||
}
|
||||
|
||||
const PerformanceSummary = forwardRef<HTMLDivElement, PerformanceSummaryProps>(
|
||||
export const PerformanceSummary = forwardRef<HTMLDivElement, PerformanceSummaryProps>(
|
||||
({ className, metrics, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
@@ -58,5 +58,3 @@ const PerformanceSummary = forwardRef<HTMLDivElement, PerformanceSummaryProps>(
|
||||
);
|
||||
|
||||
PerformanceSummary.displayName = "PerformanceSummary";
|
||||
|
||||
export { PerformanceSummary };
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface ProgressBarProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "gradient";
|
||||
}
|
||||
|
||||
const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(
|
||||
export const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -75,5 +75,3 @@ const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(
|
||||
);
|
||||
|
||||
ProgressBar.displayName = "ProgressBar";
|
||||
|
||||
export { ProgressBar };
|
||||
|
||||
23
app/_components/ui/ServiceWorkerRegister.tsx
Normal file
23
app/_components/ui/ServiceWorkerRegister.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const ServiceWorkerRegister = (): null => {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
const register = async () => {
|
||||
try {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
const alreadyRegistered = registrations.some((r) =>
|
||||
r.scope.endsWith("/")
|
||||
);
|
||||
if (alreadyRegistered) return;
|
||||
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||
} catch (_err) {}
|
||||
};
|
||||
register();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export interface SidebarProps extends HTMLAttributes<HTMLDivElement> {
|
||||
};
|
||||
}
|
||||
|
||||
const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
|
||||
export const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -92,7 +92,7 @@ const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="absolute -right-3 top-6 w-6 h-6 bg-background border border-border rounded-full items-center justify-center hover:bg-accent transition-colors z-40 hidden lg:flex"
|
||||
className="absolute -right-3 top-[21.5vh] w-6 h-6 bg-background border border-border rounded-full items-center justify-center hover:bg-accent transition-colors z-40 hidden lg:flex"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
@@ -185,5 +185,3 @@ const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
|
||||
);
|
||||
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
export { Sidebar };
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface StatusBadgeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
|
||||
export const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -105,5 +105,3 @@ const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
|
||||
);
|
||||
|
||||
StatusBadge.displayName = "StatusBadge";
|
||||
|
||||
export { StatusBadge };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cn } from "@/app/_utils/cn";
|
||||
import { HTMLAttributes, forwardRef } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
export interface SystemStatusProps extends HTMLAttributes<HTMLDivElement> {
|
||||
status: string;
|
||||
@@ -10,7 +9,7 @@ export interface SystemStatusProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isUpdating?: boolean;
|
||||
}
|
||||
|
||||
const SystemStatus = forwardRef<HTMLDivElement, SystemStatusProps>(
|
||||
export const SystemStatus = forwardRef<HTMLDivElement, SystemStatusProps>(
|
||||
(
|
||||
{ className, status, details, timestamp, isUpdating = false, ...props },
|
||||
ref
|
||||
@@ -80,5 +79,3 @@ const SystemStatus = forwardRef<HTMLDivElement, SystemStatusProps>(
|
||||
);
|
||||
|
||||
SystemStatus.displayName = "SystemStatus";
|
||||
|
||||
export { SystemStatus };
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import { type ThemeProviderProps } from 'next-themes/dist/types';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from './Button';
|
||||
|
||||
export function ThemeToggle() {
|
||||
export const ThemeToggle = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
@@ -28,8 +28,4 @@ export function ThemeToggle() {
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||
import { cn } from "@/app/_utils/cn";
|
||||
import { ErrorDetailsModal } from "../modals/ErrorDetailsModal";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
@@ -10,11 +11,22 @@ export interface Toast {
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
errorDetails?: {
|
||||
title: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
command?: string;
|
||||
output?: string;
|
||||
stderr?: string;
|
||||
timestamp: string;
|
||||
jobId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toast: Toast;
|
||||
onRemove: (id: string) => void;
|
||||
onErrorClick?: (errorDetails: Toast["errorDetails"]) => void;
|
||||
}
|
||||
|
||||
const toastIcons = {
|
||||
@@ -33,7 +45,7 @@ const toastStyles = {
|
||||
"border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
|
||||
};
|
||||
|
||||
export function Toast({ toast, onRemove }: ToastProps) {
|
||||
export const Toast = ({ toast, onRemove, onErrorClick }: ToastProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const Icon = toastIcons[toast.type];
|
||||
|
||||
@@ -56,11 +68,23 @@ export function Toast({ toast, onRemove }: ToastProps) {
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`flex-1 min-w-0 ${
|
||||
toast.type === "error" && toast.errorDetails ? "cursor-pointer" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (toast.type === "error" && toast.errorDetails && onErrorClick) {
|
||||
onErrorClick(toast.errorDetails);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h4 className="font-medium text-sm">{toast.title}</h4>
|
||||
{toast.message && (
|
||||
<p className="text-sm opacity-90 mt-1">{toast.message}</p>
|
||||
)}
|
||||
{toast.type === "error" && toast.errorDetails && (
|
||||
<p className="text-xs opacity-70 mt-1">Click for details</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -73,10 +97,14 @@ export function Toast({ toast, onRemove }: ToastProps) {
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function ToastContainer() {
|
||||
export const ToastContainer = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [errorModalOpen, setErrorModalOpen] = useState(false);
|
||||
const [selectedError, setSelectedError] = useState<
|
||||
Toast["errorDetails"] | null
|
||||
>(null);
|
||||
|
||||
const addToast = (toast: Omit<Toast, "id">) => {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
@@ -87,6 +115,11 @@ export function ToastContainer() {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
};
|
||||
|
||||
const handleErrorClick = (errorDetails: Toast["errorDetails"]) => {
|
||||
setSelectedError(errorDetails);
|
||||
setErrorModalOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(window as any).showToast = addToast;
|
||||
return () => {
|
||||
@@ -95,21 +128,39 @@ export function ToastContainer() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
|
||||
{toasts.map((toast) => (
|
||||
<Toast key={toast.id} toast={toast} onRemove={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onRemove={removeToast}
|
||||
onErrorClick={handleErrorClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{errorModalOpen && (
|
||||
<ErrorDetailsModal
|
||||
isOpen={errorModalOpen}
|
||||
onClose={() => {
|
||||
setErrorModalOpen(false);
|
||||
setSelectedError(null);
|
||||
}}
|
||||
error={selectedError || null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function showToast(
|
||||
export const showToast = (
|
||||
type: Toast["type"],
|
||||
title: string,
|
||||
message?: string,
|
||||
duration?: number
|
||||
) {
|
||||
duration?: number,
|
||||
errorDetails?: Toast["errorDetails"]
|
||||
) => {
|
||||
if (typeof window !== "undefined" && (window as any).showToast) {
|
||||
(window as any).showToast({ type, title, message, duration });
|
||||
(window as any).showToast({ type, title, message, duration, errorDetails });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface TruncatedTextProps extends HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TruncatedText = forwardRef<HTMLDivElement, TruncatedTextProps>(
|
||||
export const TruncatedText = forwardRef<HTMLDivElement, TruncatedTextProps>(
|
||||
({ className, text, maxLength = 50, showTooltip = true, ...props }, ref) => {
|
||||
const [showTooltipState, setShowTooltipState] = useState(false);
|
||||
const shouldTruncate = text.length > maxLength;
|
||||
@@ -42,5 +42,3 @@ const TruncatedText = forwardRef<HTMLDivElement, TruncatedTextProps>(
|
||||
);
|
||||
|
||||
TruncatedText.displayName = "TruncatedText";
|
||||
|
||||
export { TruncatedText };
|
||||
|
||||
107
app/_components/ui/UserFilter.tsx
Normal file
107
app/_components/ui/UserFilter.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { ChevronDown, User, X } from "lucide-react";
|
||||
import { fetchAvailableUsers } from "@/app/_server/actions/cronjobs";
|
||||
|
||||
interface UserFilterProps {
|
||||
selectedUser: string | null;
|
||||
onUserChange: (user: string | null) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UserFilter = ({
|
||||
selectedUser,
|
||||
onUserChange,
|
||||
className = "",
|
||||
}: UserFilterProps) => {
|
||||
const [users, setUsers] = useState<string[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const availableUsers = await fetchAvailableUsers();
|
||||
setUsers(availableUsers);
|
||||
} catch (error) {
|
||||
console.error("Error loading users:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md ${className}`}
|
||||
>
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Loading users...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
{selectedUser ? `User: ${selectedUser}` : "All users"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedUser && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUserChange(null);
|
||||
}}
|
||||
className="p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 max-h-48 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => {
|
||||
onUserChange(null);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${!selectedUser ? "bg-accent text-accent-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
All users
|
||||
</button>
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user}
|
||||
onClick={() => {
|
||||
onUserChange(user);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${selectedUser === user ? "bg-accent text-accent-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{user}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
app/_components/ui/UserSwitcher.tsx
Normal file
85
app/_components/ui/UserSwitcher.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { ChevronDown, User } from "lucide-react";
|
||||
import { fetchAvailableUsers } from "@/app/_server/actions/cronjobs";
|
||||
|
||||
interface UserSwitcherProps {
|
||||
selectedUser: string;
|
||||
onUserChange: (user: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UserSwitcher = ({
|
||||
selectedUser,
|
||||
onUserChange,
|
||||
className = "",
|
||||
}: UserSwitcherProps) => {
|
||||
const [users, setUsers] = useState<string[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const availableUsers = await fetchAvailableUsers();
|
||||
setUsers(availableUsers);
|
||||
if (availableUsers.length > 0 && !selectedUser) {
|
||||
onUserChange(availableUsers[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading users:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadUsers();
|
||||
}, [selectedUser, onUserChange]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md ${className}`}
|
||||
>
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Loading users...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span className="text-sm">{selectedUser || "Select user"}</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 max-h-48 overflow-y-auto">
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user}
|
||||
onClick={() => {
|
||||
onUserChange(user);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${selectedUser === user ? "bg-accent text-accent-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{user}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,72 +5,40 @@ import {
|
||||
addCronJob,
|
||||
deleteCronJob,
|
||||
updateCronJob,
|
||||
getSystemInfo,
|
||||
pauseCronJob,
|
||||
resumeCronJob,
|
||||
cleanupCrontab,
|
||||
type CronJob,
|
||||
type SystemInfo,
|
||||
} from "@/app/_utils/system";
|
||||
import {
|
||||
getAllTargetUsers,
|
||||
getUserInfo,
|
||||
} from "@/app/_utils/system/hostCrontab";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getScriptPath } from "@/app/_utils/scripts";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
export async function fetchCronJobs(): Promise<CronJob[]> {
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const fetchCronJobs = async (): Promise<CronJob[]> => {
|
||||
try {
|
||||
return await getCronJobs();
|
||||
} catch (error) {
|
||||
console.error("Error fetching cron jobs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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(
|
||||
export const createCronJob = async (
|
||||
formData: FormData
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||
try {
|
||||
const schedule = formData.get("schedule") as string;
|
||||
const command = formData.get("command") as string;
|
||||
const comment = formData.get("comment") as string;
|
||||
const selectedScriptId = formData.get("selectedScriptId") as string;
|
||||
const user = formData.get("user") as string;
|
||||
|
||||
if (!schedule) {
|
||||
return { success: false, message: "Schedule is required" };
|
||||
@@ -84,7 +52,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" };
|
||||
}
|
||||
@@ -95,22 +63,26 @@ export async function createCronJob(
|
||||
};
|
||||
}
|
||||
|
||||
const success = await addCronJob(schedule, finalCommand, comment);
|
||||
const success = await addCronJob(schedule, finalCommand, comment, user);
|
||||
if (success) {
|
||||
revalidatePath("/");
|
||||
return { success: true, message: "Cron job created successfully" };
|
||||
} else {
|
||||
return { success: false, message: "Failed to create cron job" };
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Error creating cron job:", error);
|
||||
return { success: false, message: "Error creating cron job" };
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "Error creating cron job",
|
||||
details: error.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function removeCronJob(
|
||||
export const removeCronJob = async (
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||
try {
|
||||
const success = await deleteCronJob(id);
|
||||
if (success) {
|
||||
@@ -119,15 +91,19 @@ export async function removeCronJob(
|
||||
} else {
|
||||
return { success: false, message: "Failed to delete cron job" };
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting cron job:", error);
|
||||
return { success: false, message: "Error deleting cron job" };
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "Error deleting cron job",
|
||||
details: error.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function editCronJob(
|
||||
export const editCronJob = async (
|
||||
formData: FormData
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||
try {
|
||||
const id = formData.get("id") as string;
|
||||
const schedule = formData.get("schedule") as string;
|
||||
@@ -145,16 +121,20 @@ export async function editCronJob(
|
||||
} else {
|
||||
return { success: false, message: "Failed to update cron job" };
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Error updating cron job:", error);
|
||||
return { success: false, message: "Error updating cron job" };
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "Error updating cron job",
|
||||
details: error.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function cloneCronJob(
|
||||
export const cloneCronJob = async (
|
||||
id: string,
|
||||
newComment: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||
try {
|
||||
const cronJobs = await getCronJobs();
|
||||
const originalJob = cronJobs.find((job) => job.id === id);
|
||||
@@ -166,7 +146,8 @@ export async function cloneCronJob(
|
||||
const success = await addCronJob(
|
||||
originalJob.schedule,
|
||||
originalJob.command,
|
||||
newComment
|
||||
newComment,
|
||||
originalJob.user
|
||||
);
|
||||
|
||||
if (success) {
|
||||
@@ -175,8 +156,151 @@ export async function cloneCronJob(
|
||||
} else {
|
||||
return { success: false, message: "Failed to clone cron job" };
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Error cloning cron job:", error);
|
||||
return { success: false, message: "Error cloning cron job" };
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "Error cloning cron job",
|
||||
details: error.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const pauseCronJobAction = async (
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||
try {
|
||||
const success = await pauseCronJob(id);
|
||||
if (success) {
|
||||
revalidatePath("/");
|
||||
return { success: true, message: "Cron job paused successfully" };
|
||||
} else {
|
||||
return { success: false, message: "Failed to pause cron job" };
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error pausing cron job:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "Error pausing cron job",
|
||||
details: error.stack,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const resumeCronJobAction = async (
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||
try {
|
||||
const success = await resumeCronJob(id);
|
||||
if (success) {
|
||||
revalidatePath("/");
|
||||
return { success: true, message: "Cron job resumed successfully" };
|
||||
} else {
|
||||
return { success: false, message: "Failed to resume cron job" };
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error resuming cron job:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "Error resuming cron job",
|
||||
details: error.stack,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchAvailableUsers = async (): Promise<string[]> => {
|
||||
try {
|
||||
return await getAllTargetUsers();
|
||||
} catch (error) {
|
||||
console.error("Error fetching available users:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanupCrontabAction = async (): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
details?: string;
|
||||
}> => {
|
||||
try {
|
||||
const success = await cleanupCrontab();
|
||||
if (success) {
|
||||
revalidatePath("/");
|
||||
return { success: true, message: "Crontab cleaned successfully" };
|
||||
} else {
|
||||
return { success: false, message: "Failed to clean crontab" };
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error cleaning crontab:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "Error cleaning crontab",
|
||||
details: error.stack,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const runCronJob = async (
|
||||
id: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
output?: string;
|
||||
details?: string;
|
||||
}> => {
|
||||
try {
|
||||
const cronJobs = await getCronJobs();
|
||||
const job = cronJobs.find((j) => j.id === id);
|
||||
|
||||
if (!job) {
|
||||
return { success: false, message: "Cron job not found" };
|
||||
}
|
||||
|
||||
if (job.paused) {
|
||||
return { success: false, message: "Cannot run paused cron job" };
|
||||
}
|
||||
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
let command = job.command;
|
||||
|
||||
if (isDocker) {
|
||||
const userInfo = await getUserInfo(job.user);
|
||||
const dockerExecUser = process.env.DOCKER_EXEC_USER;
|
||||
|
||||
let executionUser = userInfo ? userInfo.username : "root";
|
||||
|
||||
if (dockerExecUser && executionUser === "root") {
|
||||
console.log(
|
||||
`Overriding root execution. Running command as user: ${dockerExecUser}`
|
||||
);
|
||||
executionUser = dockerExecUser;
|
||||
}
|
||||
|
||||
const escapedCommand = job.command.replace(/'/g, "'\\''");
|
||||
command = `nsenter -t 1 -m -u -i -n -p su - ${executionUser} -c '${escapedCommand}'`;
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: 30000,
|
||||
cwd: process.env.HOME || "/home",
|
||||
});
|
||||
|
||||
const output = stdout || stderr || "Command executed successfully";
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Cron job executed successfully",
|
||||
output: output.trim(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Error running cron job:", error);
|
||||
const errorMessage =
|
||||
error.stderr || error.message || "Unknown error occurred";
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to execute cron job",
|
||||
output: errorMessage,
|
||||
details: error.stack,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,14 +6,14 @@ import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { SCRIPTS_DIR } from "@/app/_utils/scripts";
|
||||
import { SCRIPTS_DIR, normalizeLineEndings } from "@/app/_utils/scripts";
|
||||
import { loadAllScripts, type Script } from "@/app/_utils/scriptScanner";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export type { Script } from "@/app/_utils/scriptScanner";
|
||||
|
||||
function sanitizeScriptName(name: string): string {
|
||||
const sanitizeScriptName = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
@@ -21,9 +21,9 @@ function sanitizeScriptName(name: string): string {
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.substring(0, 50);
|
||||
}
|
||||
};
|
||||
|
||||
async function generateUniqueFilename(baseName: string): Promise<string> {
|
||||
const generateUniqueFilename = async (baseName: string): Promise<string> => {
|
||||
const scripts = await loadAllScripts();
|
||||
let filename = `${sanitizeScriptName(baseName)}.sh`;
|
||||
let counter = 1;
|
||||
@@ -34,44 +34,55 @@ async function generateUniqueFilename(baseName: string): Promise<string> {
|
||||
}
|
||||
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
|
||||
async function ensureScriptsDirectory() {
|
||||
if (!existsSync(SCRIPTS_DIR)) {
|
||||
await mkdir(SCRIPTS_DIR, { recursive: true });
|
||||
const ensureScriptsDirectory = async () => {
|
||||
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 ensureHostScriptsDirectory = async () => {
|
||||
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 saveScriptFile = async (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);
|
||||
await writeFile(scriptPath, content, "utf8");
|
||||
}
|
||||
|
||||
async function deleteScriptFile(filename: string) {
|
||||
const scriptPath = join(SCRIPTS_DIR, filename);
|
||||
const scriptPath = join(scriptsDir, filename);
|
||||
await writeFile(scriptPath, content, "utf8");
|
||||
|
||||
try {
|
||||
await execAsync(`chmod +x "${scriptPath}"`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to set execute permissions on ${scriptPath}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteScriptFile = async (filename: string) => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const scriptsDir = isDocker ? "/app/scripts" : await SCRIPTS_DIR();
|
||||
const scriptPath = join(scriptsDir, filename);
|
||||
if (existsSync(scriptPath)) {
|
||||
await unlink(scriptPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function fetchScripts(): Promise<Script[]> {
|
||||
export const fetchScripts = async (): Promise<Script[]> => {
|
||||
return await loadAllScripts();
|
||||
}
|
||||
};
|
||||
|
||||
export async function createScript(
|
||||
export const createScript = async (
|
||||
formData: FormData
|
||||
): Promise<{ success: boolean; message: string; script?: Script }> {
|
||||
): Promise<{ success: boolean; message: string; script?: Script }> => {
|
||||
try {
|
||||
const name = formData.get("name") as string;
|
||||
const description = formData.get("description") as string;
|
||||
@@ -92,7 +103,8 @@ export async function createScript(
|
||||
|
||||
`;
|
||||
|
||||
const fullContent = metadataHeader + content;
|
||||
const normalizedContent = normalizeLineEndings(content);
|
||||
const fullContent = metadataHeader + normalizedContent;
|
||||
|
||||
await saveScriptFile(filename, fullContent);
|
||||
revalidatePath("/");
|
||||
@@ -114,11 +126,11 @@ export async function createScript(
|
||||
console.error("Error creating script:", error);
|
||||
return { success: false, message: "Error creating script" };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function updateScript(
|
||||
export const updateScript = async (
|
||||
formData: FormData
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const id = formData.get("id") as string;
|
||||
const name = formData.get("name") as string;
|
||||
@@ -142,7 +154,8 @@ export async function updateScript(
|
||||
|
||||
`;
|
||||
|
||||
const fullContent = metadataHeader + content;
|
||||
const normalizedContent = normalizeLineEndings(content);
|
||||
const fullContent = metadataHeader + normalizedContent;
|
||||
|
||||
await saveScriptFile(existingScript.filename, fullContent);
|
||||
revalidatePath("/");
|
||||
@@ -152,11 +165,11 @@ export async function updateScript(
|
||||
console.error("Error updating script:", error);
|
||||
return { success: false, message: "Error updating script" };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function deleteScript(
|
||||
export const deleteScript = async (
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const scripts = await loadAllScripts();
|
||||
const script = scripts.find((s) => s.id === id);
|
||||
@@ -173,12 +186,12 @@ export async function deleteScript(
|
||||
console.error("Error deleting script:", error);
|
||||
return { success: false, message: "Error deleting script" };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function cloneScript(
|
||||
export const cloneScript = async (
|
||||
id: string,
|
||||
newName: string
|
||||
): Promise<{ success: boolean; message: string; script?: Script }> {
|
||||
): Promise<{ success: boolean; message: string; script?: Script }> => {
|
||||
try {
|
||||
const scripts = await loadAllScripts();
|
||||
const originalScript = scripts.find((s) => s.id === id);
|
||||
@@ -200,7 +213,8 @@ export async function cloneScript(
|
||||
|
||||
`;
|
||||
|
||||
const fullContent = metadataHeader + originalContent;
|
||||
const normalizedContent = normalizeLineEndings(originalContent);
|
||||
const fullContent = metadataHeader + normalizedContent;
|
||||
|
||||
await saveScriptFile(filename, fullContent);
|
||||
revalidatePath("/");
|
||||
@@ -222,11 +236,15 @@ export async function cloneScript(
|
||||
console.error("Error cloning script:", error);
|
||||
return { success: false, message: "Error cloning script" };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function getScriptContent(filename: string): Promise<string> {
|
||||
export const getScriptContent = async (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");
|
||||
@@ -251,13 +269,15 @@ export async function getScriptContent(filename: string): Promise<string> {
|
||||
console.error("Error reading script content:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function executeScript(filename: string): Promise<{
|
||||
export const executeScript = async (
|
||||
filename: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
error: string;
|
||||
}> {
|
||||
}> => {
|
||||
try {
|
||||
await ensureHostScriptsDirectory();
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
@@ -289,4 +309,4 @@ export async function executeScript(filename: string): Promise<{
|
||||
error: error.message || "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
loadAllSnippets,
|
||||
searchBashSnippets,
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
|
||||
export { type BashSnippet } from "@/app/_utils/snippetScanner";
|
||||
|
||||
export async function fetchSnippets(): Promise<BashSnippet[]> {
|
||||
export const fetchSnippets = async (): Promise<BashSnippet[]> => {
|
||||
try {
|
||||
return await loadAllSnippets();
|
||||
} catch (error) {
|
||||
@@ -20,7 +19,7 @@ export async function fetchSnippets(): Promise<BashSnippet[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchSnippets(query: string): Promise<BashSnippet[]> {
|
||||
export const searchSnippets = async (query: string): Promise<BashSnippet[]> => {
|
||||
try {
|
||||
const snippets = await loadAllSnippets();
|
||||
return searchBashSnippets(snippets, query);
|
||||
@@ -30,7 +29,7 @@ export async function searchSnippets(query: string): Promise<BashSnippet[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSnippetCategories(): Promise<string[]> {
|
||||
export const fetchSnippetCategories = async (): Promise<string[]> => {
|
||||
try {
|
||||
const snippets = await loadAllSnippets();
|
||||
return getSnippetCategories(snippets);
|
||||
@@ -40,9 +39,9 @@ export async function fetchSnippetCategories(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSnippetById(
|
||||
export const fetchSnippetById = async (
|
||||
id: string
|
||||
): Promise<BashSnippet | undefined> {
|
||||
): Promise<BashSnippet | undefined> => {
|
||||
try {
|
||||
const snippets = await loadAllSnippets();
|
||||
return getSnippetById(snippets, id);
|
||||
@@ -52,9 +51,9 @@ export async function fetchSnippetById(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSnippetsByCategory(
|
||||
export const fetchSnippetsByCategory = async (
|
||||
category: string
|
||||
): Promise<BashSnippet[]> {
|
||||
): Promise<BashSnippet[]> => {
|
||||
try {
|
||||
const snippets = await loadAllSnippets();
|
||||
return snippets.filter((snippet) => snippet.category === category);
|
||||
@@ -64,9 +63,9 @@ export async function fetchSnippetsByCategory(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSnippetsBySource(
|
||||
export const fetchSnippetsBySource = async (
|
||||
source: "builtin" | "user"
|
||||
): Promise<BashSnippet[]> {
|
||||
): Promise<BashSnippet[]> => {
|
||||
try {
|
||||
const snippets = await loadAllSnippets();
|
||||
return snippets.filter((snippet) => snippet.source === source);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
export const cn = (...inputs: ClassValue[]) => {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
59
app/_utils/cron/files-manipulation.ts
Normal file
59
app/_utils/cron/files-manipulation.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
"use server";
|
||||
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { readHostCrontab, writeHostCrontab } from "../system/hostCrontab";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const cleanCrontabContent = async (content: string): Promise<string> => {
|
||||
const lines = content.split("\n");
|
||||
const cleanedLines: string[] = [];
|
||||
let consecutiveEmptyLines = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "") {
|
||||
consecutiveEmptyLines++;
|
||||
if (consecutiveEmptyLines <= 1) {
|
||||
cleanedLines.push("");
|
||||
}
|
||||
} else {
|
||||
consecutiveEmptyLines = 0;
|
||||
cleanedLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedLines.join("\n").trim();
|
||||
}
|
||||
|
||||
export const readCronFiles = async (): Promise<string> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
|
||||
if (!isDocker) {
|
||||
try {
|
||||
const { stdout } = await execAsync('crontab -l 2>/dev/null || echo ""');
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
console.error("Error reading crontab:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return await readHostCrontab();
|
||||
}
|
||||
|
||||
export const writeCronFiles = async (content: string): Promise<boolean> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
|
||||
if (!isDocker) {
|
||||
try {
|
||||
await execAsync('echo "' + content + '" | crontab -');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error writing crontab:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return await writeHostCrontab(content);
|
||||
}
|
||||
441
app/_utils/cron/line-manipulation.ts
Normal file
441
app/_utils/cron/line-manipulation.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { CronJob } from "../system";
|
||||
|
||||
export const pauseJobInLines = (
|
||||
lines: string[],
|
||||
targetJobIndex: number
|
||||
): string[] => {
|
||||
const newCronEntries: string[] = [];
|
||||
let currentJobIndex = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
if (
|
||||
newCronEntries.length > 0 &&
|
||||
newCronEntries[newCronEntries.length - 1] !== ""
|
||||
) {
|
||||
newCronEntries.push("");
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("# User:") ||
|
||||
trimmedLine.startsWith("# System Crontab")
|
||||
) {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# PAUSED:")) {
|
||||
newCronEntries.push(line);
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
currentJobIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
if (
|
||||
i + 1 < lines.length &&
|
||||
!lines[i + 1].trim().startsWith("#") &&
|
||||
lines[i + 1].trim()
|
||||
) {
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const comment = trimmedLine.substring(1).trim();
|
||||
const nextLine = lines[i + 1].trim();
|
||||
const pausedEntry = `# PAUSED: ${comment}\n# ${nextLine}`;
|
||||
newCronEntries.push(pausedEntry);
|
||||
i += 2;
|
||||
currentJobIndex++;
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
currentJobIndex++;
|
||||
}
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const pausedEntry = `# PAUSED:\n# ${trimmedLine}`;
|
||||
newCronEntries.push(pausedEntry);
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
}
|
||||
|
||||
currentJobIndex++;
|
||||
i++;
|
||||
}
|
||||
|
||||
return newCronEntries;
|
||||
};
|
||||
|
||||
export const resumeJobInLines = (
|
||||
lines: string[],
|
||||
targetJobIndex: number
|
||||
): string[] => {
|
||||
const newCronEntries: string[] = [];
|
||||
let currentJobIndex = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
if (
|
||||
newCronEntries.length > 0 &&
|
||||
newCronEntries[newCronEntries.length - 1] !== ""
|
||||
) {
|
||||
newCronEntries.push("");
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("# User:") ||
|
||||
trimmedLine.startsWith("# System Crontab")
|
||||
) {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# PAUSED:")) {
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const comment = trimmedLine.substring(9).trim();
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
const cronLine = lines[i + 1].trim().substring(2);
|
||||
const resumedEntry = comment ? `# ${comment}\n${cronLine}` : cronLine;
|
||||
newCronEntries.push(resumedEntry);
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
currentJobIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
newCronEntries.push(line);
|
||||
currentJobIndex++;
|
||||
i++;
|
||||
}
|
||||
|
||||
return newCronEntries;
|
||||
};
|
||||
|
||||
export const parseJobsFromLines = (
|
||||
lines: string[],
|
||||
user: string
|
||||
): CronJob[] => {
|
||||
const jobs: CronJob[] = [];
|
||||
let currentComment = "";
|
||||
let jobIndex = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("# User:") ||
|
||||
trimmedLine.startsWith("# System Crontab")
|
||||
) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# PAUSED:")) {
|
||||
const comment = trimmedLine.substring(9).trim();
|
||||
|
||||
if (i + 1 < lines.length) {
|
||||
const nextLine = lines[i + 1].trim();
|
||||
if (nextLine.startsWith("# ")) {
|
||||
const commentedCron = nextLine.substring(2);
|
||||
const parts = commentedCron.split(/\s+/);
|
||||
if (parts.length >= 6) {
|
||||
const schedule = parts.slice(0, 5).join(" ");
|
||||
const command = parts.slice(5).join(" ");
|
||||
|
||||
jobs.push({
|
||||
id: `${user}-${jobIndex}`,
|
||||
schedule,
|
||||
command,
|
||||
comment: comment || undefined,
|
||||
user,
|
||||
paused: true,
|
||||
});
|
||||
|
||||
jobIndex++;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
if (
|
||||
i + 1 < lines.length &&
|
||||
!lines[i + 1].trim().startsWith("#") &&
|
||||
lines[i + 1].trim()
|
||||
) {
|
||||
currentComment = trimmedLine.substring(1).trim();
|
||||
i++;
|
||||
continue;
|
||||
} else {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let schedule, command;
|
||||
const parts = trimmedLine.split(/(?:\s|\t)+/);
|
||||
|
||||
if (parts[0].startsWith("@")) {
|
||||
if (parts.length >= 2) {
|
||||
schedule = parts[0];
|
||||
command = trimmedLine.slice(trimmedLine.indexOf(parts[1]));
|
||||
}
|
||||
} else if (parts.length >= 6) {
|
||||
schedule = parts.slice(0, 5).join(" ");
|
||||
command = trimmedLine.slice(trimmedLine.indexOf(parts[5]));
|
||||
}
|
||||
|
||||
if (schedule && command) {
|
||||
jobs.push({
|
||||
id: `${user}-${jobIndex}`,
|
||||
schedule,
|
||||
command,
|
||||
comment: currentComment || undefined,
|
||||
user,
|
||||
paused: false,
|
||||
});
|
||||
|
||||
jobIndex++;
|
||||
currentComment = "";
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return jobs;
|
||||
};
|
||||
|
||||
export const deleteJobInLines = (
|
||||
lines: string[],
|
||||
targetJobIndex: number
|
||||
): string[] => {
|
||||
const newCronEntries: string[] = [];
|
||||
let currentJobIndex = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
if (
|
||||
newCronEntries.length > 0 &&
|
||||
newCronEntries[newCronEntries.length - 1] !== ""
|
||||
) {
|
||||
newCronEntries.push("");
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("# User:") ||
|
||||
trimmedLine.startsWith("# System Crontab")
|
||||
) {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# PAUSED:")) {
|
||||
if (currentJobIndex !== targetJobIndex) {
|
||||
newCronEntries.push(line);
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
currentJobIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
if (
|
||||
i + 1 < lines.length &&
|
||||
!lines[i + 1].trim().startsWith("#") &&
|
||||
lines[i + 1].trim()
|
||||
) {
|
||||
if (currentJobIndex !== targetJobIndex) {
|
||||
newCronEntries.push(line);
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
}
|
||||
i += 2;
|
||||
currentJobIndex++;
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentJobIndex !== targetJobIndex) {
|
||||
newCronEntries.push(line);
|
||||
}
|
||||
|
||||
currentJobIndex++;
|
||||
i++;
|
||||
}
|
||||
|
||||
return newCronEntries;
|
||||
};
|
||||
|
||||
export const updateJobInLines = (
|
||||
lines: string[],
|
||||
targetJobIndex: number,
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = ""
|
||||
): string[] => {
|
||||
const newCronEntries: string[] = [];
|
||||
let currentJobIndex = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
if (
|
||||
newCronEntries.length > 0 &&
|
||||
newCronEntries[newCronEntries.length - 1] !== ""
|
||||
) {
|
||||
newCronEntries.push("");
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("# User:") ||
|
||||
trimmedLine.startsWith("# System Crontab")
|
||||
) {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# PAUSED:")) {
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const newEntry = comment
|
||||
? `# PAUSED: ${comment}\n# ${schedule} ${command}`
|
||||
: `# PAUSED:\n# ${schedule} ${command}`;
|
||||
newCronEntries.push(newEntry);
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
currentJobIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
if (
|
||||
i + 1 < lines.length &&
|
||||
!lines[i + 1].trim().startsWith("#") &&
|
||||
lines[i + 1].trim()
|
||||
) {
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const newEntry = comment
|
||||
? `# ${comment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
newCronEntries.push(newEntry);
|
||||
i += 2;
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
}
|
||||
currentJobIndex++;
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const newEntry = comment
|
||||
? `# ${comment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
newCronEntries.push(newEntry);
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
}
|
||||
|
||||
currentJobIndex++;
|
||||
i++;
|
||||
}
|
||||
|
||||
return newCronEntries;
|
||||
};
|
||||
@@ -7,7 +7,7 @@ export interface CronExplanation {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function parseCronExpression(expression: string): CronExplanation {
|
||||
export const parseCronExpression = (expression: string): CronExplanation => {
|
||||
try {
|
||||
const cleanExpression = expression.trim();
|
||||
|
||||
|
||||
63
app/_utils/errorState.ts
Normal file
63
app/_utils/errorState.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface JobError {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
command?: string;
|
||||
output?: string;
|
||||
stderr?: string;
|
||||
timestamp: string;
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "cronmaster-job-errors";
|
||||
|
||||
export const getJobErrors = (): JobError[] => {
|
||||
if (typeof window === "undefined") return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const setJobError = (error: JobError) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const errors = getJobErrors();
|
||||
const existingIndex = errors.findIndex((e) => e.id === error.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
errors[existingIndex] = error;
|
||||
} else {
|
||||
errors.push(error);
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(errors));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const removeJobError = (errorId: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const errors = getJobErrors();
|
||||
const filtered = errors.filter((e) => e.id !== errorId);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const getJobErrorsByJobId = (jobId: string): JobError[] => {
|
||||
return getJobErrors().filter((error) => error.jobId === jobId);
|
||||
};
|
||||
|
||||
export const clearAllJobErrors = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {}
|
||||
};
|
||||
@@ -15,7 +15,7 @@ interface ScriptMetadata {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function parseMetadata(content: string): ScriptMetadata {
|
||||
const parseMetadata = (content: string): ScriptMetadata => {
|
||||
const metadata: ScriptMetadata = {};
|
||||
const lines = content.split("\n");
|
||||
|
||||
@@ -34,7 +34,7 @@ function parseMetadata(content: string): ScriptMetadata {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async function scanScriptsDirectory(dirPath: string): Promise<Script[]> {
|
||||
const scanScriptsDirectory = async (dirPath: string): Promise<Script[]> => {
|
||||
const scripts: Script[] = [];
|
||||
|
||||
try {
|
||||
@@ -46,13 +46,13 @@ async function scanScriptsDirectory(dirPath: string): Promise<Script[]> {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const metadata = parseMetadata(content);
|
||||
|
||||
if (metadata.id && metadata.title && metadata.description) {
|
||||
if (metadata.id && metadata.title) {
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
scripts.push({
|
||||
id: metadata.id,
|
||||
name: metadata.title,
|
||||
description: metadata.description,
|
||||
description: metadata.description || "",
|
||||
filename: file,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
});
|
||||
@@ -66,7 +66,7 @@ async function scanScriptsDirectory(dirPath: string): Promise<Script[]> {
|
||||
return scripts;
|
||||
}
|
||||
|
||||
export async function loadAllScripts(): Promise<Script[]> {
|
||||
export const loadAllScripts = async (): Promise<Script[]> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const scriptsDir = isDocker
|
||||
? "/app/scripts"
|
||||
@@ -74,7 +74,7 @@ export async function loadAllScripts(): Promise<Script[]> {
|
||||
return await scanScriptsDirectory(scriptsDir);
|
||||
}
|
||||
|
||||
export function searchScripts(scripts: Script[], query: string): Script[] {
|
||||
export const searchScripts = (scripts: Script[], query: string): Script[] => {
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return scripts.filter(
|
||||
(script) =>
|
||||
@@ -83,9 +83,9 @@ export function searchScripts(scripts: Script[], query: string): Script[] {
|
||||
);
|
||||
}
|
||||
|
||||
export function getScriptById(
|
||||
export const getScriptById = (
|
||||
scripts: Script[],
|
||||
id: string
|
||||
): Script | undefined {
|
||||
): Script | undefined => {
|
||||
return scripts.find((script) => script.id === id);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
"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 const getScriptPath = async (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 const getHostScriptPath = async (filename: string): Promise<string> => {
|
||||
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
|
||||
|
||||
const hostScriptsDir = join(hostProjectDir, "scripts");
|
||||
return `bash ${join(hostScriptsDir, filename)}`;
|
||||
}
|
||||
|
||||
export const normalizeLineEndings = (content: string): string => {
|
||||
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
};
|
||||
|
||||
export { SCRIPTS_DIR };
|
||||
|
||||
@@ -20,7 +20,7 @@ interface SnippetMetadata {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
function parseMetadata(content: string): SnippetMetadata {
|
||||
const parseMetadata = (content: string): SnippetMetadata => {
|
||||
const metadata: SnippetMetadata = {};
|
||||
const lines = content.split("\n");
|
||||
|
||||
@@ -53,7 +53,7 @@ function parseMetadata(content: string): SnippetMetadata {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function extractTemplate(content: string): string {
|
||||
const extractTemplate = (content: string): string => {
|
||||
const lines = content.split("\n");
|
||||
const templateLines: string[] = [];
|
||||
let inTemplate = false;
|
||||
@@ -75,10 +75,10 @@ function extractTemplate(content: string): string {
|
||||
return templateLines.join("\n").trim();
|
||||
}
|
||||
|
||||
async function scanSnippetDirectory(
|
||||
const scanSnippetDirectory = async (
|
||||
dirPath: string,
|
||||
source: "builtin" | "user"
|
||||
): Promise<BashSnippet[]> {
|
||||
): Promise<BashSnippet[]> => {
|
||||
const snippets: BashSnippet[] = [];
|
||||
|
||||
try {
|
||||
@@ -117,7 +117,7 @@ async function scanSnippetDirectory(
|
||||
return snippets;
|
||||
}
|
||||
|
||||
export async function loadAllSnippets(): Promise<BashSnippet[]> {
|
||||
export const loadAllSnippets = async (): Promise<BashSnippet[]> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
|
||||
let builtinSnippetsPath: string;
|
||||
@@ -141,10 +141,10 @@ export async function loadAllSnippets(): Promise<BashSnippet[]> {
|
||||
return [...builtinSnippets, ...userSnippets];
|
||||
}
|
||||
|
||||
export function searchBashSnippets(
|
||||
export const searchBashSnippets = (
|
||||
snippets: BashSnippet[],
|
||||
query: string
|
||||
): BashSnippet[] {
|
||||
): BashSnippet[] => {
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return snippets.filter(
|
||||
(snippet) =>
|
||||
@@ -155,14 +155,14 @@ export function searchBashSnippets(
|
||||
);
|
||||
}
|
||||
|
||||
export function getSnippetCategories(snippets: BashSnippet[]): string[] {
|
||||
export const getSnippetCategories = (snippets: BashSnippet[]): string[] => {
|
||||
const categories = new Set(snippets.map((snippet) => snippet.category));
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
|
||||
export function getSnippetById(
|
||||
export const getSnippetById = (
|
||||
snippets: BashSnippet[],
|
||||
id: string
|
||||
): BashSnippet | undefined {
|
||||
): BashSnippet | undefined => {
|
||||
return snippets.find((snippet) => snippet.id === id);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export { getSystemInfo, type SystemInfo } from "./system/info";
|
||||
export {
|
||||
getCronJobs,
|
||||
addCronJob,
|
||||
deleteCronJob,
|
||||
updateCronJob,
|
||||
type CronJob
|
||||
pauseCronJob,
|
||||
resumeCronJob,
|
||||
cleanupCrontab,
|
||||
type CronJob,
|
||||
} from "./system/cron";
|
||||
|
||||
@@ -1,258 +1,228 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { isDocker, readCronFilesDocker, writeCronFilesDocker } from "./docker";
|
||||
import {
|
||||
readAllHostCrontabs,
|
||||
writeHostCrontabForUser,
|
||||
} from "./hostCrontab";
|
||||
import { parseJobsFromLines, deleteJobInLines, updateJobInLines, pauseJobInLines, resumeJobInLines } from "../cron/line-manipulation";
|
||||
import { cleanCrontabContent, readCronFiles, writeCronFiles } from "../cron/files-manipulation";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
schedule: string;
|
||||
command: string;
|
||||
comment?: string;
|
||||
id: string;
|
||||
schedule: string;
|
||||
command: string;
|
||||
comment?: string;
|
||||
user: string;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
async function readCronFiles(): Promise<string> {
|
||||
if (!isDocker) {
|
||||
try {
|
||||
const { stdout } = await execAsync('crontab -l 2>/dev/null || echo ""');
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
console.error("Error reading crontab:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const isDocker = (): boolean => process.env.DOCKER === "true";
|
||||
|
||||
return await readCronFilesDocker();
|
||||
}
|
||||
const readUserCrontab = async (user: string): Promise<string> => {
|
||||
if (isDocker()) {
|
||||
const userCrontabs = await readAllHostCrontabs();
|
||||
const targetUserCrontab = userCrontabs.find((uc) => uc.user === user);
|
||||
return targetUserCrontab?.content || "";
|
||||
} else {
|
||||
const { stdout } = await execAsync(
|
||||
`crontab -l -u ${user} 2>/dev/null || echo ""`
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
};
|
||||
|
||||
async function writeCronFiles(content: string): Promise<boolean> {
|
||||
if (!isDocker) {
|
||||
try {
|
||||
await execAsync('echo "' + content + '" | crontab -');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error writing crontab:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return await writeCronFilesDocker(content);
|
||||
}
|
||||
|
||||
export async function getCronJobs(): Promise<CronJob[]> {
|
||||
const writeUserCrontab = async (user: string, content: string): Promise<boolean> => {
|
||||
if (isDocker()) {
|
||||
return await writeHostCrontabForUser(user, content);
|
||||
} else {
|
||||
try {
|
||||
const cronContent = await readCronFiles();
|
||||
|
||||
if (!cronContent.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = cronContent.split("\n");
|
||||
const jobs: CronJob[] = [];
|
||||
let currentComment = "";
|
||||
let currentUser = "";
|
||||
let jobIndex = 0;
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) return;
|
||||
|
||||
if (trimmedLine.startsWith("# User: ")) {
|
||||
currentUser = trimmedLine.substring(8).trim();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# System Crontab")) {
|
||||
currentUser = "system";
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
currentComment = trimmedLine.substring(1).trim();
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = trimmedLine.split(/\s+/);
|
||||
if (parts.length >= 6) {
|
||||
const schedule = parts.slice(0, 5).join(" ");
|
||||
const command = parts.slice(5).join(" ");
|
||||
|
||||
jobs.push({
|
||||
id: `unix-${jobIndex}`,
|
||||
schedule,
|
||||
command,
|
||||
comment: currentComment,
|
||||
});
|
||||
|
||||
currentComment = "";
|
||||
jobIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
return jobs;
|
||||
await execAsync(`echo '${content}' | crontab -u ${user} -`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error getting cron jobs:", error);
|
||||
return [];
|
||||
console.error(`Error writing crontab for user ${user}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getAllUsers = async (): Promise<{ user: string; content: string }[]> => {
|
||||
if (isDocker()) {
|
||||
return await readAllHostCrontabs();
|
||||
} else {
|
||||
const { getAllTargetUsers } = await import("./hostCrontab");
|
||||
const users = await getAllTargetUsers();
|
||||
const results: { user: string; content: string }[] = [];
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`crontab -l -u ${user} 2>/dev/null || echo ""`
|
||||
);
|
||||
results.push({ user, content: stdout });
|
||||
} catch (error) {
|
||||
console.error(`Error reading crontab for user ${user}:`, error);
|
||||
results.push({ user, content: "" });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCronJobs = async (): Promise<CronJob[]> => {
|
||||
try {
|
||||
const userCrontabs = await getAllUsers();
|
||||
let allJobs: CronJob[] = [];
|
||||
|
||||
for (const { user, content } of userCrontabs) {
|
||||
if (!content.trim()) continue;
|
||||
|
||||
const lines = content.split("\n");
|
||||
const jobs = parseJobsFromLines(lines, user);
|
||||
allJobs.push(...jobs);
|
||||
}
|
||||
|
||||
return allJobs;
|
||||
} catch (error) {
|
||||
console.error("Error getting cron jobs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCronJob(
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = ""
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const cronContent = await readCronFiles();
|
||||
export const addCronJob = async (
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = "",
|
||||
user?: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
if (user) {
|
||||
const cronContent = await readUserCrontab(user);
|
||||
const newEntry = comment
|
||||
? `# ${comment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
|
||||
if (isDocker) {
|
||||
const lines = cronContent.split("\n");
|
||||
let hasUserSection = false;
|
||||
let userSectionEnd = -1;
|
||||
let newCron;
|
||||
if (cronContent.trim() === "") {
|
||||
newCron = newEntry;
|
||||
} else {
|
||||
const existingContent = cronContent.trim();
|
||||
newCron = await cleanCrontabContent(existingContent + "\n" + newEntry);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
return await writeUserCrontab(user, newCron);
|
||||
} else {
|
||||
const cronContent = await readCronFiles();
|
||||
|
||||
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 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);
|
||||
}
|
||||
} else {
|
||||
const newEntry = comment
|
||||
? `# ${comment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
const newCron = cronContent + "\n" + newEntry;
|
||||
await writeCronFiles(newCron);
|
||||
}
|
||||
let newCron;
|
||||
if (cronContent.trim() === "") {
|
||||
newCron = newEntry;
|
||||
} else {
|
||||
const existingContent = cronContent.trim();
|
||||
newCron = await cleanCrontabContent(existingContent + "\n" + newEntry);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error adding cron job:", error);
|
||||
return false;
|
||||
return await writeCronFiles(newCron);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCronJob(id: string): Promise<boolean> {
|
||||
try {
|
||||
const cronContent = await readCronFiles();
|
||||
const lines = cronContent.split("\n");
|
||||
let currentComment = "";
|
||||
let cronEntries: string[] = [];
|
||||
let jobIndex = 0;
|
||||
let targetJobIndex = parseInt(id.replace("unix-", ""));
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
if (trimmedLine.startsWith("# User:") || trimmedLine.startsWith("# System Crontab")) {
|
||||
cronEntries.push(trimmedLine);
|
||||
} else if (trimmedLine.startsWith("#")) {
|
||||
if (i + 1 < lines.length && !lines[i + 1].trim().startsWith("#") && lines[i + 1].trim()) {
|
||||
currentComment = trimmedLine;
|
||||
} else {
|
||||
cronEntries.push(trimmedLine);
|
||||
}
|
||||
} else {
|
||||
if (jobIndex !== targetJobIndex) {
|
||||
const entryWithComment = currentComment
|
||||
? `${currentComment}\n${trimmedLine}`
|
||||
: trimmedLine;
|
||||
cronEntries.push(entryWithComment);
|
||||
}
|
||||
jobIndex++;
|
||||
currentComment = "";
|
||||
}
|
||||
}
|
||||
|
||||
const newCron = cronEntries.join("\n") + "\n";
|
||||
await writeCronFiles(newCron);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error deleting cron job:", error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error adding cron job:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCronJob(
|
||||
id: string,
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = ""
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const cronContent = await readCronFiles();
|
||||
const lines = cronContent.split("\n");
|
||||
let currentComment = "";
|
||||
let cronEntries: string[] = [];
|
||||
let jobIndex = 0;
|
||||
let targetJobIndex = parseInt(id.replace("unix-", ""));
|
||||
export const deleteCronJob = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const [user, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
const cronContent = await readUserCrontab(user);
|
||||
const lines = cronContent.split("\n");
|
||||
const newCronEntries = deleteJobInLines(lines, jobIndex);
|
||||
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
|
||||
|
||||
if (!trimmedLine) continue;
|
||||
return await writeUserCrontab(user, newCron);
|
||||
} catch (error) {
|
||||
console.error("Error deleting cron job:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# User:") || trimmedLine.startsWith("# System Crontab")) {
|
||||
cronEntries.push(trimmedLine);
|
||||
} else if (trimmedLine.startsWith("#")) {
|
||||
if (i + 1 < lines.length && !lines[i + 1].trim().startsWith("#") && lines[i + 1].trim()) {
|
||||
currentComment = trimmedLine;
|
||||
} else {
|
||||
cronEntries.push(trimmedLine);
|
||||
}
|
||||
} else {
|
||||
if (jobIndex === targetJobIndex) {
|
||||
const newEntry = comment
|
||||
? `# ${comment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
cronEntries.push(newEntry);
|
||||
} else {
|
||||
const entryWithComment = currentComment
|
||||
? `${currentComment}\n${trimmedLine}`
|
||||
: trimmedLine;
|
||||
cronEntries.push(entryWithComment);
|
||||
}
|
||||
jobIndex++;
|
||||
currentComment = "";
|
||||
}
|
||||
}
|
||||
export const updateCronJob = async (
|
||||
id: string,
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = ""
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const [user, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
|
||||
const newCron = cronEntries.join("\n") + "\n";
|
||||
await writeCronFiles(newCron);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error updating cron job:", error);
|
||||
const cronContent = await readUserCrontab(user);
|
||||
const lines = cronContent.split("\n");
|
||||
const newCronEntries = updateJobInLines(lines, jobIndex, schedule, command, comment);
|
||||
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
|
||||
|
||||
return await writeUserCrontab(user, newCron);
|
||||
} catch (error) {
|
||||
console.error("Error updating cron job:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const pauseCronJob = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const [user, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
|
||||
const cronContent = await readUserCrontab(user);
|
||||
const lines = cronContent.split("\n");
|
||||
const newCronEntries = pauseJobInLines(lines, jobIndex);
|
||||
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
|
||||
|
||||
return await writeUserCrontab(user, newCron);
|
||||
} catch (error) {
|
||||
console.error("Error pausing cron job:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const resumeCronJob = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const [user, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
|
||||
const cronContent = await readUserCrontab(user);
|
||||
const lines = cronContent.split("\n");
|
||||
const newCronEntries = resumeJobInLines(lines, jobIndex);
|
||||
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
|
||||
|
||||
return await writeUserCrontab(user, newCron);
|
||||
} catch (error) {
|
||||
console.error("Error resuming cron job:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanupCrontab = async (): Promise<boolean> => {
|
||||
try {
|
||||
const userCrontabs = await getAllUsers();
|
||||
|
||||
for (const { user, content } of userCrontabs) {
|
||||
if (!content.trim()) continue;
|
||||
|
||||
const cleanedContent = await cleanCrontabContent(content);
|
||||
await writeUserCrontab(user, cleanedContent);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error cleaning crontab:", 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 };
|
||||
211
app/_utils/system/hostCrontab.ts
Normal file
211
app/_utils/system/hostCrontab.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface UserInfo {
|
||||
username: string;
|
||||
uid: number;
|
||||
gid: number;
|
||||
}
|
||||
|
||||
const execHostCrontab = async (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;
|
||||
}
|
||||
}
|
||||
|
||||
const getTargetUser = async (): 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 const getAllTargetUsers = async (): Promise<string[]> => {
|
||||
try {
|
||||
if (process.env.HOST_CRONTAB_USER) {
|
||||
return process.env.HOST_CRONTAB_USER.split(",").map((u) => u.trim());
|
||||
}
|
||||
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
if (isDocker) {
|
||||
const singleUser = await getTargetUser();
|
||||
return [singleUser];
|
||||
} else {
|
||||
try {
|
||||
const { stdout } = await execAsync("ls /var/spool/cron/crontabs/");
|
||||
const users = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((user) => user.trim());
|
||||
return users.length > 0 ? users : ["root"];
|
||||
} catch (error) {
|
||||
console.error("Error detecting users from crontabs directory:", error);
|
||||
return ["root"];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting all target users:", error);
|
||||
return ["root"];
|
||||
}
|
||||
}
|
||||
|
||||
export const readHostCrontab = async (): 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 const readAllHostCrontabs = async (): Promise<
|
||||
{ user: string; content: string }[]
|
||||
> => {
|
||||
try {
|
||||
const users = await getAllTargetUsers();
|
||||
const results: { user: string; content: string }[] = [];
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
const content = await execHostCrontab(
|
||||
`crontab -l -u ${user} 2>/dev/null || echo ""`
|
||||
);
|
||||
results.push({ user, content });
|
||||
} catch (error) {
|
||||
console.warn(`Error reading crontab for user ${user}:`, error);
|
||||
results.push({ user, content: "" });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("Error reading all host crontabs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const writeHostCrontab = async (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;
|
||||
}
|
||||
}
|
||||
|
||||
export const writeHostCrontabForUser = async (
|
||||
user: string,
|
||||
content: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
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 for user ${user}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserInfo(username: string): Promise<UserInfo | null> {
|
||||
try {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
|
||||
if (isDocker) {
|
||||
const uidResult = await execHostCrontab(`id -u ${username}`);
|
||||
const gidResult = await execHostCrontab(`id -g ${username}`);
|
||||
|
||||
const uid = parseInt(uidResult.trim());
|
||||
const gid = parseInt(gidResult.trim());
|
||||
|
||||
if (isNaN(uid) || isNaN(gid)) {
|
||||
console.error(`Invalid UID/GID for user ${username}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { username, uid, gid };
|
||||
} else {
|
||||
const { stdout } = await execAsync(`id -u ${username}`);
|
||||
const uid = parseInt(stdout.trim());
|
||||
|
||||
const { stdout: gidStdout } = await execAsync(`id -g ${username}`);
|
||||
const gid = parseInt(gidStdout.trim());
|
||||
|
||||
if (isNaN(uid) || isNaN(gid)) {
|
||||
console.error(`Invalid UID/GID for user ${username}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { username, uid, gid };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting user info for ${username}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
44
app/api/auth/login/route.ts
Normal file
44
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { password } = await request.json()
|
||||
|
||||
const authPassword = process.env.AUTH_PASSWORD
|
||||
|
||||
if (!authPassword) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Authentication not configured' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (password !== authPassword) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Invalid password' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = NextResponse.json(
|
||||
{ success: true, message: 'Login successful' },
|
||||
{ status: 200 }
|
||||
)
|
||||
|
||||
response.cookies.set('cronmaster-auth', 'authenticated', {
|
||||
httpOnly: true,
|
||||
secure: request.url.startsWith('https://'),
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
26
app/api/auth/logout/route.ts
Normal file
26
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const response = NextResponse.json(
|
||||
{ success: true, message: 'Logout successful' },
|
||||
{ status: 200 }
|
||||
)
|
||||
|
||||
response.cookies.set('cronmaster-auth', '', {
|
||||
httpOnly: true,
|
||||
secure: request.url.startsWith('https://'),
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
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: any = null;
|
||||
if (Array.isArray(networkInfo) && networkInfo.length > 0) {
|
||||
mainInterface = networkInfo.find(net =>
|
||||
net.iface && !net.iface.includes('lo') && net.operstate === 'up'
|
||||
) || networkInfo.find(net =>
|
||||
net.iface && !net.iface.includes('lo')
|
||||
) || networkInfo[0];
|
||||
}
|
||||
|
||||
const networkSpeed = mainInterface && 'rx_sec' in mainInterface && 'tx_sec' in mainInterface
|
||||
? `${Math.round(((mainInterface.rx_sec || 0) + (mainInterface.tx_sec || 0)) / 1024 / 1024)} Mbps`
|
||||
: "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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { JetBrains_Mono, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "./_components/ui/ThemeProvider";
|
||||
import { ServiceWorkerRegister } from "./_components/ui/ServiceWorkerRegister";
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
@@ -17,8 +18,16 @@ const inter = Inter({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Cr*nMaster - Cron Management made easy",
|
||||
description:
|
||||
"The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
|
||||
description: "The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "Cr*nMaster",
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
icons: {
|
||||
icon: "/logo.png",
|
||||
shortcut: "/logo.png",
|
||||
@@ -26,6 +35,14 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: "#3b82f6",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -33,15 +50,24 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="application-name" content="Cr*nMaster" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Cr*nMaster" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
</head>
|
||||
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans`}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
<ServiceWorkerRegister />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
14
app/login/page.tsx
Normal file
14
app/login/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
'use server';
|
||||
|
||||
import { LoginForm } from "../_components/features/LoginForm/LoginForm";
|
||||
|
||||
export default async function LoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
<div className="hero-gradient absolute inset-0 -z-10"></div>
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
app/page.tsx
53
app/page.tsx
@@ -1,25 +1,60 @@
|
||||
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 { LogoutButton } from "./_components/ui/LogoutButton";
|
||||
import { ToastContainer } from "./_components/ui/Toast";
|
||||
import { PWAInstallPrompt } from "./_components/ui/PWAInstallPrompt";
|
||||
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>
|
||||
<div className="relative z-10">
|
||||
<header className="border-b border-border/50 bg-background/80 backdrop-blur-md sticky top-0 z-20 shadow-sm lg:h-[90px]">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center justify-between lg:justify-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<img src="/logo.png" alt="logo" className="w-14 h-14" />
|
||||
@@ -34,11 +69,16 @@ export default async function Home() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{process.env.AUTH_PASSWORD && (
|
||||
<div className="lg:absolute lg:right-10">
|
||||
<LogoutButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
@@ -49,8 +89,9 @@ export default async function Home() {
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
<div className="flex items-center gap-2 fixed bottom-4 left-4 lg:right-4 lg:left-auto">
|
||||
<div className="flex items-center gap-2 fixed bottom-4 left-4 lg:right-4 lg:left-auto z-10 bg-background/80 backdrop-blur-md border border-border/50 rounded-lg p-1">
|
||||
<ThemeToggle />
|
||||
<PWAInstallPrompt />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
cronjob-manager:
|
||||
image: ghcr.io/fccview/cronmaster:main
|
||||
image: ghcr.io/fccview/cronmaster:latest
|
||||
container_name: cronmaster
|
||||
user: "root"
|
||||
ports:
|
||||
@@ -9,34 +9,45 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DOCKER=true
|
||||
|
||||
# --- MAP HOST PROJECT DIRECTORY, THIS IS MANDATORY FOR SCRIPTS TO WORK
|
||||
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||
- NEXT_PUBLIC_HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
||||
|
||||
# --- PASSWORD PROTECTION
|
||||
# Uncomment to enable password protection (replace "password" with your own)
|
||||
- AUTH_PASSWORD=very_strong_password
|
||||
|
||||
# --- CRONTAB USERS
|
||||
# This is used to read the crontabs for the specific user.
|
||||
# replace root with your user - find it with: ls -asl /var/spool/cron/crontabs/
|
||||
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=root,user1,user2
|
||||
- HOST_CRONTAB_USER=root
|
||||
|
||||
# --- !! IMPORTANT !!DOCKER EXEC USER
|
||||
# If you do not specify this user to be a valid user on your system,
|
||||
# any cronjob containing a docker command will fail. IDEALLY you should not be running
|
||||
# docker commands as root, so this is only a fallback. ONLY ONE USER IS ALLOWED.
|
||||
- DOCKER_EXEC_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
|
||||
# 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 ---
|
||||
# --- MOUNT DATA
|
||||
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
|
||||
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
|
||||
# will target this 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
|
||||
restart: unless-stopped
|
||||
|
||||
# --- USE HOST PID NAMESPACE FOR HOST COMMAND EXECUTION
|
||||
# --- RUN IN PRIVILEGED MODE FOR NSENTER ACCESS
|
||||
pid: "host"
|
||||
privileged: true
|
||||
restart: always
|
||||
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
|
||||
|
||||
37
middleware.ts
Normal file
37
middleware.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
if (pathname.startsWith('/api/') || pathname.startsWith('/_next/') || pathname.includes('.')) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const authPassword = process.env.AUTH_PASSWORD
|
||||
|
||||
if (!authPassword) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const isAuthenticated = request.cookies.has('cronmaster-auth')
|
||||
|
||||
if (pathname === '/login') {
|
||||
if (isAuthenticated || !authPassword) {
|
||||
return NextResponse.redirect(new URL('/', request.url))
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon.ico|site.webmanifest|sw.js|app-icons).*)",
|
||||
],
|
||||
}
|
||||
@@ -1,6 +1,30 @@
|
||||
const withPWA = require('next-pwa')({
|
||||
dest: 'public',
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
disable: process.env.NODE_ENV === 'development',
|
||||
buildExcludes: [/middleware-manifest\.json$/]
|
||||
})
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Removed standalone output for traditional Next.js deployment
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/manifest.json',
|
||||
headers: [
|
||||
{ key: 'Content-Type', value: 'application/manifest+json' },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/sw.js',
|
||||
headers: [
|
||||
{ key: 'Service-Worker-Allowed', value: '/' },
|
||||
{ key: 'Cache-Control', value: 'no-cache' },
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
module.exports = withPWA(nextConfig)
|
||||
|
||||
@@ -23,22 +23,28 @@
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^2.0.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"cron-parser": "^5.3.0",
|
||||
"cronstrue": "^3.2.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"minimatch": "^10.0.3",
|
||||
"next": "14.0.4",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/minimatch": "^6.0.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.0.4"
|
||||
}
|
||||
|
||||
26
public/manifest.json
Normal file
26
public/manifest.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Cr*nMaster",
|
||||
"short_name": "Cr*nMaster",
|
||||
"description": "The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f0f23",
|
||||
"theme_color": "#3b82f6",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/logo.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["productivity", "utilities"],
|
||||
"lang": "en"
|
||||
}
|
||||
1
public/sw.js
Normal file
1
public/sw.js
Normal file
File diff suppressed because one or more lines are too long
1
public/workbox-1bb06f5e.js
Normal file
1
public/workbox-1bb06f5e.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
#!/bin/bash
|
||||
# @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
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "es6"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es6"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -19,9 +23,18 @@
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user