49 Commits

Author SHA1 Message Date
fccview
b2dc0a3cb3 Merge pull request #68 from fccview/develop
Lift off!!
2025-12-14 09:09:41 +00:00
fccview
e40b0c0f63 bump package version 2025-12-14 09:06:55 +00:00
fccview
79fd223416 fix documentation, patch next vulnerability and deal with the next upgrad annoyances 2025-12-14 09:06:19 +00:00
fccview
eaca3fe44a remove test scripts 2025-11-20 19:00:14 +00:00
fccview
e033caacf6 try a new strategy for log watch 2025-11-20 18:59:01 +00:00
fccview
4beb7053f7 Merge branch 'develop' 2025-11-20 07:26:04 +00:00
fccview
d26ce0e810 update package.json version 2025-11-20 07:26:00 +00:00
fccview
d6b6aff44e Merge pull request #64 from fccview/develop
fix api auth issue
2025-11-20 07:25:41 +00:00
fccview
7954111d05 fix api auth issue 2025-11-20 07:22:51 +00:00
fccview
0ab3358e28 Merge pull request #60 from fccview/develop
Lift Off!
2025-11-19 20:46:15 +00:00
fccview
f53905c002 make sure evil single quote wont cause issues 2025-11-19 20:41:43 +00:00
fccview
90775cac7c add optional variable to automatically go to oidc provider rather than manually click on a button 2025-11-19 20:35:02 +00:00
fccview
54188eb1c0 limit max output of live logs and paginate 2025-11-19 20:23:14 +00:00
fccview
bf208e3075 fix draft feature and copy button 2025-11-19 20:01:27 +00:00
fccview
a5fb5ff484 update package.json version 2025-11-19 16:48:45 +00:00
fccview
25190f3154 Merge branch 'feature/1-5-0-improvements' into develop 2025-11-19 16:47:54 +00:00
fccview
437bdbd81f fix parenthesis issue and give immutable ids to old jobs too 2025-11-19 16:47:43 +00:00
fccview
d8ab3839c6 Merge branch 'main' into develop 2025-11-19 15:54:58 +00:00
fccview
13fe6c5f3d fix bash language highlight and enhance editor with tabbing functionality 2025-11-19 15:54:42 +00:00
fccview
9fb904d68a Merge pull request #51 from fccview/develop
fix issue with overflow for jobs
2025-11-13 20:13:34 +00:00
fccview
b95cd79239 add min height 2025-11-13 20:09:21 +00:00
fccview
7a4a22f8e9 fix issue with overflow for jobs 2025-11-13 20:03:29 +00:00
fccview
df6ab8774d Merge pull request #49 from fccview/develop
Lift off!!!
2025-11-13 16:07:50 +00:00
fccview
feeb56ece8 finish readme and prepare release 2025-11-13 15:57:31 +00:00
fccview
1b6f5b6e34 fix minor styling, allow custom translations and prepare for release 2025-11-13 15:11:49 +00:00
fccview
1f2379db59 fix minor styling 2025-11-13 14:55:21 +00:00
fccview
ef5153ce54 improve performance, add minimal mode, make UI make more sense 2025-11-13 14:30:21 +00:00
fccview
8faf4d26d0 send fixes to develop for automatic ID assignation 2025-11-12 20:56:54 +00:00
fccview
1fd2689296 add copy id/command 2025-11-11 18:12:09 +00:00
fccview
01c87ab82f add backups and sort out uuid situation 2025-11-11 15:25:59 +00:00
fccview
beebdf878e add oidc and update readme to be more accurate 2025-11-11 11:33:51 +00:00
fccview
7e3d5db2be add SSE event pushing for logs to show live updates on the UI for healthy/unhealthy logs and show logs as a job runs to fix the bug of jobs failing if they are too big 2025-11-11 09:18:55 +00:00
fccview
0cefff769b create system stats utilities to avoid pointless repetitions 2025-11-11 07:35:10 +00:00
fccview
1a10eebe01 fix translations 2025-11-11 07:29:27 +00:00
fccview
1f82e85833 major improvements to the wrapper and fix api route mess 2025-11-11 07:27:03 +00:00
fccview
30d856b9ce add logs and fix a bunch of major issues 2025-11-10 11:41:04 +00:00
fccview
11c96d0aed fix server side translations 2025-11-05 22:03:36 +00:00
fccview
b9fb009923 huge translations work 2025-11-05 21:41:15 +00:00
fccview
b1a4d081ad finish initial refactor 2025-11-05 20:30:01 +00:00
fccview
e129bac619 continue refactor, this is looking good! 2025-11-05 20:11:06 +00:00
fccview
2ba9cdc622 remove the annoying HOST_PROJECT_DIR and DOCKER env variables 2025-11-05 19:52:20 +00:00
fccview
ce379a8cc9 use nsenter regardless if docker or not, slowly removing the DOCKER env variable 2025-11-05 18:49:41 +00:00
fccview
14fba08cb2 start cleaning up and refactoring 2025-11-05 18:31:47 +00:00
fccview
7e3bd590e9 update develop and improve pipelines 2025-11-05 18:10:35 +00:00
fccview
e2e95968ef Merge pull request #43 from Navino16/feature/extra-groups-ids
Very happy with these changes, thank you for the help!
2025-10-08 16:04:56 +01:00
fccview
a4ae5ec148 update readme 2025-10-08 16:03:02 +01:00
fccview
8e8069ee92 remove docker exec user and update readme with a note for docker compose as root 2025-10-08 15:44:31 +01:00
fccview
f96c37b55c remove debugging logs 2025-10-08 15:01:18 +01:00
fccview
cda9685e6d Add Navino to readme table 2025-10-08 14:53:56 +01:00
147 changed files with 12216 additions and 2554 deletions

52
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Reusable Docker Build Logic
on:
workflow_call:
inputs:
platform: { required: true, type: string }
suffix: { required: true, type: string }
runner: { required: true, type: string }
secrets:
token: { required: true }
jobs:
build:
runs-on: ${{ inputs.runner }}
permissions: { contents: read, packages: write }
steps:
- { name: Checkout repository, uses: actions/checkout@v4 }
- name: "Prepare repository name in lowercase"
id: repo
run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- { name: Set up QEMU, uses: docker/setup-qemu-action@v3 }
- { name: Set up Docker Buildx, uses: docker/setup-buildx-action@v3 }
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.token }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ steps.repo.outputs.name }}
tags: |
type=ref,event=branch,suffix=${{ inputs.suffix }}
type=ref,event=tag,suffix=${{ inputs.suffix }}
type=raw,value=latest,suffix=${{ inputs.suffix }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: ${{ inputs.platform }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,27 +1,69 @@
name: Docker
name: Build and Publish Multi-Platform Docker Image
on:
push:
branches: ["main", "legacy", "feature/*"]
branches: ["main", "develop"]
tags: ["*"]
pull_request:
branches: ["main"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
build-amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Prepare repository name in lowercase
id: repo
run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ steps.repo.outputs.name }}
tags: |
type=ref,event=branch,suffix=-amd64
type=ref,event=tag,suffix=-amd64
type=raw,value=latest,suffix=-amd64,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- name: Build and push AMD64 Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-arm64:
runs-on: ubuntu-22.04-arm
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Prepare repository name in lowercase
id: repo
run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -31,27 +73,68 @@ jobs:
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
images: |
ghcr.io/${{ steps.repo.outputs.name }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=ref,event=branch,suffix=-arm64
type=ref,event=tag,suffix=-arm64
type=raw,value=latest,suffix=-arm64,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- name: Build and push Docker image
- name: Build and push ARM64 Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
manifest:
needs: [build-amd64, build-arm64]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Prepare repository name in lowercase
id: repo
run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for final manifest
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ steps.repo.outputs.name }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- name: Create and push manifest list
run: |
echo "${{ steps.meta.outputs.tags }}" | while read -r tag; do
if [ -z "$tag" ]; then continue; fi
echo "Creating manifest for ${tag}"
docker buildx imagetools create --tag "${tag}" \
"${tag}-amd64" \
"${tag}-arm64"
done

4
.gitignore vendored
View File

@@ -13,4 +13,6 @@ node_modules
.cursorignore
.idea
tsconfig.tsbuildinfo
docker-compose.test.yml
docker-compose.test.yml
/data
claude.md

37
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,37 @@
# How to contribute
Hi, it's amazing having a community willing to push new feature to the app, and I am VERY open to contributors pushing their idea, it's what makes open source amazing.
That said for the sake of sanity let's all follow the same structure:
- When creating a new branch, do off from the develop branch, this will always be ahead of main and it's what gets released
- When creating a pull request, direct it back into develop, I'll then review it and merge it. Your code will end up in the next release that way and we all avoid conflicts!
- Please bear with on reviews, it may take a bit of time for me to go through it all on top of life/work/hobbies :)
## Some best practices
### Code Quality
- Follow the existing code style and structure
- Keep files modular and under 250-300 (split into smaller components if needed) lines unless it's a major server action, these can get intense I know
- Avoid code duplication - reuse existing functions and UI components, don't hardcode html when a component already exists (e.g. <button> vs <Button>)
- All imports should be at the top of the file unless it's for specific server actions
- Avoid using `any`
- Don't hardcode colors! Use the theme variables to make sure light/dark mode keep working well
- Make sure the UI is consistent with the current one, look for spacing issues, consistent spacing really makes a difference
### Pull Requests
- Keep PRs focused on a single feature or fix
- Update documentation if your changes affect user-facing features
- Test your changes locally before submitting
### Getting Started
1. Fork the repository
2. Create a feature branch from `develop`
3. Make your changes
4. Test thoroughly
5. Submit a pull request to `develop`
Thank you for contributing! <3

View File

