diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..bef6c5a --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0d1d491..0d3b2d2 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 94c7e7e..375e0d3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ node_modules .cursorignore .idea tsconfig.tsbuildinfo -docker-compose.test.yml \ No newline at end of file +docker-compose.test.yml +/data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ca34af5..3846f42 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index c4d5099..8dcc625 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,40 @@

+## Table of Contents + +- [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) +- [REST API](#rest-api) +- [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. - **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. @@ -41,66 +69,72 @@ If you find my projects helpful and want to fuel my late-night coding sessions w

- - + +
+ + ## Quick Start + + ### 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 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)** + + + +## API + +`cr*nmaster` includes a REST API for programmatic access to your checklists and notes. This is perfect for integrations. + +📖 **For the complete API documentation, see [howto/API.md](howto/API.md)** + + + +## 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)** + + + +## Localization + +`cr*nmaster` officially support [some languages](app/_transations) 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: @@ -125,6 +159,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 + + ### Local Development 1. Install dependencies: @@ -141,40 +177,141 @@ yarn dev 3. Open your browser and navigate to `http://localhost:3000` + + ### 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 | +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//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. + + +## 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! + +For detailed setup instructions, see **[README_SSO.md](README_SSO.md)** + +Quick example: + +```yaml +environment: + - SSO_MODE=oidc + - OIDC_ISSUER=https://your-sso-provider.com + - OIDC_CLIENT_ID=your_client_id + - APP_URL=https://your-cronmaster-domain.com +``` + +### Combined Authentication + +You can enable **both** password and SSO authentication simultaneously: + +```yaml +environment: + - AUTH_PASSWORD=your_password + - SSO_MODE=oidc + - OIDC_ISSUER=https://your-sso-provider.com + - OIDC_CLIENT_ID=your_client_id +``` + +The login page will display both options, allowing users to choose their preferred method. + +### Security Features + +- ✅ **Secure session management** with cryptographically random session IDs +- ✅ **30-day session expiration** with automatic cleanup +- ✅ **HTTP-only cookies** to prevent XSS attacks +- ✅ **Proper JWT verification** for OIDC tokens using provider's public keys (JWKS) +- ✅ **PKCE support** for OIDC authentication (or confidential client mode) + + + +## REST API + +Cr\*nMaster provides a full REST API for programmatic access. Perfect for: + +- External monitoring tools +- Automation scripts +- CI/CD integrations +- Custom dashboards + +### API Authentication + +Protect your API with an optional API key: + +```yaml +environment: + - API_KEY=your-secret-api-key-here +``` + +Use the API key in your requests: + +```bash +curl -H "Authorization: Bearer YOUR_API_KEY" \ + https://your-domain.com/api/cronjobs +``` + +For complete API documentation with examples, see **[howto/API.md](howto/API.md)** + +### Available Endpoints + +- `GET /api/cronjobs` - List all cron jobs +- `POST /api/cronjobs` - Create a new cron job +- `GET /api/cronjobs/:id` - Get a specific cron job +- `PATCH /api/cronjobs/:id` - Update a cron job +- `DELETE /api/cronjobs/:id` - Delete a cron job +- `POST /api/cronjobs/:id/execute` - Manually execute a job +- `GET /api/scripts` - List all scripts +- `POST /api/scripts` - Create a new script +- `GET /api/system-stats` - Get system statistics +- `GET /api/logs/stream?runId=xxx` - Stream job logs +- `GET /api/events` - SSE stream for real-time updates + + + ## Usage + + ### Viewing System Information The application automatically detects your operating system and displays: @@ -184,6 +321,8 @@ The application automatically detects your operating system and displays: - CPU Information - GPU Information (if supported) + + ### Managing Cron Jobs 1. **View Existing Jobs**: All current cron jobs are displayed with their schedules and commands @@ -192,6 +331,131 @@ 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 + + + +### Job Execution Logging + +CronMaster includes an optional logging feature that captures detailed execution information for your cronjobs: + +#### How It Works + +When you enable logging for a cronjob, CronMaster automatically wraps your command with a log wrapper script. This wrapper: + +- Captures **stdout** and **stderr** output +- Records the **exit code** of your command +- Timestamps the **start and end** of execution +- Calculates **execution duration** +- Stores all this information in organized log files + +#### Enabling Logs + +1. When creating or editing a cronjob, check the "Enable Logging" checkbox +2. The wrapper is automatically added to your crontab entry +3. Jobs run independently - they continue to work even if CronMaster is offline + +#### Log Storage + +Logs are stored in the `./data/logs/` directory with descriptive folder names: + +- If a job has a **description/comment**: `{sanitized-description}_{jobId}/` +- If a job has **no description**: `{jobId}/` + +Example structure: + +``` +./data/logs/ +├── backup-database_root-0/ +│ ├── 2025-11-10_14-30-00.log +│ ├── 2025-11-10_15-30-00.log +│ └── 2025-11-10_16-30-00.log +├── daily-cleanup_root-1/ +│ └── 2025-11-10_14-35-00.log +├── root-2/ (no description provided) +│ └── 2025-11-10_14-40-00.log +``` + +**Note**: Folder names are sanitized to be filesystem-safe (lowercase, alphanumeric with hyphens, max 50 chars for the description part). + +#### Log Format + +Each log file includes: + +``` +========================================== +=== CronMaster Job Execution Log === +========================================== +Log Folder: backup-database_root-0 +Command: bash /app/scripts/backup.sh +Started: 2025-11-10 14:30:00 +========================================== + +[command output here] + +========================================== +=== Execution Summary === +========================================== +Completed: 2025-11-10 14:30:45 +Duration: 45 seconds +Exit code: 0 +========================================== +``` + +#### Automatic Cleanup + +Logs are automatically cleaned up to prevent disk space issues: + +- **Maximum logs per job**: 50 log files +- **Maximum age**: 30 days +- **Cleanup trigger**: When viewing logs or after manual execution +- **Method**: Oldest logs are deleted first when limits are exceeded + +#### Custom Wrapper Script + +You can override the default log wrapper by creating your own at `./data/wrapper-override.sh`. This allows you to: + +- Customize log format +- Add additional metadata +- Integrate with external logging services +- Implement custom retention policies + +**Example custom wrapper**: + +```bash +#!/bin/bash +JOB_ID="$1" +shift + +# Your custom logic here +LOG_FILE="/custom/path/${JOB_ID}_$(date '+%Y%m%d').log" + +{ + echo "=== Custom Log Format ===" + echo "Job: $JOB_ID" + "$@" + echo "Exit: $?" +} >> "$LOG_FILE" 2>&1 +``` + +#### Docker Considerations + +- Mount the `./data` directory to persist logs on the host +- The wrapper script location: `./data/cron-log-wrapper.sh`. This will be generated automatically the first time you enable logging. + +#### Non-Docker Considerations + +- Logs are stored at `./data/logs/` relative to the project directory +- The codebase wrapper script location: `./app/_scripts/cron-log-wrapper.sh` +- The running wrapper script location: `./data/cron-log-wrapper.sh` + +#### Important Notes + +- Logging is **optional** and disabled by default +- Jobs with logging enabled are marked with a blue "Logged" badge in the UI +- Logs are captured for both scheduled runs and manual executions +- Commands with file redirections (>, >>) may conflict with logging +- The crontab stores the **wrapped command**, so jobs run independently of CronMaster ### Cron Schedule Format @@ -203,6 +467,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) + + ### Managing Scripts 1. **View Existing Scripts**: All current user created scripts are displayed with their name and descriptions @@ -211,6 +477,8 @@ 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 @@ -220,13 +488,14 @@ The application uses standard cron format: `* * * * *` - **next-themes**: Dark/light mode support - **Docker**: Containerization + + ## Contributing 1. Fork the repository -2. Create a feature branch +2. Create a feature branch from the `develop` branch 3. Make your changes -4. Add tests if applicable -5. Submit a pull request +4. Submit a pull request to the `develop` branch ## Community shouts @@ -271,6 +540,8 @@ I would like to thank the following members for raising issues and help test/deb + + ## License This project is licensed under the MIT License. diff --git a/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx b/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx new file mode 100644 index 0000000..01ac95c --- /dev/null +++ b/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx @@ -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(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 ( + <> + + +
+
+
+ +
+
+ + {t("cronjobs.scheduledTasks")} + +