@@ -5,6 +5,15 @@ RUN apt-get update && apt-get install -y \
curl \
iputils-ping \
util-linux \
ca-certificates \
gnupg \
lsb-release \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt-get update \
&& apt-get install -y docker-ce-cli \
&& rm -rf /var/lib/apt/lists/*
FROM base AS deps

239
README.md
View File

@@ -2,27 +2,59 @@
<img src="public/heading.png" width="400px">
</p>
# ATTENTION BREAKING UPDATE!!
## Table of Contents
> 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](#features)
- [Quick Start](#quick-start)
- [Using Docker (Recommended)](#using-docker-recommended)
- [API](#api)
- [Single Sign-On (SSO) with OIDC](#single-sign-on-sso-with-oidc)
- [Localization](#localization)
- [Local Development](#local-development)
- [Environment Variables](howto/ENV_VARIABLES.md)
- [Authentication](#authentication)
- [Usage](#usage)
- [Viewing System Information](#viewing-system-information)
- [Managing Cron Jobs](#managing-cron-jobs)
- [Job Execution Logging](#job-execution-logging)
- [Managing Scripts](#managing-scripts)
- [Technologies Used](#technologies-used)
- [Contributing](#contributing)
- [License](#license)
---
## Features
- **Modern UI**: Beautiful, responsive interface with dark/light mode.
- **System Information**: Display hostname, IP address, uptime, memory, network and CPU info.
- **System Information**: Display uptime, memory, network, CPU, and GPU info.
- **Cron Job Management**: View, create, and delete cron jobs with comments.
- **Script management**: View, create, and delete bash scripts on the go to use within your cron jobs.
- **Job Execution Logging**: Optional logging for cronjobs with automatic cleanup, capturing stdout, stderr, exit codes, and timestamps.
- **Live Updates (SSE)**: Real-time job status updates and live log streaming for long-running jobs (when logging is enabled).
- **Smart Job Execution**: Jobs with logging run in background with live updates, jobs without logging run synchronously with 5-minute timeout.
- **Authentication**: Secure password-based and/or OIDC (SSO) authentication with proper session management.
- **REST API**: Full REST API with optional API key authentication for external integrations.
- **Docker Support**: Runs entirely from a Docker container.
- **Easy Setup**: Quick presets for common cron schedules.
<br />
---
<p align="center">
<a href="http://discord.gg/invite/mMuk2WzVZu">
<img width="40" src="public/repo-images/discord_icon.webp">
</a>
<br />
<i>Join the discord server for more info</i>
<br />
</p>
---
<br />
## Before we start
Hey there! 👋 Just a friendly heads-up: I'm a big believer in open source and love sharing my work with the community. Everything you find in my GitHub repos is and always will be 100% free. If someone tries to sell you a "premium" version of any of my projects while claiming to be me, please know that this is not legitimate. 🚫
@@ -36,72 +68,72 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
</p>
<div align="center">
<img width="500px" src="screenshots/jobs-view.png">
<img width="500px" src="screenshots/scripts-view.png" />
<img width="500px" src="screenshots/home.png">
<img width="500px" src="screenshots/live-running.png" />
</div>
<a id="quick-start"></a>
## Quick Start
<a id="using-docker-recommended"></a>
### Using Docker (Recommended)
1. Create a `docker-compose.yml` file with this content:
1. Create a `docker-compose.yml` file with this minimal configuration:
```bash
```yaml
# For all configuration options, see howto/DOCKER.md
services:
cronjob-manager:
cronmaster:
image: ghcr.io/fccview/cronmaster:latest
container_name: cronmaster
user: "root"
ports:
# Feel free to change port, 3000 is very common so I like to map it to something else
- "40123:3000"
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
# --- 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:
# --- MOUNT DOCKER SOCKET
# Mount Docker socket to execute commands on host
- /var/run/docker.sock:/var/run/docker.sock
# --- MOUNT DATA
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
- ./scripts:/app/scripts
- ./data:/app/data
- ./snippets:/app/snippets
# --- USE HOST PID NAMESPACE FOR HOST COMMAND EXECUTION
# --- RUN IN PRIVILEGED MODE FOR NSENTER ACCESS
pid: "host"
privileged: true
restart: always
init: true
# --- DEFAULT PLATFORM IS SET TO AMD64, UNCOMMENT TO USE ARM64.
#platform: linux/arm64
```
📖 **For all available configuration options, see [`howto/DOCKER.md`](howto/DOCKER.md)**
<a id="api"></a>
## API
`cr*nmaster` includes a REST API for programmatic access to your cron jobs and system information. This is perfect for integrations.
📖 **For the complete API documentation, see [howto/API.md](howto/API.md)**
<a id="single-sign-on-sso-with-oidc"></a>
## Single Sign-On (SSO) with OIDC
`cr*nmaster` supports any OIDC provider (Authentik, Auth0, Keycloak, Okta, Google, EntraID, etc.)
📖 **For the complete SSO documentation, see [howto/SSO.md](howto/SSO.md)**
<a id="localization"></a>
## Localization
`cr*nmaster` officially support [some languages](app/_translations) and allows you to create your custom translations locally on your own machine.
📖 **For the complete Translations documentation, see [howto/TRANSLATIONS.md](howto/TRANSLATIONS.md)**
### ARM64 Support
The application supports both AMD64 and ARM64 architectures:
@@ -126,6 +158,8 @@ docker compose up --build
**Note**: The Docker implementation uses direct file access to read and write crontab files, ensuring real-time synchronization with the host system's cron jobs. This approach bypasses the traditional `crontab` command limitations in containerized environments
<a id="local-development"></a>
### Local Development
1. Install dependencies:
@@ -142,39 +176,70 @@ yarn dev
3. Open your browser and navigate to `http://localhost:3000`
<a id="environment-variables"></a>
### Environment Variables
The following environment variables can be configured:
📖 **For the complete environment variables reference, see [`howto/ENV_VARIABLES.md`](howto/ENV_VARIABLES.md)**
| Variable | Default | Description |
| ----------------------------------- | ------- | ------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL` | `30000` | Clock update interval in milliseconds (30 seconds) |
| `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. |
This includes all configuration options for:
**Example**: To change the clock update interval to 60 seconds:
```bash
NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=60000 docker-compose up
```
**Example**: Your `docker-compose.yml` file or repository are in `~/homelab/cronmaster/`
```bash
HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
```
- Core application settings
- Docker configuration
- UI customization
- Logging settings
- Authentication (password, SSO/OIDC, API keys)
- Development and debugging options
### 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`
- `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.
- The Docker socket and data volume mounts are required for proper functionality
**Important Note on Root Commands**: When running commands as `root` within Cronmaster, ensure that these commands also function correctly as `root` on your host machine. If a command works as `root` on your host but fails within Cronmaster, please open an issue with detailed information.
<a id="authentication"></a>
## Authentication
Cr\*nMaster supports multiple authentication methods to secure your instance:
### Password Authentication
Set a password to protect access to your Cronmaster instance:
```yaml
environment:
- AUTH_PASSWORD=your_secure_password
```
Users will be prompted to enter this password before accessing the application.
### SSO Authentication (OIDC)
Cr\*nMaster supports SSO via OIDC (OpenID Connect), compatible with providers like:
- Authentik
- Auth0
- Keycloak
- Okta
- Google
- Entra ID (Azure AD)
- And many more!
You can enable **both** password and SSO authentication simultaneously:
The login page will display both options, allowing users to choose their preferred method.
**For detailed setup instructions, see **[howto/SSO.md](howto/SSO.md)**
<a id="usage"></a>
## Usage
<a id="viewing-system-information"></a>
### Viewing System Information
The application automatically detects your operating system and displays:
@@ -184,6 +249,8 @@ The application automatically detects your operating system and displays:
- CPU Information
- GPU Information (if supported)
<a id="managing-cron-jobs"></a>
### Managing Cron Jobs
1. **View Existing Jobs**: All current cron jobs are displayed with their schedules and commands
@@ -192,6 +259,13 @@ The application automatically detects your operating system and displays:
4. **Add Comments**: Include descriptions for your cron jobs
5. **Delete Jobs**: Remove unwanted cron jobs with the delete button
6. **Clone Jobs**: Clone jobs to quickly edit the command in case it's similar
7. **Enable Logging**: Optionally enable execution logging for any cronjob to capture detailed execution information
<a id="job-execution-logging"></a>
### Job Execution Logging
📖 **For complete logging documentation, see [howto/LOGS.md](howto/LOGS.md)**
### Cron Schedule Format
@@ -203,6 +277,8 @@ The application uses standard cron format: `* * * * *`
- Fourth field: Month (1-12)
- Fifth field: Day of week (0-7, where 0 and 7 are Sunday)
<a id="managing-scripts"></a>
### Managing Scripts
1. **View Existing Scripts**: All current user created scripts are displayed with their name and descriptions
@@ -211,23 +287,6 @@ The application uses standard cron format: `* * * * *`
4. **Delete Scripts**: Remove unwanted scripts (this won't delete the cronjob, you will need to manually remove these yourself)
5. **Clone Scripts**: Clone scripts to quickly edit them in case they are similar to one another.
## Technologies Used
- **Next.js 14**: React framework with App Router
- **TypeScript**: Type-safe JavaScript
- **Tailwind CSS**: Utility-first CSS framework
- **Lucide React**: Beautiful icons
- **next-themes**: Dark/light mode support
- **Docker**: Containerization
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
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!
@@ -264,10 +323,20 @@ I would like to thank the following members for raising issues and help test/deb
<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>
<td align="center" valign="top" width="20%">
<a href="https://github.com/Navino16"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/22234867?v=4&size=100"><br />Navino16</a>
</td>
</tr>
<tr>
<td align="center" valign="top" width="20%">
<a href="https://github.com/ShadowTox"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/558536?v=4&size=100"><br />ShadowTox</a>
</td>
</tr>
</tbody>
</table>
<a id="license"></a>
## License
This project is licensed under the MIT License.

View File

@@ -0,0 +1,433 @@
"use client";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/app/_components/GlobalComponents/Cards/Card";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Switch } from "@/app/_components/GlobalComponents/UIElements/Switch";
import {
Clock,
Plus,
Archive,
ChevronDown,
Code,
MessageSquare,
Settings,
Loader2,
Filter,
} from "lucide-react";
import { CronJob } from "@/app/_utils/cronjob-utils";
import { Script } from "@/app/_utils/scripts-utils";
import { UserFilter } from "@/app/_components/FeatureComponents/User/UserFilter";
import { useCronJobState } from "@/app/_hooks/useCronJobState";
import { CronJobItem } from "@/app/_components/FeatureComponents/Cronjobs/Parts/CronJobItem";
import { MinimalCronJobItem } from "@/app/_components/FeatureComponents/Cronjobs/Parts/MinimalCronJobItem";
import { CronJobEmptyState } from "@/app/_components/FeatureComponents/Cronjobs/Parts/CronJobEmptyState";
import { CronJobListModals } from "@/app/_components/FeatureComponents/Modals/CronJobListsModals";
import { LogsModal } from "@/app/_components/FeatureComponents/Modals/LogsModal";
import { LiveLogModal } from "@/app/_components/FeatureComponents/Modals/LiveLogModal";
import { RestoreBackupModal } from "@/app/_components/FeatureComponents/Modals/RestoreBackupModal";
import { FiltersModal } from "@/app/_components/FeatureComponents/Modals/FiltersModal";
import { useTranslations } from "next-intl";
import { useSSEContext } from "@/app/_contexts/SSEContext";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
fetchBackupFiles,
restoreCronJob,
deleteBackup,
backupAllCronJobs,
restoreAllCronJobs,
} from "@/app/_server/actions/cronjobs";
import { showToast } from "@/app/_components/GlobalComponents/UIElements/Toast";
interface CronJobListProps {
cronJobs: CronJob[];
scripts: Script[];
}
export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
const t = useTranslations();
const router = useRouter();
const { subscribe } = useSSEContext();
const [isBackupModalOpen, setIsBackupModalOpen] = useState(false);
const [backupFiles, setBackupFiles] = useState<
Array<{
filename: string;
job: CronJob;
backedUpAt: string;
}>
>([]);
const [scheduleDisplayMode, setScheduleDisplayMode] = useState<
"cron" | "human" | "both"
>("both");
const [loadedSettings, setLoadedSettings] = useState<boolean>(false);
const [isFiltersModalOpen, setIsFiltersModalOpen] = useState(false);
const [minimalMode, setMinimalMode] = useState(false);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
try {
const savedScheduleMode = localStorage.getItem(
"cronjob-schedule-display-mode"
);
if (
savedScheduleMode === "cron" ||
savedScheduleMode === "human" ||
savedScheduleMode === "both"
) {
setScheduleDisplayMode(savedScheduleMode);
}
const savedMinimalMode = localStorage.getItem("cronjob-minimal-mode");
if (savedMinimalMode === "true") {
setMinimalMode(true);
}
setLoadedSettings(true);
} catch (error) {
console.warn("Failed to load settings from localStorage:", error);
}
}, []);
useEffect(() => {
const unsubscribe = subscribe((event) => {
if (event.type === "job-completed" || event.type === "job-failed") {
router.refresh();
}
});
return unsubscribe;
}, [subscribe, router]);
useEffect(() => {
if (!isClient) return;
try {
localStorage.setItem(
"cronjob-schedule-display-mode",
scheduleDisplayMode
);
} catch (error) {
console.warn(
"Failed to save schedule display mode to localStorage:",
error
);
}
}, [scheduleDisplayMode, isClient]);
useEffect(() => {
if (!isClient) return;
try {
localStorage.setItem("cronjob-minimal-mode", minimalMode.toString());
} catch (error) {
console.warn("Failed to save minimal mode to localStorage:", error);
}
}, [minimalMode, isClient]);
const loadBackupFiles = async () => {
const backups = await fetchBackupFiles();
setBackupFiles(backups);
};
const handleRestore = async (filename: string) => {
const result = await restoreCronJob(filename);
if (result.success) {
showToast("success", t("cronjobs.restoreJobSuccess"));
router.refresh();
loadBackupFiles();
} else {
showToast("error", t("cronjobs.restoreJobFailed"), result.message);
}
};
const handleRestoreAll = async () => {
const result = await restoreAllCronJobs();
if (result.success) {
showToast("success", result.message);
router.refresh();
setIsBackupModalOpen(false);
} else {
showToast("error", "Failed to restore all jobs", result.message);
}
};
const handleBackupAll = async () => {
const result = await backupAllCronJobs();
if (result.success) {
showToast("success", result.message);
loadBackupFiles();
} else {
showToast("error", t("cronjobs.backupAllFailed"), result.message);
}
};
const handleDeleteBackup = async (filename: string) => {
const result = await deleteBackup(filename);
if (result.success) {
showToast("success", t("cronjobs.backupDeleted"));
loadBackupFiles();
} else {
showToast("error", "Failed to delete backup", result.message);
}
};
const {
deletingId,
runningJobId,
selectedUser,
setSelectedUser,
jobErrors,
errorModalOpen,
setErrorModalOpen,
selectedError,
setSelectedError,
isLogsModalOpen,
setIsLogsModalOpen,
jobForLogs,
isLiveLogModalOpen,
setIsLiveLogModalOpen,
liveLogRunId,
liveLogJobId,
liveLogJobComment,
filteredJobs,
isNewCronModalOpen,
setIsNewCronModalOpen,
isEditModalOpen,
setIsEditModalOpen,
isDeleteModalOpen,
setIsDeleteModalOpen,
isCloneModalOpen,
setIsCloneModalOpen,
jobToDelete,
jobToClone,
isCloning,
editForm,
setEditForm,
newCronForm,
setNewCronForm,
handleErrorClickLocal,
refreshJobErrorsLocal,
handleDeleteLocal,
handleCloneLocal,
handlePauseLocal,
handleResumeLocal,
handleRunLocal,
handleToggleLoggingLocal,
handleViewLogs,
confirmDelete,
confirmClone,
handleEdit,
handleEditSubmitLocal,
handleNewCronSubmitLocal,
handleBackupLocal,
} = useCronJobState({ cronJobs, scripts });
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">
{t("cronjobs.scheduledTasks")}
</CardTitle>
<p className="text-sm text-muted-foreground">
{t("cronjobs.nOfNJObs", {
filtered: filteredJobs.length,
total: cronJobs.length,
})}{" "}
{selectedUser &&
t("cronjobs.forUser", { user: selectedUser })}
</p>
</div>
</div>
<div className="flex gap-2 w-full justify-between sm:w-auto">
<div className="flex gap-2">
<Button
onClick={() => setIsFiltersModalOpen(true)}
variant="outline"
className="btn-outline"
title={t("cronjobs.filters")}
>
<Filter className="h-4 w-4" />
</Button>
<Button
onClick={() => setIsBackupModalOpen(true)}
variant="outline"
className="btn-outline"
title={t("cronjobs.backups")}
>
<Archive className="h-4 w-4" />
</Button>
</div>
<Button
onClick={() => setIsNewCronModalOpen(true)}
className="btn-primary glow-primary"
>
<Plus className="h-4 w-4 mr-2" />
{t("cronjobs.newTask")}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<label
className="text-sm font-medium text-foreground cursor-pointer"
onClick={() => setMinimalMode(!minimalMode)}
>
{t("cronjobs.minimalMode")}
</label>
<Switch checked={minimalMode} onCheckedChange={setMinimalMode} />
</div>
</div>
{filteredJobs.length === 0 ? (
<CronJobEmptyState
selectedUser={selectedUser}
onNewTaskClick={() => setIsNewCronModalOpen(true)}
/>
) : (
<div className="space-y-3 max-h-[55vh] min-h-[55vh] overflow-y-auto">
{loadedSettings ? (
filteredJobs.map((job) =>
minimalMode ? (
<MinimalCronJobItem
key={job.id}
job={job}
errors={jobErrors[job.id] || []}
runningJobId={runningJobId}
deletingId={deletingId}
scheduleDisplayMode={scheduleDisplayMode}
onRun={handleRunLocal}
onEdit={handleEdit}
onClone={confirmClone}
onResume={handleResumeLocal}
onPause={handlePauseLocal}
onToggleLogging={handleToggleLoggingLocal}
onViewLogs={handleViewLogs}
onDelete={confirmDelete}
onBackup={handleBackupLocal}
onErrorClick={handleErrorClickLocal}
/>
) : (
<CronJobItem
key={job.id}
job={job}
errors={jobErrors[job.id] || []}
runningJobId={runningJobId}
deletingId={deletingId}
scheduleDisplayMode={scheduleDisplayMode}
onRun={handleRunLocal}
onEdit={handleEdit}
onClone={confirmClone}
onResume={handleResumeLocal}
onPause={handlePauseLocal}
onToggleLogging={handleToggleLoggingLocal}
onViewLogs={handleViewLogs}
onDelete={confirmDelete}
onBackup={handleBackupLocal}
onErrorClick={handleErrorClickLocal}
onErrorDismiss={refreshJobErrorsLocal}
/>
)
)
) : (
<div className="flex items-center justify-center h-full min-h-[55vh]">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)}
</div>
)}
</CardContent>
</Card>
<CronJobListModals
cronJobs={cronJobs}
scripts={scripts}
isNewCronModalOpen={isNewCronModalOpen}
onNewCronModalClose={() => setIsNewCronModalOpen(false)}
onNewCronSubmit={handleNewCronSubmitLocal}
newCronForm={newCronForm}
onNewCronFormChange={(updates) =>
setNewCronForm((prev) => ({ ...prev, ...updates }))
}
isEditModalOpen={isEditModalOpen}
onEditModalClose={() => setIsEditModalOpen(false)}
onEditSubmit={handleEditSubmitLocal}
editForm={editForm}
onEditFormChange={(updates) =>
setEditForm((prev) => ({ ...prev, ...updates }))
}
isDeleteModalOpen={isDeleteModalOpen}
onDeleteModalClose={() => setIsDeleteModalOpen(false)}
onDeleteConfirm={() =>
jobToDelete ? handleDeleteLocal(jobToDelete.id) : undefined
}
jobToDelete={jobToDelete}
isCloneModalOpen={isCloneModalOpen}
onCloneModalClose={() => setIsCloneModalOpen(false)}
onCloneConfirm={handleCloneLocal}
jobToClone={jobToClone}
isCloning={isCloning}
isErrorModalOpen={errorModalOpen}
onErrorModalClose={() => {
setErrorModalOpen(false);
setSelectedError(null);
}}
selectedError={selectedError}
/>
{jobForLogs && (
<LogsModal
isOpen={isLogsModalOpen}
onClose={() => setIsLogsModalOpen(false)}
jobId={jobForLogs.id}
jobComment={jobForLogs.comment}
preSelectedLog={jobForLogs.logError?.lastFailedLog}
/>
)}
<LiveLogModal
isOpen={isLiveLogModalOpen}
onClose={() => setIsLiveLogModalOpen(false)}
runId={liveLogRunId}
jobId={liveLogJobId}
jobComment={liveLogJobComment}
/>
<RestoreBackupModal
isOpen={isBackupModalOpen}
onClose={() => setIsBackupModalOpen(false)}
backups={backupFiles}
onRestore={handleRestore}
onRestoreAll={handleRestoreAll}
onBackupAll={handleBackupAll}
onDelete={handleDeleteBackup}
onRefresh={loadBackupFiles}
/>
<FiltersModal
isOpen={isFiltersModalOpen}
onClose={() => setIsFiltersModalOpen(false)}
selectedUser={selectedUser}
onUserChange={setSelectedUser}
scheduleDisplayMode={scheduleDisplayMode}
onScheduleDisplayModeChange={setScheduleDisplayMode}
/>
</>
);
};

View File

@@ -0,0 +1,40 @@
"use client";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Clock, Plus } from "lucide-react";
interface CronJobEmptyStateProps {
selectedUser: string | null;
onNewTaskClick: () => void;
}
export const CronJobEmptyState = ({
selectedUser,
onNewTaskClick,
}: CronJobEmptyStateProps) => {
return (
<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={onNewTaskClick}
className="btn-primary glow-primary"
size="lg"
>
<Plus className="h-5 w-5 mr-2" />
Create Your First Task
</Button>
</div>
);
};

View File

@@ -0,0 +1,381 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { DropdownMenu } from "@/app/_components/GlobalComponents/UIElements/DropdownMenu";
import {
Trash2,
Edit,
Files,
User,
Play,
Pause,
Code,
Info,
FileOutput,
FileX,
FileText,
AlertCircle,
CheckCircle,
AlertTriangle,
Download,
Hash,
Check,
} from "lucide-react";
import { CronJob } from "@/app/_utils/cronjob-utils";
import { JobError } from "@/app/_utils/error-utils";
import { ErrorBadge } from "@/app/_components/GlobalComponents/Badges/ErrorBadge";
import {
parseCronExpression,
type CronExplanation,
} from "@/app/_utils/parser-utils";
import { unwrapCommand } from "@/app/_utils/wrapper-utils-client";
import { useLocale } from "next-intl";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/app/_utils/global-utils";
interface CronJobItemProps {
job: CronJob;
errors: JobError[];
runningJobId: string | null;
deletingId: string | null;
scheduleDisplayMode: "cron" | "human" | "both";
onRun: (id: string) => void;
onEdit: (job: CronJob) => void;
onClone: (job: CronJob) => void;
onResume: (id: string) => void;
onPause: (id: string) => void;
onDelete: (job: CronJob) => void;
onToggleLogging: (id: string) => void;
onViewLogs: (job: CronJob) => void;
onBackup: (id: string) => void;
onErrorClick: (error: JobError) => void;
onErrorDismiss: () => void;
}
export const CronJobItem = ({
job,
errors,
runningJobId,
deletingId,
scheduleDisplayMode,
onRun,
onEdit,
onClone,
onResume,
onPause,
onDelete,
onToggleLogging,
onViewLogs,
onBackup,
onErrorClick,
onErrorDismiss,
}: CronJobItemProps) => {
const [cronExplanation, setCronExplanation] =
useState<CronExplanation | null>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [showCopyConfirmation, setShowCopyConfirmation] = useState(false);
const locale = useLocale();
const t = useTranslations();
const displayCommand = unwrapCommand(job.command);
const [commandCopied, setCommandCopied] = useState<string | null>(null);
useEffect(() => {
if (job.schedule) {
const explanation = parseCronExpression(job.schedule, locale);
setCronExplanation(explanation);
} else {
setCronExplanation(null);
}
}, [job.schedule]);
const dropdownMenuItems = [
{
label: t("cronjobs.editCronJob"),
icon: <Edit className="h-3 w-3" />,
onClick: () => onEdit(job),
},
{
label: job.logsEnabled
? t("cronjobs.disableLogging")
: t("cronjobs.enableLogging"),
icon: job.logsEnabled ? (
<FileX className="h-3 w-3" />
) : (
<FileOutput className="h-3 w-3" />
),
onClick: () => onToggleLogging(job.id),
},
...(job.logsEnabled
? [
{
label: t("cronjobs.viewLogs"),
icon: <FileText className="h-3 w-3" />,
onClick: () => onViewLogs(job),
},
]
: []),
{
label: job.paused
? t("cronjobs.resumeCronJob")
: t("cronjobs.pauseCronJob"),
icon: job.paused ? (
<Play className="h-3 w-3" />
) : (
<Pause className="h-3 w-3" />
),
onClick: () => (job.paused ? onResume(job.id) : onPause(job.id)),
},
{
label: t("cronjobs.cloneCronJob"),
icon: <Files className="h-3 w-3" />,
onClick: () => onClone(job),
},
{
label: t("cronjobs.backupJob"),
icon: <Download className="h-3 w-3" />,
onClick: () => onBackup(job.id),
},
{
label: t("cronjobs.deleteCronJob"),
icon: <Trash2 className="h-3 w-3" />,
onClick: () => onDelete(job),
variant: "destructive" as const,
disabled: deletingId === job.id,
},
];
return (
<div
key={job.id}
className={`glass-card p-4 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors ${
isDropdownOpen ? "relative z-10" : ""
}`}
>
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
{(scheduleDisplayMode === "cron" ||
scheduleDisplayMode === "both") && (
<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>
)}
{scheduleDisplayMode === "human" && cronExplanation?.isValid && (
<div className="flex items-start gap-1.5 border-b border-primary/30 bg-primary/10 rounded text-primary px-2 py-0.5">
<Info className="h-3 w-3 text-primary mt-0.5 flex-shrink-0" />
<p className="text-sm italic">
{cronExplanation.humanReadable}
</p>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0 w-full">
{commandCopied === job.id && (
<Check className="h-3 w-3 text-green-600" />
)}
<pre
onClick={(e) => {
e.stopPropagation();
copyToClipboard(unwrapCommand(job.command));
setCommandCopied(job.id);
setTimeout(() => setCommandCopied(null), 3000);
}}
className="w-full cursor-pointer overflow-x-auto text-sm font-medium text-foreground bg-muted/30 px-2 py-1 rounded border border-border/30 hide-scrollbar"
>
{unwrapCommand(displayCommand)}
</pre>
</div>
</div>
</div>
<div className="flex items-center gap-2 pb-2 pt-4">
{scheduleDisplayMode === "both" && cronExplanation?.isValid && (
<div className="flex items-start gap-1.5 border-b border-primary/30 bg-primary/10 rounded text-primary px-2 py-0.5">
<Info className="h-3 w-3 text-primary mt-0.5 flex-shrink-0" />
<p className="text-xs italic">
{cronExplanation.humanReadable}
</p>
</div>
)}
{job.comment && (
<p
className="text-xs text-muted-foreground italic truncate"
title={job.comment}
>
{job.comment}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2 py-3">
<div className="flex items-center gap-1 text-xs bg-muted/50 text-muted-foreground px-2 py-0.5 rounded border border-border/30 cursor-pointer hover:bg-muted/70 transition-colors relative">
<User className="h-3 w-3" />
<span>{job.user}</span>
</div>
<div
className="flex items-center gap-1 text-xs bg-muted/50 text-muted-foreground px-2 py-0.5 rounded border border-border/30 cursor-pointer hover:bg-muted/70 transition-colors relative"
title="Click to copy Job UUID"
onClick={async () => {
const success = await copyToClipboard(job.id);
if (success) {
setShowCopyConfirmation(true);
setTimeout(() => setShowCopyConfirmation(false), 3000);
}
}}
>
{showCopyConfirmation ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Hash className="h-3 w-3" />
)}
<span className="font-mono">{job.id}</span>
</div>
{job.paused && (
<span className="text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 px-2 py-0.5 rounded border border-yellow-500/20">
{t("cronjobs.paused")}
</span>
)}
{job.logsEnabled && (
<span className="text-xs bg-blue-500/10 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded border border-blue-500/20">
{t("cronjobs.logged")}
</span>
)}
{job.logsEnabled && job.logError?.hasError && (
<button
onClick={(e) => {
e.stopPropagation();
onViewLogs(job);
}}
className="flex items-center gap-1 text-xs bg-red-500/10 text-red-600 dark:text-red-400 px-2 py-0.5 rounded border border-red-500/30 hover:bg-red-500/20 transition-colors cursor-pointer"
title="Latest execution failed - Click to view error log"
>
<AlertCircle className="h-3 w-3" />
<span>
{t("cronjobs.failed", {
exitCode: job.logError?.exitCode?.toString() ?? "",
})}
</span>
</button>
)}
{job.logsEnabled &&
!job.logError?.hasError &&
job.logError?.hasHistoricalFailures && (
<button
onClick={(e) => {
e.stopPropagation();
onViewLogs(job);
}}
className="flex items-center gap-1 text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 px-2 py-0.5 rounded border border-yellow-500/30 hover:bg-yellow-500/20 transition-colors cursor-pointer"
title="Latest execution succeeded, but has historical failures - Click to view logs"
>
<CheckCircle className="h-3 w-3" />
<span>{t("cronjobs.healthy")}</span>
<AlertTriangle className="h-3 w-3" />
</button>
)}
{job.logsEnabled &&
!job.logError?.hasError &&
!job.logError?.hasHistoricalFailures &&
job.logError?.latestExitCode === 0 && (
<div className="flex items-center gap-1 text-xs bg-green-500/10 text-green-600 dark:text-green-400 px-2 py-0.5 rounded border border-green-500/30">
<CheckCircle className="h-3 w-3" />
<span>{t("cronjobs.healthy")}</span>
</div>
)}
{!job.logsEnabled && (
<ErrorBadge
errors={errors}
onErrorClick={onErrorClick}
onErrorDismiss={onErrorDismiss}
/>
)}
</div>
</div>
<div className="flex items-center gap-2 justify-between sm:justify-end">
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => onRun(job.id)}
disabled={runningJobId === job.id || job.paused}
className="btn-outline h-8 px-3"
title={t("cronjobs.runCronManually")}
aria-label={t("cronjobs.runCronManually")}
>
{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={() => {
if (job.paused) {
onResume(job.id);
} else {
onPause(job.id);
}
}}
className="btn-outline h-8 px-3"
title={t("cronjobs.pauseCronJob")}
aria-label={t("cronjobs.pauseCronJob")}
>
{job.paused ? (
<Play className="h-3 w-3" />
) : (
<Pause className="h-3 w-3" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
if (job.logsEnabled) {
onViewLogs(job);
} else {
onToggleLogging(job.id);
}
}}
className="btn-outline h-8 px-3"
title={
job.logsEnabled
? t("cronjobs.viewLogs")
: t("cronjobs.enableLogging")
}
aria-label={
job.logsEnabled
? t("cronjobs.viewLogs")
: t("cronjobs.enableLogging")
}
>
{job.logsEnabled ? (
<FileText className="h-3 w-3" />
) : (
<FileOutput className="h-3 w-3" />
)}
</Button>
</div>
<DropdownMenu
items={dropdownMenuItems}
onOpenChange={setIsDropdownOpen}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,303 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { DropdownMenu } from "@/app/_components/GlobalComponents/UIElements/DropdownMenu";
import {
Trash2,
Edit,
Files,
Play,
Pause,
Code,
Info,
Download,
Check,
FileX,
FileText,
FileOutput,
} from "lucide-react";
import { CronJob } from "@/app/_utils/cronjob-utils";
import { JobError } from "@/app/_utils/error-utils";
import {
parseCronExpression,
type CronExplanation,
} from "@/app/_utils/parser-utils";
import { unwrapCommand } from "@/app/_utils/wrapper-utils-client";
import { useLocale } from "next-intl";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/app/_utils/global-utils";
interface MinimalCronJobItemProps {
job: CronJob;
errors: JobError[];
runningJobId: string | null;
deletingId: string | null;
scheduleDisplayMode: "cron" | "human" | "both";
onRun: (id: string) => void;
onEdit: (job: CronJob) => void;
onClone: (job: CronJob) => void;
onResume: (id: string) => void;
onPause: (id: string) => void;
onDelete: (job: CronJob) => void;
onToggleLogging: (id: string) => void;
onViewLogs: (job: CronJob) => void;
onBackup: (id: string) => void;
onErrorClick: (error: JobError) => void;
}
export const MinimalCronJobItem = ({
job,
errors,
runningJobId,
deletingId,
scheduleDisplayMode,
onRun,
onEdit,
onClone,
onResume,
onPause,
onDelete,
onToggleLogging,
onViewLogs,
onBackup,
onErrorClick,
}: MinimalCronJobItemProps) => {
const [cronExplanation, setCronExplanation] =
useState<CronExplanation | null>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [commandCopied, setCommandCopied] = useState<string | null>(null);
const locale = useLocale();
const t = useTranslations();
const displayCommand = unwrapCommand(job.command);
useEffect(() => {
if (job.schedule) {
const explanation = parseCronExpression(job.schedule, locale);
setCronExplanation(explanation);
} else {
setCronExplanation(null);
}
}, [job.schedule]);
const dropdownMenuItems = [
{
label: t("cronjobs.editCronJob"),
icon: <Edit className="h-3 w-3" />,
onClick: () => onEdit(job),
},
{
label: job.logsEnabled
? t("cronjobs.disableLogging")
: t("cronjobs.enableLogging"),
icon: job.logsEnabled ? (
<FileX className="h-3 w-3" />
) : (
<Code className="h-3 w-3" />
),
onClick: () => onToggleLogging(job.id),
},
...(job.logsEnabled
? [
{
label: t("cronjobs.viewLogs"),
icon: <Code className="h-3 w-3" />,
onClick: () => onViewLogs(job),
},
]
: []),
{
label: job.paused
? t("cronjobs.resumeCronJob")
: t("cronjobs.pauseCronJob"),
icon: job.paused ? (
<Play className="h-3 w-3" />
) : (
<Pause className="h-3 w-3" />
),
onClick: () => (job.paused ? onResume(job.id) : onPause(job.id)),
},
{
label: t("cronjobs.cloneCronJob"),
icon: <Files className="h-3 w-3" />,
onClick: () => onClone(job),
},
{
label: t("cronjobs.backupJob"),
icon: <Download className="h-3 w-3" />,
onClick: () => onBackup(job.id),
},
{
label: t("cronjobs.deleteCronJob"),
icon: <Trash2 className="h-3 w-3" />,
onClick: () => onDelete(job),
variant: "destructive" as const,
disabled: deletingId === job.id,
},
];
return (
<div
key={job.id}
className={`glass-card p-3 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors ${isDropdownOpen ? "relative z-10" : ""
}`}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 flex-shrink-0">
{scheduleDisplayMode === "cron" && (
<code className="text-xs bg-purple-500/10 text-purple-600 dark:text-purple-400 px-1.5 py-0.5 rounded font-mono border border-purple-500/20">
{job.schedule}
</code>
)}
{scheduleDisplayMode === "human" && cronExplanation?.isValid && (
<div className="flex items-center gap-1 border-b border-primary/30 bg-primary/10 rounded text-primary px-1.5 py-0.5">
<Info className="h-3 w-3 text-primary flex-shrink-0" />
<span className="text-xs italic truncate max-w-32">
{cronExplanation.humanReadable}
</span>
</div>
)}
{scheduleDisplayMode === "both" && (
<div className="flex items-center gap-1">
<code className="text-xs bg-purple-500/10 text-purple-600 dark:text-purple-400 px-1 py-0.5 rounded font-mono border border-purple-500/20">
{job.schedule}
</code>
{cronExplanation?.isValid && (
<div
className="flex items-center gap-1 border-b border-primary/30 bg-primary/10 rounded text-primary px-1 py-0.5 cursor-help"
title={cronExplanation.humanReadable}
>
<Info className="h-2.5 w-2.5 text-primary flex-shrink-0" />
</div>
)}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{commandCopied === job.id && (
<Check className="h-3 w-3 text-green-600 flex-shrink-0" />
)}
<pre
onClick={(e) => {
e.stopPropagation();
copyToClipboard(unwrapCommand(job.command));
setCommandCopied(job.id);
setTimeout(() => setCommandCopied(null), 3000);
}}
className="flex-1 cursor-pointer overflow-hidden text-sm font-medium text-foreground bg-muted/30 px-2 py-1 rounded border border-border/30 truncate"
title={unwrapCommand(job.command)}
>
{unwrapCommand(displayCommand)}
</pre>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{job.logsEnabled && (
<div
className="w-2 h-2 bg-blue-500 rounded-full"
title={t("cronjobs.logged")}
/>
)}
{job.paused && (
<div
className="w-2 h-2 bg-yellow-500 rounded-full"
title={t("cronjobs.paused")}
/>
)}
{!job.logError?.hasError && job.logsEnabled && (
<div
className="w-2 h-2 bg-green-500 rounded-full"
title={t("cronjobs.healthy")}
/>
)}
{job.logsEnabled && job.logError?.hasError && (
<div
className="w-2 h-2 bg-red-500 rounded-full cursor-pointer"
title="Latest execution failed - Click to view error log"
onClick={(e) => {
e.stopPropagation();
onViewLogs(job);
}}
/>
)}
{!job.logsEnabled && errors.length > 0 && (
<div
className="w-2 h-2 bg-orange-500 rounded-full cursor-pointer"
title={`${errors.length} error(s)`}
onClick={(e) => onErrorClick(errors[0])}
/>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => onRun(job.id)}
disabled={runningJobId === job.id || job.paused}
className="h-6 w-6 p-0"
title={t("cronjobs.runCronManually")}
>
{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="ghost"
size="sm"
onClick={() => {
if (job.paused) {
onResume(job.id);
} else {
onPause(job.id);
}
}}
className="h-6 w-6 p-0"
title={t("cronjobs.pauseCronJob")}
>
{job.paused ? (
<Play className="h-3 w-3" />
) : (
<Pause className="h-3 w-3" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (job.logsEnabled) {
onViewLogs(job);
} else {
onToggleLogging(job.id);
}
}}
className="h-6 w-6 p-0"
title={
job.logsEnabled
? t("cronjobs.viewLogs")
: t("cronjobs.enableLogging")
}
>
{job.logsEnabled ? (
<FileText className="h-3 w-3" />
) : (
<FileOutput className="h-3 w-3" />
)}
</Button>
<DropdownMenu
items={dropdownMenuItems}
onOpenChange={setIsDropdownOpen}
/>
</div>
</div>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import { JobError, setJobError } from "@/app/_utils/errorState";
import { showToast } from "@/app/_components/ui/Toast";
import { JobError, setJobError } from "@/app/_utils/error-utils";
import { showToast } from "@/app/_components/GlobalComponents/UIElements/Toast";
import {
removeCronJob,
editCronJob,
@@ -8,8 +8,10 @@ import {
pauseCronJobAction,
resumeCronJobAction,
runCronJob,
toggleCronJobLogging,
backupCronJob,
} from "@/app/_server/actions/cronjobs";
import { CronJob } from "@/app/_utils/system";
import { CronJob } from "@/app/_utils/cronjob-utils";
interface HandlerProps {
setDeletingId: (id: string | null) => void;
@@ -24,12 +26,17 @@ interface HandlerProps {
setNewCronForm: (form: any) => void;
setRunningJobId: (id: string | null) => void;
refreshJobErrors: () => void;
setIsLiveLogModalOpen?: (open: boolean) => void;
setLiveLogRunId?: (runId: string) => void;
setLiveLogJobId?: (jobId: string) => void;
setLiveLogJobComment?: (comment: string) => void;
jobToClone: CronJob | null;
editingJob: CronJob | null;
editForm: {
schedule: string;
command: string;
comment: string;
logsEnabled: boolean;
};
newCronForm: {
schedule: string;
@@ -37,6 +44,7 @@ interface HandlerProps {
comment: string;
selectedScriptId: string | null;
user: string;
logsEnabled: boolean;
};
}
@@ -61,7 +69,7 @@ export const refreshJobErrors = (
setJobErrors(errors);
};
export const handleDelete = async (id: string, props: HandlerProps) => {
export const handleDelete = async (job: CronJob, props: HandlerProps) => {
const {
setDeletingId,
setIsDeleteModalOpen,
@@ -69,19 +77,25 @@ export const handleDelete = async (id: string, props: HandlerProps) => {
refreshJobErrors,
} = props;
setDeletingId(id);
setDeletingId(job.id);
try {
const result = await removeCronJob(id);
const result = await removeCronJob({
id: job.id,
schedule: job.schedule,
command: job.command,
comment: job.comment,
user: job.user,
});
if (result.success) {
showToast("success", "Cron job deleted successfully");
} else {
const errorId = `delete-${id}-${Date.now()}`;
const errorId = `delete-${job.id}-${Date.now()}`;
const jobError: JobError = {
id: errorId,
title: "Failed to delete cron job",
message: result.message,
timestamp: new Date().toISOString(),
jobId: id,
jobId: job.id,
};
setJobError(jobError);
refreshJobErrors();
@@ -99,14 +113,14 @@ export const handleDelete = async (id: string, props: HandlerProps) => {
);
}
} catch (error: any) {
const errorId = `delete-${id}-${Date.now()}`;
const errorId = `delete-${job.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,
jobId: job.id,
};
setJobError(jobError);
showToast(
@@ -150,9 +164,15 @@ export const handleClone = async (newComment: string, props: HandlerProps) => {
}
};
export const handlePause = async (id: string) => {
export const handlePause = async (job: any) => {
try {
const result = await pauseCronJobAction(id);
const result = await pauseCronJobAction({
id: job.id,
schedule: job.schedule,
command: job.command,
comment: job.comment,
user: job.user,
});
if (result.success) {
showToast("success", "Cron job paused successfully");
} else {
@@ -163,9 +183,36 @@ export const handlePause = async (id: string) => {
}
};
export const handleResume = async (id: string) => {
export const handleToggleLogging = async (job: any) => {
try {
const result = await resumeCronJobAction(id);
const result = await toggleCronJobLogging({
id: job.id,
schedule: job.schedule,
command: job.command,
comment: job.comment,
user: job.user,
logsEnabled: job.logsEnabled,
});
if (result.success) {
showToast("success", result.message);
} else {
showToast("error", "Failed to toggle logging", result.message);
}
} catch (error: any) {
console.error("Error toggling logging:", error);
showToast("error", "Error toggling logging", error.message);
}
};
export const handleResume = async (job: any) => {
try {
const result = await resumeCronJobAction({
id: job.id,
schedule: job.schedule,
command: job.command,
comment: job.comment,
user: job.user,
});
if (result.success) {
showToast("success", "Cron job resumed successfully");
} else {
@@ -176,14 +223,32 @@ export const handleResume = async (id: string) => {
}
};
export const handleRun = async (id: string, props: HandlerProps) => {
const { setRunningJobId, refreshJobErrors } = props;
export const handleRun = async (id: string, props: HandlerProps, job: CronJob) => {
const {
setRunningJobId,
refreshJobErrors,
setIsLiveLogModalOpen,
setLiveLogRunId,
setLiveLogJobId,
setLiveLogJobComment,
} = props;
setRunningJobId(id);
try {
const result = await runCronJob(id);
if (result.success) {
showToast("success", "Cron job executed successfully");
if (result.mode === "async" && result.runId) {
if (setIsLiveLogModalOpen && setLiveLogRunId && setLiveLogJobId) {
setLiveLogRunId(result.runId);
setLiveLogJobId(id);
if (setLiveLogJobComment) {
setLiveLogJobComment(job.comment || "");
}
setIsLiveLogModalOpen(true);
}
} else {
showToast("success", "Cron job executed successfully");
}
} else {
const errorId = `run-${id}-${Date.now()}`;
const jobError: JobError = {
@@ -261,6 +326,7 @@ export const handleEditSubmit = async (
formData.append("schedule", editForm.schedule);
formData.append("command", editForm.command);
formData.append("comment", editForm.comment);
formData.append("logsEnabled", editForm.logsEnabled.toString());
const result = await editCronJob(formData);
if (result.success) {
@@ -335,6 +401,7 @@ export const handleNewCronSubmit = async (
formData.append("command", newCronForm.command);
formData.append("comment", newCronForm.comment);
formData.append("user", newCronForm.user);
formData.append("logsEnabled", newCronForm.logsEnabled.toString());
if (newCronForm.selectedScriptId) {
formData.append("selectedScriptId", newCronForm.selectedScriptId);
}
@@ -348,6 +415,7 @@ export const handleNewCronSubmit = async (
comment: "",
selectedScriptId: null,
user: "",
logsEnabled: false,
});
showToast("success", "Cron job created successfully");
} else {
@@ -357,3 +425,17 @@ export const handleNewCronSubmit = async (
showToast("error", "Failed to create cron job", "Please try again later.");
}
};
export const handleBackup = async (job: any) => {
try {
const result = await backupCronJob(job);
if (result.success) {
showToast("success", "Job backed up successfully");
} else {
showToast("error", "Failed to backup job", result.message);
}
} catch (error: any) {
console.error("Error backing up job:", error);
showToast("error", "Error backing up job", error.message);
}
};

View File

@@ -1,4 +1,4 @@
import { cn } from "@/app/_utils/cn";
import { cn } from "@/app/_utils/global-utils";
import { HTMLAttributes, forwardRef, useState, useEffect } from "react";
import React from "react";
import {
@@ -11,10 +11,10 @@ import {
HardDrive,
Wifi,
} from "lucide-react";
import { useTranslations } from "next-intl";
export interface SidebarProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
title?: string;
defaultCollapsed?: boolean;
quickStats?: {
cpu: number;
@@ -28,13 +28,13 @@ export const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
{
className,
children,
title = "System Overview",
defaultCollapsed = false,
quickStats,
...props
},
ref
) => {
const t = useTranslations();
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [isMobileOpen, setIsMobileOpen] = useState(false);
@@ -113,7 +113,7 @@ export const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
</div>
{(!isCollapsed || !isCollapsed) && (
<h2 className="text-sm font-semibold text-foreground truncate">
{title}
{t("sidebar.systemOverview")}
</h2>
)}
</div>

View File

@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { CronJobList } from "./features/Cronjobs/CronJobList";
import { ScriptsManager } from "./ScriptsManager";
import { CronJob } from "@/app/_utils/system";
import { type Script } from "@/app/_server/actions/scripts";
import { CronJobList } from "@/app/_components/FeatureComponents/Cronjobs/CronJobList";
import { ScriptsManager } from "@/app/_components/FeatureComponents/Scripts/ScriptsManager";
import { CronJob } from "@/app/_utils/cronjob-utils";
import { Script } from "@/app/_utils/scripts-utils";
import { Clock, FileText } from "lucide-react";
import { useTranslations } from "next-intl";
interface TabbedInterfaceProps {
cronJobs: CronJob[];
@@ -19,6 +20,7 @@ export const TabbedInterface = ({
const [activeTab, setActiveTab] = useState<"cronjobs" | "scripts">(
"cronjobs"
);
const t = useTranslations();
return (
<div className="space-y-6">
@@ -33,7 +35,7 @@ export const TabbedInterface = ({
}`}
>
<Clock className="h-4 w-4" />
Cron Jobs
{t("cronjobs.cronJobs")}
<span className="ml-1 text-xs bg-primary/20 text-primary px-2 py-0.5 rounded-full font-medium">
{cronJobs.length}
</span>
@@ -47,7 +49,7 @@ export const TabbedInterface = ({
}`}
>
<FileText className="h-4 w-4" />
Scripts
{t("scripts.scripts")}
<span className="ml-1 text-xs bg-primary/20 text-primary px-2 py-0.5 rounded-full font-medium">
{scripts.length}
</span>
@@ -55,7 +57,7 @@ export const TabbedInterface = ({
</div>
</div>
<div className="min-h-[400px]">
<div className="min-h-[60vh]">
{activeTab === "cronjobs" ? (
<CronJobList cronJobs={cronJobs} scripts={scripts} />
) : (

View File

@@ -0,0 +1,215 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/app/_components/GlobalComponents/Cards/Card";
import { Lock, Eye, EyeOff, Shield, AlertTriangle, Loader2 } from "lucide-react";
interface LoginFormProps {
hasPassword?: boolean;
hasOIDC?: boolean;
oidcAutoRedirect?: boolean;
version?: string;
}
export const LoginForm = ({
hasPassword = false,
hasOIDC = false,
oidcAutoRedirect = false,
version,
}: LoginFormProps) => {
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [isRedirecting, setIsRedirecting] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations();
useEffect(() => {
const errorParam = searchParams.get("error");
if (errorParam) {
setError(decodeURIComponent(errorParam));
return;
}
if (oidcAutoRedirect && !hasPassword && hasOIDC) {
setIsRedirecting(true);
window.location.href = "/api/oidc/login";
}
}, [oidcAutoRedirect, hasPassword, hasOIDC, searchParams]);
const handlePasswordSubmit = 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 || t("login.loginFailed"));
}
} catch (error) {
setError(t("login.genericError"));
} finally {
setIsLoading(false);
}
};
const handleOIDCLogin = () => {
setIsLoading(true);
window.location.href = "/api/oidc/login";
};
if (isRedirecting) {
return (
<Card className="w-full max-w-md shadow-xl">
<CardContent className="pt-6">
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<Loader2 className="w-12 h-12 text-primary animate-spin" />
<div className="text-center">
<p className="text-lg font-medium">{t("login.redirectingToOIDC")}</p>
<p className="text-sm text-muted-foreground mt-1">
{t("login.pleaseWait")}
</p>
</div>
</div>
</CardContent>
</Card>
);
}
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>{t("login.welcomeTitle")}</CardTitle>
<CardDescription>
{hasPassword && hasOIDC
? t("login.signInWithPasswordOrSSO")
: hasOIDC
? t("login.signInWithSSO")
: t("login.enterPasswordToContinue")}
</CardDescription>
</CardHeader>
<CardContent>
{!hasPassword && !hasOIDC && (
<div className="mb-4 p-3 bg-amber-500/10 border border-amber-500/20 rounded-md">
<div className="flex items-start space-x-2">
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-amber-700 dark:text-amber-400">
<div className="font-medium">
{t("login.authenticationNotConfigured")}
</div>
<div className="mt-1">{t("login.noAuthMethodsEnabled")}</div>
</div>
</div>
</div>
)}
<div className="space-y-4">
{hasPassword && (
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("login.enterPassword")}
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>
<Button
type="submit"
className="w-full"
disabled={isLoading || !password.trim()}
>
{isLoading ? t("login.signingIn") : t("login.signIn")}
</Button>
</form>
)}
{hasPassword && hasOIDC && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
{t("login.orContinueWith")}
</span>
</div>
</div>
)}
{hasOIDC && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleOIDCLogin}
disabled={isLoading}
>
<Shield className="w-4 h-4 mr-2" />
{isLoading ? t("login.redirecting") : t("login.signInWithSSO")}
</Button>
)}
{error && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-md p-3">
{error}
</div>
)}
</div>
{version && (
<div className="mt-6 pt-4 border-t border-border/50">
<div className="text-center text-xs text-muted-foreground">
Cr*nMaster {t("common.version", { version })}
</div>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,42 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/app/_components/GlobalComponents/UIElements/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>
);
};

View File

@@ -2,10 +2,10 @@
import { useState } from "react";
import { Copy } from "lucide-react";
import { Button } from "../ui/Button";
import { Modal } from "../ui/Modal";
import { Input } from "../ui/Input";
import { type Script } from "@/app/_server/actions/scripts";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import { Script } from "@/app/_utils/scripts-utils";
interface CloneScriptModalProps {
script: Script | null;

View File

@@ -2,10 +2,10 @@
import { useState } from "react";
import { Copy } from "lucide-react";
import { Button } from "../ui/Button";
import { Modal } from "../ui/Modal";
import { Input } from "../ui/Input";
import { type CronJob } from "@/app/_utils/system";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import { type CronJob } from "@/app/_utils/cronjob-utils";
interface CloneTaskModalProps {
cronJob: CronJob | null;

View File

@@ -1,7 +1,7 @@
"use client";
import { Plus } from "lucide-react";
import { ScriptModal } from "./ScriptModal";
import { ScriptModal } from "@/app/_components/FeatureComponents/Modals/ScriptModal";
interface CreateScriptModalProps {
isOpen: boolean;
@@ -15,6 +15,8 @@ interface CreateScriptModalProps {
content: string;
};
onFormChange: (updates: Partial<CreateScriptModalProps["form"]>) => void;
isDraft?: boolean;
onClearDraft?: () => void;
}
export const CreateScriptModal = ({
@@ -23,6 +25,8 @@ export const CreateScriptModal = ({
onSubmit,
form,
onFormChange,
isDraft,
onClearDraft,
}: CreateScriptModalProps) => {
return (
<ScriptModal
@@ -34,6 +38,8 @@ export const CreateScriptModal = ({
submitButtonIcon={<Plus className="h-4 w-4 mr-2" />}
form={form}
onFormChange={onFormChange}
isDraft={isDraft}
onClearDraft={onClearDraft}
/>
);
}

View File

@@ -1,15 +1,16 @@
"use client";
import { useState, useEffect } from "react";
import { Modal } from "../ui/Modal";
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 { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import { CronExpressionHelper } from "@/app/_components/FeatureComponents/Scripts/CronExpressionHelper";
import { SelectScriptModal } from "@/app/_components/FeatureComponents/Modals/SelectScriptModal";
import { UserSwitcher } from "@/app/_components/FeatureComponents/User/UserSwitcher";
import { Plus, Terminal, FileText, X, FileOutput } from "lucide-react";
import { getScriptContent } from "@/app/_server/actions/scripts";
import { getHostScriptPath } from "@/app/_utils/scripts";
import { getHostScriptPath } from "@/app/_server/actions/scripts";
import { useTranslations } from "next-intl";
interface Script {
id: string;
@@ -30,6 +31,7 @@ interface CreateTaskModalProps {
comment: string;
selectedScriptId: string | null;
user: string;
logsEnabled: boolean;
};
onFormChange: (updates: Partial<CreateTaskModalProps["form"]>) => void;
}
@@ -46,6 +48,7 @@ export const CreateTaskModal = ({
useState<string>("");
const [isSelectScriptModalOpen, setIsSelectScriptModalOpen] = useState(false);
const selectedScript = scripts.find((s) => s.id === form.selectedScriptId);
const t = useTranslations();
useEffect(() => {
const loadScriptContent = async () => {
@@ -61,9 +64,10 @@ export const CreateTaskModal = ({
}, [selectedScript]);
const handleScriptSelect = async (script: Script) => {
const scriptPath = await getHostScriptPath(script.filename);
onFormChange({
selectedScriptId: script.id,
command: await getHostScriptPath(script.filename),
command: scriptPath,
});
};
@@ -86,13 +90,13 @@ export const CreateTaskModal = ({
<Modal
isOpen={isOpen}
onClose={onClose}
title="Create New Scheduled Task"
title={t("cronjobs.createNewScheduledTask")}
size="lg"
>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">
User
{t("common.user")}
</label>
<UserSwitcher
selectedUser={form.user}
@@ -102,7 +106,7 @@ export const CreateTaskModal = ({
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Schedule
{t("cronjobs.schedule")}
</label>
<CronExpressionHelper
value={form.schedule}
@@ -114,7 +118,7 @@ export const CreateTaskModal = ({
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Task Type
{t("cronjobs.taskType")}
</label>
<div className="grid grid-cols-2 gap-3">
<button
@@ -128,8 +132,12 @@ export const CreateTaskModal = ({
<div className="flex items-center gap-3">
<Terminal className="h-5 w-5" />
<div className="text-left">
<div className="font-medium">Custom Command</div>
<div className="text-xs opacity-70">Single command</div>
<div className="font-medium">
{t("cronjobs.customCommand")}
</div>
<div className="text-xs opacity-70">
{t("cronjobs.singleCommand")}
</div>
</div>
</div>
</button>
@@ -145,9 +153,11 @@ export const CreateTaskModal = ({
<div className="flex items-center gap-3">
<FileText className="h-5 w-5" />
<div className="text-left">
<div className="font-medium">Saved Script</div>
<div className="font-medium">
{t("scripts.savedScript")}
</div>
<div className="text-xs opacity-70">
Select from library
{t("scripts.selectFromLibrary")}
</div>
</div>
</div>
@@ -182,7 +192,7 @@ export const CreateTaskModal = ({
onClick={() => setIsSelectScriptModalOpen(true)}
className="h-8 px-2 text-xs"
>
Change
{t("common.change")}
</Button>
<Button
type="button"
@@ -201,7 +211,7 @@ export const CreateTaskModal = ({
{!form.selectedScriptId && !selectedScript && (
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Command
{t("cronjobs.command")}
</label>
<div className="relative">
<textarea
@@ -222,8 +232,7 @@ export const CreateTaskModal = ({
</div>
{form.selectedScriptId && (
<p className="text-xs text-muted-foreground mt-1">
Script path is read-only. Edit the script in the Scripts
Library.
{t("scripts.scriptPathReadOnly")}
</p>
)}
</div>
@@ -231,17 +240,45 @@ export const CreateTaskModal = ({
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Description{" "}
<span className="text-muted-foreground">(Optional)</span>
{t("common.description")}
<span className="text-muted-foreground">
({t("common.optional")})
</span>
</label>
<Input
value={form.comment}
onChange={(e) => onFormChange({ comment: e.target.value })}
placeholder="What does this task do?"
placeholder={t("cronjobs.whatDoesThisTaskDo")}
className="bg-muted/30 border-border/50 focus:border-primary/50"
/>
</div>
<div className="border border-border/30 bg-muted/10 rounded-lg p-4">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="logsEnabled"
checked={form.logsEnabled}
onChange={(e) =>
onFormChange({ logsEnabled: e.target.checked })
}
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary/20 cursor-pointer"
/>
<div className="flex-1">
<label
htmlFor="logsEnabled"
className="flex items-center gap-2 text-sm font-medium text-foreground cursor-pointer"
>
<FileOutput className="h-4 w-4 text-primary" />
{t("cronjobs.enableLogging")}
</label>
<p className="text-xs text-muted-foreground mt-1">
{t("cronjobs.loggingDescription")}
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-border/50">
<Button
type="button"
@@ -249,11 +286,11 @@ export const CreateTaskModal = ({
onClick={onClose}
className="btn-outline"
>
Cancel
{t("common.cancel")}
</Button>
<Button type="submit" className="btn-primary glow-primary">
<Plus className="h-4 w-4 mr-2" />
Create Task
{t("cronjobs.createTask")}
</Button>
</div>
</form>
@@ -268,4 +305,4 @@ export const CreateTaskModal = ({
/>
</>
);
}
};

View File

@@ -0,0 +1,121 @@
"use client";
import { CreateTaskModal } from "@/app/_components/FeatureComponents/Modals/CreateTaskModal";
import { EditTaskModal } from "@/app/_components/FeatureComponents/Modals/EditTaskModal";
import { DeleteTaskModal } from "@/app/_components/FeatureComponents/Modals/DeleteTaskModal";
import { CloneTaskModal } from "@/app/_components/FeatureComponents/Modals/CloneTaskModal";
import { ErrorDetailsModal } from "@/app/_components/FeatureComponents/Modals/ErrorDetailsModal";
import { CronJob } from "@/app/_utils/cronjob-utils";
import { Script } from "@/app/_utils/scripts-utils";
import { JobError } from "@/app/_utils/error-utils";
interface CronJobListModalsProps {
cronJobs: CronJob[];
scripts: Script[];
isNewCronModalOpen: boolean;
onNewCronModalClose: () => void;
onNewCronSubmit: (e: React.FormEvent) => Promise<void>;
newCronForm: any;
onNewCronFormChange: (updates: any) => void;
isEditModalOpen: boolean;
onEditModalClose: () => void;
onEditSubmit: (e: React.FormEvent) => Promise<void>;
editForm: any;
onEditFormChange: (updates: any) => void;
isDeleteModalOpen: boolean;
onDeleteModalClose: () => void;
onDeleteConfirm: () => void;
jobToDelete: CronJob | null;
isCloneModalOpen: boolean;
onCloneModalClose: () => void;
onCloneConfirm: (newComment: string) => Promise<void>;
jobToClone: CronJob | null;
isCloning: boolean;
isErrorModalOpen: boolean;
onErrorModalClose: () => void;
selectedError: JobError | null;
}
export const CronJobListModals = ({
scripts,
isNewCronModalOpen,
onNewCronModalClose,
onNewCronSubmit,
newCronForm,
onNewCronFormChange,
isEditModalOpen,
onEditModalClose,
onEditSubmit,
editForm,
onEditFormChange,
isDeleteModalOpen,
onDeleteModalClose,
onDeleteConfirm,
jobToDelete,
isCloneModalOpen,
onCloneModalClose,
onCloneConfirm,
jobToClone,
isCloning,
isErrorModalOpen,
onErrorModalClose,
selectedError,
}: CronJobListModalsProps) => {
return (
<>
<CreateTaskModal
isOpen={isNewCronModalOpen}
onClose={onNewCronModalClose}
onSubmit={onNewCronSubmit}
scripts={scripts}
form={newCronForm}
onFormChange={onNewCronFormChange}
/>
<EditTaskModal
isOpen={isEditModalOpen}
onClose={onEditModalClose}
onSubmit={onEditSubmit}
form={editForm}
onFormChange={onEditFormChange}
/>
<DeleteTaskModal
isOpen={isDeleteModalOpen}
onClose={onDeleteModalClose}
onConfirm={onDeleteConfirm}
job={jobToDelete}
/>
<CloneTaskModal
cronJob={jobToClone}
isOpen={isCloneModalOpen}
onClose={onCloneModalClose}
onConfirm={onCloneConfirm}
isCloning={isCloning}
/>
{isErrorModalOpen && selectedError && (
<ErrorDetailsModal
isOpen={isErrorModalOpen}
onClose={onErrorModalClose}
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,
}}
/>
)}
</>
);
};

View File

@@ -1,9 +1,9 @@
"use client";
import { Modal } from "../ui/Modal";
import { Button } from "../ui/Button";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { FileText, AlertCircle, Trash2 } from "lucide-react";
import { type Script } from "@/app/_server/actions/scripts";
import { Script } from "@/app/_utils/scripts-utils";
interface DeleteScriptModalProps {
script: Script | null;

View File

@@ -1,7 +1,7 @@
"use client";
import { Modal } from "../ui/Modal";
import { Button } from "../ui/Button";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import {
Calendar,
Terminal,
@@ -9,7 +9,7 @@ import {
AlertCircle,
Trash2,
} from "lucide-react";
import { CronJob } from "@/app/_utils/system";
import { CronJob } from "@/app/_utils/cronjob-utils";
interface DeleteTaskModalProps {
isOpen: boolean;
@@ -45,7 +45,7 @@ export const DeleteTaskModal = ({
<div className="flex items-start gap-2">
<Terminal className="h-3 w-3 text-muted-foreground mt-0.5 flex-shrink-0" />
<pre className="text-xs font-medium text-foreground break-words bg-muted/30 px-1 py-0.5 rounded border border-border/30 flex-1">
<pre className="max-w-full overflow-x-auto text-xs font-medium text-foreground break-words bg-muted/30 px-1 py-0.5 rounded border border-border/30 flex-1 hide-scrollbar">
{job.command}
</pre>
</div>
@@ -91,4 +91,4 @@ export const DeleteTaskModal = ({
</div>
</Modal>
);
}
};

View File

@@ -1,8 +1,8 @@
"use client";
import { Edit } from "lucide-react";
import { type Script } from "@/app/_server/actions/scripts";
import { ScriptModal } from "./ScriptModal";
import { Script } from "@/app/_utils/scripts-utils";
import { ScriptModal } from "@/app/_components/FeatureComponents/Modals/ScriptModal";
interface EditScriptModalProps {
isOpen: boolean;

View File

@@ -1,10 +1,11 @@
"use client";
import { Modal } from "../ui/Modal";
import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { CronExpressionHelper } from "../CronExpressionHelper";
import { Edit, Terminal } from "lucide-react";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import { CronExpressionHelper } from "@/app/_components/FeatureComponents/Scripts/CronExpressionHelper";
import { Edit, Terminal, FileOutput } from "lucide-react";
import { useTranslations } from "next-intl";
interface EditTaskModalProps {
isOpen: boolean;
@@ -14,6 +15,7 @@ interface EditTaskModalProps {
schedule: string;
command: string;
comment: string;
logsEnabled: boolean;
};
onFormChange: (updates: Partial<EditTaskModalProps["form"]>) => void;
}
@@ -25,11 +27,13 @@ export const EditTaskModal = ({
form,
onFormChange,
}: EditTaskModalProps) => {
const t = useTranslations();
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Edit Scheduled Task"
title={t("cronjobs.editScheduledTask")}
size="xl"
>
<form onSubmit={onSubmit} className="space-y-4">
@@ -67,17 +71,43 @@ export const EditTaskModal = ({
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Description{" "}
<span className="text-muted-foreground">(Optional)</span>
{t("common.description")}{" "}
<span className="text-muted-foreground">
({t("common.optional")})
</span>
</label>
<Input
value={form.comment}
onChange={(e) => onFormChange({ comment: e.target.value })}
placeholder="What does this task do?"
placeholder={t("cronjobs.whatDoesThisTaskDo")}
className="bg-muted/30 border-border/50 focus:border-primary/50"
/>
</div>
<div className="border border-border/30 bg-muted/10 rounded-lg p-4">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="logsEnabled"
checked={form.logsEnabled}
onChange={(e) => onFormChange({ logsEnabled: e.target.checked })}
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary/20 cursor-pointer"
/>
<div className="flex-1">
<label
htmlFor="logsEnabled"
className="flex items-center gap-2 text-sm font-medium text-foreground cursor-pointer"
>
<FileOutput className="h-4 w-4 text-primary" />
{t("cronjobs.enableLogging")}
</label>
<p className="text-xs text-muted-foreground mt-1">
{t("cronjobs.loggingDescription")}
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-border/50">
<Button
type="button"
@@ -95,4 +125,4 @@ export const EditTaskModal = ({
</form>
</Modal>
);
}
};

View File

@@ -1,9 +1,9 @@
"use client";
import { Modal } from "../ui/Modal";
import { Button } from "../ui/Button";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { AlertCircle, Copy, X } from "lucide-react";
import { showToast } from "../ui/Toast";
import { showToast } from "@/app/_components/GlobalComponents/UIElements/Toast";
interface ErrorDetails {
title: string;

View File

@@ -0,0 +1,158 @@
"use client";
import { useState, useEffect } from "react";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { ChevronDown, Code, MessageSquare } from "lucide-react";
import { UserFilter } from "@/app/_components/FeatureComponents/User/UserFilter";
import { useTranslations } from "next-intl";
interface FiltersModalProps {
isOpen: boolean;
onClose: () => void;
selectedUser: string | null;
onUserChange: (user: string | null) => void;
scheduleDisplayMode: "cron" | "human" | "both";
onScheduleDisplayModeChange: (mode: "cron" | "human" | "both") => void;
}
export const FiltersModal = ({
isOpen,
onClose,
selectedUser,
onUserChange,
scheduleDisplayMode,
onScheduleDisplayModeChange,
}: FiltersModalProps) => {
const t = useTranslations();
const [localScheduleMode, setLocalScheduleMode] =
useState(scheduleDisplayMode);
const [isScheduleDropdownOpen, setIsScheduleDropdownOpen] = useState(false);
useEffect(() => {
setLocalScheduleMode(scheduleDisplayMode);
}, [scheduleDisplayMode]);
const handleSave = () => {
onScheduleDisplayModeChange(localScheduleMode);
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t("cronjobs.filtersAndDisplay")}
size="md"
>
<div className="space-y-6">
<div className="space-y-4 min-h-[200px]">
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("cronjobs.filterByUser")}
</label>
<UserFilter
selectedUser={selectedUser}
onUserChange={onUserChange}
className="w-full"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("cronjobs.scheduleDisplay")}
</label>
<div className="relative">
<Button
variant="outline"
onClick={() =>
setIsScheduleDropdownOpen(!isScheduleDropdownOpen)
}
className="btn-outline w-full justify-between"
>
<div className="flex items-center">
{localScheduleMode === "cron" && (
<Code className="h-4 w-4 mr-2" />
)}
{localScheduleMode === "human" && (
<MessageSquare className="h-4 w-4 mr-2" />
)}
{localScheduleMode === "both" && (
<>
<Code className="h-4 w-4 mr-1" />
<MessageSquare className="h-4 w-4 mr-2" />
</>
)}
<span>
{localScheduleMode === "cron" && t("cronjobs.cronSyntax")}
{localScheduleMode === "human" &&
t("cronjobs.humanReadable")}
{localScheduleMode === "both" && t("cronjobs.both")}
</span>
</div>
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
{isScheduleDropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 min-w-[140px]">
<button
onClick={() => {
setLocalScheduleMode("cron");
setIsScheduleDropdownOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 ${
localScheduleMode === "cron"
? "bg-accent text-accent-foreground"
: ""
}`}
>
<Code className="h-3 w-3" />
{t("cronjobs.cronSyntax")}
</button>
<button
onClick={() => {
setLocalScheduleMode("human");
setIsScheduleDropdownOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 ${
localScheduleMode === "human"
? "bg-accent text-accent-foreground"
: ""
}`}
>
<MessageSquare className="h-3 w-3" />
{t("cronjobs.humanReadable")}
</button>
<button
onClick={() => {
setLocalScheduleMode("both");
setIsScheduleDropdownOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 ${
localScheduleMode === "both"
? "bg-accent text-accent-foreground"
: ""
}`}
>
<Code className="h-3 w-3" />
<MessageSquare className="h-3 w-3" />
{t("cronjobs.both")}
</button>
</div>
)}
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button className="btn-primary" onClick={handleSave}>
{t("cronjobs.applyFilters")}
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,366 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { Loader2, CheckCircle2, XCircle, AlertTriangle, Minimize2, Maximize2 } from "lucide-react";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { useSSEContext } from "@/app/_contexts/SSEContext";
import { SSEEvent } from "@/app/_utils/sse-events";
import { usePageVisibility } from "@/app/_hooks/usePageVisibility";
import { useTranslations } from "next-intl";
interface LiveLogModalProps {
isOpen: boolean;
onClose: () => void;
runId: string;
jobId: string;
jobComment?: string;
}
const MAX_LINES_FULL_RENDER = 10000;
const TAIL_LINES = 5000;
export const LiveLogModal = ({
isOpen,
onClose,
runId,
jobId,
jobComment,
}: LiveLogModalProps) => {
const t = useTranslations();
const [logContent, setLogContent] = useState<string>("");
const [status, setStatus] = useState<"running" | "completed" | "failed">(
"running"
);
const [exitCode, setExitCode] = useState<number | null>(null);
const [tailMode, setTailMode] = useState<boolean>(false);
const [showSizeWarning, setShowSizeWarning] = useState<boolean>(false);
const logEndRef = useRef<HTMLDivElement>(null);
const { subscribe } = useSSEContext();
const isPageVisible = usePageVisibility();
const lastOffsetRef = useRef<number>(0);
const abortControllerRef = useRef<AbortController | null>(null);
const [fileSize, setFileSize] = useState<number>(0);
const [lineCount, setLineCount] = useState<number>(0);
const [maxLines, setMaxLines] = useState<number>(500);
const [totalLines, setTotalLines] = useState<number>(0);
const [truncated, setTruncated] = useState<boolean>(false);
const [showFullLog, setShowFullLog] = useState<boolean>(false);
const [isJobComplete, setIsJobComplete] = useState<boolean>(false);
useEffect(() => {
if (isOpen) {
lastOffsetRef.current = 0;
setLogContent("");
setTailMode(false);
setShowSizeWarning(false);
setFileSize(0);
setLineCount(0);
setShowFullLog(false);
setIsJobComplete(false);
}
}, [isOpen, runId]);
useEffect(() => {
if (isOpen && runId && !isJobComplete) {
lastOffsetRef.current = 0;
setLogContent("");
fetchLogs();
}
}, [maxLines]);
const fetchLogs = useCallback(async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
const url = `/api/logs/stream?runId=${runId}&offset=${lastOffsetRef.current}&maxLines=${maxLines}`;
const response = await fetch(url, {
signal: abortController.signal,
});
const data = await response.json();
if (data.fileSize !== undefined) {
lastOffsetRef.current = data.fileSize;
setFileSize(data.fileSize);
if (data.fileSize > 10 * 1024 * 1024) {
setShowSizeWarning(true);
}
}
if (data.totalLines !== undefined) {
setTotalLines(data.totalLines);
}
setLineCount(data.displayedLines || 0);
if (data.truncated !== undefined) {
setTruncated(data.truncated);
}
if (lastOffsetRef.current === 0 && data.content) {
setLogContent(data.content);
if (data.truncated) {
setTailMode(true);
}
} else if (data.newContent) {
setLogContent((prev) => {
const combined = prev + data.newContent;
const lines = combined.split("\n");
if (lines.length > maxLines) {
return lines.slice(-maxLines).join("\n");
}
return combined;
});
}
const jobStatus = data.status || "running";
setStatus(jobStatus);
if (jobStatus === "completed" || jobStatus === "failed") {
setIsJobComplete(true);
}
if (data.exitCode !== undefined) {
setExitCode(data.exitCode);
}
} catch (error: any) {
if (error.name !== "AbortError") {
console.error("Failed to fetch logs:", error);
}
}
}, [runId, maxLines]);
useEffect(() => {
if (!isOpen || !runId || !isPageVisible) return;
fetchLogs();
let interval: NodeJS.Timeout | null = null;
if (isPageVisible && !isJobComplete) {
interval = setInterval(fetchLogs, 3000);
}
return () => {
if (interval) {
clearInterval(interval);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [isOpen, runId, isPageVisible, fetchLogs, isJobComplete]);
useEffect(() => {
if (!isOpen) return;
const unsubscribe = subscribe((event: SSEEvent) => {
if (event.type === "job-completed" && event.data.runId === runId) {
setStatus("completed");
setExitCode(event.data.exitCode);
fetch(`/api/logs/stream?runId=${runId}&offset=0`)
.then((res) => res.json())
.then((data) => {
if (data.content) {
const lines = data.content.split("\n");
setLineCount(lines.length);
if (tailMode && lines.length > TAIL_LINES) {
setLogContent(lines.slice(-TAIL_LINES).join("\n"));
} else {
setLogContent(data.content);
}
}
});
} else if (event.type === "job-failed" && event.data.runId === runId) {
setStatus("failed");
setExitCode(event.data.exitCode);
fetch(`/api/logs/stream?runId=${runId}&offset=0`)
.then((res) => res.json())
.then((data) => {
if (data.content) {
const lines = data.content.split("\n");
setLineCount(lines.length);
if (tailMode && lines.length > TAIL_LINES) {
setLogContent(lines.slice(-TAIL_LINES).join("\n"));
} else {
setLogContent(data.content);
}
}
});
}
});
return unsubscribe;
}, [isOpen, runId, subscribe, tailMode]);
useEffect(() => {
if (logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: "instant" });
}
}, [logContent]);
const toggleTailMode = () => {
setTailMode(!tailMode);
if (!tailMode) {
const lines = logContent.split("\n");
if (lines.length > TAIL_LINES) {
setLogContent(lines.slice(-TAIL_LINES).join("\n"));
}
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const titleWithStatus = (
<div className="flex items-center gap-3">
<span>{t("cronjobs.liveJobExecution")}{jobComment && `: ${jobComment}`}</span>
{status === "running" && (
<span className="flex items-center gap-1 text-sm text-blue-500">
<Loader2 className="w-4 h-4 animate-spin" />
{t("cronjobs.running")}
</span>
)}
{status === "completed" && (
<span className="flex items-center gap-1 text-sm text-green-500">
<CheckCircle2 className="w-4 h-4" />
{t("cronjobs.completed", { exitCode: exitCode ?? 0 })}
</span>
)}
{status === "failed" && (
<span className="flex items-center gap-1 text-sm text-red-500">
<XCircle className="w-4 h-4" />
{t("cronjobs.jobFailed", { exitCode: exitCode ?? 1 })}
</span>
)}
</div>
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={titleWithStatus as any}
size="xl"
preventCloseOnClickOutside={status === "running"}
>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
{!showFullLog ? (
<>
<label htmlFor="maxLines" className="text-sm text-muted-foreground">
{t("cronjobs.showLast")}
</label>
<select
id="maxLines"
value={maxLines}
onChange={(e) => setMaxLines(parseInt(e.target.value, 10))}
className="bg-background border border-border rounded px-2 py-1 text-sm"
>
<option value="100">{t("cronjobs.nLines", { count: "100" })}</option>
<option value="500">{t("cronjobs.nLines", { count: "500" })}</option>
<option value="1000">{t("cronjobs.nLines", { count: "1,000" })}</option>
<option value="2000">{t("cronjobs.nLines", { count: "2,000" })}</option>
<option value="5000">{t("cronjobs.nLines", { count: "5,000" })}</option>
</select>
{truncated && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowFullLog(true);
setMaxLines(50000);
}}
className="text-xs"
>
{totalLines > 0
? t("cronjobs.viewFullLog", { totalLines: totalLines.toLocaleString() })
: t("cronjobs.viewFullLogNoCount")}
</Button>
)}
</>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{totalLines > 0
? t("cronjobs.viewingFullLog", { totalLines: totalLines.toLocaleString() })
: t("cronjobs.viewingFullLogNoCount")}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowFullLog(false);
setMaxLines(500);
}}
className="text-xs"
>
{t("cronjobs.backToWindowedView")}
</Button>
</div>
)}
</div>
{truncated && !showFullLog && (
<div className="text-sm text-orange-500 flex items-center gap-1">
<AlertTriangle className="h-4 w-4" />
{t("cronjobs.showingLastOf", {
lineCount: lineCount.toLocaleString(),
totalLines: totalLines.toLocaleString()
})}
</div>
)}
</div>
{showSizeWarning && (
<div className="bg-orange-500/10 border border-orange-500/30 rounded-lg p-3 flex items-start gap-3">
<AlertTriangle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">
<span className="font-medium">{t("cronjobs.largeLogFileDetected")}</span> ({formatFileSize(fileSize)})
{tailMode && ` - ${t("cronjobs.tailModeEnabled", { tailLines: TAIL_LINES.toLocaleString() })}`}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={toggleTailMode}
className="text-orange-500 hover:text-orange-400 hover:bg-orange-500/10 h-auto py-1 px-2 text-xs"
title={tailMode ? t("cronjobs.showAllLines") : t("cronjobs.enableTailMode")}
>
{tailMode ? <Maximize2 className="h-3 w-3" /> : <Minimize2 className="h-3 w-3" />}
</Button>
</div>
)}
<div className="bg-black/90 dark:bg-black/60 rounded-lg p-4 max-h-[60vh] overflow-auto">
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap break-words">
{logContent || t("cronjobs.waitingForJobToStart")}
<div ref={logEndRef} />
</pre>
</div>
<div className="flex justify-between items-center text-xs text-muted-foreground">
<span>
{t("cronjobs.runIdJobId", { runId, jobId })}
</span>
</div>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,298 @@
"use client";
import { useState, useEffect } from "react";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { FileText, Trash2, Eye, X, RefreshCw, AlertCircle, CheckCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import {
getJobLogs,
getLogContent,
deleteLogFile,
deleteAllJobLogs,
getJobLogStats,
} from "@/app/_server/actions/logs";
interface LogEntry {
filename: string;
timestamp: string;
fullPath: string;
size: number;
dateCreated: Date;
exitCode?: number;
hasError?: boolean;
}
interface LogsModalProps {
isOpen: boolean;
onClose: () => void;
jobId: string;
jobComment?: string;
preSelectedLog?: string;
}
export const LogsModal = ({
isOpen,
onClose,
jobId,
jobComment,
preSelectedLog,
}: LogsModalProps) => {
const t = useTranslations();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [selectedLog, setSelectedLog] = useState<string | null>(null);
const [logContent, setLogContent] = useState<string>("");
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [isLoadingContent, setIsLoadingContent] = useState(false);
const [stats, setStats] = useState<{
count: number;
totalSize: number;
totalSizeMB: number;
} | null>(null);
const loadLogs = async () => {
setIsLoadingLogs(true);
try {
const [logsData, statsData] = await Promise.all([
getJobLogs(jobId, false, true),
getJobLogStats(jobId),
]);
setLogs(logsData);
setStats(statsData);
} catch (error) {
console.error("Error loading logs:", error);
} finally {
setIsLoadingLogs(false);
}
};
useEffect(() => {
if (isOpen) {
loadLogs().then(() => {
if (preSelectedLog) {
handleViewLog(preSelectedLog);
}
});
if (!preSelectedLog) {
setSelectedLog(null);
setLogContent("");
}
}
}, [isOpen, jobId, preSelectedLog]);
const handleViewLog = async (filename: string) => {
setIsLoadingContent(true);
setSelectedLog(filename);
try {
const content = await getLogContent(jobId, filename);
setLogContent(content);
} catch (error) {
console.error("Error loading log content:", error);
setLogContent("Error loading log content");
} finally {
setIsLoadingContent(false);
}
};
const handleDeleteLog = async (filename: string) => {
if (!confirm(t("cronjobs.confirmDeleteLog"))) return;
try {
const result = await deleteLogFile(jobId, filename);
if (result.success) {
await loadLogs();
if (selectedLog === filename) {
setSelectedLog(null);
setLogContent("");
}
} else {
alert(result.message);
}
} catch (error) {
console.error("Error deleting log:", error);
alert("Error deleting log file");
}
};
const handleDeleteAllLogs = async () => {
if (!confirm(t("cronjobs.confirmDeleteAllLogs"))) return;
try {
const result = await deleteAllJobLogs(jobId);
if (result.success) {
await loadLogs();
setSelectedLog(null);
setLogContent("");
} else {
alert(result.message);
}
} catch (error) {
console.error("Error deleting all logs:", error);
alert("Error deleting all logs");
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
const formatTimestamp = (timestamp: string): string => {
const [datePart, timePart] = timestamp.split("_");
const [year, month, day] = datePart.split("-");
const [hour, minute, second] = timePart.split("-");
const date = new Date(
parseInt(year),
parseInt(month) - 1,
parseInt(day),
parseInt(hour),
parseInt(minute),
parseInt(second)
);
return date.toLocaleString();
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={t("cronjobs.viewLogs")} size="xl">
<div className="flex flex-col h-[600px]">
<div className="flex items-center justify-between mb-4 pb-4 border-b border-border">
<div>
<h3 className="font-semibold text-lg">{jobComment || jobId}</h3>
{stats && (
<p className="text-sm text-muted-foreground">
{stats.count} {t("cronjobs.logs")} {stats.totalSizeMB} MB
</p>
)}
</div>
<div className="flex gap-2">
<Button
onClick={loadLogs}
disabled={isLoadingLogs}
className="btn-primary glow-primary"
size="sm"
>
<RefreshCw
className={`w-4 h-4 mr-2 ${isLoadingLogs ? "animate-spin" : ""
}`}
/>
{t("common.refresh")}
</Button>
{logs.length > 0 && (
<Button
onClick={handleDeleteAllLogs}
className="btn-destructive glow-primary"
size="sm"
>
<Trash2 className="w-4 h-4 mr-2" />
{t("cronjobs.deleteAll")}
</Button>
)}
</div>
</div>
<div className="flex-1 flex gap-4 overflow-hidden">
<div className="w-1/3 flex flex-col border-r border-border pr-4 overflow-hidden">
<h4 className="font-semibold mb-2">{t("cronjobs.logFiles")}</h4>
<div className="flex-1 overflow-y-auto space-y-2">
{isLoadingLogs ? (
<div className="text-center py-8 text-muted-foreground">
{t("common.loading")}...
</div>
) : logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("cronjobs.noLogsFound")}
</div>
) : (
logs.map((log) => (
<div
key={log.filename}
className={`p-3 rounded border cursor-pointer transition-colors ${selectedLog === log.filename
? "border-primary bg-primary/10"
: log.hasError
? "border-red-500/50 hover:border-red-500"
: "border-border hover:border-primary/50"
}`}
onClick={() => handleViewLog(log.filename)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{log.hasError ? (
<AlertCircle className="w-4 h-4 flex-shrink-0 text-red-500" />
) : log.exitCode === 0 ? (
<CheckCircle className="w-4 h-4 flex-shrink-0 text-green-500" />
) : (
<FileText className="w-4 h-4 flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">
{formatTimestamp(log.timestamp)}
</span>
</div>
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground">
{formatFileSize(log.size)}
</p>
{log.exitCode !== undefined && (
<span
className={`text-xs px-1.5 py-0.5 rounded ${log.hasError
? "bg-red-500/10 text-red-600 dark:text-red-400"
: "bg-green-500/10 text-green-600 dark:text-green-400"
}`}
>
Exit: {log.exitCode}
</span>
)}
</div>
</div>
<Button
onClick={(e) => {
e.stopPropagation();
handleDeleteLog(log.filename);
}}
className="btn-destructive glow-primary p-1 h-auto"
size="sm"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
))
)}
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<h4 className="font-semibold mb-2">{t("cronjobs.logContent")}</h4>
<div className="flex-1 overflow-hidden">
{isLoadingContent ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
{t("common.loading")}...
</div>
) : selectedLog ? (
<pre className="h-full overflow-auto bg-muted/50 p-4 rounded border border-border text-xs font-mono whitespace-pre-wrap">
{logContent}
</pre>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>{t("cronjobs.selectLogToView")}</p>
</div>
</div>
)}
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border flex justify-end">
<Button onClick={onClose} className="btn-primary glow-primary">
<X className="w-4 h-4 mr-2" />
{t("common.close")}
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,218 @@
"use client";
import { useState, useEffect } from "react";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import {
Upload,
Trash2,
Calendar,
User,
Download,
RefreshCw,
Check,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { CronJob } from "@/app/_utils/cronjob-utils";
import { unwrapCommand } from "@/app/_utils/wrapper-utils-client";
import { copyToClipboard } from "@/app/_utils/global-utils";
interface BackupFile {
filename: string;
job: CronJob;
backedUpAt: string;
}
interface RestoreBackupModalProps {
isOpen: boolean;
onClose: () => void;
backups: BackupFile[];
onRestore: (filename: string) => void;
onRestoreAll: () => void;
onBackupAll: () => void;
onDelete: (filename: string) => void;
onRefresh: () => void;
}
export const RestoreBackupModal = ({
isOpen,
onClose,
backups,
onRestore,
onRestoreAll,
onBackupAll,
onDelete,
onRefresh,
}: RestoreBackupModalProps) => {
const t = useTranslations();
const [deletingFilename, setDeletingFilename] = useState<string | null>(null);
const [commandCopied, setCommandCopied] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
onRefresh();
}
}, [isOpen]);
const handleRestoreAll = () => {
if (window.confirm(t("cronjobs.confirmRestoreAll"))) {
onRestoreAll();
}
};
const handleDelete = async (filename: string) => {
if (window.confirm(t("cronjobs.confirmDeleteBackup"))) {
setDeletingFilename(filename);
await onDelete(filename);
setDeletingFilename(null);
onRefresh();
}
};
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch {
return dateString;
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t("cronjobs.backups")}
size="xl"
>
<div className="space-y-4">
<div className="flex gap-2">
<Button
variant="outline"
onClick={onBackupAll}
className="btn-outline flex-1"
>
<Download className="h-4 w-4 mr-2" />
{t("cronjobs.backupAll")}
</Button>
{backups.length > 0 && (
<Button
variant="outline"
onClick={handleRestoreAll}
className="btn-primary flex-1"
>
<Upload className="h-4 w-4 mr-2" />
{t("cronjobs.restoreAll")}
</Button>
)}
<Button
variant="outline"
onClick={onRefresh}
className="btn-outline"
title={t("common.refresh")}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{backups.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>{t("cronjobs.noBackupsFound")}</p>
</div>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{backups.map((backup) => (
<div
key={backup.filename}
className="glass-card p-3 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<code className="text-xs bg-purple-500/10 text-purple-600 dark:text-purple-400 px-1.5 py-0.5 rounded font-mono border border-purple-500/20">
{backup.job.schedule}
</code>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{commandCopied === backup.filename && (
<Check className="h-3 w-3 text-green-600 flex-shrink-0" />
)}
<pre
onClick={(e) => {
e.stopPropagation();
copyToClipboard(unwrapCommand(backup.job.command));
setCommandCopied(backup.filename);
setTimeout(() => setCommandCopied(null), 3000);
}}
className="flex-1 cursor-pointer overflow-hidden text-sm font-medium text-foreground bg-muted/30 px-2 py-1 rounded border border-border/30 truncate"
title={unwrapCommand(backup.job.command)}
>
{unwrapCommand(backup.job.command)}
</pre>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-shrink-0">
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span>{backup.job.user}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatDate(backup.backedUpAt)}</span>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => {
onRestore(backup.filename);
onClose();
}}
className="h-7 w-7 p-0"
title={t("cronjobs.restoreThisBackup")}
>
<Upload className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(backup.filename)}
disabled={deletingFilename === backup.filename}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
title={t("cronjobs.deleteBackup")}
>
{deletingFilename === backup.filename ? (
<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>
{backup.job.comment && (
<p className="text-xs text-muted-foreground italic mt-2 ml-0">
{backup.job.comment}
</p>
)}
</div>
))}
</div>
)}
<div className="flex justify-between gap-2 pt-4 border-t border-border/50">
<p className="text-sm text-muted-foreground">
{t("cronjobs.availableBackups")}: {backups.length}
</p>
<Button variant="outline" onClick={onClose} className="btn-outline">
{t("common.close")}
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -1,12 +1,13 @@
"use client";
import { Modal } from "../ui/Modal";
import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { BashEditor } from "../BashEditor";
import { BashSnippetHelper } from "../BashSnippetHelper";
import { FileText, Code } from "lucide-react";
import { showToast } from "../ui/Toast";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import { BashEditor } from "@/app/_components/FeatureComponents/Scripts/BashEditor";
import { BashSnippetHelper } from "@/app/_components/FeatureComponents/Scripts/BashSnippetHelper";
import { showToast } from "@/app/_components/GlobalComponents/UIElements/Toast";
import { FileText, Code, Info, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
interface ScriptModalProps {
isOpen: boolean;
@@ -24,6 +25,8 @@ interface ScriptModalProps {
};
onFormChange: (updates: Partial<ScriptModalProps["form"]>) => void;
additionalFormData?: Record<string, string>;
isDraft?: boolean;
onClearDraft?: () => void;
}
export const ScriptModal = ({
@@ -36,7 +39,11 @@ export const ScriptModal = ({
form,
onFormChange,
additionalFormData = {},
isDraft = false,
onClearDraft,
}: ScriptModalProps) => {
const t = useTranslations();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -72,7 +79,7 @@ export const ScriptModal = ({
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="xl">
<Modal isOpen={isOpen} onClose={onClose} title={title} size="2xl">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -110,7 +117,7 @@ export const ScriptModal = ({
<Code className="h-4 w-4 text-primary" />
<h3 className="text-sm font-medium text-foreground">Snippets</h3>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="flex-1 overflow-y-auto min-h-0 !pr-0">
<BashSnippetHelper onInsertSnippet={handleInsertSnippet} />
</div>
</div>
@@ -121,6 +128,11 @@ export const ScriptModal = ({
<h3 className="text-sm font-medium text-foreground">
Script Content <span className="text-red-500">*</span>
</h3>
{isDraft && (
<span className="ml-auto px-2 py-0.5 text-xs font-medium bg-blue-500/10 text-blue-500 border border-blue-500/30 rounded-full">
{t("scripts.draft")}
</span>
)}
</div>
<div className="flex-1 min-h-0">
<BashEditor
@@ -133,21 +145,36 @@ export const ScriptModal = ({
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border/30">
<Button
type="button"
variant="outline"
onClick={onClose}
className="btn-outline"
>
Cancel
</Button>
<Button type="submit" className="btn-primary glow-primary">
{submitButtonIcon}
{submitButtonText}
</Button>
<div className="flex justify-between items-center gap-3 pt-4 border-t border-border/30">
<div>
{isDraft && onClearDraft && (
<Button
type="button"
variant="ghost"
onClick={onClearDraft}
className="text-muted-foreground hover:text-foreground"
>
<Trash2 className="h-4 w-4 mr-2" />
{t("scripts.clearDraft")}
</Button>
)}
</div>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={onClose}
className="btn-outline"
>
{t("scripts.close")}
</Button>
<Button type="submit" className="btn-primary glow-primary">
{submitButtonIcon}
{submitButtonText}
</Button>
</div>
</div>
</form>
</Modal>
);
}
};

View File

@@ -1,13 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import { Modal } from "../ui/Modal";
import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import { FileText, Search, Check, Terminal } from "lucide-react";
import { type Script } from "@/app/_server/actions/scripts";
import { Script } from "@/app/_utils/scripts-utils";
import { getScriptContent } from "@/app/_server/actions/scripts";
import { getHostScriptPath } from "@/app/_utils/scripts";
import { getHostScriptPath } from "@/app/_server/actions/scripts";
import { useTranslations } from "next-intl";
interface SelectScriptModalProps {
isOpen: boolean;
@@ -24,6 +25,7 @@ export const SelectScriptModal = ({
onScriptSelect,
selectedScriptId,
}: SelectScriptModalProps) => {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState("");
const [previewScript, setPreviewScript] = useState<Script | null>(null);
const [previewContent, setPreviewContent] = useState<string>("");
@@ -77,7 +79,7 @@ export const SelectScriptModal = ({
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Select Script"
title={t("scripts.selectScript")}
size="xl"
>
<div className="space-y-4">
@@ -86,7 +88,7 @@ export const SelectScriptModal = ({
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search scripts..."
placeholder={t("scripts.searchScripts")}
className="pl-10"
/>
</div>
@@ -95,13 +97,13 @@ export const SelectScriptModal = ({
<div className="border border-border rounded-lg overflow-hidden">
<div className="bg-muted/30 px-4 py-2 border-b border-border">
<h3 className="text-sm font-medium text-foreground">
Available Scripts ({filteredScripts.length})
{t("scripts.availableScripts", { count: filteredScripts.length })}
</h3>
</div>
<div className="overflow-y-auto h-full max-h-80">
{filteredScripts.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
{searchQuery ? "No scripts found" : "No scripts available"}
{searchQuery ? t("scripts.noScriptsFound") : t("scripts.noScriptsAvailable")}
</div>
) : (
<div className="divide-y divide-border">
@@ -110,8 +112,8 @@ export const SelectScriptModal = ({
key={script.id}
onClick={() => handleScriptClick(script)}
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"
: ""
? "bg-primary/5 border-r-2 border-primary"
: ""
}`}
>
<div className="flex items-start justify-between">
@@ -143,7 +145,7 @@ export const SelectScriptModal = ({
<div className="border border-border rounded-lg overflow-hidden">
<div className="bg-muted/30 px-4 py-2 border-b border-border">
<h3 className="text-sm font-medium text-foreground">
Script Preview
{t("scripts.scriptPreview")}
</h3>
</div>
<div className="p-4 h-full max-h-80 overflow-y-auto">
@@ -162,7 +164,7 @@ export const SelectScriptModal = ({
<div className="flex items-center gap-2 mb-2">
<Terminal className="h-4 w-4 text-primary" />
<span className="text-sm font-medium text-foreground">
Command Preview
{t("scripts.commandPreview")}
</span>
</div>
<div className="bg-muted/30 p-3 rounded border border-border/30">
@@ -174,7 +176,7 @@ export const SelectScriptModal = ({
<div>
<span className="text-sm font-medium text-foreground">
Script Content
{t("scripts.scriptContent")}
</span>
<div className="bg-muted/30 p-3 rounded border border-border/30 mt-2 max-h-32 overflow-auto">
<pre className="text-xs font-mono text-foreground whitespace-pre-wrap">
@@ -186,7 +188,7 @@ export const SelectScriptModal = ({
) : (
<div className="text-center text-muted-foreground py-8">
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Select a script to preview</p>
<p>{t("scripts.selectScriptToPreview")}</p>
</div>
)}
</div>
@@ -200,7 +202,7 @@ export const SelectScriptModal = ({
onClick={handleClose}
className="btn-outline"
>
Cancel
{t("common.cancel")}
</Button>
<Button
type="button"
@@ -209,7 +211,7 @@ export const SelectScriptModal = ({
className="btn-primary glow-primary"
>
<Check className="h-4 w-4 mr-2" />
Select Script
{t("scripts.selectScript")}
</Button>
</div>
</div>

View File

@@ -1,11 +1,12 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { EditorView } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { javascript } from "@codemirror/lang-javascript";
import { EditorView, keymap } from "@codemirror/view";
import { EditorState, Transaction } from "@codemirror/state";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { StreamLanguage } from "@codemirror/language";
import { oneDark } from "@codemirror/theme-one-dark";
import { Button } from "./ui/Button";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Terminal, Copy, Check } from "lucide-react";
interface BashEditorProps {
@@ -21,25 +22,94 @@ export const BashEditor = ({
onChange,
placeholder = "#!/bin/bash\n# Your bash script here\necho 'Hello World'",
className = "",
label = "Bash Script",
label,
}: BashEditorProps) => {
const [copied, setCopied] = useState(false);
const editorRef = useRef<HTMLDivElement>(null);
const editorViewRef = useRef<EditorView | null>(null);
const insertFourSpaces = ({
state,
dispatch,
}: {
state: EditorState;
dispatch: (tr: Transaction) => void;
}) => {
if (state.selection.ranges.some((range) => !range.empty)) {
const changes = state.selection.ranges
.map((range) => {
const fromLine = state.doc.lineAt(range.from).number;
const toLine = state.doc.lineAt(range.to).number;
const changes = [];
for (let line = fromLine; line <= toLine; line++) {
const lineObj = state.doc.line(line);
changes.push({ from: lineObj.from, insert: " " });
}
return changes;
})
.flat();
dispatch(state.update({ changes }));
} else {
dispatch(state.update(state.replaceSelection(" ")));
}
return true;
};
const removeFourSpaces = ({
state,
dispatch,
}: {
state: EditorState;
dispatch: (tr: Transaction) => void;
}) => {
if (state.selection.ranges.some((range) => !range.empty)) {
const changes = state.selection.ranges
.map((range) => {
const fromLine = state.doc.lineAt(range.from).number;
const toLine = state.doc.lineAt(range.to).number;
const changes = [];
for (let line = fromLine; line <= toLine; line++) {
const lineObj = state.doc.line(line);
const indent = lineObj.text.match(/^ /);
if (indent) {
changes.push({ from: lineObj.from, to: lineObj.from + 4 });
}
}
return changes;
})
.flat();
dispatch(state.update({ changes }));
} else {
const cursor = state.selection.main.head;
const line = state.doc.lineAt(cursor);
const beforeCursor = line.text.slice(0, cursor - line.from);
const spacesToRemove = beforeCursor.match(/ {1,4}$/);
if (spacesToRemove) {
const removeCount = spacesToRemove[0].length;
dispatch(
state.update({
changes: { from: cursor - removeCount, to: cursor },
})
);
}
}
return true;
};
useEffect(() => {
if (!editorRef.current) return;
const bashLanguage = javascript({
typescript: false,
jsx: false,
});
const bashLanguage = StreamLanguage.define(shell);
const state = EditorState.create({
doc: value || placeholder,
extensions: [
bashLanguage,
oneDark,
keymap.of([
{ key: "Tab", run: insertFourSpaces },
{ key: "Shift-Tab", run: removeFourSpaces },
]),
EditorView.updateListener.of((update: any) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
@@ -115,6 +185,7 @@ export const BashEditor = ({
<span className="text-sm font-medium">{label}</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopy}
@@ -134,4 +205,4 @@ export const BashEditor = ({
</div>
</div>
);
}
};

View File

@@ -1,8 +1,8 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "./ui/Button";
import { Input } from "./ui/Input";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import {
Search,
FileText,
@@ -18,7 +18,7 @@ import {
fetchSnippetCategories,
searchSnippets,
type BashSnippet,
} from "../_server/actions/snippets";
} from "@/app/_server/actions/snippets";
interface BashSnippetHelperProps {
onInsertSnippet: (snippet: string) => void;
@@ -34,7 +34,9 @@ const categoryIcons = {
"Custom Scripts": Code,
};
export const 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);
@@ -161,7 +163,7 @@ export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) =
</div>
)}
<div className="space-y-2 overflow-y-auto custom-scrollbar">
<div className="space-y-2 overflow-y-auto !pr-0 custom-scrollbar">
{filteredSnippets.map((snippet) => {
const Icon =
categoryIcons[snippet.category as keyof typeof categoryIcons] ||
@@ -243,4 +245,4 @@ export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) =
</div>
</div>
);
}
};

View File

@@ -5,9 +5,9 @@ import {
parseCronExpression,
cronPatterns,
type CronExplanation,
} from "../_utils/cronParser";
import { Button } from "./ui/Button";
import { Input } from "./ui/Input";
} from "@/app/_utils/parser-utils";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import {
Clock,
Info,
@@ -18,6 +18,7 @@ import {
ChevronUp,
Search,
} from "lucide-react";
import { useLocale } from "next-intl";
interface CronExpressionHelperProps {
value: string;
@@ -34,6 +35,7 @@ export const CronExpressionHelper = ({
className = "",
showPatterns = true,
}: CronExpressionHelperProps) => {
const locale = useLocale();
const [explanation, setExplanation] = useState<CronExplanation | null>(null);
const [showPatternsPanel, setShowPatternsPanel] = useState(false);
const [debouncedValue, setDebouncedValue] = useState(value);
@@ -49,7 +51,7 @@ export const CronExpressionHelper = ({
useEffect(() => {
if (debouncedValue) {
const result = parseCronExpression(debouncedValue);
const result = parseCronExpression(debouncedValue, locale);
setExplanation(result);
} else {
setExplanation(null);

View File

@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
import { Button } from "./ui/Button";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/GlobalComponents/Cards/Card";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import {
FileText,
Plus,
@@ -13,7 +13,7 @@ import {
CheckCircle,
Files,
} from "lucide-react";
import { type Script } from "@/app/_server/actions/scripts";
import { Script } from "@/app/_utils/scripts-utils";
import {
createScript,
updateScript,
@@ -21,16 +21,19 @@ import {
cloneScript,
getScriptContent,
} from "@/app/_server/actions/scripts";
import { CreateScriptModal } from "./modals/CreateScriptModal";
import { EditScriptModal } from "./modals/EditScriptModal";
import { DeleteScriptModal } from "./modals/DeleteScriptModal";
import { CloneScriptModal } from "./modals/CloneScriptModal";
import { showToast } from "./ui/Toast";
import { CreateScriptModal } from "@/app/_components/FeatureComponents/Modals/CreateScriptModal";
import { EditScriptModal } from "@/app/_components/FeatureComponents/Modals/EditScriptModal";
import { DeleteScriptModal } from "@/app/_components/FeatureComponents/Modals/DeleteScriptModal";
import { CloneScriptModal } from "@/app/_components/FeatureComponents/Modals/CloneScriptModal";
import { showToast } from "@/app/_components/GlobalComponents/UIElements/Toast";
import { useTranslations } from "next-intl";
interface ScriptsManagerProps {
scripts: Script[];
}
const DRAFT_STORAGE_KEY = "cronjob_script_draft";
export const ScriptsManager = ({
scripts: initialScripts,
}: ScriptsManagerProps) => {
@@ -43,12 +46,15 @@ export const ScriptsManager = ({
const [copiedId, setCopiedId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isCloning, setIsCloning] = useState(false);
const t = useTranslations();
const [createForm, setCreateForm] = useState({
const defaultFormValues = {
name: "",
description: "",
content: "#!/bin/bash\n# Your script here\necho 'Hello World'",
});
};
const [createForm, setCreateForm] = useState(defaultFormValues);
const [editForm, setEditForm] = useState({
name: "",
@@ -56,6 +62,37 @@ export const ScriptsManager = ({
content: "",
});
useEffect(() => {
try {
const savedDraft = localStorage.getItem(DRAFT_STORAGE_KEY);
if (savedDraft) {
const parsedDraft = JSON.parse(savedDraft);
setCreateForm(parsedDraft);
}
} catch (error) {
console.error("Failed to load draft from localStorage:", error);
}
}, []);
useEffect(() => {
try {
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(createForm));
} catch (error) {
console.error("Failed to save draft to localStorage:", error);
}
}, [createForm]);
const isDraft =
createForm.name.trim() !== "" ||
createForm.description.trim() !== "" ||
createForm.content !== defaultFormValues.content;
const handleClearDraft = () => {
setCreateForm(defaultFormValues);
localStorage.removeItem(DRAFT_STORAGE_KEY);
showToast("success", t("scripts.draftCleared"));
};
const refreshScripts = async () => {
try {
const { fetchScripts } = await import("@/app/_server/actions/scripts");
@@ -76,6 +113,8 @@ export const ScriptsManager = ({
if (result.success) {
await refreshScripts();
setIsCreateModalOpen(false);
setCreateForm(defaultFormValues);
localStorage.removeItem(DRAFT_STORAGE_KEY);
showToast("success", "Script created successfully");
} else {
showToast("error", "Failed to create script", result.message);
@@ -173,10 +212,10 @@ export const ScriptsManager = ({
</div>
<div>
<CardTitle className="text-xl brand-gradient">
Scripts Library
{t("scripts.scriptsLibrary")}
</CardTitle>
<p className="text-sm text-muted-foreground">
{scripts.length} saved script{scripts.length !== 1 ? "s" : ""}
{t("scripts.nOfNSavedScripts", { count: scripts.length })}
</p>
</div>
</div>
@@ -185,7 +224,7 @@ export const ScriptsManager = ({
className="btn-primary glow-primary"
>
<Plus className="h-4 w-4 mr-2" />
New Script
{t("scripts.newScript")}
</Button>
</div>
</CardHeader>
@@ -196,10 +235,10 @@ export const ScriptsManager = ({
<FileText className="h-10 w-10 text-primary" />
</div>
<h3 className="text-xl font-semibold mb-3 brand-gradient">
No scripts yet
{t("scripts.noScriptsYet")}
</h3>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
Create reusable bash scripts to use in your scheduled tasks.
{t("scripts.createReusableBashScripts")}
</p>
<Button
onClick={() => setIsCreateModalOpen(true)}
@@ -207,7 +246,7 @@ export const ScriptsManager = ({
size="lg"
>
<Plus className="h-5 w-5 mr-2" />
Create Your First Script
{t("scripts.createYourFirstScript")}
</Button>
</div>
) : (
@@ -233,7 +272,7 @@ export const ScriptsManager = ({
</p>
)}
<div className="text-xs text-muted-foreground">
File: {script.filename}
{t("scripts.file")}: {script.filename}
</div>
</div>
@@ -316,6 +355,8 @@ export const ScriptsManager = ({
onFormChange={(updates) =>
setCreateForm((prev) => ({ ...prev, ...updates }))
}
isDraft={isDraft}
onClearDraft={handleClearDraft}
/>
<EditScriptModal

View File

@@ -1,7 +1,7 @@
import { cn } from "@/app/_utils/cn";
import { cn } from "@/app/_utils/global-utils";
import { HTMLAttributes, forwardRef } from "react";
import { Zap } from "lucide-react";
import { StatusBadge } from "./StatusBadge";
import { StatusBadge } from "@/app/_components/GlobalComponents/Badges/StatusBadge";
export interface PerformanceMetric {
label: string;

View File

@@ -1,16 +1,10 @@
"use client";
import { MetricCard } from "./ui/MetricCard";
import { SystemStatus } from "./ui/SystemStatus";
import { PerformanceSummary } from "./ui/PerformanceSummary";
import { Sidebar } from "./ui/Sidebar";
import {
Clock,
HardDrive,
Cpu,
Monitor,
Wifi,
} from "lucide-react";
import { MetricCard } from "@/app/_components/GlobalComponents/Cards/MetricCard";
import { SystemStatus } from "@/app/_components/FeatureComponents/System/SystemStatus";
import { PerformanceSummary } from "@/app/_components/FeatureComponents/System/PerformanceSummary";
import { Sidebar } from "@/app/_components/FeatureComponents/Layout/Sidebar";
import { Clock, HardDrive, Cpu, Monitor, Wifi } from "lucide-react";
interface SystemInfoType {
hostname: string;
@@ -54,7 +48,11 @@ interface SystemInfoType {
details: string;
};
}
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { useSSEContext } from "@/app/_contexts/SSEContext";
import { SSEEvent } from "@/app/_utils/sse-events";
import { usePageVisibility } from "@/app/_hooks/usePageVisibility";
interface SystemInfoCardProps {
systemInfo: SystemInfoType;
@@ -67,55 +65,102 @@ export const SystemInfoCard = ({
const [systemInfo, setSystemInfo] =
useState<SystemInfoType>(initialSystemInfo);
const [isUpdating, setIsUpdating] = useState(false);
const [isDisabled, setIsDisabled] = useState(false);
const t = useTranslations();
const { subscribe } = useSSEContext();
const isPageVisible = usePageVisibility();
const abortControllerRef = useRef<AbortController | null>(null);
const updateSystemInfo = async () => {
if (isDisabled) {
return;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
setIsUpdating(true);
const response = await fetch('/api/system-stats');
const response = await fetch("/api/system-stats", {
signal: abortController.signal,
});
if (!response.ok) {
throw new Error('Failed to fetch system stats');
throw new Error("Failed to fetch system stats");
}
const freshData = await response.json();
if (freshData === null) {
setIsDisabled(true);
return;
}
setSystemInfo(freshData);
} catch (error) {
console.error("Failed to update system info:", error);
} catch (error: any) {
if (error.name !== "AbortError") {
console.error("Failed to update system info:", error);
}
} finally {
setIsUpdating(false);
if (!abortControllerRef.current?.signal.aborted) {
setIsUpdating(false);
}
}
};
useEffect(() => {
const unsubscribe = subscribe((event: SSEEvent) => {
if (event.type === "system-stats" && event.data !== null) {
setSystemInfo(event.data);
}
});
return unsubscribe;
}, [subscribe]);
useEffect(() => {
const updateTime = () => {
setCurrentTime(new Date().toLocaleTimeString());
};
updateTime();
updateSystemInfo();
if (isPageVisible) {
updateSystemInfo();
}
const updateInterval = parseInt(
process.env.NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL || "30000"
);
let mounted = true;
let timeoutId: NodeJS.Timeout | null = null;
const doUpdate = () => {
if (!mounted) return;
if (!mounted || !isPageVisible || isDisabled) return;
updateTime();
updateSystemInfo().finally(() => {
if (mounted) {
setTimeout(doUpdate, updateInterval);
if (mounted && isPageVisible && !isDisabled) {
timeoutId = setTimeout(doUpdate, updateInterval);
}
});
};
setTimeout(doUpdate, updateInterval);
if (isPageVisible && !isDisabled) {
timeoutId = setTimeout(doUpdate, updateInterval);
}
return () => {
mounted = false;
if (timeoutId) {
clearTimeout(timeoutId);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
}, [isPageVisible, isDisabled]);
const quickStats = {
cpu: systemInfo.cpu.usage,
@@ -126,7 +171,7 @@ export const SystemInfoCard = ({
const basicInfoItems = [
{
icon: Clock,
label: "Uptime",
label: t("sidebar.uptime"),
value: systemInfo.uptime,
color: "text-orange-500",
},
@@ -135,7 +180,7 @@ export const SystemInfoCard = ({
const performanceItems = [
{
icon: HardDrive,
label: "Memory",
label: t("sidebar.memory"),
value: `${systemInfo.memory.used} / ${systemInfo.memory.total}`,
detail: `${systemInfo.memory.free} free`,
status: systemInfo.memory.status,
@@ -145,7 +190,7 @@ export const SystemInfoCard = ({
},
{
icon: Cpu,
label: "CPU",
label: t("sidebar.cpu"),
value: systemInfo.cpu.model,
detail: `${systemInfo.cpu.cores} cores`,
status: systemInfo.cpu.status,
@@ -155,7 +200,7 @@ export const SystemInfoCard = ({
},
{
icon: Monitor,
label: "GPU",
label: t("sidebar.gpu"),
value: systemInfo.gpu.model,
detail: systemInfo.gpu.memory
? `${systemInfo.gpu.memory} VRAM`
@@ -163,40 +208,44 @@ export const 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",
}] : []),
...(systemInfo.network
? [
{
icon: Wifi,
label: t("sidebar.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 = [
{
label: "CPU Usage",
label: t("sidebar.cpuUsage"),
value: `${systemInfo.cpu.usage}%`,
status: systemInfo.cpu.status,
},
{
label: "Memory Usage",
label: t("sidebar.memoryUsage"),
value: `${systemInfo.memory.usage}%`,
status: systemInfo.memory.status,
},
...(systemInfo.network ? [{
label: "Network Latency",
value: `${systemInfo.network.latency}ms`,
status: systemInfo.network.status,
}] : []),
...(systemInfo.network
? [
{
label: t("sidebar.networkLatency"),
value: `${systemInfo.network.latency}ms`,
status: systemInfo.network.status,
},
]
: []),
];
return (
<Sidebar
title="System Overview"
defaultCollapsed={false}
quickStats={quickStats}
>
<Sidebar defaultCollapsed={false} quickStats={quickStats}>
<SystemStatus
status={systemInfo.systemStatus.overall}
details={systemInfo.systemStatus.details}
@@ -206,7 +255,7 @@ export const SystemInfoCard = ({
<div>
<h3 className="text-xs font-semibold text-foreground mb-2 uppercase tracking-wide">
System Information
{t("sidebar.systemInformation")}
</h3>
<div className="space-y-2">
{basicInfoItems.map((item) => (
@@ -224,7 +273,7 @@ export const SystemInfoCard = ({
<div>
<h3 className="text-xs font-semibold text-foreground mb-2 uppercase tracking-wide">
Performance Metrics
{t("sidebar.performanceMetrics")}
</h3>
<div className="space-y-2">
{performanceItems.map((item) => (
@@ -247,16 +296,16 @@ export const SystemInfoCard = ({
<PerformanceSummary metrics={performanceMetrics} />
<div className="text-xs text-muted-foreground text-center p-2 bg-muted/20 rounded-lg">
💡 Stats update every{" "}
{t("sidebar.statsUpdateEvery")}{" "}
{Math.round(
parseInt(process.env.NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL || "30000") /
1000
)}
s Network speed estimated from latency
s {t("sidebar.networkSpeedEstimatedFromLatency")}
{isUpdating && (
<span className="ml-2 animate-pulse">🔄 Updating...</span>
<span className="ml-2 animate-pulse">{t("sidebar.updating")}...</span>
)}
</div>
</Sidebar>
);
}
};

View File

@@ -1,6 +1,7 @@
import { cn } from "@/app/_utils/cn";
import { cn } from "@/app/_utils/global-utils";
import { HTMLAttributes, forwardRef } from "react";
import { Activity } from "lucide-react";
import { useTranslations } from "next-intl";
export interface SystemStatusProps extends HTMLAttributes<HTMLDivElement> {
status: string;
@@ -14,6 +15,7 @@ export const SystemStatus = forwardRef<HTMLDivElement, SystemStatusProps>(
{ className, status, details, timestamp, isUpdating = false, ...props },
ref
) => {
const t = useTranslations();
const getStatusConfig = (status: string) => {
const lowerStatus = status.toLowerCase();
@@ -64,11 +66,11 @@ export const SystemStatus = forwardRef<HTMLDivElement, SystemStatusProps>(
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">
System Status: {status}
{t("system.systemStatus")}: {status}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{details} Last updated: {timestamp}
{details} {t("system.lastUpdated")}: {timestamp}
{isUpdating && <span className="ml-2 animate-pulse">🔄</span>}
</p>
</div>

View File

@@ -0,0 +1,69 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { AlertTriangle, X } from "lucide-react";
export const WrapperScriptWarning = () => {
const [isVisible, setIsVisible] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const t = useTranslations();
useEffect(() => {
const dismissed = localStorage.getItem("wrapper-warning-dismissed");
if (dismissed === "true") {
setIsLoading(false);
return;
}
checkWrapperScriptModification();
}, []);
const checkWrapperScriptModification = async () => {
try {
const response = await fetch("/api/system/wrapper-check");
if (response.ok) {
const data = await response.json();
setIsVisible(data.modified);
}
} catch (error) {
console.error("Failed to check wrapper script:", error);
} finally {
setIsLoading(false);
}
};
const dismissWarning = () => {
setIsVisible(false);
localStorage.setItem("wrapper-warning-dismissed", "true");
};
if (isLoading || !isVisible) {
return null;
}
return (
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4 mb-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3">
<AlertTriangle className="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-400">
{t("warnings.wrapperScriptModified")}
</h3>
<p className="text-sm text-amber-700 dark:text-amber-500 mt-1">
{t("warnings.wrapperScriptModifiedDescription")}
</p>
</div>
</div>
<button
onClick={dismissWarning}
className="text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300 transition-colors ml-4"
aria-label="Dismiss warning"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
};

View File

@@ -3,7 +3,7 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { Button } from './Button';
import { Button } from '@/app/_components/GlobalComponents/UIElements/Button';
export const ThemeToggle = () => {
const [mounted, setMounted] = useState(false);

View File

@@ -1,9 +1,10 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "./Button";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { ChevronDown, User, X } from "lucide-react";
import { fetchAvailableUsers } from "@/app/_server/actions/cronjobs";
import { useTranslations } from "next-intl";
interface UserFilterProps {
selectedUser: string | null;
@@ -19,6 +20,7 @@ export const UserFilter = ({
const [users, setUsers] = useState<string[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const t = useTranslations();
useEffect(() => {
const loadUsers = async () => {
@@ -48,32 +50,33 @@ export const UserFilter = ({
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>
)}
<div className="flex items-center gap-1">
<Button
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className="flex-1 justify-between"
>
<div className="flex items-center gap-2">
<User className="h-4 w-4" />
<span className="text-sm">
{selectedUser
? `${t("common.userWithUsername", { user: selectedUser })}`
: t("common.allUsers")}
</span>
</div>
<ChevronDown className="h-4 w-4" />
</div>
</Button>
</Button>
{selectedUser && (
<Button
variant="ghost"
size="sm"
onClick={() => onUserChange(null)}
className="p-2 h-8 w-8 flex-shrink-0"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{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">
@@ -82,10 +85,11 @@ export const UserFilter = ({
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" : ""
}`}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${
!selectedUser ? "bg-accent text-accent-foreground" : ""
}`}
>
All users
{t("common.allUsers")}
</button>
{users.map((user) => (
<button
@@ -94,8 +98,9 @@ export const UserFilter = ({
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" : ""
}`}
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>
@@ -104,4 +109,4 @@ export const UserFilter = ({
)}
</div>
);
}
};

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "./Button";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { ChevronDown, User } from "lucide-react";
import { fetchAvailableUsers } from "@/app/_server/actions/cronjobs";
@@ -52,8 +52,13 @@ export const UserSwitcher = ({
return (
<div className={`relative ${className}`}>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsOpen(!isOpen);
}}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
@@ -67,13 +72,17 @@ export const UserSwitcher = ({
<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
type="button"
key={user}
onClick={() => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
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" : ""
}`}
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>
@@ -82,4 +91,4 @@ export const UserSwitcher = ({
)}
</div>
);
}
};

View File

@@ -1,7 +1,7 @@
"use client";
import { AlertCircle, X } from "lucide-react";
import { JobError, removeJobError } from "@/app/_utils/errorState";
import { JobError, removeJobError } from "@/app/_utils/error-utils";
interface ErrorBadgeProps {
errors: JobError[];

View File

@@ -1,6 +1,7 @@
import { cn } from "@/app/_utils/cn";
import { cn } from "@/app/_utils/global-utils";
import { HTMLAttributes, forwardRef } from "react";
import { CheckCircle, AlertTriangle, XCircle, Activity } from "lucide-react";
import { useTranslations } from "next-intl";
export interface StatusBadgeProps extends HTMLAttributes<HTMLDivElement> {
status: string;
@@ -21,6 +22,7 @@ export const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
},
ref
) => {
const t = useTranslations();
const getStatusConfig = (status: string) => {
const lowerStatus = status.toLowerCase();
@@ -33,7 +35,7 @@ export const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
bgColor: "bg-emerald-500/10",
borderColor: "border-emerald-500/20",
icon: CheckCircle,
label: "Optimal",
label: t("system.optimal"),
};
case "moderate":
case "warning":
@@ -42,7 +44,7 @@ export const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
bgColor: "bg-yellow-500/10",
borderColor: "border-yellow-500/20",
icon: AlertTriangle,
label: "Warning",
label: t("system.warning"),
};
case "high":
case "slow":
@@ -51,7 +53,7 @@ export const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
bgColor: "bg-orange-500/10",
borderColor: "border-orange-500/20",
icon: AlertTriangle,
label: "High",
label: t("system.high"),
};
case "critical":
case "poor":
@@ -61,7 +63,7 @@ export const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
bgColor: "bg-destructive/10",
borderColor: "border-destructive/20",
icon: XCircle,
label: "Critical",
label: t("system.critical"),
};
default:
return {
@@ -69,7 +71,7 @@ export const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
bgColor: "bg-muted",
borderColor: "border-border",
icon: Activity,
label: "Unknown",
label: t("system.unknown"),
};
}
};

View File

@@ -1,4 +1,4 @@
import { cn } from '@/app/_utils/cn';
import { cn } from '@/app/_utils/global-utils';
import { HTMLAttributes, forwardRef } from 'react';
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(

View File

@@ -1,9 +1,9 @@
import { cn } from "@/app/_utils/cn";
import { cn } from "@/app/_utils/global-utils";
import { HTMLAttributes, forwardRef } from "react";
import { LucideIcon } from "lucide-react";
import { StatusBadge } from "./StatusBadge";
import { ProgressBar } from "./ProgressBar";
import { TruncatedText } from "./TruncatedText";
import { StatusBadge } from "@/app/_components/GlobalComponents/Badges/StatusBadge";
import { ProgressBar } from "@/app/_components/GlobalComponents/UIElements/ProgressBar";
import { TruncatedText } from "@/app/_components/GlobalComponents/UIElements/TruncatedText";
export interface MetricCardProps extends HTMLAttributes<HTMLDivElement> {
icon: LucideIcon;

View File

@@ -1,4 +1,4 @@
import { cn } from '@/app/_utils/cn';
import { cn } from '@/app/_utils/global-utils';
import { InputHTMLAttributes, forwardRef } from 'react';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { }

View File

@@ -1,4 +1,4 @@
import { cn } from '@/app/_utils/cn';
import { cn } from '@/app/_utils/global-utils';
import { ButtonHTMLAttributes, forwardRef } from 'react';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {

View File

@@ -0,0 +1,130 @@
"use client";
import { useState, useRef, useEffect, ReactNode } from "react";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { MoreVertical } from "lucide-react";
const DROPDOWN_HEIGHT = 200;
interface DropdownMenuItem {
label: string;
icon?: ReactNode;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "destructive";
}
interface DropdownMenuProps {
items: DropdownMenuItem[];
triggerLabel?: string;
triggerIcon?: ReactNode;
triggerClassName?: string;
onOpenChange?: (isOpen: boolean) => void;
}
export const DropdownMenu = ({
items,
triggerLabel,
triggerIcon = <MoreVertical className="h-3 w-3" />,
triggerClassName = "btn-outline h-8 px-3",
onOpenChange,
}: DropdownMenuProps) => {
const [isOpen, setIsOpen] = useState(false);
const [positionAbove, setPositionAbove] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const handleOpenChange = (open: boolean) => {
if (open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
setPositionAbove(spaceBelow < DROPDOWN_HEIGHT && spaceAbove > spaceBelow);
}
setIsOpen(open);
onOpenChange?.(open);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
handleOpenChange(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
handleOpenChange(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen]);
const handleItemClick = (item: DropdownMenuItem) => {
if (!item.disabled) {
item.onClick();
handleOpenChange(false);
}
};
return (
<div className="relative inline-block" ref={dropdownRef}>
<Button
ref={triggerRef}
variant="outline"
size="sm"
onClick={() => handleOpenChange(!isOpen)}
className={triggerClassName}
aria-label={triggerLabel || "Open menu"}
title={triggerLabel || "Open menu"}
>
{triggerIcon}
{triggerLabel && <span className="ml-2">{triggerLabel}</span>}
</Button>
{isOpen && (
<div
className={`absolute right-0 w-56 rounded-lg border border-border/50 bg-background shadow-lg z-[9999] overflow-hidden ${
positionAbove ? "bottom-full mb-2" : "top-full mt-2"
}`}
>
<div className="py-1">
{items.map((item, index) => (
<button
key={index}
onClick={() => handleItemClick(item)}
disabled={item.disabled}
className={`w-full flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
item.disabled
? "opacity-50 cursor-not-allowed"
: item.variant === "destructive"
? "text-destructive hover:bg-destructive/10"
: "text-foreground hover:bg-accent"
}`}
>
{item.icon && (
<span className="flex-shrink-0">{item.icon}</span>
)}
<span className="flex-1 text-left">{item.label}</span>
</button>
))}
</div>
</div>
)}
</div>
);
};

View File

@@ -2,17 +2,18 @@
import { useEffect, useRef } from "react";
import { X } from "lucide-react";
import { cn } from "@/app/_utils/cn";
import { Button } from "./Button";
import { cn } from "@/app/_utils/global-utils";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
size?: "sm" | "md" | "lg" | "xl";
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl";
showCloseButton?: boolean;
preventCloseOnClickOutside?: boolean;
className?: string;
}
export const Modal = ({
@@ -23,6 +24,7 @@ export const Modal = ({
size = "md",
showCloseButton = true,
preventCloseOnClickOutside = false,
className = "",
}: ModalProps) => {
const modalRef = useRef<HTMLDivElement>(null);
@@ -78,6 +80,8 @@ export const Modal = ({
md: "max-w-lg",
lg: "max-w-2xl",
xl: "max-w-4xl",
"2xl": "max-w-6xl",
"3xl": "max-w-8xl",
};
return (
@@ -90,10 +94,11 @@ export const Modal = ({
<div
ref={modalRef}
className={cn(
"relative w-full bg-card border border-border shadow-lg overflow-y-auto",
"relative w-full bg-card border border-border shadow-lg",
"max-h-[85vh]",
"sm:rounded-lg sm:max-h-[90vh] sm:w-full",
sizeClasses[size]
sizeClasses[size],
className
)}
>
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border sticky top-0 bg-card z-10">
@@ -110,8 +115,10 @@ export const Modal = ({
)}
</div>
<div className="p-4 sm:p-6">{children}</div>
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(80vh-100px)]">
{children}
</div>
</div>
</div>
);
}
};

View File

@@ -1,4 +1,4 @@
import { cn } from "@/app/_utils/cn";
import { cn } from "@/app/_utils/global-utils";
import { HTMLAttributes, forwardRef } from "react";
export interface ProgressBarProps extends HTMLAttributes<HTMLDivElement> {

View File

@@ -0,0 +1,35 @@
"use client";
import { cn } from "@/app/_utils/global-utils";
interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
className?: string;
disabled?: boolean;
}
export const Switch = ({
checked,
onCheckedChange,
className = "",
disabled = false,
}: SwitchProps) => {
return (
<label
className={cn(
"relative inline-flex items-center cursor-pointer",
className
)}
>
<input
type="checkbox"
className="sr-only peer"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
disabled={disabled}
/>
<div className="w-9 h-5 bg-muted peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary/25 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary peer-disabled:opacity-50 peer-disabled:cursor-not-allowed"></div>
</label>
);
};

View File

@@ -2,8 +2,8 @@
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";
import { cn } from "@/app/_utils/global-utils";
import { ErrorDetailsModal } from "@/app/_components/FeatureComponents/Modals/ErrorDetailsModal";
export interface Toast {
id: string;
@@ -69,9 +69,8 @@ export const Toast = ({ toast, onRemove, onErrorClick }: ToastProps) => {
>
<Icon className="h-5 w-5 flex-shrink-0 mt-0.5" />
<div
className={`flex-1 min-w-0 ${
toast.type === "error" && toast.errorDetails ? "cursor-pointer" : ""
}`}
className={`flex-1 min-w-0 ${toast.type === "error" && toast.errorDetails ? "cursor-pointer" : ""
}`}
onClick={() => {
if (toast.type === "error" && toast.errorDetails && onErrorClick) {
onErrorClick(toast.errorDetails);

View File

@@ -1,4 +1,4 @@
import { cn } from "@/app/_utils/cn";
import { cn } from "@/app/_utils/global-utils";
import { HTMLAttributes, forwardRef, useState } from "react";
export interface TruncatedTextProps extends HTMLAttributes<HTMLDivElement> {

View File

@@ -1,514 +0,0 @@
"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,
}}
/>
)}
</>
);
};

View File

@@ -1,99 +0,0 @@
"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>
);
};

View File

@@ -1,43 +0,0 @@
"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>
);
};

32
app/_consts/commands.ts Normal file
View File

@@ -0,0 +1,32 @@
export const WRITE_CRONTAB = (content: string, user: string) => {
return `crontab -u ${user} - << 'EOF'\n${content}\nEOF`;
};
export const READ_CRONTAB = (user: string) =>
`crontab -l -u ${user} 2>/dev/null || echo ""`;
export const READ_CRON_FILE = () => 'crontab -l 2>/dev/null || echo ""';
export const WRITE_CRON_FILE = (content: string) => {
return `crontab - << 'EOF'\n${content}\nEOF`;
};
export const WRITE_HOST_CRONTAB = (base64Content: string, user: string) => {
const escapedContent = base64Content.replace(/'/g, "'\\''");
return `echo '${escapedContent}' | base64 -d | crontab -u ${user} -`;
};
export const ID_U = (username: string) => `id -u ${username}`;
export const ID_G = (username: string) => `id -g ${username}`;
export const MAKE_SCRIPT_EXECUTABLE = (scriptPath: string) =>
`chmod +x "${scriptPath}"`;
export const RUN_SCRIPT = (scriptPath: string) => `bash "${scriptPath}"`;
export const GET_TARGET_USER = `getent passwd | grep ":/home/" | head -1 | cut -d: -f1`;
export const GET_DOCKER_SOCKET_OWNER = 'stat -c "%U" /var/run/docker.sock';
export const READ_CRONTABS_DIRECTORY = `ls /var/spool/cron/crontabs/ 2>/dev/null || echo ''`;

5
app/_consts/file.ts Normal file
View File

@@ -0,0 +1,5 @@
import path from "path";
export const SCRIPTS_DIR = path.join("scripts");
export const SNIPPETS_DIR = path.join("snippets");
export const DATA_DIR = path.join("data");

7
app/_consts/nsenter.ts Normal file
View File

@@ -0,0 +1,7 @@
export const NSENTER_RUN_JOB = (
executionUser: string,
escapedCommand: string
) => `nsenter -t 1 -m -u -i -n -p su - ${executionUser} -c '${escapedCommand}'`;
export const NSENTER_HOST_CRONTAB = (command: string) =>
`nsenter -t 1 -m -u -i -n -p sh -c "${command}"`;

View File

@@ -0,0 +1,96 @@
"use client";
import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { SSEEvent } from "@/app/_utils/sse-events";
import { usePageVisibility } from "@/app/_hooks/usePageVisibility";
interface SSEContextType {
isConnected: boolean;
subscribe: (callback: (event: SSEEvent) => void) => () => void;
}
const SSEContext = createContext<SSEContextType | null>(null);
export const SSEProvider: React.FC<{
children: React.ReactNode;
liveUpdatesEnabled: boolean;
}> = ({ children, liveUpdatesEnabled }) => {
const [isConnected, setIsConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const subscribersRef = useRef<Set<(event: SSEEvent) => void>>(new Set());
const isPageVisible = usePageVisibility();
useEffect(() => {
if (!liveUpdatesEnabled || !isPageVisible) {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
setIsConnected(false);
}
return;
}
const eventSource = new EventSource("/api/events");
eventSource.onopen = () => {
setIsConnected(true);
};
eventSource.onerror = () => {
setIsConnected(false);
};
const eventTypes = [
"job-started",
"job-completed",
"job-failed",
"log-line",
"system-stats",
"heartbeat",
];
eventTypes.forEach((eventType) => {
eventSource.addEventListener(eventType, (event: MessageEvent) => {
try {
const data = JSON.parse(event.data) as SSEEvent;
subscribersRef.current.forEach((callback) => callback(data));
} catch (error) {
console.error(`[SSE] Failed to parse ${eventType} event:`, error);
}
});
});
eventSourceRef.current = eventSource;
return () => {
eventSource.close();
};
}, [liveUpdatesEnabled, isPageVisible]);
const subscribe = (callback: (event: SSEEvent) => void) => {
subscribersRef.current.add(callback);
return () => {
subscribersRef.current.delete(callback);
};
};
return (
<SSEContext.Provider value={{ isConnected, subscribe }}>
{children}
</SSEContext.Provider>
);
};
export const useSSEContext = () => {
const context = useContext(SSEContext);
if (!context) {
throw new Error("useSSEContext must be used within SSEProvider");
}
return context;
};

View File

@@ -0,0 +1,258 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { CronJob } from "@/app/_utils/cronjob-utils";
import { Script } from "@/app/_utils/scripts-utils";
import {
getJobErrorsByJobId,
JobError,
} from "@/app/_utils/error-utils";
import {
handleErrorClick,
handleDelete,
handleClone,
handlePause,
handleResume,
handleRun,
handleEditSubmit,
handleNewCronSubmit,
handleToggleLogging,
handleBackup,
} from "@/app/_components/FeatureComponents/Cronjobs/helpers";
interface CronJobListProps {
cronJobs: CronJob[];
scripts: Script[];
}
export const useCronJobState = ({ 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);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [jobForLogs, setJobForLogs] = useState<CronJob | null>(null);
const [isLiveLogModalOpen, setIsLiveLogModalOpen] = useState(false);
const [liveLogRunId, setLiveLogRunId] = useState<string>("");
const [liveLogJobId, setLiveLogJobId] = useState<string>("");
const [liveLogJobComment, setLiveLogJobComment] = useState<string>("");
const [editForm, setEditForm] = useState({
schedule: "",
command: "",
comment: "",
logsEnabled: false,
});
const [newCronForm, setNewCronForm] = useState({
schedule: "",
command: "",
comment: "",
selectedScriptId: null as string | null,
user: "",
logsEnabled: false,
});
useEffect(() => {
const savedUser = localStorage.getItem("selectedCronUser");
if (savedUser) {
setSelectedUser(savedUser);
}
}, []);
useEffect(() => {
if (selectedUser) {
localStorage.setItem("selectedCronUser", selectedUser);
} else {
localStorage.removeItem("selectedCronUser");
}
}, [selectedUser]);
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 refreshJobErrorsLocal = () => {
const errors: Record<string, JobError[]> = {};
filteredJobs.forEach((job) => {
errors[job.id] = getJobErrorsByJobId(job.id);
});
setJobErrors(errors);
};
const getHelperState = () => ({
setDeletingId,
setIsDeleteModalOpen,
setJobToDelete,
setIsCloneModalOpen,
setJobToClone,
setIsCloning,
setIsEditModalOpen,
setEditingJob,
setIsNewCronModalOpen,
setNewCronForm,
setRunningJobId,
refreshJobErrors: refreshJobErrorsLocal,
setIsLiveLogModalOpen,
setLiveLogRunId,
setLiveLogJobId,
setLiveLogJobComment,
jobToClone,
editingJob,
editForm,
newCronForm,
});
const handleErrorClickLocal = (error: JobError) => {
handleErrorClick(error, setSelectedError, setErrorModalOpen);
};
const handleDeleteLocal = async (id: string) => {
const job = cronJobs.find(j => j.id === id);
if (job) {
await handleDelete(job, getHelperState());
}
};
const handleCloneLocal = async (newComment: string) => {
await handleClone(newComment, getHelperState());
};
const handlePauseLocal = async (id: string) => {
const job = cronJobs.find(j => j.id === id);
if (job) {
await handlePause(job);
}
};
const handleResumeLocal = async (id: string) => {
const job = cronJobs.find(j => j.id === id);
if (job) {
await handleResume(job);
}
};
const handleRunLocal = async (id: string) => {
const job = cronJobs.find(j => j.id === id);
if (!job) return;
await handleRun(id, getHelperState(), job);
};
const handleToggleLoggingLocal = async (id: string) => {
const job = cronJobs.find(j => j.id === id);
if (job) {
await handleToggleLogging(job);
}
};
const handleViewLogs = (job: CronJob) => {
setJobForLogs(job);
setIsLogsModalOpen(true);
};
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 || "",
logsEnabled: job.logsEnabled || false,
});
setIsEditModalOpen(true);
};
const handleEditSubmitLocal = async (e: React.FormEvent) => {
await handleEditSubmit(e, getHelperState());
};
const handleNewCronSubmitLocal = async (e: React.FormEvent) => {
await handleNewCronSubmit(e, getHelperState());
};
const handleBackupLocal = async (id: string) => {
const job = cronJobs.find(j => j.id === id);
if (job) {
await handleBackup(job);
}
};
return {
deletingId,
runningJobId,
selectedUser,
setSelectedUser,
jobErrors,
errorModalOpen,
setErrorModalOpen,
selectedError,
setSelectedError,
isLogsModalOpen,
setIsLogsModalOpen,
jobForLogs,
isLiveLogModalOpen,
setIsLiveLogModalOpen,
liveLogRunId,
liveLogJobId,
liveLogJobComment,
filteredJobs,
isNewCronModalOpen,
setIsNewCronModalOpen,
isEditModalOpen,
setIsEditModalOpen,
isDeleteModalOpen,
setIsDeleteModalOpen,
isCloneModalOpen,
setIsCloneModalOpen,
jobToDelete,
jobToClone,
isCloning,
editForm,
setEditForm,
newCronForm,
setNewCronForm,
handleErrorClickLocal,
refreshJobErrorsLocal,
handleDeleteLocal,
handleCloneLocal,
handlePauseLocal,
handleResumeLocal,
handleRunLocal,
handleToggleLoggingLocal,
handleViewLogs,
confirmDelete,
confirmClone,
handleEdit,
handleEditSubmitLocal,
handleNewCronSubmitLocal,
handleBackupLocal,
};
};

View File

@@ -0,0 +1,24 @@
"use client";
import { useEffect, useState } from "react";
export function usePageVisibility(): boolean {
const [isVisible, setIsVisible] = useState<boolean>(
typeof document !== "undefined" ? !document.hidden : true
);
useEffect(() => {
const handleVisibilityChange = () => {
setIsVisible(!document.hidden);
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return isVisible;
}

126
app/_hooks/useSSE.ts Normal file
View File

@@ -0,0 +1,126 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import { SSEEvent, SSEEventType } from "@/app/_utils/sse-events";
type SSEEventHandler = (event: SSEEvent) => void;
type SSEErrorHandler = (error: Event) => void;
interface UseSSEOptions {
enabled?: boolean;
onEvent?: SSEEventHandler;
onError?: SSEErrorHandler;
onConnect?: () => void;
onDisconnect?: () => void;
}
/**
* Custom hook for consuming Server-Sent Events
*
* @param options Configuration options
* @returns Object with connection status and manual control functions
*
* @example
* ```tsx
* const { isConnected } = useSSE({
* enabled: true,
* onEvent: (event) => {
* if (event.type === 'job-started') {
* console.log('Job started:', event.data);
* }
* },
* });
* ```
*/
export const useSSE = (options: UseSSEOptions = {}) => {
const { enabled = true, onEvent, onError, onConnect, onDisconnect } = options;
const eventSourceRef = useRef<EventSource | null>(null);
const isConnectedRef = useRef(false);
const onEventRef = useRef(onEvent);
const onErrorRef = useRef(onError);
const onConnectRef = useRef(onConnect);
const onDisconnectRef = useRef(onDisconnect);
useEffect(() => {
onEventRef.current = onEvent;
onErrorRef.current = onError;
onConnectRef.current = onConnect;
onDisconnectRef.current = onDisconnect;
});
const connect = useCallback(() => {
if (eventSourceRef.current || !enabled) {
return;
}
try {
const eventSource = new EventSource("/api/events");
eventSource.onopen = () => {
isConnectedRef.current = true;
onConnectRef.current?.();
};
eventSource.onerror = (error) => {
isConnectedRef.current = false;
onErrorRef.current?.(error);
if (eventSource.readyState === EventSource.CLOSED) {
onDisconnectRef.current?.();
}
};
const eventTypes: SSEEventType[] = [
"job-started",
"job-completed",
"job-failed",
"log-line",
"system-stats",
"heartbeat",
];
eventTypes.forEach((eventType) => {
eventSource.addEventListener(eventType, (event: MessageEvent) => {
try {
const data = JSON.parse(event.data) as SSEEvent;
onEventRef.current?.(data);
} catch (error) {
console.error(`[SSE] Failed to parse ${eventType} event:`, error);
}
});
});
eventSourceRef.current = eventSource;
} catch (error) {
console.error("[SSE] Failed to create EventSource:", error);
}
}, [enabled]);
const disconnect = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
isConnectedRef.current = false;
onDisconnectRef.current?.();
}
}, []);
useEffect(() => {
if (enabled) {
connect();
} else {
disconnect();
}
return () => {
disconnect();
};
}, [enabled, connect, disconnect]);
return {
isConnected: isConnectedRef.current,
connect,
disconnect,
};
};

View File

@@ -0,0 +1,69 @@
#!/bin/bash
# Cr*nmaster Log Wrapper Script
# Captures stdout, stderr, exit code, and timestamps for cronjob executions
#
# Usage: cron-log-wrapper.sh <logFolderName> <command...>
# Example: cron-log-wrapper.sh "backup-database" bash /app/scripts/backup.sh
set -u
if [ $# -lt 2 ]; then
echo "ERROR: Usage: $0 <logFolderName> <command...>" >&2
exit 1
fi
LOG_FOLDER_NAME="$1"
shift
# Get the script's absolute directory path (e.g., ./data)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="${SCRIPT_DIR}/logs/${LOG_FOLDER_NAME}"
# Ensure the log directory exists
mkdir -p "$LOG_DIR"
TIMESTAMP_FILE=$(date '+%Y-%m-%d_%H-%M-%S')
HUMAN_START_TIME=$(date '+%Y-%m-%d %H:%M:%S')
LOG_FILE="${LOG_DIR}/${TIMESTAMP_FILE}.log"
START_SECONDS=$SECONDS
{
echo "--- [ JOB START ] ----------------------------------------------------"
echo "Command : $*"
echo "Timestamp : ${HUMAN_START_TIME}"
echo "Host : $(hostname)"
echo "User : $(whoami)"
echo "--- [ JOB OUTPUT ] ---------------------------------------------------"
echo ""
# Execute the command, capturing its exit code
"$@"
EXIT_CODE=$?
DURATION=$((SECONDS - START_SECONDS))
HUMAN_END_TIME=$(date '+%Y-%m-%d %H:%M:%S')
if [ $EXIT_CODE -eq 0 ]; then
STATUS="SUCCESS"
else
STATUS="FAILED"
fi
echo ""
echo "--- [ JOB SUMMARY ] --------------------------------------------------"
echo "Timestamp : ${HUMAN_END_TIME}"
echo "Duration : ${DURATION}s"
# ⚠️ ATTENTION: DO NOT MODIFY THE EXIT CODE LINE ⚠️
# The UI reads this exact format to detect job failures. Keep it as: "Exit Code : ${EXIT_CODE}"
echo "Exit Code : ${EXIT_CODE}"
echo "Status : ${STATUS}"
echo "--- [ JOB END ] ------------------------------------------------------"
exit $EXIT_CODE
} >> "$LOG_FILE" 2>&1
# Pass the command's exit code back to cron
exit $?

View File

@@ -3,23 +3,27 @@
import {
getCronJobs,
addCronJob,
deleteCronJob,
updateCronJob,
pauseCronJob,
resumeCronJob,
cleanupCrontab,
readUserCrontab,
writeUserCrontab,
findJobIndex,
updateCronJob,
type CronJob,
} from "@/app/_utils/system";
import {
getAllTargetUsers,
getUserInfo,
} from "@/app/_utils/system/hostCrontab";
} from "@/app/_utils/cronjob-utils";
import { getAllTargetUsers } from "@/app/_utils/crontab-utils";
import { revalidatePath } from "next/cache";
import { getScriptPath } from "@/app/_utils/scripts";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
import { getScriptPathForCron } from "@/app/_server/actions/scripts";
import { isDocker } from "@/app/_server/actions/global";
import {
runJobSynchronously,
runJobInBackground,
} from "@/app/_utils/job-execution-utils";
import {
pauseJobInLines,
resumeJobInLines,
deleteJobInLines,
} from "@/app/_utils/line-manipulation-utils";
import { cleanCrontabContent } from "@/app/_utils/files-manipulation-utils";
export const fetchCronJobs = async (): Promise<CronJob[]> => {
try {
@@ -39,6 +43,7 @@ export const createCronJob = async (
const comment = formData.get("comment") as string;
const selectedScriptId = formData.get("selectedScriptId") as string;
const user = formData.get("user") as string;
const logsEnabled = formData.get("logsEnabled") === "true";
if (!schedule) {
return { success: false, message: "Schedule is required" };
@@ -47,12 +52,12 @@ export const createCronJob = async (
let finalCommand = command;
if (selectedScriptId) {
const { fetchScripts } = await import("../scripts");
const { fetchScripts } = await import("@/app/_server/actions/scripts");
const scripts = await fetchScripts();
const selectedScript = scripts.find((s) => s.id === selectedScriptId);
if (selectedScript) {
finalCommand = await getScriptPath(selectedScript.filename);
finalCommand = await getScriptPathForCron(selectedScript.filename);
} else {
return { success: false, message: "Selected script not found" };
}
@@ -63,7 +68,13 @@ export const createCronJob = async (
};
}
const success = await addCronJob(schedule, finalCommand, comment, user);
const success = await addCronJob(
schedule,
finalCommand,
comment,
user,
logsEnabled
);
if (success) {
revalidatePath("/");
return { success: true, message: "Cron job created successfully" };
@@ -81,10 +92,22 @@ export const createCronJob = async (
};
export const removeCronJob = async (
id: string
jobData: { id: string; schedule: string; command: string; comment?: string; user: string }
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const success = await deleteCronJob(id);
const cronContent = await readUserCrontab(jobData.user);
const lines = cronContent.split("\n");
const jobIndex = findJobIndex(jobData, lines, jobData.user);
if (jobIndex === -1) {
return { success: false, message: "Cron job not found in crontab" };
}
const newCronEntries = deleteJobInLines(lines, jobIndex);
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
const success = await writeUserCrontab(jobData.user, newCron);
if (success) {
revalidatePath("/");
return { success: true, message: "Cron job deleted successfully" };
@@ -109,12 +132,26 @@ export const editCronJob = async (
const schedule = formData.get("schedule") as string;
const command = formData.get("command") as string;
const comment = formData.get("comment") as string;
const logsEnabled = formData.get("logsEnabled") === "true";
if (!id || !schedule || !command) {
return { success: false, message: "Missing required fields" };
}
const success = await updateCronJob(id, schedule, command, comment);
const cronJobs = await getCronJobs(false);
const job = cronJobs.find((j) => j.id === id);
if (!job) {
return { success: false, message: "Cron job not found" };
}
const success = await updateCronJob(
job,
schedule,
command,
comment,
logsEnabled
);
if (success) {
revalidatePath("/");
return { success: true, message: "Cron job updated successfully" };
@@ -136,7 +173,7 @@ export const cloneCronJob = async (
newComment: string
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const cronJobs = await getCronJobs();
const cronJobs = await getCronJobs(false);
const originalJob = cronJobs.find((job) => job.id === id);
if (!originalJob) {
@@ -167,10 +204,22 @@ export const cloneCronJob = async (
};
export const pauseCronJobAction = async (
id: string
jobData: { id: string; schedule: string; command: string; comment?: string; user: string }
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const success = await pauseCronJob(id);
const cronContent = await readUserCrontab(jobData.user);
const lines = cronContent.split("\n");
const jobIndex = findJobIndex(jobData, lines, jobData.user);
if (jobIndex === -1) {
return { success: false, message: "Cron job not found in crontab" };
}
const newCronEntries = pauseJobInLines(lines, jobIndex, jobData.id);
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
const success = await writeUserCrontab(jobData.user, newCron);
if (success) {
revalidatePath("/");
return { success: true, message: "Cron job paused successfully" };
@@ -188,10 +237,22 @@ export const pauseCronJobAction = async (
};
export const resumeCronJobAction = async (
id: string
jobData: { id: string; schedule: string; command: string; comment?: string; user: string }
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const success = await resumeCronJob(id);
const cronContent = await readUserCrontab(jobData.user);
const lines = cronContent.split("\n");
const jobIndex = findJobIndex(jobData, lines, jobData.user);
if (jobIndex === -1) {
return { success: false, message: "Cron job not found in crontab" };
}
const newCronEntries = resumeJobInLines(lines, jobIndex, jobData.id);
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
const success = await writeUserCrontab(jobData.user, newCron);
if (success) {
revalidatePath("/");
return { success: true, message: "Cron job resumed successfully" };
@@ -240,6 +301,41 @@ export const cleanupCrontabAction = async (): Promise<{
}
};
export const toggleCronJobLogging = async (
jobData: { id: string; schedule: string; command: string; comment?: string; user: string; logsEnabled?: boolean }
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const newLogsEnabled = !jobData.logsEnabled;
const success = await updateCronJob(
jobData,
jobData.schedule,
jobData.command,
jobData.comment || "",
newLogsEnabled
);
if (success) {
revalidatePath("/");
return {
success: true,
message: newLogsEnabled
? "Logging enabled successfully"
: "Logging disabled successfully",
};
} else {
return { success: false, message: "Failed to toggle logging" };
}
} catch (error: any) {
console.error("Error toggling logging:", error);
return {
success: false,
message: error.message || "Error toggling logging",
details: error.stack,
};
}
};
export const runCronJob = async (
id: string
): Promise<{
@@ -247,9 +343,11 @@ export const runCronJob = async (
message: string;
output?: string;
details?: string;
runId?: string;
mode?: "sync" | "async";
}> => {
try {
const cronJobs = await getCronJobs();
const cronJobs = await getCronJobs(false);
const job = cronJobs.find((j) => j.id === id);
if (!job) {
@@ -260,38 +358,17 @@ export const runCronJob = async (
return { success: false, message: "Cannot run paused cron job" };
}
const isDocker = process.env.DOCKER === "true";
let command = job.command;
const docker = await isDocker();
const liveUpdatesEnabled =
(typeof process.env.LIVE_UPDATES === "boolean" &&
process.env.LIVE_UPDATES === true) ||
process.env.LIVE_UPDATES !== "false";
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}'`;
if (job.logsEnabled && liveUpdatesEnabled) {
return runJobInBackground(job, docker);
}
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(),
};
return runJobSynchronously(job, docker);
} catch (error: any) {
console.error("Error running cron job:", error);
const errorMessage =
@@ -299,7 +376,240 @@ export const runCronJob = async (
return {
success: false,
message: "Failed to execute cron job",
output: errorMessage,
output: errorMessage.trim(),
details: error.stack,
};
}
};
export const executeJob = async (
id: string,
runInBackground: boolean = true
): Promise<{
success: boolean;
message: string;
output?: string;
details?: string;
runId?: string;
mode?: "sync" | "async";
}> => {
try {
const cronJobs = await getCronJobs(false);
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 docker = await isDocker();
if (runInBackground) {
return runJobInBackground(job, docker);
}
return runJobSynchronously(job, docker);
} catch (error: any) {
console.error("Error executing cron job:", error);
const errorMessage =
error.stderr || error.message || "Unknown error occurred";
return {
success: false,
message: "Failed to execute cron job",
output: errorMessage.trim(),
details: error.stack,
};
}
};
export const backupCronJob = async (
job: CronJob
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const {
backupJobToFile,
} = await import("@/app/_utils/backup-utils");
const success = await backupJobToFile(job);
if (success) {
return { success: true, message: "Cron job backed up successfully" };
} else {
return { success: false, message: "Failed to backup cron job" };
}
} catch (error: any) {
console.error("Error backing up cron job:", error);
return {
success: false,
message: error.message || "Error backing up cron job",
details: error.stack,
};
}
};
export const backupAllCronJobs = async (): Promise<{
success: boolean;
message: string;
details?: string;
}> => {
try {
const {
backupAllJobsToFiles,
} = await import("@/app/_utils/backup-utils");
const result = await backupAllJobsToFiles();
if (result.success) {
return {
success: true,
message: `Backed up ${result.count} cron job(s) successfully`,
};
} else {
return { success: false, message: "Failed to backup cron jobs" };
}
} catch (error: any) {
console.error("Error backing up all cron jobs:", error);
return {
success: false,
message: error.message || "Error backing up all cron jobs",
details: error.stack,
};
}
};
export const fetchBackupFiles = async (): Promise<Array<{
filename: string;
job: CronJob;
backedUpAt: string;
}>> => {
try {
const {
getAllBackupFiles,
} = await import("@/app/_utils/backup-utils");
return await getAllBackupFiles();
} catch (error) {
console.error("Error fetching backup files:", error);
return [];
}
};
export const restoreCronJob = async (
filename: string
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const {
restoreJobFromBackup,
} = await import("@/app/_utils/backup-utils");
const result = await restoreJobFromBackup(filename);
if (!result.success || !result.job) {
return { success: false, message: "Failed to read backup file" };
}
const job = result.job;
const success = await addCronJob(
job.schedule,
job.command,
job.comment || "",
job.user,
job.logsEnabled || false
);
if (success) {
revalidatePath("/");
return { success: true, message: "Cron job restored successfully" };
} else {
return { success: false, message: "Failed to restore cron job" };
}
} catch (error: any) {
console.error("Error restoring cron job:", error);
return {
success: false,
message: error.message || "Error restoring cron job",
details: error.stack,
};
}
};
export const deleteBackup = async (
filename: string
): Promise<{ success: boolean; message: string; details?: string }> => {
try {
const {
deleteBackupFile,
} = await import("@/app/_utils/backup-utils");
const success = await deleteBackupFile(filename);
if (success) {
return { success: true, message: "Backup deleted successfully" };
} else {
return { success: false, message: "Failed to delete backup" };
}
} catch (error: any) {
console.error("Error deleting backup:", error);
return {
success: false,
message: error.message || "Error deleting backup",
details: error.stack,
};
}
};
export const restoreAllCronJobs = async (): Promise<{
success: boolean;
message: string;
details?: string;
}> => {
try {
const {
getAllBackupFiles,
} = await import("@/app/_utils/backup-utils");
const backups = await getAllBackupFiles();
if (backups.length === 0) {
return { success: false, message: "No backup files found" };
}
let successCount = 0;
let failedCount = 0;
for (const backup of backups) {
const job = backup.job;
const success = await addCronJob(
job.schedule,
job.command,
job.comment || "",
job.user,
job.logsEnabled || false
);
if (success) {
successCount++;
} else {
failedCount++;
}
}
revalidatePath("/");
if (failedCount === 0) {
return {
success: true,
message: `Successfully restored ${successCount} cron job(s)`,
};
} else {
return {
success: true,
message: `Restored ${successCount} job(s), ${failedCount} failed`,
};
}
} catch (error: any) {
console.error("Error restoring all cron jobs:", error);
return {
success: false,
message: error.message || "Error restoring all cron jobs",
details: error.stack,
};
}

View File

@@ -0,0 +1,86 @@
"use server";
import { existsSync, readFileSync } from "fs";
import { execSync } from "child_process";
export const isDocker = async (): Promise<boolean> => {
try {
if (existsSync("/.dockerenv")) {
return true;
}
if (existsSync("/proc/1/cgroup")) {
const cgroupContent = readFileSync("/proc/1/cgroup", "utf8");
return cgroupContent.includes("/docker/");
}
return false;
} catch (error) {
return false;
}
};
export const getContainerIdentifier = async (): Promise<string | null> => {
try {
const docker = await isDocker();
if (!docker) {
return null;
}
const containerId = execSync("hostname").toString().trim();
return containerId;
} catch (error) {
console.error("Failed to get container identifier:", error);
return null;
}
};
export const getHostDataPath = async (): Promise<string | null> => {
try {
const docker = await isDocker();
if (!docker) {
return null;
}
const containerId = await getContainerIdentifier();
if (!containerId) {
return null;
}
const stdout = execSync(
`docker inspect --format '{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Source}}{{end}}{{end}}' ${containerId}`,
{ encoding: "utf8" }
);
const hostPath = stdout.trim();
return hostPath || null;
} catch (error) {
console.error("Failed to get host data path:", error);
return null;
}
};
export const getHostScriptsPath = async (): Promise<string | null> => {
try {
const docker = await isDocker();
if (!docker) {
return null;
}
const containerId = await getContainerIdentifier();
if (!containerId) {
return null;
}
const stdout = execSync(
`docker inspect --format '{{range .Mounts}}{{if eq .Destination "/app/scripts"}}{{.Source}}{{end}}{{end}}' ${containerId}`,
{ encoding: "utf8" }
);
const hostPath = stdout.trim();
return hostPath || null;
} catch (error) {
console.error("Failed to get host scripts path:", error);
return null;
}
};

View File

@@ -0,0 +1,354 @@
"use server";
import { readdir, readFile, unlink, stat } from "fs/promises";
import path from "path";
import { existsSync } from "fs";
import { DATA_DIR } from "@/app/_consts/file";
export interface LogEntry {
filename: string;
timestamp: string;
fullPath: string;
size: number;
dateCreated: Date;
exitCode?: number;
hasError?: boolean;
}
export interface JobLogError {
hasError: boolean;
lastFailedLog?: string;
lastFailedTimestamp?: Date;
exitCode?: number;
latestExitCode?: number;
hasHistoricalFailures?: boolean;
}
const MAX_LOGS_PER_JOB = process.env.MAX_LOGS_PER_JOB
? parseInt(process.env.MAX_LOGS_PER_JOB)
: 50;
const MAX_LOG_AGE_DAYS = process.env.MAX_LOG_AGE_DAYS
? parseInt(process.env.MAX_LOG_AGE_DAYS)
: 30;
const getLogBasePath = async (): Promise<string> => {
return path.join(process.cwd(), DATA_DIR, "logs");
};
const getJobLogPath = async (jobId: string): Promise<string | null> => {
const basePath = await getLogBasePath();
if (!existsSync(basePath)) {
return null;
}
try {
const allFolders = await readdir(basePath);
const matchingFolder = allFolders.find(
(folder) => folder === jobId || folder.endsWith(`_${jobId}`)
);
if (matchingFolder) {
return path.join(basePath, matchingFolder);
}
return path.join(basePath, jobId);
} catch (error) {
console.error("Error finding log path:", error);
return path.join(basePath, jobId);
}
};
export const getJobLogs = async (
jobId: string,
skipCleanup: boolean = false,
includeExitCodes: boolean = false
): Promise<LogEntry[]> => {
try {
const logDir = await getJobLogPath(jobId);
if (!logDir || !existsSync(logDir)) {
return [];
}
if (!skipCleanup) {
await cleanupJobLogs(jobId);
}
const files = await readdir(logDir);
const logFiles = files.filter((f) => f.endsWith(".log"));
const entries: LogEntry[] = [];
for (const file of logFiles) {
const fullPath = path.join(logDir, file);
const stats = await stat(fullPath);
let exitCode: number | undefined;
let hasError: boolean | undefined;
if (includeExitCodes) {
const exitCodeValue = await getExitCodeForLog(fullPath);
if (exitCodeValue !== null) {
exitCode = exitCodeValue;
hasError = exitCode !== 0;
}
}
entries.push({
filename: file,
timestamp: file.replace(".log", ""),
fullPath,
size: stats.size,
dateCreated: stats.birthtime,
exitCode,
hasError,
});
}
return entries.sort(
(a, b) => b.dateCreated.getTime() - a.dateCreated.getTime()
);
} catch (error) {
console.error(`Error reading logs for job ${jobId}:`, error);
return [];
}
};
export const getLogContent = async (
jobId: string,
filename: string
): Promise<string> => {
try {
const logDir = await getJobLogPath(jobId);
if (!logDir) {
return "Log directory not found";
}
const logPath = path.join(logDir, filename);
const content = await readFile(logPath, "utf-8");
return content;
} catch (error) {
console.error(`Error reading log file ${filename}:`, error);
return "Error reading log file";
}
};
export const deleteLogFile = async (
jobId: string,
filename: string
): Promise<{ success: boolean; message: string }> => {
try {
const logDir = await getJobLogPath(jobId);
if (!logDir) {
return {
success: false,
message: "Log directory not found",
};
}
const logPath = path.join(logDir, filename);
await unlink(logPath);
return {
success: true,
message: "Log file deleted successfully",
};
} catch (error: any) {
console.error(`Error deleting log file ${filename}:`, error);
return {
success: false,
message: error.message || "Error deleting log file",
};
}
};
export const deleteAllJobLogs = async (
jobId: string
): Promise<{ success: boolean; message: string; deletedCount: number }> => {
try {
const logs = await getJobLogs(jobId);
let deletedCount = 0;
for (const log of logs) {
const result = await deleteLogFile(jobId, log.filename);
if (result.success) {
deletedCount++;
}
}
return {
success: true,
message: `Deleted ${deletedCount} log files`,
deletedCount,
};
} catch (error: any) {
console.error(`Error deleting all logs for job ${jobId}:`, error);
return {
success: false,
message: error.message || "Error deleting log files",
deletedCount: 0,
};
}
};
export const cleanupJobLogs = async (
jobId: string
): Promise<{ success: boolean; message: string; deletedCount: number }> => {
try {
const logs = await getJobLogs(jobId, true);
if (logs.length === 0) {
return {
success: true,
message: "No logs to clean up",
deletedCount: 0,
};
}
let deletedCount = 0;
const now = new Date();
const maxAgeMs = MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000;
for (const log of logs) {
const ageMs = now.getTime() - log.dateCreated.getTime();
if (ageMs > maxAgeMs) {
const result = await deleteLogFile(jobId, log.filename);
if (result.success) {
deletedCount++;
}
}
}
const remainingLogs = await getJobLogs(jobId, true);
if (remainingLogs.length > MAX_LOGS_PER_JOB) {
const logsToDelete = remainingLogs.slice(MAX_LOGS_PER_JOB);
for (const log of logsToDelete) {
const result = await deleteLogFile(jobId, log.filename);
if (result.success) {
deletedCount++;
}
}
}
return {
success: true,
message: `Cleaned up ${deletedCount} log files`,
deletedCount,
};
} catch (error: any) {
console.error(`Error cleaning up logs for job ${jobId}:`, error);
return {
success: false,
message: error.message || "Error cleaning up log files",
deletedCount: 0,
};
}
};
export const getJobLogStats = async (
jobId: string
): Promise<{ count: number; totalSize: number; totalSizeMB: number }> => {
try {
const logs = await getJobLogs(jobId);
const totalSize = logs.reduce((sum, log) => sum + log.size, 0);
const totalSizeMB = totalSize / (1024 * 1024);
return {
count: logs.length,
totalSize,
totalSizeMB: Math.round(totalSizeMB * 100) / 100,
};
} catch (error) {
console.error(`Error getting log stats for job ${jobId}:`, error);
return {
count: 0,
totalSize: 0,
totalSizeMB: 0,
};
}
};
const getExitCodeForLog = async (logPath: string): Promise<number | null> => {
try {
const content = await readFile(logPath, "utf-8");
const exitCodeMatch = content.match(/Exit Code\s*:\s*(-?\d+)/i);
if (exitCodeMatch) {
return parseInt(exitCodeMatch[1]);
}
return null;
} catch (error) {
console.error(`Error getting exit code for ${logPath}:`, error);
return null;
}
};
export const getJobLogError = async (jobId: string): Promise<JobLogError> => {
try {
const logs = await getJobLogs(jobId);
if (logs.length === 0) {
return { hasError: false };
}
const latestLog = logs[0];
const latestExitCode = await getExitCodeForLog(latestLog.fullPath);
if (latestExitCode !== null && latestExitCode !== 0) {
return {
hasError: true,
lastFailedLog: latestLog.filename,
lastFailedTimestamp: latestLog.dateCreated,
exitCode: latestExitCode,
latestExitCode,
hasHistoricalFailures: false,
};
}
let hasHistoricalFailures = false;
let lastFailedLog: string | undefined;
let lastFailedTimestamp: Date | undefined;
let failedExitCode: number | undefined;
for (let i = 1; i < logs.length; i++) {
const exitCode = await getExitCodeForLog(logs[i].fullPath);
if (exitCode !== null && exitCode !== 0) {
hasHistoricalFailures = true;
lastFailedLog = logs[i].filename;
lastFailedTimestamp = logs[i].dateCreated;
failedExitCode = exitCode;
break;
}
}
return {
hasError: false,
latestExitCode: latestExitCode ?? undefined,
hasHistoricalFailures,
lastFailedLog,
lastFailedTimestamp,
exitCode: failedExitCode,
};
} catch (error) {
console.error(`Error checking log errors for job ${jobId}:`, error);
return { hasError: false };
}
};
export const getAllJobLogErrors = async (
jobIds: string[]
): Promise<Map<string, JobLogError>> => {
const errorMap = new Map<string, JobLogError>();
await Promise.all(
jobIds.map(async (jobId) => {
const error = await getJobLogError(jobId);
errorMap.set(jobId, error);
})
);
return errorMap;
};

View File

@@ -2,16 +2,40 @@
import { revalidatePath } from "next/cache";
import { writeFile, readFile, unlink, mkdir } from "fs/promises";
import { join } from "path";
import path from "path";
import { existsSync } from "fs";
import { exec } from "child_process";
import { promisify } from "util";
import { SCRIPTS_DIR, normalizeLineEndings } from "@/app/_utils/scripts";
import { loadAllScripts, type Script } from "@/app/_utils/scriptScanner";
import { SCRIPTS_DIR } from "@/app/_consts/file";
import { loadAllScripts, Script } from "@/app/_utils/scripts-utils";
import { MAKE_SCRIPT_EXECUTABLE, RUN_SCRIPT } from "@/app/_consts/commands";
import { isDocker, getHostScriptsPath } from "@/app/_server/actions/global";
const execAsync = promisify(exec);
export type { Script } from "@/app/_utils/scriptScanner";
export const getScriptPathForCron = async (
filename: string
): Promise<string> => {
const docker = await isDocker();
if (docker) {
const hostScriptsPath = await getHostScriptsPath();
if (hostScriptsPath) {
return `bash ${path.join(hostScriptsPath, filename)}`;
}
console.warn("Could not determine host scripts path, using container path");
}
return `bash ${path.join(process.cwd(), SCRIPTS_DIR, filename)}`;
};
export const getHostScriptPath = async (filename: string): Promise<string> => {
return `bash ${path.join(process.cwd(), SCRIPTS_DIR, filename)}`;
};
export const normalizeLineEndings = async (content: string): Promise<string> => {
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
};
const sanitizeScriptName = (name: string): string => {
return name
@@ -37,40 +61,34 @@ const generateUniqueFilename = async (baseName: string): Promise<string> => {
};
const ensureScriptsDirectory = async () => {
const scriptsDir = await SCRIPTS_DIR();
const scriptsDir = path.join(process.cwd(), SCRIPTS_DIR);
if (!existsSync(scriptsDir)) {
await mkdir(scriptsDir, { recursive: true });
}
};
const ensureHostScriptsDirectory = async () => {
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
const hostScriptsDir = join(hostProjectDir, "scripts");
const hostScriptsDir = path.join(process.cwd(), SCRIPTS_DIR);
if (!existsSync(hostScriptsDir)) {
await mkdir(hostScriptsDir, { recursive: true });
}
};
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(scriptsDir, filename);
const scriptPath = path.join(process.cwd(), SCRIPTS_DIR, filename);
await writeFile(scriptPath, content, "utf8");
try {
await execAsync(`chmod +x "${scriptPath}"`);
await execAsync(MAKE_SCRIPT_EXECUTABLE(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);
const scriptPath = path.join(process.cwd(), SCRIPTS_DIR, filename);
if (existsSync(scriptPath)) {
await unlink(scriptPath);
}
@@ -103,7 +121,7 @@ export const createScript = async (
`;
const normalizedContent = normalizeLineEndings(content);
const normalizedContent = await normalizeLineEndings(content);
const fullContent = metadataHeader + normalizedContent;
await saveScriptFile(filename, fullContent);
@@ -154,7 +172,7 @@ export const updateScript = async (
`;
const normalizedContent = normalizeLineEndings(content);
const normalizedContent = await normalizeLineEndings(content);
const fullContent = metadataHeader + normalizedContent;
await saveScriptFile(existingScript.filename, fullContent);
@@ -213,7 +231,7 @@ export const cloneScript = async (
`;
const normalizedContent = normalizeLineEndings(originalContent);
const normalizedContent = await normalizeLineEndings(originalContent);
const fullContent = metadataHeader + normalizedContent;
await saveScriptFile(filename, fullContent);
@@ -240,10 +258,7 @@ export const cloneScript = async (
export const getScriptContent = async (filename: string): Promise<string> => {
try {
const isDocker = process.env.DOCKER === "true";
const scriptPath = isDocker
? join("/app/scripts", filename)
: join(process.cwd(), "scripts", filename);
const scriptPath = path.join(process.cwd(), SCRIPTS_DIR, filename);
if (existsSync(scriptPath)) {
const content = await readFile(scriptPath, "utf8");
@@ -280,10 +295,7 @@ export const executeScript = async (
}> => {
try {
await ensureHostScriptsDirectory();
const isDocker = process.env.DOCKER === "true";
const hostScriptPath = isDocker
? join("/app/scripts", filename)
: join(process.cwd(), "scripts", filename);
const hostScriptPath = await getHostScriptPath(filename);
if (!existsSync(hostScriptPath)) {
return {
@@ -293,7 +305,7 @@ export const executeScript = async (
};
}
const { stdout, stderr } = await execAsync(`bash "${hostScriptPath}"`, {
const { stdout, stderr } = await execAsync(RUN_SCRIPT(hostScriptPath), {
timeout: 30000,
});

View File

@@ -6,9 +6,9 @@ import {
getSnippetCategories,
getSnippetById,
type BashSnippet,
} from "@/app/_utils/snippetScanner";
} from "@/app/_utils/snippets-utils";
export { type BashSnippet } from "@/app/_utils/snippetScanner";
export { type BashSnippet } from "@/app/_utils/snippets-utils";
export const fetchSnippets = async (): Promise<BashSnippet[]> => {
try {

View File

@@ -0,0 +1,59 @@
import fs from "fs";
import path from "path";
import "server-only";
/**
* Load translation messages for a given locale.
* First checks for custom translations in ./data/translations/,
* then falls back to built-in translations in app/_translations/.
*
* This function is server-only and should only be called from server components
* or server actions.
*/
export const loadTranslationMessages = async (locale: string): Promise<any> => {
const customTranslationPath = path.join(
process.cwd(),
"data",
"translations",
`${locale}.json`
);
try {
if (fs.existsSync(customTranslationPath)) {
const customMessages = JSON.parse(
fs.readFileSync(customTranslationPath, "utf8")
);
return customMessages;
}
} catch (error) {
console.warn(`Failed to load custom translation for ${locale}:`, error);
}
try {
const messages = (await import(`../../../_translations/${locale}.json`))
.default;
return messages;
} catch (error) {
const fallbackMessages = (await import("../../../_translations/en.json"))
.default;
return fallbackMessages;
}
};
type TranslationFunction = (key: string) => string;
export const getTranslations = async (
locale: string = process.env.LOCALE || "en"
): Promise<TranslationFunction> => {
const messages = await loadTranslationMessages(locale);
return (key: string) => {
const keys = key.split(".");
let value: any = messages;
for (const k of keys) {
value = value?.[k];
}
return value || key;
};
};

192
app/_translations/en.json Normal file
View File

@@ -0,0 +1,192 @@
{
"common": {
"cronManagementMadeEasy": "Cron Management made easy",
"allUsers": "All users",
"userWithUsername": "User: {user}",
"user": "User",
"change": "Change",
"description": "Description",
"optional": "Optional",
"cancel": "Cancel",
"close": "Close",
"refresh": "Refresh",
"loading": "Loading",
"version": "{version}"
},
"cronjobs": {
"cronJobs": "Cron Jobs",
"cronJob": "Cron Job",
"scheduledTasks": "Scheduled Tasks",
"nOfNJObs": "{filtered} of {total} scheduled tasks",
"forUser": "for user {user}",
"newTask": "New Task",
"runCronManually": "Run cron job manually",
"editCronJob": "Edit cron job",
"cloneCronJob": "Clone cron job",
"deleteCronJob": "Delete cron job",
"pauseCronJob": "Pause cron job",
"resumeCronJob": "Resume cron job",
"runCronJob": "Run cron job",
"runCronJobSuccess": "Cron job executed successfully",
"runCronJobFailed": "Failed to execute cron job",
"paused": "Paused",
"createNewScheduledTask": "Create new scheduled task",
"schedule": "Schedule",
"taskType": "Task Type",
"customCommand": "Custom Command",
"singleCommand": "Single command",
"command": "Command",
"whatDoesThisTaskDo": "What does this task do?",
"createTask": "Create Task",
"editScheduledTask": "Edit Scheduled Task",
"enableLogging": "Enable Logging",
"disableLogging": "Disable Logging",
"loggingDescription": "Capture stdout, stderr, exit codes, and timestamps for job executions. Logs are stored in ./data/logs and automatically cleaned up (defaults to 50 logs per job and 30 days retention, you can change these values in the environment variables).",
"logged": "Logged",
"viewLogs": "View Logs",
"logs": "logs",
"logFiles": "Log Files",
"logContent": "Log Content",
"selectLogToView": "Select a log file to view its content",
"noLogsFound": "No logs found for this job",
"confirmDeleteLog": "Are you sure you want to delete this log file?",
"confirmDeleteAllLogs": "Are you sure you want to delete all log files for this job? This action cannot be undone.",
"deleteAll": "Delete All",
"refresh": "Refresh",
"loading": "Loading",
"close": "Close",
"healthy": "Healthy",
"failed": "Failed (Exit: {exitCode})",
"backupJob": "Backup job",
"restoreJob": "Restore job",
"backupAll": "Backup All",
"backups": "Backups",
"restoreAll": "Restore All",
"confirmRestoreAll": "Are you sure you want to restore all backed up jobs? This will add them to your crontab.",
"backupJobSuccess": "Job backed up successfully",
"backupJobFailed": "Failed to backup job",
"backupAllSuccess": "All jobs backed up successfully",
"backupAllFailed": "Failed to backup all jobs",
"restoreJobSuccess": "Job restored successfully",
"restoreJobFailed": "Failed to restore job",
"moreActions": "More actions",
"restoreBackups": "Restore Backups",
"availableBackups": "Available Backups",
"noBackupsFound": "No backup files found",
"backedUpAt": "Backed up at",
"restoreThisBackup": "Restore this backup",
"deleteBackup": "Delete backup",
"confirmDeleteBackup": "Are you sure you want to delete this backup? This action cannot be undone.",
"backupDeleted": "Backup deleted successfully",
"filters": "Filters",
"filtersAndDisplay": "Filters & Display Options",
"filterByUser": "Filter by User",
"scheduleDisplay": "Schedule Display",
"cronSyntax": "Cron Syntax",
"humanReadable": "Human Readable",
"both": "Both",
"minimalMode": "Minimal Mode",
"minimalModeDescription": "Show compact view with icons instead of full text",
"applyFilters": "Apply Filters",
"nLines": "{count} lines",
"liveJobExecution": "Live Job Execution",
"running": "Running...",
"completed": "Completed (Exit: {exitCode})",
"jobFailed": "Failed (Exit: {exitCode})",
"showLast": "Show last:",
"viewFullLog": "View Full Log ({totalLines} lines)",
"viewFullLogNoCount": "View Full Log",
"viewingFullLog": "Viewing full log ({totalLines} lines)",
"viewingFullLogNoCount": "Viewing full log",
"backToWindowedView": "Back to Windowed View",
"showingLastOf": "Showing last {lineCount} of {totalLines} lines",
"showingLastLines": "Showing last {lineCount} lines",
"largeLogFileDetected": "Large log file detected",
"tailModeEnabled": "Tail mode enabled, showing last {tailLines} lines",
"showAllLines": "Show all lines",
"enableTailMode": "Enable tail mode",
"waitingForJobToStart": "Waiting for job to start...\n\nLogs will appear here in real-time.",
"runIdJobId": "Run ID: {runId} | Job ID: {jobId}"
},
"scripts": {
"scripts": "Scripts",
"scriptsLibrary": "Scripts Library",
"file": "File",
"newScript": "New Script",
"noScriptsYet": "No scripts yet",
"createReusableBashScripts": "Create reusable bash scripts to use in your scheduled tasks.",
"createYourFirstScript": "Create Your First Script",
"nOfNSavedScripts": "{count} saved scripts",
"savedScript": "Saved Script",
"selectFromLibrary": "Select from library",
"scriptPathReadOnly": "Script path is read-only. Edit the script in the Scripts Library",
"selectScript": "Select Script",
"availableScripts": "{count} available scripts",
"noScriptsFound": "No scripts found",
"noScriptsAvailable": "No scripts available",
"scriptPreview": "Script Preview",
"commandPreview": "Command Preview",
"scriptContent": "Script Content",
"selectScriptToPreview": "Select a script to preview",
"searchScripts": "Search scripts...",
"draft": "Draft",
"clearDraft": "Clear Draft",
"close": "Close",
"draftCleared": "Draft cleared"
},
"sidebar": {
"systemOverview": "System Overview",
"uptime": "Uptime",
"memory": "Memory",
"cpu": "CPU",
"gpu": "GPU",
"network": "Network",
"networkLatency": "Network Latency",
"memoryUsage": "Memory Usage",
"cpuUsage": "CPU Usage",
"systemInformation": "System Information",
"performanceMetrics": "Performance Metrics",
"statsUpdateEvery": "Stats update every",
"updating": "Updating",
"networkSpeedEstimatedFromLatency": "Network speed estimated from latency"
},
"system": {
"optimal": "Optimal",
"critical": "Critical",
"high": "High",
"moderate": "Moderate",
"warning": "Warning",
"unknown": "Unknown",
"connected": "Connected",
"allSystemsRunningNormally": "All systems running normally",
"highResourceUsageDetectedImmediateAttentionRequired": "High resource usage detected - immediate attention required",
"moderateResourceUsageMonitoringRecommended": "Moderate resource usage - monitoring recommended",
"unknownGPU": "Unknown GPU",
"noGPUDetected": "No GPU detected",
"gpuDetectionFailed": "GPU detection failed",
"available": "Available",
"systemStatus": "System Status",
"lastUpdated": "Last updated"
},
"login": {
"welcomeTitle": "Welcome to Cr*nMaster",
"signInWithPasswordOrSSO": "Sign in with password or SSO",
"signInWithSSO": "Sign in with SSO",
"enterPasswordToContinue": "Enter your password to continue",
"authenticationNotConfigured": "Authentication Not Configured",
"noAuthMethodsEnabled": "Neither password authentication nor OIDC SSO is enabled. Please configure at least one authentication method in your environment variables to be able to log in.",
"enterPassword": "Enter password",
"signingIn": "Signing in...",
"signIn": "Sign In",
"redirecting": "Redirecting...",
"redirectingToOIDC": "Redirecting to OIDC provider",
"pleaseWait": "Please wait...",
"orContinueWith": "Or continue with",
"loginFailed": "Login failed",
"genericError": "An error occurred. Please try again."
},
"warnings": {
"wrapperScriptModified": "Wrapper Script Modified",
"wrapperScriptModifiedDescription": "Your cron-log-wrapper.sh script has been modified from the official version. This may affect logging functionality. Consider reverting to the official version or ensure your changes don't break the required format for log parsing."
}
}

188
app/_translations/it.json Normal file
View File

@@ -0,0 +1,188 @@
{
"common": {
"cronManagementMadeEasy": "Gestione Cron semplificata",
"allUsers": "Tutti gli utenti",
"userWithUsername": "Utente: {user}",
"user": "Utente",
"change": "Modifica",
"description": "Descrizione",
"optional": "Opzionale",
"cancel": "Annulla",
"refresh": "Aggiorna",
"close": "Chiudi",
"version": "{version}"
},
"cronjobs": {
"cronJobs": "Operazioni Cron",
"cronJob": "Operazione Cron",
"scheduledTasks": "Operazioni Pianificate",
"nOfNJObs": "{filtered} di {total} operazioni pianificate",
"forUser": "per l'utente {user}",
"newTask": "Nuova Operazione",
"runCronManually": "Esegui operazione cron manualmente",
"editCronJob": "Modifica operazione cron",
"cloneCronJob": "Clona operazione cron",
"deleteCronJob": "Elimina operazione cron",
"pauseCronJob": "Pausa operazione cron",
"resumeCronJob": "Riprendi operazione cron",
"runCronJob": "Esegui operazione cron",
"runCronJobSuccess": "Operazione cron eseguita con successo",
"runCronJobFailed": "Esecuzione operazione cron fallita",
"paused": "In pausa",
"createNewScheduledTask": "Crea nuova operazione pianificata",
"schedule": "Pianificazione",
"taskType": "Tipo di Operazione",
"customCommand": "Comando Personalizzato",
"singleCommand": "Comando singolo",
"command": "Comando",
"whatDoesThisTaskDo": "Cosa fa questa operazione?",
"createTask": "Crea Operazione",
"editScheduledTask": "Modifica Operazione Pianificata",
"enableLogging": "Abilita Logging",
"disableLogging": "Disabilita Logging",
"loggingDescription": "Cattura stdout, stderr, codici di uscita e timestamp per le esecuzioni dei job. I log sono memorizzati in ./data/logs e automaticamente puliti (per impostazione predefinita 50 log per job e 30 giorni di conservazione, puoi modificare questi valori nelle env variables).",
"logged": "Loggato",
"viewLogs": "Visualizza Log",
"logs": "log",
"logFiles": "File",
"logContent": "Contenuto Log",
"selectLogToView": "Seleziona un file per visualizzarne il contenuto",
"noLogsFound": "Nessun log trovato per questa operazione",
"confirmDeleteLog": "Sei sicuro di voler eliminare questo file?",
"confirmDeleteAllLogs": "Sei sicuro di voler eliminare tutti i file per questa operazione? Questa azione non può essere annullata.",
"deleteAll": "Elimina Tutto",
"refresh": "Aggiorna",
"loading": "Caricamento",
"close": "Chiudi",
"healthy": "Sano",
"failed": "Fallito (Exit: {exitCode})",
"backupJob": "Backup operazione",
"restoreJob": "Ripristina operazione",
"backupAll": "Backup Tutti",
"backups": "Backups",
"restoreAll": "Ripristina Tutti",
"confirmRestoreAll": "Sei sicuro di voler ripristinare tutte le operazioni salvate? Verranno aggiunte al tuo crontab.",
"backupJobSuccess": "Backup operazione completato con successo",
"backupJobFailed": "Backup operazione fallito",
"backupAllSuccess": "Backup di tutte le operazioni completato con successo",
"backupAllFailed": "Backup di tutte le operazioni fallito",
"restoreJobSuccess": "Operazione ripristinata con successo",
"restoreJobFailed": "Ripristino operazione fallito",
"moreActions": "Altre azioni",
"restoreBackups": "Ripristina Backup",
"availableBackups": "Backup Disponibili",
"noBackupsFound": "Nessun file di backup trovato",
"backedUpAt": "Backup effettuato il",
"restoreThisBackup": "Ripristina questo backup",
"deleteBackup": "Elimina backup",
"confirmDeleteBackup": "Sei sicuro di voler eliminare questo backup? Questa azione non può essere annullata.",
"backupDeleted": "Backup eliminato con successo",
"filters": "Filtri",
"filtersAndDisplay": "Filtri e Opzioni di Visualizzazione",
"filterByUser": "Filtra per Utente",
"scheduleDisplay": "Visualizzazione Pianificazione",
"cronSyntax": "Sintassi Cron",
"humanReadable": "Comprensibile",
"both": "Entrambi",
"minimalMode": "Modalità Minima",
"minimalModeDescription": "Mostra vista compatta con icone invece del testo completo",
"applyFilters": "Applica Filtri",
"nLines": "{count} linee",
"liveJobExecution": "Esecuzione Lavoro Live",
"running": "In esecuzione...",
"completed": "Completato (Exit: {exitCode})",
"jobFailed": "Fallito (Exit: {exitCode})",
"showLast": "Mostra ultime:",
"viewFullLog": "Visualizza Log Completo ({totalLines} linee)",
"viewingFullLog": "Visualizzazione log completo ({totalLines} linee)",
"backToWindowedView": "Torna alla Vista Finestrata",
"showingLastOf": "Mostrando ultime {lineCount} di {totalLines} linee",
"largeLogFileDetected": "Rilevato file di log di grandi dimensioni",
"tailModeEnabled": "Modalità tail abilitata, mostrando ultime {tailLines} linee",
"showAllLines": "Mostra tutte le linee",
"enableTailMode": "Abilita modalità tail",
"waitingForJobToStart": "In attesa che il lavoro inizi...\n\nI log appariranno qui in tempo reale.",
"runIdJobId": "ID Esecuzione: {runId} | ID Lavoro: {jobId}"
},
"scripts": {
"scripts": "Script",
"scriptsLibrary": "Libreria Script",
"file": "File",
"newScript": "Nuovo Script",
"noScriptsYet": "Ancora nessuno script",
"createReusableBashScripts": "Crea script bash riutilizzabili da usare nelle tue operazioni pianificate.",
"createYourFirstScript": "Crea il tuo primo script",
"nOfNSavedScripts": "{count} script salvati",
"savedScript": "Script Salvato",
"selectFromLibrary": "Seleziona dalla libreria",
"scriptPathReadOnly": "Il percorso dello script è di sola lettura. Modifica lo script nella Libreria Script",
"selectScript": "Seleziona Script",
"availableScripts": "{count} script disponibili",
"noScriptsFound": "Nessuno script trovato",
"noScriptsAvailable": "Nessuno script disponibile",
"scriptPreview": "Anteprima Script",
"commandPreview": "Anteprima Comando",
"scriptContent": "Contenuto Script",
"selectScriptToPreview": "Seleziona uno script per l'anteprima",
"searchScripts": "Cerca script...",
"draft": "Bozza",
"clearDraft": "Cancella Bozza",
"close": "Chiudi",
"draftCleared": "Bozza cancellata"
},
"sidebar": {
"systemOverview": "Panoramica del Sistema",
"uptime": "Uptime",
"memory": "Memoria",
"cpu": "CPU",
"gpu": "GPU",
"network": "Rete",
"networkLatency": "Latenza di Rete",
"memoryUsage": "Utilizzo Memoria",
"cpuUsage": "Utilizzo CPU",
"systemInformation": "Informazioni di Sistema",
"performanceMetrics": "Metriche delle Prestazioni",
"statsUpdateEvery": "Statistiche aggiornate ogni",
"updating": "Aggiornamento",
"networkSpeedEstimatedFromLatency": "Velocità di rete stimata dalla latenza"
},
"system": {
"optimal": "Ottimale",
"critical": "Critico",
"high": "Alto",
"moderate": "Moderato",
"warning": "Avviso",
"unknown": "Sconosciuto",
"connected": "Connesso",
"allSystemsRunningNormally": "Tutti i sistemi funzionano normalmente",
"highResourceUsageDetectedImmediateAttentionRequired": "Rilevato utilizzo elevato delle risorse - richiesta attenzione immediata",
"moderateResourceUsageMonitoringRecommended": "Utilizzo moderato delle risorse - monitoraggio raccomandato",
"unknownGPU": "GPU Sconosciuta",
"noGPUDetected": "Nessuna GPU rilevata",
"gpuDetectionFailed": "Rilevamento GPU fallito",
"available": "Disponibile",
"systemStatus": "Stato del Sistema",
"lastUpdated": "Ultimo aggiornamento"
},
"login": {
"welcomeTitle": "Benvenuto in Cr*nMaster",
"signInWithPasswordOrSSO": "Accedi con password o SSO",
"signInWithSSO": "Accedi con SSO",
"enterPasswordToContinue": "Inserisci la tua password per continuare",
"authenticationNotConfigured": "Autenticazione Non Configurata",
"noAuthMethodsEnabled": "Né l'autenticazione password né l'OIDC SSO sono abilitati. Si prega di configurare almeno un metodo di autenticazione nelle variabili d'ambiente per poter effettuare il login.",
"enterPassword": "Inserisci password",
"signingIn": "Accesso in corso...",
"signIn": "Accedi",
"redirecting": "Reindirizzamento...",
"redirectingToOIDC": "Reindirizzamento al provider OIDC",
"pleaseWait": "Attendere prego...",
"orContinueWith": "Oppure continua con",
"loginFailed": "Accesso fallito",
"genericError": "Si è verificato un errore. Riprova."
},
"warnings": {
"wrapperScriptModified": "Script Wrapper Modificato",
"wrapperScriptModifiedDescription": "Il tuo script cron-log-wrapper.sh è stato modificato dalla versione ufficiale. Questo potrebbe influenzare la funzionalità di logging. Considera di ripristinare la versione ufficiale o assicurati che le tue modifiche non interrompano il formato richiesto per l'analisi dei log."
}
}

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { validateSession, getSessionCookieName } from "./session-utils";
export function validateApiKey(request: NextRequest): boolean {
const apiKey = process.env.API_KEY;
if (!apiKey) {
return true;
}
const authHeader = request.headers.get("authorization");
if (!authHeader) {
return false;
}
const match = authHeader.match(/^Bearer\s+(.+)$/i);
if (!match) {
return false;
}
const token = match[1];
return token === apiKey;
}
export async function validateSessionRequest(
request: NextRequest
): Promise<boolean> {
const cookieName = getSessionCookieName();
const sessionId = request.cookies.get(cookieName)?.value;
if (!sessionId) {
return false;
}
return await validateSession(sessionId);
}
export function isAuthRequired(): boolean {
const hasPassword = !!process.env.AUTH_PASSWORD;
const hasSSO = process.env.SSO_MODE === "oidc";
const hasApiKey = !!process.env.API_KEY;
return hasPassword || hasSSO || hasApiKey;
}
export async function requireAuth(
request: NextRequest
): Promise<Response | null> {
if (!isAuthRequired()) {
return null;
}
const hasValidSession = await validateSessionRequest(request);
if (hasValidSession) {
return null;
}
const apiKey = process.env.API_KEY;
if (apiKey) {
const hasValidApiKey = validateApiKey(request);
if (hasValidApiKey) {
return null;
}
}
if (process.env.DEBUGGER) {
console.log("[API Auth] Unauthorized request:", {
path: request.nextUrl.pathname,
hasSession: hasValidSession,
apiKeyConfigured: !!process.env.API_KEY,
hasAuthHeader: !!request.headers.get("authorization"),
});
}
return NextResponse.json(
{
error: "Unauthorized",
message:
"Authentication required. Use session cookie or API key (Bearer token).",
},
{ status: 401 }
);
}
export function withAuth<T extends any[]>(
handler: (request: NextRequest, ...args: T) => Promise<Response>
) {
return async (request: NextRequest, ...args: T): Promise<Response> => {
const authError = await requireAuth(request);
if (authError) {
return authError;
}
return handler(request, ...args);
};
}

189
app/_utils/backup-utils.ts Normal file
View File

@@ -0,0 +1,189 @@
import { promises as fs } from "fs";
import path from "path";
import { getCronJobs, type CronJob } from "@/app/_utils/cronjob-utils";
const BACKUP_DIR = path.join(process.cwd(), "data", "backup");
const ensureBackupDirectoryExists = async (): Promise<void> => {
try {
await fs.mkdir(BACKUP_DIR, { recursive: true });
} catch (error) {
console.error("Error creating backup directory:", error);
throw error;
}
};
const sanitizeFilename = (id: string): string => {
return id.replace(/[^a-zA-Z0-9_-]/g, "_");
};
export const backupJobToFile = async (job: CronJob): Promise<boolean> => {
try {
await ensureBackupDirectoryExists();
const jobData = {
id: job.id,
schedule: job.schedule,
command: job.command,
comment: job.comment || "",
user: job.user,
paused: job.paused || false,
logsEnabled: job.logsEnabled || false,
backedUpAt: new Date().toISOString(),
};
const filename = `${sanitizeFilename(job.id)}.job`;
const filepath = path.join(BACKUP_DIR, filename);
await fs.writeFile(filepath, JSON.stringify(jobData, null, 2), "utf8");
return true;
} catch (error) {
console.error(`Error backing up job ${job.id}:`, error);
return false;
}
};
export const backupAllJobsToFiles = async (): Promise<{
success: boolean;
count: number;
}> => {
try {
await ensureBackupDirectoryExists();
const cronJobs = await getCronJobs(false);
let successCount = 0;
for (const job of cronJobs) {
const success = await backupJobToFile(job);
if (success) {
successCount++;
}
}
return {
success: successCount === cronJobs.length,
count: successCount,
};
} catch (error) {
console.error("Error backing up all jobs:", error);
return {
success: false,
count: 0,
};
}
};
export const listBackupFiles = async (): Promise<string[]> => {
try {
await ensureBackupDirectoryExists();
const files = await fs.readdir(BACKUP_DIR);
return files.filter((file) => file.endsWith(".job"));
} catch (error) {
console.error("Error listing backup files:", error);
return [];
}
};
export const readBackupFile = async (
filename: string
): Promise<CronJob | null> => {
try {
const filepath = path.join(BACKUP_DIR, filename);
const content = await fs.readFile(filepath, "utf8");
const jobData = JSON.parse(content);
return {
id: jobData.id,
schedule: jobData.schedule,
command: jobData.command,
comment: jobData.comment,
user: jobData.user,
paused: jobData.paused,
logsEnabled: jobData.logsEnabled,
};
} catch (error) {
console.error(`Error reading backup file ${filename}:`, error);
return null;
}
};
export const getAllBackupFiles = async (): Promise<
Array<{
filename: string;
job: CronJob;
backedUpAt: string;
}>
> => {
try {
await ensureBackupDirectoryExists();
const files = await fs.readdir(BACKUP_DIR);
const jobFiles = files.filter((file) => file.endsWith(".job"));
const backups = await Promise.all(
jobFiles.map(async (filename) => {
try {
const filepath = path.join(BACKUP_DIR, filename);
const content = await fs.readFile(filepath, "utf8");
const jobData = JSON.parse(content);
return {
filename,
job: {
id: jobData.id,
schedule: jobData.schedule,
command: jobData.command,
comment: jobData.comment,
user: jobData.user,
paused: jobData.paused,
logsEnabled: jobData.logsEnabled,
} as CronJob,
backedUpAt: jobData.backedUpAt,
};
} catch (error) {
console.error(`Error reading backup file ${filename}:`, error);
return null;
}
})
);
return backups.filter((backup) => backup !== null) as Array<{
filename: string;
job: CronJob;
backedUpAt: string;
}>;
} catch (error) {
console.error("Error getting all backup files:", error);
return [];
}
};
export const restoreJobFromBackup = async (
filename: string
): Promise<{ success: boolean; job?: CronJob }> => {
try {
const job = await readBackupFile(filename);
if (!job) {
return { success: false };
}
return { success: true, job };
} catch (error) {
console.error(`Error restoring job from backup ${filename}:`, error);
return { success: false };
}
};
export const deleteBackupFile = async (filename: string): Promise<boolean> => {
try {
const filepath = path.join(BACKUP_DIR, filename);
await fs.unlink(filepath);
return true;
} catch (error) {
console.error(`Error deleting backup file ${filename}:`, error);
return false;
}
};

View File

@@ -1,6 +0,0 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs))
}

429
app/_utils/cronjob-utils.ts Normal file
View File

@@ -0,0 +1,429 @@
import { exec } from "child_process";
import { promisify } from "util";
import {
readAllHostCrontabs,
writeHostCrontabForUser,
} from "@/app/_utils/crontab-utils";
import {
parseJobsFromLines,
deleteJobInLines,
updateJobInLines,
pauseJobInLines,
resumeJobInLines,
formatCommentWithMetadata,
} from "@/app/_utils/line-manipulation-utils";
import {
cleanCrontabContent,
readCronFiles,
writeCronFiles,
} from "@/app/_utils/files-manipulation-utils";
import { isDocker } from "@/app/_server/actions/global";
import { READ_CRONTAB, WRITE_CRONTAB } from "@/app/_consts/commands";
import {
wrapCommandWithLogger,
unwrapCommand,
isCommandWrapped,
} from "@/app/_utils/wrapper-utils";
import { generateShortUUID } from "@/app/_utils/uuid-utils";
const execAsync = promisify(exec);
export interface CronJob {
id: string;
schedule: string;
command: string;
comment?: string;
user: string;
paused?: boolean;
logsEnabled?: boolean;
logError?: {
hasError: boolean;
lastFailedLog?: string;
lastFailedTimestamp?: Date;
exitCode?: number;
latestExitCode?: number;
hasHistoricalFailures?: boolean;
};
}
export const readUserCrontab = async (user: string): Promise<string> => {
const docker = await isDocker();
if (docker) {
const userCrontabs = await readAllHostCrontabs();
const targetUserCrontab = userCrontabs.find((uc) => uc.user === user);
return targetUserCrontab?.content || "";
} else {
const { stdout } = await execAsync(READ_CRONTAB(user));
return stdout;
}
};
export const writeUserCrontab = async (
user: string,
content: string
): Promise<boolean> => {
const docker = await isDocker();
if (docker) {
return await writeHostCrontabForUser(user, content);
} else {
try {
await execAsync(WRITE_CRONTAB(content, user));
return true;
} catch (error) {
console.error(`Error writing crontab for user ${user}:`, error);
return false;
}
}
};
const getAllUsers = async (): Promise<{ user: string; content: string }[]> => {
const docker = await isDocker();
if (docker) {
return await readAllHostCrontabs();
} else {
const { getAllTargetUsers } = await import("@/app/_utils/crontab-utils");
const users = await getAllTargetUsers();
const results: { user: string; content: string }[] = [];
for (const user of users) {
try {
const { stdout } = await execAsync(READ_CRONTAB(user));
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 (
includeLogErrors: boolean = true
): 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);
}
if (includeLogErrors) {
const { getAllJobLogErrors } = await import("@/app/_server/actions/logs");
const jobIds = allJobs.map((job) => job.id);
const errorMap = await getAllJobLogErrors(jobIds);
allJobs = allJobs.map((job) => ({
...job,
logError: errorMap.get(job.id),
}));
}
return allJobs;
} catch (error) {
console.error("Error getting cron jobs:", error);
return [];
}
};
export const addCronJob = async (
schedule: string,
command: string,
comment: string = "",
user?: string,
logsEnabled: boolean = false
): Promise<boolean> => {
try {
const jobId = generateShortUUID();
if (user) {
const cronContent = await readUserCrontab(user);
let finalCommand = command;
if (logsEnabled && !isCommandWrapped(command)) {
const docker = await isDocker();
finalCommand = await wrapCommandWithLogger(
jobId,
command,
docker,
comment
);
} else if (logsEnabled && isCommandWrapped(command)) {
finalCommand = command;
}
const formattedComment = formatCommentWithMetadata(
comment,
logsEnabled,
jobId
);
const newEntry = `# ${formattedComment}\n${schedule} ${finalCommand}`;
let newCron;
if (cronContent.trim() === "") {
newCron = newEntry;
} else {
const existingContent = cronContent.trim();
newCron = await cleanCrontabContent(existingContent + "\n" + newEntry);
}
return await writeUserCrontab(user, newCron);
} else {
const cronContent = await readCronFiles();
let finalCommand = command;
if (logsEnabled && !isCommandWrapped(command)) {
const docker = await isDocker();
finalCommand = await wrapCommandWithLogger(
jobId,
command,
docker,
comment
);
} else if (logsEnabled && isCommandWrapped(command)) {
finalCommand = command;
}
const formattedComment = formatCommentWithMetadata(
comment,
logsEnabled,
jobId
);
const newEntry = `# ${formattedComment}\n${schedule} ${finalCommand}`;
let newCron;
if (cronContent.trim() === "") {
newCron = newEntry;
} else {
const existingContent = cronContent.trim();
newCron = await cleanCrontabContent(existingContent + "\n" + newEntry);
}
return await writeCronFiles(newCron);
}
} catch (error) {
console.error("Error adding cron job:", error);
return false;
}
};
export const deleteCronJob = async (id: string): Promise<boolean> => {
try {
const allJobs = await getCronJobs(false);
const targetJob = allJobs.find((j) => j.id === id);
if (!targetJob) {
console.error(`Job with id ${id} not found`);
return false;
}
const user = targetJob.user;
const cronContent = await readUserCrontab(user);
const lines = cronContent.split("\n");
const userJobs = parseJobsFromLines(lines, user);
const jobIndex = userJobs.findIndex((j) => j.id === id);
if (jobIndex === -1) {
console.error(`Job with id ${id} not found in parsed jobs`);
return false;
}
const newCronEntries = deleteJobInLines(lines, jobIndex);
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
return await writeUserCrontab(user, newCron);
} catch (error) {
console.error("Error deleting cron job:", error);
return false;
}
};
export const updateCronJob = async (
jobData: {
id: string;
schedule: string;
command: string;
comment?: string;
user: string;
},
schedule: string,
command: string,
comment: string = "",
logsEnabled: boolean = false
): Promise<boolean> => {
try {
const user = jobData.user;
const cronContent = await readUserCrontab(user);
const lines = cronContent.split("\n");
const jobIndex = findJobIndex(jobData, lines, user);
if (jobIndex === -1) {
console.error(`Job not found in crontab`);
return false;
}
const isWrapped = isCommandWrapped(command);
let finalCommand = command;
if (logsEnabled && !isWrapped) {
const docker = await isDocker();
finalCommand = await wrapCommandWithLogger(
jobData.id,
command,
docker,
comment
);
} else if (!logsEnabled && isWrapped) {
finalCommand = unwrapCommand(command);
} else if (logsEnabled && isWrapped) {
const unwrapped = unwrapCommand(command);
const docker = await isDocker();
finalCommand = await wrapCommandWithLogger(
jobData.id,
unwrapped,
docker,
comment
);
} else {
finalCommand = command;
}
const newCronEntries = updateJobInLines(
lines,
jobIndex,
schedule,
finalCommand,
comment,
logsEnabled,
jobData.id
);
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 allJobs = await getCronJobs(false);
const targetJob = allJobs.find((j) => j.id === id);
if (!targetJob) {
console.error(`Job with id ${id} not found`);
return false;
}
const user = targetJob.user;
const cronContent = await readUserCrontab(user);
const lines = cronContent.split("\n");
const userJobs = parseJobsFromLines(lines, user);
const jobIndex = userJobs.findIndex((j) => j.id === id);
if (jobIndex === -1) {
console.error(`Job with id ${id} not found in parsed jobs`);
return false;
}
const newCronEntries = pauseJobInLines(lines, jobIndex, id);
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 allJobs = await getCronJobs(false);
const targetJob = allJobs.find((j) => j.id === id);
if (!targetJob) {
console.error(`Job with id ${id} not found`);
return false;
}
const user = targetJob.user;
const cronContent = await readUserCrontab(user);
const lines = cronContent.split("\n");
const userJobs = parseJobsFromLines(lines, user);
const jobIndex = userJobs.findIndex((j) => j.id === id);
if (jobIndex === -1) {
console.error(`Job with id ${id} not found in parsed jobs`);
return false;
}
const newCronEntries = resumeJobInLines(lines, jobIndex, id);
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;
}
};
export const findJobIndex = (
jobData: {
id: string;
schedule: string;
command: string;
comment?: string;
user: string;
paused?: boolean;
},
lines: string[],
user: string
): number => {
const cronContentStr = lines.join("\n");
const userJobs = parseJobsFromLines(lines, user);
if (cronContentStr.includes(`id: ${jobData.id}`)) {
return userJobs.findIndex((j) => j.id === jobData.id);
}
return userJobs.findIndex(
(j) =>
j.schedule === jobData.schedule &&
j.command === jobData.command &&
j.user === jobData.user &&
(j.comment || "") === (jobData.comment || "")
);
};

View File

@@ -1,3 +1,13 @@
import {
GET_DOCKER_SOCKET_OWNER,
GET_TARGET_USER,
ID_G,
ID_U,
READ_CRONTAB,
READ_CRONTABS_DIRECTORY,
WRITE_HOST_CRONTAB,
} from "@/app/_consts/commands";
import { NSENTER_HOST_CRONTAB } from "@/app/_consts/nsenter";
import { exec } from "child_process";
import { promisify } from "util";
@@ -11,15 +21,13 @@ export interface UserInfo {
const execHostCrontab = async (command: string): Promise<string> => {
try {
const { stdout } = await execAsync(
`nsenter -t 1 -m -u -i -n -p sh -c "${command}"`
);
const { stdout } = await execAsync(NSENTER_HOST_CRONTAB(command?.trim()));
return stdout;
} catch (error: any) {
console.error("Error executing host crontab command:", error);
throw error;
}
}
};
const getTargetUser = async (): Promise<string> => {
try {
@@ -27,30 +35,14 @@ const getTargetUser = async (): Promise<string> => {
return process.env.HOST_CRONTAB_USER;
}
const { stdout } = await execAsync('stat -c "%U" /var/run/docker.sock');
const { stdout } = await execAsync(GET_DOCKER_SOCKET_OWNER);
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;
const targetUser = await execHostCrontab(GET_TARGET_USER);
if (targetUser) {
return targetUser.trim();
}
} catch (error) {
console.warn("Could not detect user from passwd:", error);
@@ -64,7 +56,7 @@ const getTargetUser = async (): Promise<string> => {
console.error("Error detecting target user:", error);
return "root";
}
}
};
export const getAllTargetUsers = async (): Promise<string[]> => {
try {
@@ -72,40 +64,34 @@ export const getAllTargetUsers = async (): Promise<string[]> => {
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"];
}
try {
const stdout = await execHostCrontab(READ_CRONTABS_DIRECTORY);
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 ""`
);
return await execHostCrontab(READ_CRONTAB(user));
} catch (error) {
console.error("Error reading host crontab:", error);
return "";
}
}
};
export const readAllHostCrontabs = async (): Promise<
{ user: string; content: string }[]
@@ -116,9 +102,7 @@ export const readAllHostCrontabs = async (): Promise<
for (const user of users) {
try {
const content = await execHostCrontab(
`crontab -l -u ${user} 2>/dev/null || echo ""`
);
const content = await execHostCrontab(READ_CRONTAB(user));
results.push({ user, content });
} catch (error) {
console.warn(`Error reading crontab for user ${user}:`, error);
@@ -131,7 +115,7 @@ export const readAllHostCrontabs = async (): Promise<
console.error("Error reading all host crontabs:", error);
return [];
}
}
};
export const writeHostCrontab = async (content: string): Promise<boolean> => {
try {
@@ -142,15 +126,13 @@ export const writeHostCrontab = async (content: string): Promise<boolean> => {
}
const base64Content = Buffer.from(finalContent).toString("base64");
await execHostCrontab(
`echo '${base64Content}' | base64 -d | crontab -u ${user} -`
);
await execHostCrontab(WRITE_HOST_CRONTAB(base64Content, user));
return true;
} catch (error) {
console.error("Error writing host crontab:", error);
return false;
}
}
};
export const writeHostCrontabForUser = async (
user: string,
@@ -163,47 +145,30 @@ export const writeHostCrontabForUser = async (
}
const base64Content = Buffer.from(finalContent).toString("base64");
await execHostCrontab(
`echo '${base64Content}' | base64 -d | crontab -u ${user} -`
);
await execHostCrontab(WRITE_HOST_CRONTAB(base64Content, 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> {
export const getUserInfo = async (
username: string
): Promise<UserInfo | null> => {
try {
const isDocker = process.env.DOCKER === "true";
const uidResult = await execHostCrontab(ID_U(username));
const gidResult = await execHostCrontab(ID_G(username));
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());
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 };
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;

View File

@@ -11,13 +11,44 @@ export interface JobError {
}
const STORAGE_KEY = "cronmaster-job-errors";
const MAX_LOG_AGE_DAYS = parseInt(
process.env.NEXT_PUBLIC_MAX_LOG_AGE_DAYS || "30",
10
);
/**
* Clean up old errors from localStorage based on MAX_LOG_AGE_DAYS.
* This is called automatically when getting errors.
*/
const cleanupOldErrors = (errors: JobError[]): JobError[] => {
const maxAgeMs = MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000;
const now = Date.now();
return errors.filter((error) => {
try {
const errorTime = new Date(error.timestamp).getTime();
const age = now - errorTime;
return age < maxAgeMs;
} catch {
return true;
}
});
};
export const getJobErrors = (): JobError[] => {
if (typeof window === "undefined") return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
const errors = stored ? JSON.parse(stored) : [];
const cleanedErrors = cleanupOldErrors(errors);
if (cleanedErrors.length !== errors.length) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cleanedErrors));
}
return cleanedErrors;
} catch {
return [];
}

View File

@@ -2,7 +2,9 @@
import { exec } from "child_process";
import { promisify } from "util";
import { readHostCrontab, writeHostCrontab } from "../system/hostCrontab";
import { readHostCrontab, writeHostCrontab } from "@/app/_utils/crontab-utils";
import { isDocker } from "@/app/_server/actions/global";
import { READ_CRON_FILE, WRITE_CRON_FILE } from "@/app/_consts/commands";
const execAsync = promisify(exec);
@@ -27,11 +29,11 @@ export const cleanCrontabContent = async (content: string): Promise<string> => {
}
export const readCronFiles = async (): Promise<string> => {
const isDocker = process.env.DOCKER === "true";
const docker = await isDocker();
if (!isDocker) {
if (!docker) {
try {
const { stdout } = await execAsync('crontab -l 2>/dev/null || echo ""');
const { stdout } = await execAsync(READ_CRON_FILE());
return stdout;
} catch (error) {
console.error("Error reading crontab:", error);
@@ -43,11 +45,11 @@ export const readCronFiles = async (): Promise<string> => {
}
export const writeCronFiles = async (content: string): Promise<boolean> => {
const isDocker = process.env.DOCKER === "true";
const docker = await isDocker();
if (!isDocker) {
if (!docker) {
try {
await execAsync('echo "' + content + '" | crontab -');
await execAsync(WRITE_CRON_FILE(content));
return true;
} catch (error) {
console.error("Error writing crontab:", error);

View File

@@ -0,0 +1,30 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
} else {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
textArea.style.top = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand("copy");
document.body.removeChild(textArea);
return successful;
}
} catch (err) {
console.error("Failed to copy to clipboard:", err);
return false;
}
};

View File

@@ -0,0 +1,236 @@
import { exec, spawn } from "child_process";
import { promisify } from "util";
import { CronJob } from "./cronjob-utils";
import { getUserInfo } from "./crontab-utils";
import { NSENTER_RUN_JOB } from "../_consts/nsenter";
import {
saveRunningJob,
updateRunningJob,
getRunningJob,
removeRunningJob,
} from "./running-jobs-utils";
import { sseBroadcaster } from "./sse-broadcaster";
import { generateLogFolderName, cleanupOldLogFiles } from "./wrapper-utils";
import { watchForLogFile } from "./log-watcher";
const execAsync = promisify(exec);
export const runJobSynchronously = async (
job: CronJob,
docker: boolean
): Promise<{
success: boolean;
message: string;
output?: string;
mode: "sync";
}> => {
let command: string;
if (docker) {
const userInfo = await getUserInfo(job.user);
const executionUser = userInfo ? userInfo.username : "root";
const escapedCommand = job.command.replace(/'/g, "'\\''");
command = NSENTER_RUN_JOB(executionUser, escapedCommand);
} else {
command = job.command;
}
const { stdout, stderr } = await execAsync(command, {
timeout: 300000,
cwd: process.env.HOME || "/home",
});
const output = stdout || stderr || "Command executed successfully";
return {
success: true,
message: "Cron job executed successfully",
output: output.trim(),
mode: "sync",
};
};
export const runJobInBackground = async (
job: CronJob,
docker: boolean
): Promise<{
success: boolean;
message: string;
runId: string;
mode: "async";
}> => {
const runId = `run-${job.id}-${Date.now()}`;
const logFolderName = generateLogFolderName(job.id, job.comment);
let command: string;
let shellArgs: string[];
if (docker) {
const userInfo = await getUserInfo(job.user);
const executionUser = userInfo ? userInfo.username : "root";
const escapedCommand = job.command.replace(/'/g, "'\\''");
const nsenterCmd = NSENTER_RUN_JOB(executionUser, escapedCommand);
command = "sh";
shellArgs = ["-c", nsenterCmd];
} else {
command = "sh";
shellArgs = ["-c", job.command];
}
const child = spawn(command, shellArgs, {
detached: true,
stdio: "ignore",
});
child.unref();
const jobStartTime = new Date();
saveRunningJob({
id: runId,
cronJobId: job.id,
pid: child.pid!,
startTime: jobStartTime.toISOString(),
status: "running",
logFolderName,
});
watchForLogFile(runId, logFolderName, jobStartTime, (logFileName) => {
try {
updateRunningJob(runId, { logFileName });
console.log(`[RunningJob] Cached logFileName for ${runId}: ${logFileName}`);
} catch (error) {
console.error(`[RunningJob] Failed to cache logFileName for ${runId}:`, error);
}
});
sseBroadcaster.broadcast({
type: "job-started",
timestamp: jobStartTime.toISOString(),
data: {
runId,
cronJobId: job.id,
hasLogging: true,
},
});
monitorRunningJob(runId, child.pid!);
return {
success: true,
message: "Job started in background",
runId,
mode: "async",
};
};
const monitorRunningJob = (runId: string, pid: number): void => {
const checkInterval = setInterval(async () => {
try {
const isRunning = await isProcessStillRunning(pid);
if (!isRunning) {
clearInterval(checkInterval);
const exitCode = await getExitCodeFromLog(runId);
updateRunningJob(runId, {
status: exitCode === 0 ? "completed" : "failed",
exitCode,
});
setTimeout(async () => {
try {
removeRunningJob(runId);
await cleanupOldLogFiles(runningJob?.cronJobId || "");
} catch (error) {
console.error(`Error cleaning up job ${runId}:`, error);
}
}, 5000);
const runningJob = getRunningJob(runId);
if (runningJob) {
if (exitCode === 0) {
sseBroadcaster.broadcast({
type: "job-completed",
timestamp: new Date().toISOString(),
data: {
runId,
cronJobId: runningJob.cronJobId,
exitCode,
},
});
} else {
sseBroadcaster.broadcast({
type: "job-failed",
timestamp: new Date().toISOString(),
data: {
runId,
cronJobId: runningJob.cronJobId,
exitCode: exitCode ?? -1,
},
});
}
}
}
} catch (error) {
console.error(`[Monitor] Error checking job ${runId}:`, error);
clearInterval(checkInterval);
}
}, 2000);
};
const isProcessStillRunning = async (pid: number): Promise<boolean> => {
try {
await execAsync(`kill -0 ${pid} 2>/dev/null`);
return true;
} catch {
return false;
}
};
const getExitCodeFromLog = async (
runId: string
): Promise<number | undefined> => {
try {
const { readdir, readFile, access } = await import("fs/promises");
const path = await import("path");
const job = getRunningJob(runId);
if (!job || !job.logFolderName) {
return undefined;
}
const logDir = path.join(process.cwd(), "data", "logs", job.logFolderName);
try {
await access(logDir);
} catch {
return undefined;
}
const files = await readdir(logDir);
const sortedFiles = files.sort().reverse();
if (sortedFiles.length === 0) {
return undefined;
}
const latestLog = await readFile(
path.join(logDir, sortedFiles[0]),
"utf-8"
);
const exitCodeMatch = latestLog.match(/Exit Code\s*:\s*(\d+)/);
if (exitCodeMatch) {
return parseInt(exitCodeMatch[1], 10);
}
return undefined;
} catch (error) {
console.error("Error reading exit code from log:", error);
return undefined;
}
};

View File

@@ -1,8 +1,25 @@
import { CronJob } from "../system";
import { CronJob } from "@/app/_utils/cronjob-utils";
import { generateShortUUID } from "@/app/_utils/uuid-utils";
import { createHash } from "crypto";
const generateStableJobId = (
schedule: string,
command: string,
user: string,
comment?: string,
lineIndex?: number
): string => {
const content = `${schedule}|${command}|${user}|${comment || ""}|${
lineIndex || 0
}`;
const hash = createHash("md5").update(content).digest("hex");
return hash.substring(0, 8);
};
export const pauseJobInLines = (
lines: string[],
targetJobIndex: number
targetJobIndex: number,
uuid: string
): string[] => {
const newCronEntries: string[] = [];
let currentJobIndex = 0;
@@ -51,9 +68,15 @@ export const pauseJobInLines = (
lines[i + 1].trim()
) {
if (currentJobIndex === targetJobIndex) {
const comment = trimmedLine.substring(1).trim();
const commentText = trimmedLine.substring(1).trim();
const { comment, logsEnabled } = parseCommentMetadata(commentText);
const formattedComment = formatCommentWithMetadata(
comment,
logsEnabled,
uuid
);
const nextLine = lines[i + 1].trim();
const pausedEntry = `# PAUSED: ${comment}\n# ${nextLine}`;
const pausedEntry = `# PAUSED: ${formattedComment}\n# ${nextLine}`;
newCronEntries.push(pausedEntry);
i += 2;
currentJobIndex++;
@@ -71,7 +94,8 @@ export const pauseJobInLines = (
}
if (currentJobIndex === targetJobIndex) {
const pausedEntry = `# PAUSED:\n# ${trimmedLine}`;
const formattedComment = formatCommentWithMetadata("", false, uuid);
const pausedEntry = `# PAUSED: ${formattedComment}\n# ${trimmedLine}`;
newCronEntries.push(pausedEntry);
} else {
newCronEntries.push(line);
@@ -86,7 +110,8 @@ export const pauseJobInLines = (
export const resumeJobInLines = (
lines: string[],
targetJobIndex: number
targetJobIndex: number,
uuid: string
): string[] => {
const newCronEntries: string[] = [];
let currentJobIndex = 0;
@@ -118,10 +143,18 @@ export const resumeJobInLines = (
if (trimmedLine.startsWith("# PAUSED:")) {
if (currentJobIndex === targetJobIndex) {
const comment = trimmedLine.substring(9).trim();
const commentText = trimmedLine.substring(9).trim();
const { comment, logsEnabled } = parseCommentMetadata(commentText);
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;
const formattedComment = formatCommentWithMetadata(
comment,
logsEnabled,
uuid
);
const resumedEntry = formattedComment
? `# ${formattedComment}\n${cronLine}`
: cronLine;
newCronEntries.push(resumedEntry);
i += 2;
} else {
@@ -154,12 +187,107 @@ export const resumeJobInLines = (
return newCronEntries;
};
export const parseCommentMetadata = (
commentText: string
): { comment: string; logsEnabled: boolean; uuid?: string } => {
if (!commentText) {
return { comment: "", logsEnabled: false };
}
const parts = commentText.split("|").map((p) => p.trim());
let comment = "";
let logsEnabled = false;
let uuid: string | undefined;
if (parts.length > 1) {
const firstPartIsMetadata =
parts[0].match(/logsEnabled:\s*(true|false)/i) ||
parts[0].match(/id:\s*([a-z0-9]{8}|[a-z0-9]{4}-[a-z0-9]{4})/i);
if (firstPartIsMetadata) {
comment = "";
const metadata = parts.join("|").trim();
const logsMatch = metadata.match(/logsEnabled:\s*(true|false)/i);
if (logsMatch) {
logsEnabled = logsMatch[1].toLowerCase() === "true";
}
const uuidMatches = Array.from(
metadata.matchAll(/id:\s*([a-z0-9]{8}|[a-z0-9]{4}-[a-z0-9]{4})/gi)
);
if (uuidMatches.length > 0) {
uuid = uuidMatches[uuidMatches.length - 1][1].toLowerCase();
}
} else {
comment = parts[0] || "";
const metadata = parts.slice(1).join("|").trim();
const logsMatch = metadata.match(/logsEnabled:\s*(true|false)/i);
if (logsMatch) {
logsEnabled = logsMatch[1].toLowerCase() === "true";
}
const uuidMatches = Array.from(
metadata.matchAll(/id:\s*([a-z0-9]{8}|[a-z0-9]{4}-[a-z0-9]{4})/gi)
);
if (uuidMatches.length > 0) {
uuid = uuidMatches[uuidMatches.length - 1][1].toLowerCase();
}
}
} else {
const logsMatch = commentText.match(/logsEnabled:\s*(true|false)/i);
const uuidMatch = commentText.match(
/id:\s*([a-z0-9]{8}|[a-z0-9]{4}-[a-z0-9]{4})/i
);
if (logsMatch || uuidMatch) {
if (logsMatch) {
logsEnabled = logsMatch[1].toLowerCase() === "true";
}
if (uuidMatch) {
uuid = uuidMatch[1].toLowerCase();
}
comment = "";
} else {
comment = parts[0] || "";
}
}
return { comment, logsEnabled, uuid };
};
export const formatCommentWithMetadata = (
comment: string,
logsEnabled: boolean,
uuid: string
): string => {
const trimmedComment = comment.trim();
const metadataParts: string[] = [];
if (logsEnabled) {
metadataParts.push("logsEnabled: true");
}
metadataParts.push(`id: ${uuid}`);
const metadata = metadataParts.join(" | ");
if (trimmedComment) {
return `${trimmedComment} | ${metadata}`;
}
return metadata;
};
export const parseJobsFromLines = (
lines: string[],
user: string
): CronJob[] => {
const jobs: CronJob[] = [];
let currentComment = "";
let currentLogsEnabled = false;
let currentUuid: string | undefined;
let jobIndex = 0;
let i = 0;
@@ -181,7 +309,8 @@ export const parseJobsFromLines = (
}
if (trimmedLine.startsWith("# PAUSED:")) {
const comment = trimmedLine.substring(9).trim();
const commentText = trimmedLine.substring(9).trim();
const { comment, logsEnabled, uuid } = parseCommentMetadata(commentText);
if (i + 1 < lines.length) {
const nextLine = lines[i + 1].trim();
@@ -192,13 +321,17 @@ export const parseJobsFromLines = (
const schedule = parts.slice(0, 5).join(" ");
const command = parts.slice(5).join(" ");
const jobId =
uuid || generateStableJobId(schedule, command, user, comment, i);
jobs.push({
id: `${user}-${jobIndex}`,
id: jobId,
schedule,
command,
comment: comment || undefined,
user,
paused: true,
logsEnabled,
});
jobIndex++;
@@ -217,7 +350,12 @@ export const parseJobsFromLines = (
!lines[i + 1].trim().startsWith("#") &&
lines[i + 1].trim()
) {
currentComment = trimmedLine.substring(1).trim();
const commentText = trimmedLine.substring(1).trim();
const { comment, logsEnabled, uuid } =
parseCommentMetadata(commentText);
currentComment = comment;
currentLogsEnabled = logsEnabled;
currentUuid = uuid;
i++;
continue;
} else {
@@ -240,17 +378,24 @@ export const parseJobsFromLines = (
}
if (schedule && command) {
const jobId =
currentUuid ||
generateStableJobId(schedule, command, user, currentComment, i);
jobs.push({
id: `${user}-${jobIndex}`,
id: jobId,
schedule,
command,
comment: currentComment || undefined,
user,
paused: false,
logsEnabled: currentLogsEnabled,
});
jobIndex++;
currentComment = "";
currentLogsEnabled = false;
currentUuid = undefined;
}
i++;
}
@@ -345,7 +490,9 @@ export const updateJobInLines = (
targetJobIndex: number,
schedule: string,
command: string,
comment: string = ""
comment: string = "",
logsEnabled: boolean = false,
uuid: string
): string[] => {
const newCronEntries: string[] = [];
let currentJobIndex = 0;
@@ -377,8 +524,13 @@ export const updateJobInLines = (
if (trimmedLine.startsWith("# PAUSED:")) {
if (currentJobIndex === targetJobIndex) {
const newEntry = comment
? `# PAUSED: ${comment}\n# ${schedule} ${command}`
const formattedComment = formatCommentWithMetadata(
comment,
logsEnabled,
uuid
);
const newEntry = formattedComment
? `# PAUSED: ${formattedComment}\n# ${schedule} ${command}`
: `# PAUSED:\n# ${schedule} ${command}`;
newCronEntries.push(newEntry);
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
@@ -406,8 +558,13 @@ export const updateJobInLines = (
lines[i + 1].trim()
) {
if (currentJobIndex === targetJobIndex) {
const newEntry = comment
? `# ${comment}\n${schedule} ${command}`
const formattedComment = formatCommentWithMetadata(
comment,
logsEnabled,
uuid
);
const newEntry = formattedComment
? `# ${formattedComment}\n${schedule} ${command}`
: `${schedule} ${command}`;
newCronEntries.push(newEntry);
i += 2;
@@ -425,8 +582,13 @@ export const updateJobInLines = (
}
if (currentJobIndex === targetJobIndex) {
const newEntry = comment
? `# ${comment}\n${schedule} ${command}`
const formattedComment = formatCommentWithMetadata(
comment,
logsEnabled,
uuid
);
const newEntry = formattedComment
? `# ${formattedComment}\n${schedule} ${command}`
: `${schedule} ${command}`;
newCronEntries.push(newEntry);
} else {

156
app/_utils/log-watcher.ts Normal file
View File

@@ -0,0 +1,156 @@
import { watch } from "fs";
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
import path from "path";
import { sseBroadcaster } from "./sse-broadcaster";
import { getRunningJob } from "./running-jobs-utils";
const DATA_DIR = path.join(process.cwd(), "data");
const LOGS_DIR = path.join(DATA_DIR, "logs");
let watcher: ReturnType<typeof watch> | null = null;
const parseExitCodeFromLog = (content: string): number | null => {
const match = content.match(/Exit Code\s*:\s*(\d+)/);
return match ? parseInt(match[1], 10) : null;
};
const processLogFile = (logFilePath: string) => {
try {
const pathParts = logFilePath.split(path.sep);
const logsIndex = pathParts.indexOf("logs");
if (logsIndex === -1 || logsIndex >= pathParts.length - 2) {
return;
}
const jobFolderName = pathParts[logsIndex + 1];
if (!existsSync(logFilePath)) {
return;
}
const content = readFileSync(logFilePath, "utf-8");
const exitCode = parseExitCodeFromLog(content);
if (exitCode === null) {
return;
}
const runningJob = getRunningJob(`run-${jobFolderName}`);
if (exitCode === 0) {
sseBroadcaster.broadcast({
type: "job-completed",
timestamp: new Date().toISOString(),
data: {
runId: runningJob?.id || `run-${jobFolderName}`,
cronJobId: runningJob?.cronJobId || jobFolderName,
exitCode,
},
});
} else {
sseBroadcaster.broadcast({
type: "job-failed",
timestamp: new Date().toISOString(),
data: {
runId: runningJob?.id || `run-${jobFolderName}`,
cronJobId: runningJob?.cronJobId || jobFolderName,
exitCode,
},
});
}
} catch (error) {
console.error("[LogWatcher] Error processing log file:", error);
}
};
export const startLogWatcher = () => {
if (watcher) {
return;
}
if (!existsSync(LOGS_DIR)) {
return;
}
watcher = watch(LOGS_DIR, { recursive: true }, (eventType, filename) => {
if (!filename || !filename.endsWith(".log")) {
return;
}
const fullPath = path.join(LOGS_DIR, filename);
if (eventType === "change") {
setTimeout(() => {
processLogFile(fullPath);
}, 500);
}
});
};
export const stopLogWatcher = () => {
if (watcher) {
watcher.close();
watcher = null;
}
};
export const watchForLogFile = (
runId: string,
logFolderName: string,
jobStartTime: Date,
callback: (logFileName: string) => void
): NodeJS.Timeout => {
const logDir = path.join(LOGS_DIR, logFolderName);
const startTime = jobStartTime.getTime();
const maxAttempts = 30;
let attempts = 0;
const checkInterval = setInterval(() => {
attempts++;
if (attempts > maxAttempts) {
console.warn(`[LogWatcher] Timeout waiting for log file for ${runId}`);
clearInterval(checkInterval);
return;
}
try {
if (!existsSync(logDir)) {
return;
}
const files = readdirSync(logDir);
const logFiles = files
.filter((f) => f.endsWith(".log"))
.map((f) => {
const filePath = path.join(logDir, f);
try {
const stats = statSync(filePath);
return {
name: f,
birthtime: stats.birthtime || stats.mtime,
};
} catch {
return null;
}
})
.filter((f): f is { name: string; birthtime: Date } => f !== null);
const matchingFile = logFiles.find((f) => {
const fileTime = f.birthtime.getTime();
return fileTime >= startTime - 5000 && fileTime <= startTime + 30000;
});
if (matchingFile) {
clearInterval(checkInterval);
callback(matchingFile.name);
}
} catch (error) {
console.error(`[LogWatcher] Error watching for log file ${runId}:`, error);
}
}, 500);
return checkInterval;
};

View File

@@ -1,4 +1,4 @@
import cronstrue from "cronstrue";
import cronstrue from 'cronstrue/i18n';
export interface CronExplanation {
humanReadable: string;
@@ -7,7 +7,7 @@ export interface CronExplanation {
error?: string;
}
export const parseCronExpression = (expression: string): CronExplanation => {
export const parseCronExpression = (expression: string, locale?: string): CronExplanation => {
try {
const cleanExpression = expression.trim();
@@ -23,6 +23,7 @@ export const parseCronExpression = (expression: string): CronExplanation => {
const humanReadable = cronstrue.toString(cleanExpression, {
verbose: true,
throwExceptionOnParseError: false,
locale: locale || "en",
});
return {

View File

@@ -0,0 +1,32 @@
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export const isProcessRunning = async (pid: number): Promise<boolean> => {
try {
await execAsync(`kill -0 ${pid} 2>/dev/null`);
return true;
} catch {
return false;
}
};
export const killProcess = async (pid: number, signal: string = "SIGTERM"): Promise<boolean> => {
try {
await execAsync(`kill -${signal} ${pid} 2>/dev/null`);
return true;
} catch (error) {
console.error(`Failed to kill process ${pid}:`, error);
return false;
}
};
export const getProcessInfo = async (pid: number): Promise<string | null> => {
try {
const { stdout } = await execAsync(`ps -p ${pid} -o pid,cmd 2>/dev/null`);
return stdout.trim();
} catch {
return null;
}
};

View File

@@ -0,0 +1,98 @@
import { readFileSync, writeFileSync, existsSync } from "fs";
import path from "path";
import { DATA_DIR } from "../_consts/file";
export interface RunningJob {
id: string;
cronJobId: string;
pid: number;
startTime: string;
status: "running" | "completed" | "failed";
exitCode?: number;
logFolderName?: string;
logFileName?: string;
lastReadPosition?: number;
}
const RUNNING_JOBS_FILE = path.join(process.cwd(), DATA_DIR, "running-jobs.json");
export const getAllRunningJobs = (): RunningJob[] => {
try {
if (!existsSync(RUNNING_JOBS_FILE)) {
return [];
}
const data = readFileSync(RUNNING_JOBS_FILE, "utf-8");
return JSON.parse(data);
} catch (error) {
console.error("Error reading running jobs:", error);
return [];
}
};
export const getRunningJob = (runId: string): RunningJob | null => {
const jobs = getAllRunningJobs();
return jobs.find((job) => job.id === runId) || null;
};
export const saveRunningJob = (job: RunningJob): void => {
try {
const jobs = getAllRunningJobs();
jobs.push(job);
writeFileSync(RUNNING_JOBS_FILE, JSON.stringify(jobs, null, 2), "utf-8");
} catch (error) {
console.error("Error saving running job:", error);
throw error;
}
};
export const updateRunningJob = (runId: string, updates: Partial<RunningJob>): void => {
try {
const jobs = getAllRunningJobs();
const index = jobs.findIndex((job) => job.id === runId);
if (index === -1) {
throw new Error(`Running job ${runId} not found`);
}
jobs[index] = { ...jobs[index], ...updates };
writeFileSync(RUNNING_JOBS_FILE, JSON.stringify(jobs, null, 2), "utf-8");
} catch (error) {
console.error("Error updating running job:", error);
throw error;
}
};
export const removeRunningJob = (runId: string): void => {
try {
const jobs = getAllRunningJobs();
const filtered = jobs.filter((job) => job.id !== runId);
writeFileSync(RUNNING_JOBS_FILE, JSON.stringify(filtered, null, 2), "utf-8");
} catch (error) {
console.error("Error removing running job:", error);
throw error;
}
};
export const cleanupOldRunningJobs = (): void => {
try {
const jobs = getAllRunningJobs();
const oneHourAgo = Date.now() - 60 * 60 * 1000;
const filtered = jobs.filter((job) => {
if (job.status === "running") {
return true;
}
const jobTime = new Date(job.startTime).getTime();
return jobTime > oneHourAgo;
});
writeFileSync(RUNNING_JOBS_FILE, JSON.stringify(filtered, null, 2), "utf-8");
} catch (error) {
console.error("Error cleaning up old running jobs:", error);
}
};
export const getRunningJobsForCronJob = (cronJobId: string): RunningJob[] => {
const jobs = getAllRunningJobs();
return jobs.filter((job) => job.cronJobId === cronJobId && job.status === "running");
};

View File

@@ -1,5 +1,6 @@
import { promises as fs } from "fs";
import path from "path";
import { SCRIPTS_DIR } from "../_consts/file";
export interface Script {
id: string;
@@ -67,10 +68,7 @@ const scanScriptsDirectory = async (dirPath: string): Promise<Script[]> => {
}
export const loadAllScripts = async (): Promise<Script[]> => {
const isDocker = process.env.DOCKER === "true";
const scriptsDir = isDocker
? "/app/scripts"
: path.join(process.cwd(), "scripts");
const scriptsDir = path.join(process.cwd(), SCRIPTS_DIR);
return await scanScriptsDirectory(scriptsDir);
}
@@ -88,4 +86,4 @@ export const getScriptById = (
id: string
): Script | undefined => {
return scripts.find((script) => script.id === id);
}
};

View File

@@ -1,28 +0,0 @@
"use server";
import { join } from "path";
const isDocker = process.env.DOCKER === "true";
const SCRIPTS_DIR = async () => {
if (isDocker && process.env.HOST_PROJECT_DIR) {
return `${process.env.HOST_PROJECT_DIR}/scripts`;
}
return join(process.cwd(), "scripts");
};
export const getScriptPath = async (filename: string): Promise<string> => {
return join(await SCRIPTS_DIR(), filename);
}
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 };

205
app/_utils/session-utils.ts Normal file
View File

@@ -0,0 +1,205 @@
import { readFile, writeFile, mkdir } from "fs/promises";
import { existsSync } from "fs";
import path from "path";
import crypto from "crypto";
const DATA_DIR = path.join(process.cwd(), "data");
const SESSIONS_DIR = path.join(DATA_DIR, "sessions");
const SESSIONS_FILE = path.join(SESSIONS_DIR, "sessions.json");
let fileLock: Promise<void> | null = null;
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
while (fileLock) {
await fileLock;
}
let resolve: () => void;
fileLock = new Promise((r) => {
resolve = r;
});
try {
return await fn();
} finally {
resolve!();
fileLock = null;
}
}
export type AuthType = "password" | "oidc";
export interface Session {
authType: AuthType;
createdAt: string;
expiresAt: string;
}
interface SessionStore {
[sessionId: string]: Session;
}
export function generateSessionId(): string {
return crypto.randomBytes(32).toString("base64url");
}
export function getSessionCookieName(): string {
return process.env.NODE_ENV === "production" && process.env.HTTPS === "true"
? "__Host-cronmaster-session"
: "cronmaster-session";
}
async function ensureSessionsDir() {
await mkdir(SESSIONS_DIR, { recursive: true });
}
async function loadSessions(): Promise<SessionStore> {
await ensureSessionsDir();
if (!existsSync(SESSIONS_FILE)) {
return {};
}
try {
const content = await readFile(SESSIONS_FILE, "utf-8");
return content ? JSON.parse(content) : {};
} catch (error) {
console.error("Error loading sessions:", error);
return {};
}
}
async function saveSessions(sessions: SessionStore): Promise<void> {
await ensureSessionsDir();
await writeFile(SESSIONS_FILE, JSON.stringify(sessions, null, 2), "utf-8");
}
export async function createSession(authType: AuthType): Promise<string> {
await ensureSessionsDir();
return withLock(async () => {
const sessionId = generateSessionId();
const sessions = await loadSessions();
const now = new Date();
const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
sessions[sessionId] = {
authType,
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
};
await saveSessions(sessions);
if (process.env.DEBUGGER) {
console.log("[Session] Created session:", {
sessionId: sessionId.substring(0, 10) + "...",
authType,
expiresAt: expiresAt.toISOString(),
});
}
return sessionId;
});
}
export async function validateSession(sessionId: string): Promise<boolean> {
if (!sessionId) {
return false;
}
await ensureSessionsDir();
if (!existsSync(SESSIONS_FILE)) {
return false;
}
return withLock(async () => {
const sessions = await loadSessions();
const session = sessions[sessionId];
if (!session) {
return false;
}
const now = new Date();
const expiresAt = new Date(session.expiresAt);
if (now > expiresAt) {
delete sessions[sessionId];
await saveSessions(sessions);
return false;
}
return true;
});
}
export async function getSession(sessionId: string): Promise<Session | null> {
if (!sessionId) {
return null;
}
await ensureSessionsDir();
if (!existsSync(SESSIONS_FILE)) {
return null;
}
const sessions = await loadSessions();
return sessions[sessionId] || null;
}
export async function deleteSession(sessionId: string): Promise<void> {
if (!sessionId) {
return;
}
await ensureSessionsDir();
if (!existsSync(SESSIONS_FILE)) {
return;
}
return withLock(async () => {
const sessions = await loadSessions();
delete sessions[sessionId];
await saveSessions(sessions);
if (process.env.DEBUGGER) {
console.log("[Session] Deleted session:", {
sessionId: sessionId.substring(0, 10) + "...",
});
}
});
}
export async function cleanExpiredSessions(): Promise<void> {
await ensureSessionsDir();
if (!existsSync(SESSIONS_FILE)) {
return;
}
return withLock(async () => {
const sessions = await loadSessions();
const now = new Date();
let cleaned = 0;
for (const [sessionId, session] of Object.entries(sessions)) {
const expiresAt = new Date(session.expiresAt);
if (now > expiresAt) {
delete sessions[sessionId];
cleaned++;
}
}
if (cleaned > 0) {
await saveSessions(sessions);
if (process.env.DEBUGGER) {
console.log(`[Session] Cleaned ${cleaned} expired sessions`);
}
}
});
}

View File

@@ -1,5 +1,6 @@
import { promises as fs } from "fs";
import path from "path";
import { isDocker } from "../_server/actions/global";
export interface BashSnippet {
id: string;
@@ -118,12 +119,12 @@ const scanSnippetDirectory = async (
}
export const loadAllSnippets = async (): Promise<BashSnippet[]> => {
const isDocker = process.env.DOCKER === "true";
const docker = await isDocker();
let builtinSnippetsPath: string;
let userSnippetsPath: string;
if (isDocker) {
if (docker) {
builtinSnippetsPath = "/app/app/_utils/snippets";
userSnippetsPath = "/app/snippets";
} else {

View File

@@ -0,0 +1,94 @@
import { SSEEvent, formatSSEEvent } from "./sse-events";
type SSEClient = {
id: string;
controller: ReadableStreamDefaultController;
connectedAt: Date;
};
class SSEBroadcaster {
private clients: Map<string, SSEClient> = new Map();
addClient(id: string, controller: ReadableStreamDefaultController): void {
this.clients.set(id, {
id,
controller,
connectedAt: new Date(),
});
if (process.env.DEBUGGER) {
console.log(
`[SSE] Client ${id} connected. Total clients: ${this.clients.size}`
);
}
}
removeClient(id: string): void {
this.clients.delete(id);
if (process.env.DEBUGGER) {
console.log(
`[SSE] Client ${id} disconnected. Total clients: ${this.clients.size}`
);
}
}
broadcast(event: SSEEvent): void {
const formattedEvent = formatSSEEvent(event);
const encoder = new TextEncoder();
const encoded = encoder.encode(formattedEvent);
let successCount = 0;
let failCount = 0;
this.clients.forEach((client, id) => {
try {
client.controller.enqueue(encoded);
successCount++;
} catch (error) {
if (process.env.DEBUGGER) {
console.error(`[SSE] Failed to send to client ${id}:`, error);
}
this.removeClient(id);
failCount++;
}
});
if (this.clients.size > 0) {
if (process.env.DEBUGGER) {
console.log(
`[SSE] Broadcast ${event.type} to ${successCount} clients (${failCount} failed)`
);
}
}
}
sendToClient(clientId: string, event: SSEEvent): void {
const client = this.clients.get(clientId);
if (!client) {
if (process.env.DEBUGGER) {
console.warn(`[SSE] Client ${clientId} not found`);
}
return;
}
try {
const formattedEvent = formatSSEEvent(event);
const encoder = new TextEncoder();
client.controller.enqueue(encoder.encode(formattedEvent));
} catch (error) {
if (process.env.DEBUGGER) {
console.error(`[SSE] Failed to send to client ${clientId}:`, error);
}
this.removeClient(clientId);
}
}
getClientCount(): number {
return this.clients.size;
}
hasClients(): boolean {
return this.clients.size > 0;
}
}
export const sseBroadcaster = new SSEBroadcaster();

86
app/_utils/sse-events.ts Normal file
View File

@@ -0,0 +1,86 @@
export type SSEEventType =
| "job-started"
| "job-completed"
| "job-failed"
| "log-line"
| "system-stats"
| "heartbeat";
export interface BaseSSEEvent {
type: SSEEventType;
timestamp: string;
}
export interface JobStartedEvent extends BaseSSEEvent {
type: "job-started";
data: {
runId: string;
cronJobId: string;
hasLogging: boolean;
};
}
export interface JobCompletedEvent extends BaseSSEEvent {
type: "job-completed";
data: {
runId: string;
cronJobId: string;
exitCode: number;
duration?: number;
};
}
export interface JobFailedEvent extends BaseSSEEvent {
type: "job-failed";
data: {
runId: string;
cronJobId: string;
exitCode: number;
error?: string;
};
}
export interface LogLineEvent extends BaseSSEEvent {
type: "log-line";
data: {
runId: string;
cronJobId: string;
line: string;
lineNumber: number;
};
}
export interface SystemStatsEvent extends BaseSSEEvent {
type: "system-stats";
data: any;
}
export interface HeartbeatEvent extends BaseSSEEvent {
type: "heartbeat";
data: {
message: string;
};
}
export type SSEEvent =
| JobStartedEvent
| JobCompletedEvent
| JobFailedEvent
| LogLineEvent
| SystemStatsEvent
| HeartbeatEvent;
export const formatSSEEvent = (event: SSEEvent): string => {
return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
};
export const createHeartbeatEvent = (): HeartbeatEvent => {
return {
type: "heartbeat",
timestamp: new Date().toISOString(),
data: {
message: "alive",
},
};
};

View File

@@ -0,0 +1,105 @@
import { exec } from "child_process";
import { promisify } from "util";
import * as si from "systeminformation";
const execAsync = promisify(exec);
export const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${
["B", "KB", "MB", "GB", "TB"][i]
}`;
};
export 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`;
if (hours > 0) return `${hours} hours, ${minutes} minutes`;
return `${minutes} minutes`;
};
export const getPing = async (): Promise<number> => {
try {
const { stdout } = await execAsync(
'ping -c 1 -W 1000 8.8.8.8 2>/dev/null || echo "timeout"'
);
const match = stdout.match(/time=(\d+\.?\d*)/);
return match ? Math.round(parseFloat(match[1])) : 0;
} catch (error) {
return 0;
}
};
export const getStatus = (
value: number,
thresholds: { critical?: number; high?: number; moderate?: number },
t: (key: string) => string
): string => {
if (thresholds.critical && value > thresholds.critical)
return t("system.critical");
if (thresholds.high && value > thresholds.high) return t("system.high");
if (thresholds.moderate && value > thresholds.moderate)
return t("system.moderate");
return t("system.optimal");
};
export const findMainInterface = (
networkInfo: si.Systeminformation.NetworkStatsData[]
) => {
if (!Array.isArray(networkInfo) || networkInfo.length === 0) return null;
return (
networkInfo.find(
(net) => net.iface && !net.iface.includes("lo") && net.operstate === "up"
) ||
networkInfo.find((net) => net.iface && !net.iface.includes("lo")) ||
networkInfo[0]
);
};
export const formatGpuInfo = (
graphics: si.Systeminformation.GraphicsData | null,
t: (key: string) => string
) => {
if (graphics && graphics.controllers && graphics.controllers.length > 0) {
const gpu = graphics.controllers[0];
return {
model: gpu.model || t("system.unknownGPU"),
memory: gpu.vram ? `${gpu.vram} MB` : undefined,
status: t("system.available"),
};
}
return {
model: t(graphics ? "system.noGPUDetected" : "system.gpuDetectionFailed"),
status: t("system.unknown"),
};
};
export const getOverallStatus = (
memUsage: number,
cpuLoad: number,
t: (key: string) => string
) => {
const criticalThreshold = 90;
const warningThreshold = 80;
if (memUsage > criticalThreshold || cpuLoad > criticalThreshold) {
return {
overall: t("system.critical"),
details: t("system.highResourceUsageDetectedImmediateAttentionRequired"),
};
}
if (memUsage > warningThreshold || cpuLoad > warningThreshold) {
return {
overall: t("system.warning"),
details: t("system.moderateResourceUsageMonitoringRecommended"),
};
}
return {
overall: t("system.optimal"),
details: t("system.allSystemsRunningNormally"),
};
};

View File

@@ -1,10 +0,0 @@
export {
getCronJobs,
addCronJob,
deleteCronJob,
updateCronJob,
pauseCronJob,
resumeCronJob,
cleanupCrontab,
type CronJob,
} from "./system/cron";

View File

@@ -1,228 +0,0 @@
import { exec } from "child_process";
import { promisify } from "util";
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;
user: string;
paused?: boolean;
}
const isDocker = (): boolean => process.env.DOCKER === "true";
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;
}
};
const writeUserCrontab = async (user: string, content: string): Promise<boolean> => {
if (isDocker()) {
return await writeHostCrontabForUser(user, content);
} else {
try {
await execAsync(`echo '${content}' | crontab -u ${user} -`);
return true;
} catch (error) {
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 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}`;
let newCron;
if (cronContent.trim() === "") {
newCron = newEntry;
} else {
const existingContent = cronContent.trim();
newCron = await cleanCrontabContent(existingContent + "\n" + newEntry);
}
return await writeUserCrontab(user, newCron);
} else {
const cronContent = await readCronFiles();
const newEntry = comment
? `# ${comment}\n${schedule} ${command}`
: `${schedule} ${command}`;
let newCron;
if (cronContent.trim() === "") {
newCron = newEntry;
} else {
const existingContent = cronContent.trim();
newCron = await cleanCrontabContent(existingContent + "\n" + newEntry);
}
return await writeCronFiles(newCron);
}
} catch (error) {
console.error("Error adding cron job:", error);
return false;
}
}
export const deleteCronJob = 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 = deleteJobInLines(lines, jobIndex);
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
return await writeUserCrontab(user, newCron);
} catch (error) {
console.error("Error deleting cron job:", error);
return false;
}
}
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 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;
}
}

15
app/_utils/uuid-utils.ts Normal file
View File

@@ -0,0 +1,15 @@
export const generateShortUUID = (): string => {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
const part1 = Array.from({ length: 4 }, () =>
chars[Math.floor(Math.random() * chars.length)]
).join('');
const part2 = Array.from({ length: 4 }, () =>
chars[Math.floor(Math.random() * chars.length)]
).join('');
return `${part1}-${part2}`;
};
export const isValidShortUUID = (uuid: string): boolean => {
return /^[a-z0-9]{4}-[a-z0-9]{4}$/.test(uuid);
};

Some files were not shown because too many files have changed in this diff Show More