+ {t("cronjobs.nOfNJObs", { + filtered: filteredJobs.length, + total: cronJobs.length, + })}{" "} + {selectedUser && + t("cronjobs.forUser", { user: selectedUser })} +

+
+
+
+
+ + +
+ +
+
+
+ +
+
+ + +
+
+ + {filteredJobs.length === 0 ? ( + setIsNewCronModalOpen(true)} + /> + ) : ( +
+ {loadedSettings ? ( + filteredJobs.map((job) => + minimalMode ? ( + + ) : ( + + ) + ) + ) : ( +
+ +
+ )} +
+ )} +
+
+ + 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 && ( + setIsLogsModalOpen(false)} + jobId={jobForLogs.id} + jobComment={jobForLogs.comment} + preSelectedLog={jobForLogs.logError?.lastFailedLog} + /> + )} + + setIsLiveLogModalOpen(false)} + runId={liveLogRunId} + jobId={liveLogJobId} + jobComment={liveLogJobComment} + /> + + setIsBackupModalOpen(false)} + backups={backupFiles} + onRestore={handleRestore} + onRestoreAll={handleRestoreAll} + onBackupAll={handleBackupAll} + onDelete={handleDeleteBackup} + onRefresh={loadBackupFiles} + /> + + setIsFiltersModalOpen(false)} + selectedUser={selectedUser} + onUserChange={setSelectedUser} + scheduleDisplayMode={scheduleDisplayMode} + onScheduleDisplayModeChange={setScheduleDisplayMode} + /> + + ); +}; diff --git a/app/_components/FeatureComponents/Cronjobs/Parts/CronJobEmptyState.tsx b/app/_components/FeatureComponents/Cronjobs/Parts/CronJobEmptyState.tsx new file mode 100644 index 0000000..5c9b9fc --- /dev/null +++ b/app/_components/FeatureComponents/Cronjobs/Parts/CronJobEmptyState.tsx @@ -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 ( +
+
+ +
+

+ {selectedUser + ? `No tasks for user ${selectedUser}` + : "No scheduled tasks yet"} +

+

+ {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."} +

+ +
+ ); +}; \ No newline at end of file diff --git a/app/_components/FeatureComponents/Cronjobs/Parts/CronJobItem.tsx b/app/_components/FeatureComponents/Cronjobs/Parts/CronJobItem.tsx new file mode 100644 index 0000000..23cd238 --- /dev/null +++ b/app/_components/FeatureComponents/Cronjobs/Parts/CronJobItem.tsx @@ -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(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(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: , + onClick: () => onEdit(job), + }, + { + label: job.logsEnabled + ? t("cronjobs.disableLogging") + : t("cronjobs.enableLogging"), + icon: job.logsEnabled ? ( + + ) : ( + + ), + onClick: () => onToggleLogging(job.id), + }, + ...(job.logsEnabled + ? [ + { + label: t("cronjobs.viewLogs"), + icon: , + onClick: () => onViewLogs(job), + }, + ] + : []), + { + label: job.paused + ? t("cronjobs.resumeCronJob") + : t("cronjobs.pauseCronJob"), + icon: job.paused ? ( + + ) : ( + + ), + onClick: () => (job.paused ? onResume(job.id) : onPause(job.id)), + }, + { + label: t("cronjobs.cloneCronJob"), + icon: , + onClick: () => onClone(job), + }, + { + label: t("cronjobs.backupJob"), + icon: , + onClick: () => onBackup(job.id), + }, + { + label: t("cronjobs.deleteCronJob"), + icon: , + onClick: () => onDelete(job), + variant: "destructive" as const, + disabled: deletingId === job.id, + }, + ]; + + return ( +
+
+
+
+ {(scheduleDisplayMode === "cron" || + scheduleDisplayMode === "both") && ( + + {job.schedule} + + )} + {scheduleDisplayMode === "human" && cronExplanation?.isValid && ( +
+ +

+ {cronExplanation.humanReadable} +

+
+ )} +
+
+ {commandCopied === job.id && ( + + )} +
 {
+                    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)}
+                
+
+
+
+ +
+ {scheduleDisplayMode === "both" && cronExplanation?.isValid && ( +
+ +

+ {cronExplanation.humanReadable} +

+
+ )} + + {job.comment && ( +

+ {job.comment} +

+ )} +
+ +
+
+ + {job.user} +
+ +
{ + const success = await copyToClipboard(job.id); + if (success) { + setShowCopyConfirmation(true); + setTimeout(() => setShowCopyConfirmation(false), 3000); + } + }} + > + {showCopyConfirmation ? ( + + ) : ( + + )} + {job.id} +
+ + {job.paused && ( + + {t("cronjobs.paused")} + + )} + + {job.logsEnabled && ( + + {t("cronjobs.logged")} + + )} + + {job.logsEnabled && job.logError?.hasError && ( + + )} + + {job.logsEnabled && + !job.logError?.hasError && + job.logError?.hasHistoricalFailures && ( + + )} + + {job.logsEnabled && + !job.logError?.hasError && + !job.logError?.hasHistoricalFailures && + job.logError?.latestExitCode === 0 && ( +
+ + {t("cronjobs.healthy")} +
+ )} + + {!job.logsEnabled && ( + + )} +
+
+ +
+
+ + + + + +
+ + +
+
+
+ ); +}; diff --git a/app/_components/FeatureComponents/Cronjobs/Parts/MinimalCronJobItem.tsx b/app/_components/FeatureComponents/Cronjobs/Parts/MinimalCronJobItem.tsx new file mode 100644 index 0000000..0c2fe03 --- /dev/null +++ b/app/_components/FeatureComponents/Cronjobs/Parts/MinimalCronJobItem.tsx @@ -0,0 +1,305 @@ +"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(null); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [commandCopied, setCommandCopied] = useState(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: , + onClick: () => onEdit(job), + }, + { + label: job.logsEnabled + ? t("cronjobs.disableLogging") + : t("cronjobs.enableLogging"), + icon: job.logsEnabled ? ( + + ) : ( + + ), + onClick: () => onToggleLogging(job.id), + }, + ...(job.logsEnabled + ? [ + { + label: t("cronjobs.viewLogs"), + icon: , + onClick: () => onViewLogs(job), + }, + ] + : []), + { + label: job.paused + ? t("cronjobs.resumeCronJob") + : t("cronjobs.pauseCronJob"), + icon: job.paused ? ( + + ) : ( + + ), + onClick: () => (job.paused ? onResume(job.id) : onPause(job.id)), + }, + { + label: t("cronjobs.cloneCronJob"), + icon: , + onClick: () => onClone(job), + }, + { + label: t("cronjobs.backupJob"), + icon: , + onClick: () => onBackup(job.id), + }, + { + label: t("cronjobs.deleteCronJob"), + icon: , + onClick: () => onDelete(job), + variant: "destructive" as const, + disabled: deletingId === job.id, + }, + ]; + + return ( +
+
+ {/* Schedule display - minimal */} +
+ {scheduleDisplayMode === "cron" && ( + + {job.schedule} + + )} + {scheduleDisplayMode === "human" && cronExplanation?.isValid && ( +
+ + + {cronExplanation.humanReadable} + +
+ )} + {scheduleDisplayMode === "both" && ( +
+ + {job.schedule} + + {cronExplanation?.isValid && ( +
+ +
+ )} +
+ )} +
+ +
+
+ {commandCopied === job.id && ( + + )} +
 {
+                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)}
+            
+
+
+ +
+ {job.logsEnabled && ( +
+ )} + {job.paused && ( +
+ )} + {!job.logError?.hasError && job.logsEnabled && ( +
+ )} + {job.logsEnabled && job.logError?.hasError && ( +
{ + e.stopPropagation(); + onViewLogs(job); + }} + /> + )} + {!job.logsEnabled && errors.length > 0 && ( +
onErrorClick(errors[0])} + /> + )} +
+ +
+ + + + + + + +
+
+
+ ); +}; diff --git a/app/_components/features/Cronjobs/helpers/index.tsx b/app/_components/FeatureComponents/Cronjobs/helpers/index.tsx similarity index 73% rename from app/_components/features/Cronjobs/helpers/index.tsx rename to app/_components/FeatureComponents/Cronjobs/helpers/index.tsx index 7460550..12551b0 100644 --- a/app/_components/features/Cronjobs/helpers/index.tsx +++ b/app/_components/FeatureComponents/Cronjobs/helpers/index.tsx @@ -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); + } +}; diff --git a/app/_components/ui/Sidebar.tsx b/app/_components/FeatureComponents/Layout/Sidebar.tsx similarity index 97% rename from app/_components/ui/Sidebar.tsx rename to app/_components/FeatureComponents/Layout/Sidebar.tsx index e4855d1..0102260 100644 --- a/app/_components/ui/Sidebar.tsx +++ b/app/_components/FeatureComponents/Layout/Sidebar.tsx @@ -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 { children: React.ReactNode; - title?: string; defaultCollapsed?: boolean; quickStats?: { cpu: number; @@ -28,13 +28,13 @@ export const Sidebar = forwardRef( { 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(
{(!isCollapsed || !isCollapsed) && (

- {title} + {t("sidebar.systemOverview")}

)}
diff --git a/app/_components/TabbedInterface.tsx b/app/_components/FeatureComponents/Layout/TabbedInterface.tsx similarity index 81% rename from app/_components/TabbedInterface.tsx rename to app/_components/FeatureComponents/Layout/TabbedInterface.tsx index 44db262..06d006d 100644 --- a/app/_components/TabbedInterface.tsx +++ b/app/_components/FeatureComponents/Layout/TabbedInterface.tsx @@ -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 (
@@ -33,7 +35,7 @@ export const TabbedInterface = ({ }`} > - Cron Jobs + {t("cronjobs.cronJobs")} {cronJobs.length} @@ -47,7 +49,7 @@ export const TabbedInterface = ({ }`} > - Scripts + {t("scripts.scripts")} {scripts.length} @@ -55,7 +57,7 @@ export const TabbedInterface = ({
-
+
{activeTab === "cronjobs" ? ( ) : ( diff --git a/app/_components/FeatureComponents/LoginForm/LoginForm.tsx b/app/_components/FeatureComponents/LoginForm/LoginForm.tsx new file mode 100644 index 0000000..b611c98 --- /dev/null +++ b/app/_components/FeatureComponents/LoginForm/LoginForm.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } 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 } from "lucide-react"; + +interface LoginFormProps { + hasPassword?: boolean; + hasOIDC?: boolean; + version?: string; +} + +export const LoginForm = ({ + hasPassword = false, + hasOIDC = false, + version, +}: LoginFormProps) => { + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const router = useRouter(); + const t = useTranslations(); + + 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"; + }; + + return ( + + +
+ +
+ {t("login.welcomeTitle")} + + {hasPassword && hasOIDC + ? t("login.signInWithPasswordOrSSO") + : hasOIDC + ? t("login.signInWithSSO") + : t("login.enterPasswordToContinue")} + +
+ + + {!hasPassword && !hasOIDC && ( +
+
+ +
+
+ {t("login.authenticationNotConfigured")} +
+
{t("login.noAuthMethodsEnabled")}
+
+
+
+ )} + +
+ {hasPassword && ( +
+
+ setPassword(e.target.value)} + placeholder={t("login.enterPassword")} + className="pr-10" + required + disabled={isLoading} + /> + +
+ + +
+ )} + + {hasPassword && hasOIDC && ( +
+
+ +
+
+ + {t("login.orContinueWith")} + +
+
+ )} + + {hasOIDC && ( + + )} + + {error && ( +
+ {error} +
+ )} +
+ + {version && ( +
+
+ Cr*nMaster {t("common.version", { version })} +
+
+ )} +
+
+ ); +}; diff --git a/app/_components/FeatureComponents/LoginForm/LogoutButton.tsx b/app/_components/FeatureComponents/LoginForm/LogoutButton.tsx new file mode 100644 index 0000000..d3c05d4 --- /dev/null +++ b/app/_components/FeatureComponents/LoginForm/LogoutButton.tsx @@ -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 ( + + ); +}; diff --git a/app/_components/modals/CloneScriptModal.tsx b/app/_components/FeatureComponents/Modals/CloneScriptModal.tsx similarity index 89% rename from app/_components/modals/CloneScriptModal.tsx rename to app/_components/FeatureComponents/Modals/CloneScriptModal.tsx index 974494d..befa1df 100644 --- a/app/_components/modals/CloneScriptModal.tsx +++ b/app/_components/FeatureComponents/Modals/CloneScriptModal.tsx @@ -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; diff --git a/app/_components/modals/CloneTaskModal.tsx b/app/_components/FeatureComponents/Modals/CloneTaskModal.tsx similarity index 89% rename from app/_components/modals/CloneTaskModal.tsx rename to app/_components/FeatureComponents/Modals/CloneTaskModal.tsx index 844af17..5244c8d 100644 --- a/app/_components/modals/CloneTaskModal.tsx +++ b/app/_components/FeatureComponents/Modals/CloneTaskModal.tsx @@ -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; diff --git a/app/_components/modals/CreateScriptModal.tsx b/app/_components/FeatureComponents/Modals/CreateScriptModal.tsx similarity index 90% rename from app/_components/modals/CreateScriptModal.tsx rename to app/_components/FeatureComponents/Modals/CreateScriptModal.tsx index a9ff876..5bc0182 100644 --- a/app/_components/modals/CreateScriptModal.tsx +++ b/app/_components/FeatureComponents/Modals/CreateScriptModal.tsx @@ -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; diff --git a/app/_components/modals/CreateTaskModal.tsx b/app/_components/FeatureComponents/Modals/CreateTaskModal.tsx similarity index 72% rename from app/_components/modals/CreateTaskModal.tsx rename to app/_components/FeatureComponents/Modals/CreateTaskModal.tsx index 8a0c22c..f3fe3e7 100644 --- a/app/_components/modals/CreateTaskModal.tsx +++ b/app/_components/FeatureComponents/Modals/CreateTaskModal.tsx @@ -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) => void; } @@ -46,6 +48,7 @@ export const CreateTaskModal = ({ useState(""); const [isSelectScriptModalOpen, setIsSelectScriptModalOpen] = useState(false); const selectedScript = scripts.find((s) => s.id === form.selectedScriptId); + const t = useTranslations(); useEffect(() => { const loadScriptContent = async () => { @@ -86,13 +89,13 @@ export const CreateTaskModal = ({
@@ -137,17 +145,20 @@ export const CreateTaskModal = ({